@ghostpaw/questlog 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -18
- package/SKILL.md +29 -15
- package/dist/chunk-3B6FMHZU.js +384 -0
- package/dist/chunk-3Y5F3SMB.js +4308 -0
- package/dist/chunk-RUSE7ZFF.js +58 -0
- package/dist/chunk-SVWAEAKC.js +5 -0
- package/dist/cli.js +11 -7468
- package/dist/cli_main-ED5BJPSR.js +626 -0
- package/dist/index.cjs +64 -45
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +64 -45
- package/dist/mcp-server.js +71 -46
- package/dist/mcp.cjs +65 -46
- package/dist/mcp.js +65 -46
- package/dist/open_questlog_db-SH3DAU4C.js +2 -0
- package/dist/questlog_skill_spec-JLZH3JL2.js +807 -0
- package/dist/serve_questlog_mcp-J4ZHNEOT.js +212 -0
- package/dist/skill_markdown-7HCMSJYR.js +80 -0
- package/package.json +7 -3
- package/skills/questlog/SKILL.md +29 -15
|
@@ -0,0 +1,4308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/questlines/map_questline_row.ts
|
|
3
|
+
function mapQuestlineRow(row) {
|
|
4
|
+
return {
|
|
5
|
+
id: Number(row.id),
|
|
6
|
+
sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
|
|
7
|
+
title: String(row.title),
|
|
8
|
+
description: row.description == null ? null : String(row.description),
|
|
9
|
+
startsAt: row.starts_at == null ? null : Number(row.starts_at),
|
|
10
|
+
dueAt: row.due_at == null ? null : Number(row.due_at),
|
|
11
|
+
createdAt: Number(row.created_at),
|
|
12
|
+
updatedAt: Number(row.updated_at),
|
|
13
|
+
archivedAt: row.archived_at == null ? null : Number(row.archived_at),
|
|
14
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/questlines/query_questline_stats.ts
|
|
19
|
+
function queryQuestlineStats(db, now, questlineIds) {
|
|
20
|
+
if (questlineIds && questlineIds.length === 0) {
|
|
21
|
+
return /* @__PURE__ */ new Map();
|
|
22
|
+
}
|
|
23
|
+
const params = [now, now];
|
|
24
|
+
const questlineFilter = questlineIds && questlineIds.length > 0 ? `AND q.questline_id IN (${questlineIds.map(() => "?").join(", ")})` : "";
|
|
25
|
+
if (questlineIds && questlineIds.length > 0) {
|
|
26
|
+
params.push(...questlineIds);
|
|
27
|
+
}
|
|
28
|
+
const rows = db.prepare(
|
|
29
|
+
`SELECT q.questline_id,
|
|
30
|
+
COUNT(*) AS total_quests,
|
|
31
|
+
SUM(CASE WHEN q.started_at IS NULL AND q.resolved_at IS NULL THEN 1 ELSE 0 END) AS open_quests,
|
|
32
|
+
SUM(CASE WHEN q.started_at IS NOT NULL AND q.resolved_at IS NULL THEN 1 ELSE 0 END) AS in_progress_quests,
|
|
33
|
+
SUM(CASE WHEN q.resolved_at IS NOT NULL AND q.success = 1 THEN 1 ELSE 0 END) AS done_quests,
|
|
34
|
+
SUM(CASE WHEN q.resolved_at IS NOT NULL AND q.success = 0 THEN 1 ELSE 0 END) AS abandoned_quests,
|
|
35
|
+
SUM(
|
|
36
|
+
CASE
|
|
37
|
+
WHEN q.resolved_at IS NULL
|
|
38
|
+
AND (q.not_before_at IS NULL OR q.not_before_at <= ?)
|
|
39
|
+
AND NOT EXISTS (
|
|
40
|
+
SELECT 1
|
|
41
|
+
FROM quest_unlocks qu
|
|
42
|
+
JOIN quests blocker ON blocker.id = qu.from_quest_id
|
|
43
|
+
WHERE qu.to_quest_id = q.id
|
|
44
|
+
AND qu.deleted_at IS NULL
|
|
45
|
+
AND blocker.deleted_at IS NULL
|
|
46
|
+
AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
|
|
47
|
+
)
|
|
48
|
+
THEN 1
|
|
49
|
+
ELSE 0
|
|
50
|
+
END
|
|
51
|
+
) AS available_quests,
|
|
52
|
+
SUM(
|
|
53
|
+
CASE
|
|
54
|
+
WHEN q.resolved_at IS NULL
|
|
55
|
+
AND COALESCE(q.due_at, ql.due_at) IS NOT NULL
|
|
56
|
+
AND COALESCE(q.due_at, ql.due_at) < ?
|
|
57
|
+
THEN 1
|
|
58
|
+
ELSE 0
|
|
59
|
+
END
|
|
60
|
+
) AS overdue_quests
|
|
61
|
+
FROM quests q
|
|
62
|
+
LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
|
|
63
|
+
WHERE q.deleted_at IS NULL
|
|
64
|
+
AND q.questline_id IS NOT NULL
|
|
65
|
+
${questlineFilter}
|
|
66
|
+
GROUP BY q.questline_id`
|
|
67
|
+
).all(...params);
|
|
68
|
+
return new Map(
|
|
69
|
+
rows.map((row) => [
|
|
70
|
+
Number(row.questline_id),
|
|
71
|
+
{
|
|
72
|
+
totalQuests: Number(row.total_quests ?? 0),
|
|
73
|
+
openQuests: Number(row.open_quests ?? 0),
|
|
74
|
+
inProgressQuests: Number(row.in_progress_quests ?? 0),
|
|
75
|
+
doneQuests: Number(row.done_quests ?? 0),
|
|
76
|
+
abandonedQuests: Number(row.abandoned_quests ?? 0),
|
|
77
|
+
availableQuests: Number(row.available_quests ?? 0),
|
|
78
|
+
overdueQuests: Number(row.overdue_quests ?? 0)
|
|
79
|
+
}
|
|
80
|
+
])
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/questlines/list_questlines.ts
|
|
85
|
+
function listQuestlines(db, now = Date.now()) {
|
|
86
|
+
const questlines = db.prepare("SELECT * FROM questlines WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC").all().map(mapQuestlineRow);
|
|
87
|
+
if (questlines.length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const statsByQuestlineId = queryQuestlineStats(
|
|
91
|
+
db,
|
|
92
|
+
now,
|
|
93
|
+
questlines.map((questline) => questline.id)
|
|
94
|
+
);
|
|
95
|
+
return questlines.map((questline) => {
|
|
96
|
+
const stats = statsByQuestlineId.get(questline.id);
|
|
97
|
+
return {
|
|
98
|
+
...questline,
|
|
99
|
+
totalQuests: stats?.totalQuests ?? 0,
|
|
100
|
+
openQuests: stats?.openQuests ?? 0,
|
|
101
|
+
inProgressQuests: stats?.inProgressQuests ?? 0,
|
|
102
|
+
doneQuests: stats?.doneQuests ?? 0,
|
|
103
|
+
abandonedQuests: stats?.abandonedQuests ?? 0,
|
|
104
|
+
availableQuests: stats?.availableQuests ?? 0,
|
|
105
|
+
overdueQuests: stats?.overdueQuests ?? 0
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/assert_non_empty.ts
|
|
111
|
+
function assertNonEmpty(value, label) {
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
if (trimmed.length === 0) {
|
|
114
|
+
throw new Error(`${label} must not be empty.`);
|
|
115
|
+
}
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/resolve_now.ts
|
|
120
|
+
function resolveNow(now) {
|
|
121
|
+
return now ?? Date.now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/quests/map_quest_row.ts
|
|
125
|
+
function asBoolean(value) {
|
|
126
|
+
return Number(value) === 1;
|
|
127
|
+
}
|
|
128
|
+
function mapQuestRow(row) {
|
|
129
|
+
return {
|
|
130
|
+
id: Number(row.id),
|
|
131
|
+
questlineId: row.questline_id == null ? null : Number(row.questline_id),
|
|
132
|
+
sourceRumorId: row.source_rumor_id == null ? null : Number(row.source_rumor_id),
|
|
133
|
+
spawnedByQuestId: row.spawned_by_quest_id == null ? null : Number(row.spawned_by_quest_id),
|
|
134
|
+
spawnedFromRepeatableId: row.spawned_from_repeatable_id == null ? null : Number(row.spawned_from_repeatable_id),
|
|
135
|
+
spawnedForAt: row.spawned_for_at == null ? null : Number(row.spawned_for_at),
|
|
136
|
+
title: String(row.title),
|
|
137
|
+
objective: String(row.objective),
|
|
138
|
+
outcome: row.outcome == null ? null : String(row.outcome),
|
|
139
|
+
createdAt: Number(row.created_at),
|
|
140
|
+
updatedAt: Number(row.updated_at),
|
|
141
|
+
startedAt: row.started_at == null ? null : Number(row.started_at),
|
|
142
|
+
resolvedAt: row.resolved_at == null ? null : Number(row.resolved_at),
|
|
143
|
+
success: row.success == null ? null : asBoolean(row.success),
|
|
144
|
+
effortSeconds: Number(row.effort_seconds),
|
|
145
|
+
estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
|
|
146
|
+
notBeforeAt: row.not_before_at == null ? null : Number(row.not_before_at),
|
|
147
|
+
dueAt: row.due_at == null ? null : Number(row.due_at),
|
|
148
|
+
scheduledStartAt: row.scheduled_start_at == null ? null : Number(row.scheduled_start_at),
|
|
149
|
+
scheduledEndAt: row.scheduled_end_at == null ? null : Number(row.scheduled_end_at),
|
|
150
|
+
allDay: asBoolean(row.all_day),
|
|
151
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/quests/get_quest_or_throw.ts
|
|
156
|
+
function getQuestOrThrow(db, questId) {
|
|
157
|
+
const row = db.prepare("SELECT * FROM quests WHERE id = ? AND deleted_at IS NULL").get(questId);
|
|
158
|
+
if (!row) {
|
|
159
|
+
throw new Error(`Quest ${questId} was not found.`);
|
|
160
|
+
}
|
|
161
|
+
return mapQuestRow(row);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/quests/resolve_quest.ts
|
|
165
|
+
function resolveQuest(db, questId, success, outcome, resolvedAt) {
|
|
166
|
+
const current = getQuestOrThrow(db, questId);
|
|
167
|
+
if (current.resolvedAt != null) {
|
|
168
|
+
throw new Error("Resolved quests cannot be resolved again.");
|
|
169
|
+
}
|
|
170
|
+
const now = resolveNow(resolvedAt);
|
|
171
|
+
db.prepare(
|
|
172
|
+
`UPDATE quests
|
|
173
|
+
SET outcome = ?,
|
|
174
|
+
resolved_at = ?,
|
|
175
|
+
success = ?,
|
|
176
|
+
updated_at = ?
|
|
177
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
178
|
+
).run(assertNonEmpty(outcome, "Quest outcome"), now, success ? 1 : 0, now, questId);
|
|
179
|
+
return getQuestOrThrow(db, questId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/quests/abandon_quest.ts
|
|
183
|
+
function abandonQuest(db, questId, outcome, resolvedAt) {
|
|
184
|
+
return resolveQuest(db, questId, false, outcome, resolvedAt);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/assert_active_row_exists.ts
|
|
188
|
+
function assertActiveRowExists(db, table, id, label) {
|
|
189
|
+
const row = db.prepare(`SELECT id FROM ${table} WHERE id = ? AND deleted_at IS NULL`).get(id);
|
|
190
|
+
if (!row) {
|
|
191
|
+
throw new Error(`${label} ${id} was not found.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/unlocks/would_create_unlock_cycle.ts
|
|
196
|
+
function wouldCreateUnlockCycle(db, fromQuestId, toQuestId) {
|
|
197
|
+
const frontier = [toQuestId];
|
|
198
|
+
const seen = /* @__PURE__ */ new Set();
|
|
199
|
+
while (frontier.length > 0) {
|
|
200
|
+
const current = frontier.pop();
|
|
201
|
+
if (current === fromQuestId) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (seen.has(current)) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
seen.add(current);
|
|
208
|
+
const rows = db.prepare(
|
|
209
|
+
`SELECT to_quest_id
|
|
210
|
+
FROM quest_unlocks
|
|
211
|
+
WHERE from_quest_id = ? AND deleted_at IS NULL`
|
|
212
|
+
).all(current);
|
|
213
|
+
for (const row of rows) {
|
|
214
|
+
frontier.push(Number(row.to_quest_id));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/unlocks/add_unlock.ts
|
|
221
|
+
function addUnlock(db, fromQuestId, toQuestId, now) {
|
|
222
|
+
assertActiveRowExists(db, "quests", fromQuestId, "Quest");
|
|
223
|
+
assertActiveRowExists(db, "quests", toQuestId, "Quest");
|
|
224
|
+
if (fromQuestId === toQuestId) {
|
|
225
|
+
throw new Error("A quest cannot unlock itself.");
|
|
226
|
+
}
|
|
227
|
+
if (wouldCreateUnlockCycle(db, fromQuestId, toQuestId)) {
|
|
228
|
+
throw new Error("Unlock would create a cycle.");
|
|
229
|
+
}
|
|
230
|
+
db.prepare(
|
|
231
|
+
`INSERT INTO quest_unlocks (from_quest_id, to_quest_id, created_at)
|
|
232
|
+
VALUES (?, ?, ?)`
|
|
233
|
+
).run(fromQuestId, toQuestId, resolveNow(now));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/with_transaction.ts
|
|
237
|
+
var transactionDepth = /* @__PURE__ */ new WeakMap();
|
|
238
|
+
function withTransaction(db, fn) {
|
|
239
|
+
const depth = transactionDepth.get(db) ?? 0;
|
|
240
|
+
const savepoint = `questlog_${depth}`;
|
|
241
|
+
if (depth === 0) {
|
|
242
|
+
db.exec("BEGIN IMMEDIATE");
|
|
243
|
+
} else {
|
|
244
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
245
|
+
}
|
|
246
|
+
transactionDepth.set(db, depth + 1);
|
|
247
|
+
try {
|
|
248
|
+
const result = fn();
|
|
249
|
+
if (depth === 0) {
|
|
250
|
+
db.exec("COMMIT");
|
|
251
|
+
} else {
|
|
252
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
253
|
+
}
|
|
254
|
+
transactionDepth.set(db, depth);
|
|
255
|
+
return result;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
try {
|
|
258
|
+
if (depth === 0) {
|
|
259
|
+
db.exec("ROLLBACK");
|
|
260
|
+
} else {
|
|
261
|
+
db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
262
|
+
db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
transactionDepth.set(db, depth);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/assert_non_negative_seconds.ts
|
|
272
|
+
function assertNonNegativeSeconds(value, label) {
|
|
273
|
+
if (value != null && value < 0) {
|
|
274
|
+
throw new Error(`${label} must not be negative.`);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/assert_non_negative_number.ts
|
|
280
|
+
function assertNonNegativeNumber(value, label) {
|
|
281
|
+
if (value != null && value < 0) {
|
|
282
|
+
throw new Error(`${label} must not be negative.`);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/rewards/replace_active_quest_rewards.ts
|
|
288
|
+
function replaceActiveQuestRewards(db, questId, rewards, now) {
|
|
289
|
+
const effectiveNow = resolveNow(now);
|
|
290
|
+
db.prepare(
|
|
291
|
+
"UPDATE quest_rewards SET deleted_at = ? WHERE quest_id = ? AND deleted_at IS NULL"
|
|
292
|
+
).run(effectiveNow, questId);
|
|
293
|
+
for (const reward of rewards) {
|
|
294
|
+
db.prepare(
|
|
295
|
+
`INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
|
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
297
|
+
).run(
|
|
298
|
+
questId,
|
|
299
|
+
assertNonEmpty(reward.kind, "Reward kind"),
|
|
300
|
+
assertNonEmpty(reward.name, "Reward name"),
|
|
301
|
+
reward.description?.trim() || null,
|
|
302
|
+
assertNonNegativeNumber(reward.quantity, "Reward quantity") ?? null,
|
|
303
|
+
effectiveNow
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/tags/normalize_tag_name.ts
|
|
309
|
+
function normalizeTagName(name) {
|
|
310
|
+
return name.trim().toLowerCase().replace(/\s+/g, " ");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/tags/replace_active_quest_tags.ts
|
|
314
|
+
function getOrCreateTagId(db, name, now) {
|
|
315
|
+
const normalizedName = normalizeTagName(name);
|
|
316
|
+
if (!normalizedName) {
|
|
317
|
+
throw new Error("Tag name must not be empty.");
|
|
318
|
+
}
|
|
319
|
+
const existing = db.prepare("SELECT id FROM tags WHERE normalized_name = ? AND deleted_at IS NULL").get(normalizedName);
|
|
320
|
+
if (existing) {
|
|
321
|
+
return Number(existing.id);
|
|
322
|
+
}
|
|
323
|
+
const created = db.prepare(
|
|
324
|
+
`INSERT INTO tags (name, normalized_name, created_at)
|
|
325
|
+
VALUES (?, ?, ?)`
|
|
326
|
+
).run(name.trim(), normalizedName, now);
|
|
327
|
+
return Number(created.lastInsertRowid);
|
|
328
|
+
}
|
|
329
|
+
function replaceActiveQuestTags(db, questId, tagNames, now) {
|
|
330
|
+
const effectiveNow = resolveNow(now);
|
|
331
|
+
db.prepare("UPDATE quest_tags SET deleted_at = ? WHERE quest_id = ? AND deleted_at IS NULL").run(
|
|
332
|
+
effectiveNow,
|
|
333
|
+
questId
|
|
334
|
+
);
|
|
335
|
+
const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
|
|
336
|
+
for (const name of uniqueNames) {
|
|
337
|
+
const tagId = getOrCreateTagId(db, name, effectiveNow);
|
|
338
|
+
db.prepare(
|
|
339
|
+
`INSERT INTO quest_tags (quest_id, tag_id, created_at)
|
|
340
|
+
VALUES (?, ?, ?)`
|
|
341
|
+
).run(questId, tagId, effectiveNow);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/quests/create_quest.ts
|
|
346
|
+
function createQuest(db, input) {
|
|
347
|
+
return withTransaction(db, () => {
|
|
348
|
+
const now = resolveNow(input.now);
|
|
349
|
+
const title = assertNonEmpty(input.title, "Quest title");
|
|
350
|
+
const objective = assertNonEmpty(input.objective, "Quest objective");
|
|
351
|
+
if (input.questlineId != null) {
|
|
352
|
+
assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
|
|
353
|
+
}
|
|
354
|
+
if (input.sourceRumorId != null) {
|
|
355
|
+
assertActiveRowExists(db, "rumors", input.sourceRumorId, "Rumor");
|
|
356
|
+
}
|
|
357
|
+
if (input.spawnedByQuestId != null) {
|
|
358
|
+
assertActiveRowExists(db, "quests", input.spawnedByQuestId, "Quest");
|
|
359
|
+
}
|
|
360
|
+
if (input.spawnedFromRepeatableId != null) {
|
|
361
|
+
assertActiveRowExists(
|
|
362
|
+
db,
|
|
363
|
+
"repeatable_quests",
|
|
364
|
+
input.spawnedFromRepeatableId,
|
|
365
|
+
"Repeatable quest"
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
assertNonNegativeSeconds(input.estimateSeconds, "Quest estimate");
|
|
369
|
+
const created = db.prepare(
|
|
370
|
+
`INSERT INTO quests (
|
|
371
|
+
questline_id,
|
|
372
|
+
source_rumor_id,
|
|
373
|
+
spawned_by_quest_id,
|
|
374
|
+
spawned_from_repeatable_id,
|
|
375
|
+
spawned_for_at,
|
|
376
|
+
title,
|
|
377
|
+
objective,
|
|
378
|
+
created_at,
|
|
379
|
+
updated_at,
|
|
380
|
+
effort_seconds,
|
|
381
|
+
estimate_seconds,
|
|
382
|
+
not_before_at,
|
|
383
|
+
due_at,
|
|
384
|
+
scheduled_start_at,
|
|
385
|
+
scheduled_end_at,
|
|
386
|
+
all_day
|
|
387
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
|
|
388
|
+
).run(
|
|
389
|
+
input.questlineId ?? null,
|
|
390
|
+
input.sourceRumorId ?? null,
|
|
391
|
+
input.spawnedByQuestId ?? null,
|
|
392
|
+
input.spawnedFromRepeatableId ?? null,
|
|
393
|
+
input.spawnedForAt ?? null,
|
|
394
|
+
title,
|
|
395
|
+
objective,
|
|
396
|
+
now,
|
|
397
|
+
now,
|
|
398
|
+
input.estimateSeconds ?? null,
|
|
399
|
+
input.notBeforeAt ?? null,
|
|
400
|
+
input.dueAt ?? null,
|
|
401
|
+
input.scheduledStartAt ?? null,
|
|
402
|
+
input.scheduledEndAt ?? null,
|
|
403
|
+
input.allDay ? 1 : 0
|
|
404
|
+
);
|
|
405
|
+
const questId = Number(created.lastInsertRowid);
|
|
406
|
+
if (input.tags && input.tags.length > 0) {
|
|
407
|
+
replaceActiveQuestTags(db, questId, input.tags, now);
|
|
408
|
+
}
|
|
409
|
+
if (input.rewards && input.rewards.length > 0) {
|
|
410
|
+
replaceActiveQuestRewards(db, questId, input.rewards, now);
|
|
411
|
+
}
|
|
412
|
+
return getQuestOrThrow(db, questId);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/quests/abandon_quest_and_spawn_followups.ts
|
|
417
|
+
function abandonQuestAndSpawnFollowups(db, questId, outcome, followups, resolvedAt) {
|
|
418
|
+
return withTransaction(db, () => {
|
|
419
|
+
const abandonedQuest = abandonQuest(db, questId, outcome, resolvedAt);
|
|
420
|
+
const now = resolveNow(resolvedAt);
|
|
421
|
+
const followupQuests = followups.map(
|
|
422
|
+
(followup) => createQuest(db, {
|
|
423
|
+
...followup,
|
|
424
|
+
questlineId: followup.questlineId ?? abandonedQuest.questlineId,
|
|
425
|
+
spawnedByQuestId: questId,
|
|
426
|
+
now
|
|
427
|
+
})
|
|
428
|
+
);
|
|
429
|
+
for (let i = 0; i < followups.length; i += 1) {
|
|
430
|
+
const followup = followups[i];
|
|
431
|
+
const created = followupQuests[i];
|
|
432
|
+
if (followup?.unlocksParent && created) {
|
|
433
|
+
addUnlock(db, created.id, questId, resolvedAt);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return { abandonedQuest, followupQuests };
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/quests/finish_quest.ts
|
|
441
|
+
function finishQuest(db, questId, outcome, resolvedAt) {
|
|
442
|
+
return resolveQuest(db, questId, true, outcome, resolvedAt);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/markers/derive_quest_marker_id.ts
|
|
446
|
+
function deriveQuestMarkerId({
|
|
447
|
+
state,
|
|
448
|
+
available,
|
|
449
|
+
hasUnclaimedReward,
|
|
450
|
+
notBeforeAt,
|
|
451
|
+
blockerCount,
|
|
452
|
+
now
|
|
453
|
+
}) {
|
|
454
|
+
if (state === "done") {
|
|
455
|
+
return hasUnclaimedReward ? "progress.complete" : null;
|
|
456
|
+
}
|
|
457
|
+
if (state === "abandoned") {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
if (state === "in_progress") {
|
|
461
|
+
return "progress.incomplete";
|
|
462
|
+
}
|
|
463
|
+
if (available) {
|
|
464
|
+
return "attention.available";
|
|
465
|
+
}
|
|
466
|
+
if (notBeforeAt != null && notBeforeAt > now) {
|
|
467
|
+
return "attention.available.future";
|
|
468
|
+
}
|
|
469
|
+
if (blockerCount > 0) {
|
|
470
|
+
return "progress.incomplete";
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/rewards/map_quest_reward_row.ts
|
|
476
|
+
function mapQuestRewardRow(row) {
|
|
477
|
+
return {
|
|
478
|
+
id: Number(row.id),
|
|
479
|
+
questId: Number(row.quest_id),
|
|
480
|
+
kind: String(row.kind),
|
|
481
|
+
name: String(row.name),
|
|
482
|
+
description: row.description == null ? null : String(row.description),
|
|
483
|
+
quantity: row.quantity == null ? null : Number(row.quantity),
|
|
484
|
+
createdAt: Number(row.created_at),
|
|
485
|
+
claimedAt: row.claimed_at == null ? null : Number(row.claimed_at),
|
|
486
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// src/quests/derive_quest_state.ts
|
|
491
|
+
function deriveQuestState(quest) {
|
|
492
|
+
if (quest.resolvedAt != null && quest.success === true) {
|
|
493
|
+
return "done";
|
|
494
|
+
}
|
|
495
|
+
if (quest.resolvedAt != null && quest.success === false) {
|
|
496
|
+
return "abandoned";
|
|
497
|
+
}
|
|
498
|
+
if (quest.startedAt != null) {
|
|
499
|
+
return "in_progress";
|
|
500
|
+
}
|
|
501
|
+
return "open";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/quests/query_quest_detail_records.ts
|
|
505
|
+
function queryQuestDetailRecords(db, filters = {}, now = Date.now(), questIds) {
|
|
506
|
+
if (questIds && questIds.length === 0) {
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
const clauses = [];
|
|
510
|
+
const params = [];
|
|
511
|
+
if (!filters.includeDeleted) {
|
|
512
|
+
clauses.push("q.deleted_at IS NULL");
|
|
513
|
+
}
|
|
514
|
+
if (questIds && questIds.length > 0) {
|
|
515
|
+
clauses.push(`q.id IN (${questIds.map(() => "?").join(", ")})`);
|
|
516
|
+
params.push(...questIds);
|
|
517
|
+
}
|
|
518
|
+
if (filters.questlineId != null) {
|
|
519
|
+
clauses.push("q.questline_id = ?");
|
|
520
|
+
params.push(filters.questlineId);
|
|
521
|
+
}
|
|
522
|
+
if (filters.tagNames && filters.tagNames.length > 0) {
|
|
523
|
+
const normalized = [...new Set(filters.tagNames.map(normalizeTagName).filter(Boolean))];
|
|
524
|
+
for (const tag of normalized) {
|
|
525
|
+
clauses.push(
|
|
526
|
+
`EXISTS (
|
|
527
|
+
SELECT 1
|
|
528
|
+
FROM quest_tags qt
|
|
529
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
530
|
+
WHERE qt.quest_id = q.id
|
|
531
|
+
AND qt.deleted_at IS NULL
|
|
532
|
+
AND t.deleted_at IS NULL
|
|
533
|
+
AND t.normalized_name = ?
|
|
534
|
+
)`
|
|
535
|
+
);
|
|
536
|
+
params.push(tag);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
540
|
+
const rows = db.prepare(
|
|
541
|
+
`SELECT q.*,
|
|
542
|
+
COALESCE(q.due_at, ql.due_at) AS effective_due_at,
|
|
543
|
+
(
|
|
544
|
+
SELECT COUNT(*)
|
|
545
|
+
FROM quest_unlocks qu
|
|
546
|
+
JOIN quests blocker ON blocker.id = qu.from_quest_id
|
|
547
|
+
WHERE qu.to_quest_id = q.id
|
|
548
|
+
AND qu.deleted_at IS NULL
|
|
549
|
+
AND blocker.deleted_at IS NULL
|
|
550
|
+
AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
|
|
551
|
+
) AS blocker_count
|
|
552
|
+
FROM quests q
|
|
553
|
+
LEFT JOIN questlines ql ON ql.id = q.questline_id AND ql.deleted_at IS NULL
|
|
554
|
+
${where}
|
|
555
|
+
ORDER BY q.created_at DESC, q.id DESC`
|
|
556
|
+
).all(...params);
|
|
557
|
+
if (rows.length === 0) {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
const ids = rows.map((row) => Number(row.id));
|
|
561
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
562
|
+
const tagNamesByQuestId = /* @__PURE__ */ new Map();
|
|
563
|
+
const rewardsByQuestId = /* @__PURE__ */ new Map();
|
|
564
|
+
const unlocksByQuestId = /* @__PURE__ */ new Map();
|
|
565
|
+
const unlockedByQuestId = /* @__PURE__ */ new Map();
|
|
566
|
+
for (const row of db.prepare(
|
|
567
|
+
`SELECT qt.quest_id, t.name
|
|
568
|
+
FROM quest_tags qt
|
|
569
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
570
|
+
WHERE qt.quest_id IN (${placeholders})
|
|
571
|
+
AND qt.deleted_at IS NULL
|
|
572
|
+
AND t.deleted_at IS NULL
|
|
573
|
+
ORDER BY qt.quest_id, t.name`
|
|
574
|
+
).all(...ids)) {
|
|
575
|
+
const questId = Number(row.quest_id);
|
|
576
|
+
const tagNames = tagNamesByQuestId.get(questId) ?? [];
|
|
577
|
+
tagNames.push(String(row.name));
|
|
578
|
+
tagNamesByQuestId.set(questId, tagNames);
|
|
579
|
+
}
|
|
580
|
+
for (const row of db.prepare(
|
|
581
|
+
`SELECT *
|
|
582
|
+
FROM quest_rewards
|
|
583
|
+
WHERE quest_id IN (${placeholders})
|
|
584
|
+
AND deleted_at IS NULL
|
|
585
|
+
ORDER BY quest_id, created_at, id`
|
|
586
|
+
).all(...ids)) {
|
|
587
|
+
const reward = mapQuestRewardRow(row);
|
|
588
|
+
const rewards = rewardsByQuestId.get(reward.questId) ?? [];
|
|
589
|
+
rewards.push(reward);
|
|
590
|
+
rewardsByQuestId.set(reward.questId, rewards);
|
|
591
|
+
}
|
|
592
|
+
for (const row of db.prepare(
|
|
593
|
+
`SELECT from_quest_id, to_quest_id
|
|
594
|
+
FROM quest_unlocks
|
|
595
|
+
WHERE from_quest_id IN (${placeholders})
|
|
596
|
+
AND deleted_at IS NULL
|
|
597
|
+
ORDER BY from_quest_id, to_quest_id`
|
|
598
|
+
).all(...ids)) {
|
|
599
|
+
const questId = Number(row.from_quest_id);
|
|
600
|
+
const unlocks = unlocksByQuestId.get(questId) ?? [];
|
|
601
|
+
unlocks.push(Number(row.to_quest_id));
|
|
602
|
+
unlocksByQuestId.set(questId, unlocks);
|
|
603
|
+
}
|
|
604
|
+
for (const row of db.prepare(
|
|
605
|
+
`SELECT from_quest_id, to_quest_id
|
|
606
|
+
FROM quest_unlocks
|
|
607
|
+
WHERE to_quest_id IN (${placeholders})
|
|
608
|
+
AND deleted_at IS NULL
|
|
609
|
+
ORDER BY to_quest_id, from_quest_id`
|
|
610
|
+
).all(...ids)) {
|
|
611
|
+
const questId = Number(row.to_quest_id);
|
|
612
|
+
const unlockedBy = unlockedByQuestId.get(questId) ?? [];
|
|
613
|
+
unlockedBy.push(Number(row.from_quest_id));
|
|
614
|
+
unlockedByQuestId.set(questId, unlockedBy);
|
|
615
|
+
}
|
|
616
|
+
return rows.map((row) => {
|
|
617
|
+
const quest = mapQuestRow(row);
|
|
618
|
+
const blockerCount = Number(row.blocker_count ?? 0);
|
|
619
|
+
const effectiveDueAt = row.effective_due_at == null ? null : Number(row.effective_due_at);
|
|
620
|
+
const rewards = rewardsByQuestId.get(quest.id) ?? [];
|
|
621
|
+
const state = deriveQuestState(quest);
|
|
622
|
+
const available = quest.deletedAt == null && quest.resolvedAt == null && (quest.notBeforeAt == null || quest.notBeforeAt <= now) && blockerCount === 0;
|
|
623
|
+
const markerId = deriveQuestMarkerId({
|
|
624
|
+
state,
|
|
625
|
+
available,
|
|
626
|
+
hasUnclaimedReward: rewards.some((reward) => reward.claimedAt == null),
|
|
627
|
+
notBeforeAt: quest.notBeforeAt,
|
|
628
|
+
blockerCount,
|
|
629
|
+
now
|
|
630
|
+
});
|
|
631
|
+
return {
|
|
632
|
+
detail: {
|
|
633
|
+
...quest,
|
|
634
|
+
state,
|
|
635
|
+
markerId,
|
|
636
|
+
effectiveDueAt,
|
|
637
|
+
available,
|
|
638
|
+
tagNames: tagNamesByQuestId.get(quest.id) ?? [],
|
|
639
|
+
rewards,
|
|
640
|
+
unlocksQuestIds: unlocksByQuestId.get(quest.id) ?? [],
|
|
641
|
+
unlockedByQuestIds: unlockedByQuestId.get(quest.id) ?? []
|
|
642
|
+
},
|
|
643
|
+
blockerCount
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/quests/get_quest_detail.ts
|
|
649
|
+
function getQuestDetail(db, questId, now = Date.now()) {
|
|
650
|
+
const [record] = queryQuestDetailRecords(db, {}, now, [questId]);
|
|
651
|
+
if (!record) {
|
|
652
|
+
throw new Error(`Quest ${questId} was not found.`);
|
|
653
|
+
}
|
|
654
|
+
return record.detail;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/quests/list_quest_details.ts
|
|
658
|
+
function listQuestDetails(db, filters = {}, now = Date.now()) {
|
|
659
|
+
return queryQuestDetailRecords(db, filters, now).map((record) => record.detail);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/quests/list_active_quests.ts
|
|
663
|
+
function listActiveQuests(db, filters = {}, now = Date.now()) {
|
|
664
|
+
return listQuestDetails(db, filters, now).filter(
|
|
665
|
+
(quest) => quest.state === "open" || quest.state === "in_progress"
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/quests/list_available_quests.ts
|
|
670
|
+
function listAvailableQuests(db, filters = {}, now = Date.now()) {
|
|
671
|
+
return listQuestDetails(db, filters, now).filter((quest) => quest.available);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/quests/list_blocked_quests.ts
|
|
675
|
+
function listBlockedQuests(db, filters = {}, now = Date.now()) {
|
|
676
|
+
return queryQuestDetailRecords(db, filters, now).filter(
|
|
677
|
+
(record) => (record.detail.state === "open" || record.detail.state === "in_progress") && record.blockerCount > 0
|
|
678
|
+
).map((record) => record.detail);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/quests/list_deferred_quests.ts
|
|
682
|
+
function listDeferredQuests(db, filters = {}, now = Date.now()) {
|
|
683
|
+
return listQuestDetails(db, filters, now).filter(
|
|
684
|
+
(quest) => quest.resolvedAt == null && quest.notBeforeAt != null && quest.notBeforeAt > now
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/quests/list_due_soon_quests.ts
|
|
689
|
+
function listDueSoonQuests(db, horizonMs, filters = {}, now = Date.now()) {
|
|
690
|
+
return listQuestDetails(db, filters, now).filter(
|
|
691
|
+
(quest) => quest.resolvedAt == null && quest.effectiveDueAt != null && quest.effectiveDueAt >= now && quest.effectiveDueAt <= now + horizonMs
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/quests/list_in_progress_quests.ts
|
|
696
|
+
function listInProgressQuests(db, filters = {}, now = Date.now()) {
|
|
697
|
+
return listQuestDetails(db, filters, now).filter((quest) => quest.state === "in_progress");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/quests/list_missed_scheduled_quests.ts
|
|
701
|
+
function listMissedScheduledQuests(db, filters = {}, now = Date.now()) {
|
|
702
|
+
return listQuestDetails(db, filters, now).filter(
|
|
703
|
+
(quest) => quest.resolvedAt == null && quest.scheduledEndAt != null && quest.scheduledEndAt < now
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/quests/list_open_quests.ts
|
|
708
|
+
function listOpenQuests(db, filters = {}, now = Date.now()) {
|
|
709
|
+
return listQuestDetails(db, filters, now).filter((quest) => quest.state === "open");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/quests/list_overdue_quests.ts
|
|
713
|
+
function listOverdueQuests(db, filters = {}, now = Date.now()) {
|
|
714
|
+
return listQuestDetails(db, filters, now).filter(
|
|
715
|
+
(quest) => quest.resolvedAt == null && quest.effectiveDueAt != null && quest.effectiveDueAt < now
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// src/quests/list_resolved_quests.ts
|
|
720
|
+
function listResolvedQuests(db, filters = {}, now = Date.now()) {
|
|
721
|
+
return listQuestDetails(db, filters, now).filter((quest) => quest.resolvedAt != null);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/quests/overlaps_schedule_window.ts
|
|
725
|
+
function overlapsScheduleWindow(start, end, windowStart, windowEnd) {
|
|
726
|
+
if (start == null) {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
const effectiveEnd = end ?? start;
|
|
730
|
+
return start < windowEnd && effectiveEnd >= windowStart;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/quests/list_scheduled_for_day.ts
|
|
734
|
+
function listScheduledForDay(db, dayStart, dayEnd, filters = {}, now = Date.now()) {
|
|
735
|
+
return listQuestDetails(db, filters, now).filter(
|
|
736
|
+
(quest) => overlapsScheduleWindow(quest.scheduledStartAt, quest.scheduledEndAt, dayStart, dayEnd)
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/quests/list_scheduled_now.ts
|
|
741
|
+
function listScheduledNow(db, filters = {}, now = Date.now()) {
|
|
742
|
+
return listQuestDetails(db, filters, now).filter(
|
|
743
|
+
(quest) => overlapsScheduleWindow(quest.scheduledStartAt, quest.scheduledEndAt, now, now + 1)
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/quests/log_quest_effort.ts
|
|
748
|
+
function logQuestEffort(db, questId, effortSeconds, now) {
|
|
749
|
+
if (effortSeconds <= 0) {
|
|
750
|
+
throw new Error("Quest effort increment must be greater than zero.");
|
|
751
|
+
}
|
|
752
|
+
getQuestOrThrow(db, questId);
|
|
753
|
+
db.prepare(
|
|
754
|
+
`UPDATE quests
|
|
755
|
+
SET effort_seconds = effort_seconds + ?, updated_at = ?
|
|
756
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
757
|
+
).run(effortSeconds, resolveNow(now), questId);
|
|
758
|
+
return getQuestOrThrow(db, questId);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/quests/plan_quest_time.ts
|
|
762
|
+
function planQuestTime(db, questId, input) {
|
|
763
|
+
const current = getQuestOrThrow(db, questId);
|
|
764
|
+
const now = resolveNow(input.now);
|
|
765
|
+
db.prepare(
|
|
766
|
+
`UPDATE quests
|
|
767
|
+
SET not_before_at = ?,
|
|
768
|
+
due_at = ?,
|
|
769
|
+
scheduled_start_at = ?,
|
|
770
|
+
scheduled_end_at = ?,
|
|
771
|
+
all_day = ?,
|
|
772
|
+
estimate_seconds = ?,
|
|
773
|
+
updated_at = ?
|
|
774
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
775
|
+
).run(
|
|
776
|
+
input.notBeforeAt === void 0 ? current.notBeforeAt : input.notBeforeAt,
|
|
777
|
+
input.dueAt === void 0 ? current.dueAt : input.dueAt,
|
|
778
|
+
input.scheduledStartAt === void 0 ? current.scheduledStartAt : input.scheduledStartAt,
|
|
779
|
+
input.scheduledEndAt === void 0 ? current.scheduledEndAt : input.scheduledEndAt,
|
|
780
|
+
input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
|
|
781
|
+
input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Quest estimate") ?? null,
|
|
782
|
+
now,
|
|
783
|
+
questId
|
|
784
|
+
);
|
|
785
|
+
return getQuestOrThrow(db, questId);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/quests/revise_quest_objective.ts
|
|
789
|
+
function reviseQuestObjective(db, questId, objective, now) {
|
|
790
|
+
const current = getQuestOrThrow(db, questId);
|
|
791
|
+
if (current.startedAt != null) {
|
|
792
|
+
throw new Error("Quest objective cannot change after the quest has actually started.");
|
|
793
|
+
}
|
|
794
|
+
db.prepare(
|
|
795
|
+
`UPDATE quests
|
|
796
|
+
SET objective = ?, updated_at = ?
|
|
797
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
798
|
+
).run(assertNonEmpty(objective, "Quest objective"), resolveNow(now), questId);
|
|
799
|
+
return getQuestOrThrow(db, questId);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/quests/soft_delete_quest.ts
|
|
803
|
+
function softDeleteQuest(db, questId, now) {
|
|
804
|
+
const deletedAt = resolveNow(now);
|
|
805
|
+
getQuestOrThrow(db, questId);
|
|
806
|
+
db.prepare(
|
|
807
|
+
`UPDATE quests
|
|
808
|
+
SET deleted_at = ?, updated_at = ?
|
|
809
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
810
|
+
).run(deletedAt, deletedAt, questId);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// src/quests/start_quest.ts
|
|
814
|
+
function startQuest(db, questId, startedAt) {
|
|
815
|
+
const current = getQuestOrThrow(db, questId);
|
|
816
|
+
if (current.startedAt != null) {
|
|
817
|
+
return current;
|
|
818
|
+
}
|
|
819
|
+
if (current.resolvedAt != null) {
|
|
820
|
+
throw new Error("Resolved quests cannot be started.");
|
|
821
|
+
}
|
|
822
|
+
const now = resolveNow(startedAt);
|
|
823
|
+
db.prepare(
|
|
824
|
+
`UPDATE quests
|
|
825
|
+
SET started_at = ?, updated_at = ?
|
|
826
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
827
|
+
).run(now, now, questId);
|
|
828
|
+
return getQuestOrThrow(db, questId);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/repeatable_quests/map_repeatable_quest_row.ts
|
|
832
|
+
function asBoolean2(value) {
|
|
833
|
+
return Number(value) === 1;
|
|
834
|
+
}
|
|
835
|
+
function mapRepeatableQuestRow(row) {
|
|
836
|
+
return {
|
|
837
|
+
id: Number(row.id),
|
|
838
|
+
questlineId: row.questline_id == null ? null : Number(row.questline_id),
|
|
839
|
+
title: String(row.title),
|
|
840
|
+
objective: String(row.objective),
|
|
841
|
+
rrule: String(row.rrule),
|
|
842
|
+
anchorAt: Number(row.anchor_at),
|
|
843
|
+
notBeforeOffsetSeconds: row.not_before_offset_seconds == null ? null : Number(row.not_before_offset_seconds),
|
|
844
|
+
dueOffsetSeconds: row.due_offset_seconds == null ? null : Number(row.due_offset_seconds),
|
|
845
|
+
scheduledStartOffsetSeconds: row.scheduled_start_offset_seconds == null ? null : Number(row.scheduled_start_offset_seconds),
|
|
846
|
+
scheduledEndOffsetSeconds: row.scheduled_end_offset_seconds == null ? null : Number(row.scheduled_end_offset_seconds),
|
|
847
|
+
allDay: asBoolean2(row.all_day),
|
|
848
|
+
estimateSeconds: row.estimate_seconds == null ? null : Number(row.estimate_seconds),
|
|
849
|
+
createdAt: Number(row.created_at),
|
|
850
|
+
updatedAt: Number(row.updated_at),
|
|
851
|
+
archivedAt: row.archived_at == null ? null : Number(row.archived_at),
|
|
852
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/repeatable_quests/get_repeatable_quest_or_throw.ts
|
|
857
|
+
function getRepeatableQuestOrThrow(db, repeatableQuestId) {
|
|
858
|
+
const row = db.prepare("SELECT * FROM repeatable_quests WHERE id = ? AND deleted_at IS NULL").get(repeatableQuestId);
|
|
859
|
+
if (!row) {
|
|
860
|
+
throw new Error(`Repeatable quest ${repeatableQuestId} was not found.`);
|
|
861
|
+
}
|
|
862
|
+
return mapRepeatableQuestRow(row);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/repeatable_quests/advance_anchor.ts
|
|
866
|
+
var WEEK_MS = 7 * 864e5;
|
|
867
|
+
function advanceAnchor(anchorAt, freq, interval) {
|
|
868
|
+
const date = new Date(anchorAt);
|
|
869
|
+
if (freq === "MONTHLY") {
|
|
870
|
+
date.setUTCMonth(date.getUTCMonth() + interval);
|
|
871
|
+
return date.getTime();
|
|
872
|
+
}
|
|
873
|
+
if (freq === "YEARLY") {
|
|
874
|
+
date.setUTCFullYear(date.getUTCFullYear() + interval);
|
|
875
|
+
return date.getTime();
|
|
876
|
+
}
|
|
877
|
+
return anchorAt + interval * WEEK_MS;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/repeatable_quests/parse_rrule.ts
|
|
881
|
+
var WEEKDAY = {
|
|
882
|
+
SU: 0,
|
|
883
|
+
MO: 1,
|
|
884
|
+
TU: 2,
|
|
885
|
+
WE: 3,
|
|
886
|
+
TH: 4,
|
|
887
|
+
FR: 5,
|
|
888
|
+
SA: 6
|
|
889
|
+
};
|
|
890
|
+
function parseRRule(rrule) {
|
|
891
|
+
const parts = Object.fromEntries(
|
|
892
|
+
rrule.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
893
|
+
const [key, value] = part.split("=");
|
|
894
|
+
return [key, value];
|
|
895
|
+
})
|
|
896
|
+
);
|
|
897
|
+
const freq = parts.FREQ;
|
|
898
|
+
if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") {
|
|
899
|
+
throw new Error(`Unsupported RRULE FREQ: ${freq ?? "missing"}`);
|
|
900
|
+
}
|
|
901
|
+
const interval = parts.INTERVAL == null ? 1 : Number(parts.INTERVAL);
|
|
902
|
+
if (!Number.isFinite(interval) || interval < 1) {
|
|
903
|
+
throw new Error("RRULE INTERVAL must be a positive integer.");
|
|
904
|
+
}
|
|
905
|
+
const count = parts.COUNT == null ? null : Number(parts.COUNT);
|
|
906
|
+
if (count != null && (!Number.isFinite(count) || count < 1)) {
|
|
907
|
+
throw new Error("RRULE COUNT must be a positive integer.");
|
|
908
|
+
}
|
|
909
|
+
const until = parts.UNTIL == null ? null : Date.parse(parts.UNTIL);
|
|
910
|
+
if (parts.UNTIL != null && Number.isNaN(until)) {
|
|
911
|
+
throw new Error("RRULE UNTIL must be parseable as a date.");
|
|
912
|
+
}
|
|
913
|
+
const byDay = parts.BYDAY == null ? null : parts.BYDAY.split(",").map((token) => {
|
|
914
|
+
const value = WEEKDAY[token];
|
|
915
|
+
if (value == null) {
|
|
916
|
+
throw new Error(`Unsupported RRULE BYDAY token: ${token}`);
|
|
917
|
+
}
|
|
918
|
+
return value;
|
|
919
|
+
});
|
|
920
|
+
return { freq, interval, count, until, byDay };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/repeatable_quests/start_of_utc_day.ts
|
|
924
|
+
function startOfUtcDay(timestamp) {
|
|
925
|
+
const date = new Date(timestamp);
|
|
926
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/repeatable_quests/start_of_utc_week.ts
|
|
930
|
+
var DAY_MS = 864e5;
|
|
931
|
+
function startOfUtcWeek(dayStart) {
|
|
932
|
+
const date = new Date(dayStart);
|
|
933
|
+
return dayStart - date.getUTCDay() * DAY_MS;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/repeatable_quests/generate_anchors_until.ts
|
|
937
|
+
var DAY_MS2 = 864e5;
|
|
938
|
+
var WEEK_MS2 = 7 * DAY_MS2;
|
|
939
|
+
function generateAnchorsUntil(repeatable, now) {
|
|
940
|
+
const rule = parseRRule(repeatable.rrule);
|
|
941
|
+
const anchors = [];
|
|
942
|
+
const until = rule.until == null ? now : Math.min(now, rule.until);
|
|
943
|
+
if (repeatable.anchorAt > until) {
|
|
944
|
+
return anchors;
|
|
945
|
+
}
|
|
946
|
+
if (rule.freq === "DAILY") {
|
|
947
|
+
let occurrence2 = 0;
|
|
948
|
+
for (let current2 = repeatable.anchorAt; current2 <= until; current2 += rule.interval * DAY_MS2) {
|
|
949
|
+
occurrence2 += 1;
|
|
950
|
+
if (rule.count != null && occurrence2 > rule.count) {
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
anchors.push(current2);
|
|
954
|
+
}
|
|
955
|
+
return anchors;
|
|
956
|
+
}
|
|
957
|
+
if (rule.freq === "WEEKLY" && rule.byDay && rule.byDay.length > 0) {
|
|
958
|
+
const anchorDayStart = startOfUtcDay(repeatable.anchorAt);
|
|
959
|
+
const anchorWeekStart = startOfUtcWeek(anchorDayStart);
|
|
960
|
+
const timeOffset = repeatable.anchorAt - anchorDayStart;
|
|
961
|
+
let occurrence2 = 0;
|
|
962
|
+
for (let day = anchorDayStart; day <= until; day += DAY_MS2) {
|
|
963
|
+
const weeksSince = Math.floor((startOfUtcWeek(day) - anchorWeekStart) / WEEK_MS2);
|
|
964
|
+
if (weeksSince < 0 || weeksSince % rule.interval !== 0) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (!rule.byDay.includes(new Date(day).getUTCDay())) {
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
const anchor = day + timeOffset;
|
|
971
|
+
if (anchor < repeatable.anchorAt || anchor > until) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
occurrence2 += 1;
|
|
975
|
+
if (rule.count != null && occurrence2 > rule.count) {
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
anchors.push(anchor);
|
|
979
|
+
}
|
|
980
|
+
return anchors;
|
|
981
|
+
}
|
|
982
|
+
let occurrence = 0;
|
|
983
|
+
let current = repeatable.anchorAt;
|
|
984
|
+
while (current <= until) {
|
|
985
|
+
occurrence += 1;
|
|
986
|
+
if (rule.count != null && occurrence > rule.count) {
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
989
|
+
anchors.push(current);
|
|
990
|
+
current = advanceAnchor(current, rule.freq, rule.interval);
|
|
991
|
+
}
|
|
992
|
+
return anchors;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// src/repeatable_quests/list_due_repeatable_quest_anchors.ts
|
|
996
|
+
function listDueRepeatableQuestAnchors(db, now) {
|
|
997
|
+
const repeatables = db.prepare(
|
|
998
|
+
`SELECT *
|
|
999
|
+
FROM repeatable_quests
|
|
1000
|
+
WHERE deleted_at IS NULL
|
|
1001
|
+
AND archived_at IS NULL`
|
|
1002
|
+
).all().map(mapRepeatableQuestRow);
|
|
1003
|
+
const anchors = [];
|
|
1004
|
+
if (repeatables.length === 0) {
|
|
1005
|
+
return anchors;
|
|
1006
|
+
}
|
|
1007
|
+
const repeatableIds = repeatables.map((repeatable) => repeatable.id);
|
|
1008
|
+
const maxAnchorAt = Math.max(
|
|
1009
|
+
...repeatables.map((repeatable) => Math.max(repeatable.anchorAt, now))
|
|
1010
|
+
);
|
|
1011
|
+
const existingRows = db.prepare(
|
|
1012
|
+
`SELECT spawned_from_repeatable_id, spawned_for_at
|
|
1013
|
+
FROM quests
|
|
1014
|
+
WHERE spawned_from_repeatable_id IN (${repeatableIds.map(() => "?").join(", ")})
|
|
1015
|
+
AND spawned_for_at IS NOT NULL
|
|
1016
|
+
AND spawned_for_at <= ?`
|
|
1017
|
+
).all(...repeatableIds, maxAnchorAt);
|
|
1018
|
+
const existingAnchors = new Set(
|
|
1019
|
+
existingRows.map(
|
|
1020
|
+
(row) => `${Number(row.spawned_from_repeatable_id)}:${Number(row.spawned_for_at)}`
|
|
1021
|
+
)
|
|
1022
|
+
);
|
|
1023
|
+
for (const repeatable of repeatables) {
|
|
1024
|
+
const dueAnchors = generateAnchorsUntil(repeatable, now);
|
|
1025
|
+
for (const anchorAt of dueAnchors) {
|
|
1026
|
+
if (!existingAnchors.has(`${repeatable.id}:${anchorAt}`)) {
|
|
1027
|
+
anchors.push({
|
|
1028
|
+
repeatableQuestId: repeatable.id,
|
|
1029
|
+
anchorAt,
|
|
1030
|
+
markerId: "attention.available.repeatable"
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return anchors.sort(
|
|
1036
|
+
(a, b) => a.anchorAt - b.anchorAt || a.repeatableQuestId - b.repeatableQuestId
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/markers/derive_rumor_marker_id.ts
|
|
1041
|
+
function deriveRumorMarkerId({ state }) {
|
|
1042
|
+
if (state === "open") {
|
|
1043
|
+
return "attention.available";
|
|
1044
|
+
}
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/rumors/derive_rumor_state.ts
|
|
1049
|
+
function deriveRumorState(rumor) {
|
|
1050
|
+
if (rumor.dismissedAt != null) {
|
|
1051
|
+
return "dismissed";
|
|
1052
|
+
}
|
|
1053
|
+
if (rumor.settledAt != null) {
|
|
1054
|
+
return "settled";
|
|
1055
|
+
}
|
|
1056
|
+
return "open";
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/rumors/map_rumor_row.ts
|
|
1060
|
+
function mapRumorRow(row) {
|
|
1061
|
+
const rumor = {
|
|
1062
|
+
id: Number(row.id),
|
|
1063
|
+
title: String(row.title),
|
|
1064
|
+
details: row.details == null ? null : String(row.details),
|
|
1065
|
+
markerId: null,
|
|
1066
|
+
createdAt: Number(row.created_at),
|
|
1067
|
+
updatedAt: Number(row.updated_at),
|
|
1068
|
+
settledAt: row.settled_at == null ? null : Number(row.settled_at),
|
|
1069
|
+
dismissedAt: row.dismissed_at == null ? null : Number(row.dismissed_at),
|
|
1070
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at)
|
|
1071
|
+
};
|
|
1072
|
+
return {
|
|
1073
|
+
...rumor,
|
|
1074
|
+
markerId: deriveRumorMarkerId({ state: deriveRumorState(rumor) })
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// src/rumors/list_rumors.ts
|
|
1079
|
+
function listRumors(db) {
|
|
1080
|
+
return db.prepare("SELECT * FROM rumors WHERE deleted_at IS NULL ORDER BY created_at DESC, id DESC").all().map(mapRumorRow);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/tools/tool_metadata.ts
|
|
1084
|
+
function defineQuestlogTool(definition) {
|
|
1085
|
+
return definition;
|
|
1086
|
+
}
|
|
1087
|
+
function stringSchema(description, options = {}) {
|
|
1088
|
+
return { type: "string", description, ...options };
|
|
1089
|
+
}
|
|
1090
|
+
function integerSchema(description, options = {}) {
|
|
1091
|
+
return { type: "integer", description, ...options };
|
|
1092
|
+
}
|
|
1093
|
+
function booleanSchema(description, options = {}) {
|
|
1094
|
+
return { type: "boolean", description, ...options };
|
|
1095
|
+
}
|
|
1096
|
+
function enumSchema(description, values) {
|
|
1097
|
+
const schema = {
|
|
1098
|
+
description,
|
|
1099
|
+
enum: values
|
|
1100
|
+
};
|
|
1101
|
+
if (values.every((value) => typeof value === "string")) {
|
|
1102
|
+
schema.type = "string";
|
|
1103
|
+
}
|
|
1104
|
+
return schema;
|
|
1105
|
+
}
|
|
1106
|
+
function arraySchema(items, description) {
|
|
1107
|
+
return {
|
|
1108
|
+
type: "array",
|
|
1109
|
+
description,
|
|
1110
|
+
items
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function objectSchema(properties, required, description) {
|
|
1114
|
+
const schema = {
|
|
1115
|
+
type: "object",
|
|
1116
|
+
properties,
|
|
1117
|
+
required,
|
|
1118
|
+
additionalProperties: false
|
|
1119
|
+
};
|
|
1120
|
+
if (description !== void 0) {
|
|
1121
|
+
schema.description = description;
|
|
1122
|
+
}
|
|
1123
|
+
return schema;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/tools/tool_names.ts
|
|
1127
|
+
var searchQuestlogToolName = "search_questlog";
|
|
1128
|
+
var reviewQuestlogToolName = "review_questlog";
|
|
1129
|
+
var inspectQuestlogItemToolName = "inspect_questlog_item";
|
|
1130
|
+
var captureRumorToolName = "capture_rumor";
|
|
1131
|
+
var shapeWorkToolName = "shape_work";
|
|
1132
|
+
var planQuestToolName = "plan_quest";
|
|
1133
|
+
var runQuestToolName = "run_quest";
|
|
1134
|
+
var organizeWorkToolName = "organize_work";
|
|
1135
|
+
var manageRepeatableToolName = "manage_repeatable";
|
|
1136
|
+
var tagWorkToolName = "tag_work";
|
|
1137
|
+
var rewardWorkToolName = "reward_work";
|
|
1138
|
+
var retireWorkToolName = "retire_work";
|
|
1139
|
+
|
|
1140
|
+
// src/tools/quest_resolution_fields.ts
|
|
1141
|
+
function questResolutionFields({
|
|
1142
|
+
outcome,
|
|
1143
|
+
resolvedAt
|
|
1144
|
+
}) {
|
|
1145
|
+
const fields = {};
|
|
1146
|
+
if (outcome != null) fields.outcome = outcome;
|
|
1147
|
+
if (resolvedAt != null) fields.resolvedAt = resolvedAt;
|
|
1148
|
+
return fields;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/tools/tool_ref.ts
|
|
1152
|
+
function toQuestRef(quest) {
|
|
1153
|
+
return {
|
|
1154
|
+
kind: "quest",
|
|
1155
|
+
id: quest.id,
|
|
1156
|
+
title: quest.title,
|
|
1157
|
+
markerId: "markerId" in quest ? quest.markerId : null,
|
|
1158
|
+
state: "state" in quest ? quest.state : deriveQuestState(quest)
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function toQuestListItem(quest) {
|
|
1162
|
+
return {
|
|
1163
|
+
...questResolutionFields(quest),
|
|
1164
|
+
...toQuestRef(quest),
|
|
1165
|
+
subtitle: quest.objective,
|
|
1166
|
+
dueAt: quest.effectiveDueAt,
|
|
1167
|
+
available: quest.available
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
function toQuestlineRef(questline) {
|
|
1171
|
+
return {
|
|
1172
|
+
kind: "questline",
|
|
1173
|
+
id: questline.id,
|
|
1174
|
+
title: questline.title,
|
|
1175
|
+
state: questline.archivedAt == null ? "active" : "archived"
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
function toQuestlineListItem(questline) {
|
|
1179
|
+
return {
|
|
1180
|
+
...toQuestlineRef(questline),
|
|
1181
|
+
subtitle: questline.description ?? void 0,
|
|
1182
|
+
dueAt: questline.dueAt,
|
|
1183
|
+
available: questline.availableQuests > 0
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
function toRepeatableQuestRef(repeatable) {
|
|
1187
|
+
return {
|
|
1188
|
+
kind: "repeatable_quest",
|
|
1189
|
+
id: repeatable.id,
|
|
1190
|
+
title: repeatable.title,
|
|
1191
|
+
state: repeatable.archivedAt == null ? "active" : "archived"
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
function toRepeatableAnchorListItem(anchor, repeatable) {
|
|
1195
|
+
return {
|
|
1196
|
+
kind: "repeatable_quest",
|
|
1197
|
+
id: anchor.repeatableQuestId,
|
|
1198
|
+
title: repeatable?.title,
|
|
1199
|
+
markerId: anchor.markerId,
|
|
1200
|
+
state: repeatable?.archivedAt == null ? "active" : "archived",
|
|
1201
|
+
dueAt: anchor.anchorAt,
|
|
1202
|
+
subtitle: repeatable?.objective
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
function toRumorRef(rumor) {
|
|
1206
|
+
return {
|
|
1207
|
+
kind: "rumor",
|
|
1208
|
+
id: rumor.id,
|
|
1209
|
+
title: rumor.title,
|
|
1210
|
+
markerId: rumor.markerId,
|
|
1211
|
+
state: "state" in rumor ? rumor.state : deriveRumorState(rumor)
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
function toRumorListItem(rumor) {
|
|
1215
|
+
return {
|
|
1216
|
+
...toRumorRef(rumor),
|
|
1217
|
+
subtitle: rumor.details ?? void 0
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function toRewardRef(reward) {
|
|
1221
|
+
return {
|
|
1222
|
+
kind: "reward",
|
|
1223
|
+
id: reward.id,
|
|
1224
|
+
title: reward.name,
|
|
1225
|
+
state: reward.claimedAt == null ? "unclaimed" : "claimed"
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
function toSearchListItem(result) {
|
|
1229
|
+
return {
|
|
1230
|
+
kind: result.entityKind,
|
|
1231
|
+
id: result.entityId,
|
|
1232
|
+
title: result.title,
|
|
1233
|
+
markerId: result.markerId,
|
|
1234
|
+
snippet: result.snippet
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/tools/tool_summary.ts
|
|
1239
|
+
function summarizeCount(count, singular, plural = `${singular}s`) {
|
|
1240
|
+
if (count === 1) {
|
|
1241
|
+
return `Found 1 ${singular}.`;
|
|
1242
|
+
}
|
|
1243
|
+
return `Found ${count} ${plural}.`;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/tools/tool_types.ts
|
|
1247
|
+
function withOptionalFields(result, options) {
|
|
1248
|
+
if (options.next && options.next.length > 0) {
|
|
1249
|
+
result.next = options.next;
|
|
1250
|
+
}
|
|
1251
|
+
if (options.warnings && options.warnings.length > 0) {
|
|
1252
|
+
result.warnings = options.warnings;
|
|
1253
|
+
}
|
|
1254
|
+
return result;
|
|
1255
|
+
}
|
|
1256
|
+
function toolWarning(code, message) {
|
|
1257
|
+
return { code, message };
|
|
1258
|
+
}
|
|
1259
|
+
function toolSuccess(summary, data, options = {}) {
|
|
1260
|
+
return withOptionalFields(
|
|
1261
|
+
{
|
|
1262
|
+
ok: true,
|
|
1263
|
+
outcome: "success",
|
|
1264
|
+
summary,
|
|
1265
|
+
entities: options.entities ?? [],
|
|
1266
|
+
data
|
|
1267
|
+
},
|
|
1268
|
+
options
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
function toolNoOp(summary, data, options = {}) {
|
|
1272
|
+
return withOptionalFields(
|
|
1273
|
+
{
|
|
1274
|
+
ok: true,
|
|
1275
|
+
outcome: "no_op",
|
|
1276
|
+
summary,
|
|
1277
|
+
entities: options.entities ?? [],
|
|
1278
|
+
data
|
|
1279
|
+
},
|
|
1280
|
+
options
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
function toolNeedsClarification(code, question, missing, options = {}) {
|
|
1284
|
+
return withOptionalFields(
|
|
1285
|
+
{
|
|
1286
|
+
ok: false,
|
|
1287
|
+
outcome: "needs_clarification",
|
|
1288
|
+
summary: question,
|
|
1289
|
+
entities: options.entities ?? [],
|
|
1290
|
+
clarification: {
|
|
1291
|
+
code,
|
|
1292
|
+
question,
|
|
1293
|
+
missing,
|
|
1294
|
+
options: options.options
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
options
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
function toolFailure(kind, code, summary, message, options = {}) {
|
|
1301
|
+
return withOptionalFields(
|
|
1302
|
+
{
|
|
1303
|
+
ok: false,
|
|
1304
|
+
outcome: "error",
|
|
1305
|
+
summary,
|
|
1306
|
+
entities: options.entities ?? [],
|
|
1307
|
+
error: {
|
|
1308
|
+
kind,
|
|
1309
|
+
code,
|
|
1310
|
+
message,
|
|
1311
|
+
recovery: options.recovery,
|
|
1312
|
+
details: options.details
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
options
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// src/tools/review_questlog_tool.ts
|
|
1320
|
+
function hasQuestFilters(filters) {
|
|
1321
|
+
return Boolean(
|
|
1322
|
+
filters?.questlineId != null || filters?.tagNames && filters.tagNames.length > 0 || filters?.includeDeleted
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
function utcDayBounds(dayAt) {
|
|
1326
|
+
const date = new Date(dayAt);
|
|
1327
|
+
const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
1328
|
+
return { start, end: start + 24 * 60 * 60 * 1e3 };
|
|
1329
|
+
}
|
|
1330
|
+
function validateReviewInput(input) {
|
|
1331
|
+
if (input.view === "quests.scheduled_for_day") {
|
|
1332
|
+
if (input.filters?.dayAt == null) {
|
|
1333
|
+
return toolNeedsClarification(
|
|
1334
|
+
"missing_required_choice",
|
|
1335
|
+
"Which day should be reviewed for the scheduled-for-day view?",
|
|
1336
|
+
["filters.dayAt"]
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
if (input.filters?.dayAt != null) {
|
|
1342
|
+
return toolFailure(
|
|
1343
|
+
"protocol",
|
|
1344
|
+
"invalid_input",
|
|
1345
|
+
"The `dayAt` filter is only valid for the scheduled-for-day view.",
|
|
1346
|
+
'Use `filters.dayAt` only with `view: "quests.scheduled_for_day"`.'
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
if ((input.view === "rumors" || input.view === "questlines") && hasQuestFilters(input.filters)) {
|
|
1350
|
+
return toolFailure(
|
|
1351
|
+
"protocol",
|
|
1352
|
+
"invalid_input",
|
|
1353
|
+
"Quest filters are not valid for this review view.",
|
|
1354
|
+
"Use quest filters only with quest list views."
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
if (input.view === "repeatables.due_anchors" && (hasQuestFilters(input.filters) || input.filters?.dayAt != null)) {
|
|
1358
|
+
return toolFailure(
|
|
1359
|
+
"protocol",
|
|
1360
|
+
"invalid_input",
|
|
1361
|
+
"Filters are not supported for the due repeatable anchors view.",
|
|
1362
|
+
"Call the due repeatable anchors view without quest filters."
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
function questFilters(input) {
|
|
1368
|
+
const filters = {};
|
|
1369
|
+
if (input.filters?.questlineId != null) {
|
|
1370
|
+
filters.questlineId = input.filters.questlineId;
|
|
1371
|
+
}
|
|
1372
|
+
if (input.filters?.tagNames && input.filters.tagNames.length > 0) {
|
|
1373
|
+
filters.tagNames = input.filters.tagNames;
|
|
1374
|
+
}
|
|
1375
|
+
if (input.filters?.includeDeleted) {
|
|
1376
|
+
filters.includeDeleted = input.filters.includeDeleted;
|
|
1377
|
+
}
|
|
1378
|
+
return filters;
|
|
1379
|
+
}
|
|
1380
|
+
function listQuestItems(view, db, filters, now, dayAt) {
|
|
1381
|
+
switch (view) {
|
|
1382
|
+
case "quests.open":
|
|
1383
|
+
return listOpenQuests(db, filters, now);
|
|
1384
|
+
case "quests.active":
|
|
1385
|
+
return listActiveQuests(db, filters, now);
|
|
1386
|
+
case "quests.in_progress":
|
|
1387
|
+
return listInProgressQuests(db, filters, now);
|
|
1388
|
+
case "quests.available":
|
|
1389
|
+
return listAvailableQuests(db, filters, now);
|
|
1390
|
+
case "quests.blocked":
|
|
1391
|
+
return listBlockedQuests(db, filters, now);
|
|
1392
|
+
case "quests.deferred":
|
|
1393
|
+
return listDeferredQuests(db, filters, now);
|
|
1394
|
+
case "quests.due_soon":
|
|
1395
|
+
return listDueSoonQuests(db, 7 * 24 * 60 * 60 * 1e3, filters, now);
|
|
1396
|
+
case "quests.overdue":
|
|
1397
|
+
return listOverdueQuests(db, filters, now);
|
|
1398
|
+
case "quests.scheduled_now":
|
|
1399
|
+
return listScheduledNow(db, filters, now);
|
|
1400
|
+
case "quests.missed_scheduled":
|
|
1401
|
+
return listMissedScheduledQuests(db, filters, now);
|
|
1402
|
+
case "quests.resolved":
|
|
1403
|
+
return listResolvedQuests(db, filters, now);
|
|
1404
|
+
case "quests.scheduled_for_day": {
|
|
1405
|
+
const bounds = utcDayBounds(dayAt ?? now);
|
|
1406
|
+
return listScheduledForDay(db, bounds.start, bounds.end, filters, now);
|
|
1407
|
+
}
|
|
1408
|
+
default:
|
|
1409
|
+
return [];
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
function reviewQuestlogToolHandler(db, input) {
|
|
1413
|
+
const invalid = validateReviewInput(input);
|
|
1414
|
+
if (invalid) {
|
|
1415
|
+
return invalid;
|
|
1416
|
+
}
|
|
1417
|
+
const now = input.now ?? Date.now();
|
|
1418
|
+
let items = [];
|
|
1419
|
+
switch (input.view) {
|
|
1420
|
+
case "rumors":
|
|
1421
|
+
items = listRumors(db).map(toRumorListItem);
|
|
1422
|
+
break;
|
|
1423
|
+
case "questlines":
|
|
1424
|
+
items = listQuestlines(db, now).map(toQuestlineListItem);
|
|
1425
|
+
break;
|
|
1426
|
+
case "repeatables.due_anchors":
|
|
1427
|
+
items = listDueRepeatableQuestAnchors(db, now).map(
|
|
1428
|
+
(anchor) => toRepeatableAnchorListItem(anchor, getRepeatableQuestOrThrow(db, anchor.repeatableQuestId))
|
|
1429
|
+
);
|
|
1430
|
+
break;
|
|
1431
|
+
default:
|
|
1432
|
+
items = listQuestItems(input.view, db, questFilters(input), now, input.filters?.dayAt).map(
|
|
1433
|
+
toQuestListItem
|
|
1434
|
+
);
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1437
|
+
return toolSuccess(
|
|
1438
|
+
items.length === 0 ? `No items are currently in the \`${input.view}\` view.` : summarizeCount(items.length, "item"),
|
|
1439
|
+
{
|
|
1440
|
+
view: input.view,
|
|
1441
|
+
items,
|
|
1442
|
+
count: items.length,
|
|
1443
|
+
appliedFilters: input.filters,
|
|
1444
|
+
evaluatedAt: now
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
entities: items.map((item) => ({
|
|
1448
|
+
kind: item.kind,
|
|
1449
|
+
id: item.id,
|
|
1450
|
+
title: item.title,
|
|
1451
|
+
markerId: item.markerId,
|
|
1452
|
+
state: item.state
|
|
1453
|
+
})),
|
|
1454
|
+
warnings: items.length === 0 ? [toolWarning("empty_result", "This review view is currently empty.")] : void 0
|
|
1455
|
+
}
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
var reviewQuestlogTool = defineQuestlogTool({
|
|
1459
|
+
name: reviewQuestlogToolName,
|
|
1460
|
+
description: "Review one operational questlog surface such as available quests, open rumors, blocked work, or due repeatable anchors.",
|
|
1461
|
+
whenToUse: "Use this when you want a dashboard-style list view instead of inspecting one specific item.",
|
|
1462
|
+
whenNotToUse: "Do not use this when you already know the exact item you want to inspect in depth.",
|
|
1463
|
+
sideEffects: "none",
|
|
1464
|
+
readOnly: true,
|
|
1465
|
+
supportsClarification: true,
|
|
1466
|
+
targetKinds: ["quest", "questline", "repeatable_quest", "rumor"],
|
|
1467
|
+
inputDescriptions: {
|
|
1468
|
+
view: "Which operational read surface to return, such as available quests, open rumors, or due repeatable anchors.",
|
|
1469
|
+
filters: "Optional quest-list filters. Questline, tag, and deleted filters apply only to quest views. `dayAt` applies only to the scheduled-for-day view.",
|
|
1470
|
+
now: "Optional evaluation timestamp used for time-sensitive views like availability, overdue status, and due repeatable anchors."
|
|
1471
|
+
},
|
|
1472
|
+
outputDescription: "Returns compact list items for the chosen view, the item count, the applied filters, and the evaluation timestamp. Empty views return a structured success result, and the scheduled-for-day view can ask for a missing day instead of failing.",
|
|
1473
|
+
inputSchema: objectSchema(
|
|
1474
|
+
{
|
|
1475
|
+
view: enumSchema("Which operational review surface to return.", [
|
|
1476
|
+
"questlines",
|
|
1477
|
+
"quests.active",
|
|
1478
|
+
"quests.available",
|
|
1479
|
+
"quests.blocked",
|
|
1480
|
+
"quests.deferred",
|
|
1481
|
+
"quests.due_soon",
|
|
1482
|
+
"quests.in_progress",
|
|
1483
|
+
"quests.missed_scheduled",
|
|
1484
|
+
"quests.open",
|
|
1485
|
+
"quests.overdue",
|
|
1486
|
+
"quests.resolved",
|
|
1487
|
+
"quests.scheduled_for_day",
|
|
1488
|
+
"quests.scheduled_now",
|
|
1489
|
+
"repeatables.due_anchors",
|
|
1490
|
+
"rumors"
|
|
1491
|
+
]),
|
|
1492
|
+
filters: objectSchema(
|
|
1493
|
+
{
|
|
1494
|
+
questlineId: integerSchema("Optional questline id used by quest views."),
|
|
1495
|
+
tagNames: arraySchema(
|
|
1496
|
+
stringSchema("Tag name."),
|
|
1497
|
+
"Optional tag names used by quest views."
|
|
1498
|
+
),
|
|
1499
|
+
includeDeleted: booleanSchema(
|
|
1500
|
+
"Whether deleted quests should be included in quest views."
|
|
1501
|
+
),
|
|
1502
|
+
dayAt: integerSchema("UTC timestamp within the day to review for scheduled-for-day.")
|
|
1503
|
+
},
|
|
1504
|
+
[],
|
|
1505
|
+
"Optional filters for supported review surfaces."
|
|
1506
|
+
),
|
|
1507
|
+
now: integerSchema("Optional evaluation timestamp for time-sensitive views.")
|
|
1508
|
+
},
|
|
1509
|
+
["view"],
|
|
1510
|
+
"Review one questlog surface."
|
|
1511
|
+
),
|
|
1512
|
+
handler: reviewQuestlogToolHandler
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
// src/rumors/get_rumor_or_throw.ts
|
|
1516
|
+
function getRumorOrThrow(db, rumorId) {
|
|
1517
|
+
const row = db.prepare("SELECT * FROM rumors WHERE id = ? AND deleted_at IS NULL").get(rumorId);
|
|
1518
|
+
if (!row) {
|
|
1519
|
+
throw new Error(`Rumor ${rumorId} was not found.`);
|
|
1520
|
+
}
|
|
1521
|
+
return mapRumorRow(row);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/rumors/capture_rumor.ts
|
|
1525
|
+
function captureRumor(db, input) {
|
|
1526
|
+
const now = resolveNow(input.now);
|
|
1527
|
+
const title = assertNonEmpty(input.title, "Rumor title");
|
|
1528
|
+
const created = db.prepare(
|
|
1529
|
+
`INSERT INTO rumors (title, details, created_at, updated_at)
|
|
1530
|
+
VALUES (?, ?, ?, ?)`
|
|
1531
|
+
).run(title, input.details?.trim() || null, now, now);
|
|
1532
|
+
return getRumorOrThrow(db, Number(created.lastInsertRowid));
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// src/tools/tool_errors.ts
|
|
1536
|
+
function asMessage(error) {
|
|
1537
|
+
if (error instanceof Error && error.message.trim()) {
|
|
1538
|
+
return error.message.trim();
|
|
1539
|
+
}
|
|
1540
|
+
return "Unexpected system error.";
|
|
1541
|
+
}
|
|
1542
|
+
function translateToolError(error, options = {}) {
|
|
1543
|
+
const message = asMessage(error);
|
|
1544
|
+
const entities = options.entities ?? [];
|
|
1545
|
+
const next = options.next;
|
|
1546
|
+
if (/was not found\.$/.test(message)) {
|
|
1547
|
+
return toolFailure(
|
|
1548
|
+
"domain",
|
|
1549
|
+
"not_found",
|
|
1550
|
+
options.summary ?? "The requested item was not found.",
|
|
1551
|
+
message,
|
|
1552
|
+
{
|
|
1553
|
+
entities,
|
|
1554
|
+
next: next ?? [
|
|
1555
|
+
{
|
|
1556
|
+
kind: "use_tool",
|
|
1557
|
+
message: "Search for the correct item first if the id may be wrong.",
|
|
1558
|
+
tool: searchQuestlogToolName
|
|
1559
|
+
}
|
|
1560
|
+
],
|
|
1561
|
+
recovery: "Use search when the exact id is unknown, then retry with the correct target."
|
|
1562
|
+
}
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
if (message.includes("must not be empty") || message.includes("must not be negative") || message.includes("must be greater than zero") || message.includes("must be parseable") || message.includes("must be a positive integer") || message.includes("Unsupported RRULE")) {
|
|
1566
|
+
return toolFailure(
|
|
1567
|
+
"protocol",
|
|
1568
|
+
"invalid_input",
|
|
1569
|
+
options.summary ?? "The tool input was invalid.",
|
|
1570
|
+
message,
|
|
1571
|
+
{
|
|
1572
|
+
entities,
|
|
1573
|
+
next,
|
|
1574
|
+
recovery: "Fix the invalid field values and retry."
|
|
1575
|
+
}
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
if (message.includes("cannot change after") || message.includes("cannot be started") || message.includes("cannot be resolved again") || message.includes("already been claimed") || message.includes("can only be claimed") || message.includes("cannot be updated") || message.includes("cannot be removed")) {
|
|
1579
|
+
return toolFailure(
|
|
1580
|
+
"domain",
|
|
1581
|
+
"invalid_state",
|
|
1582
|
+
options.summary ?? "The request is not valid in the current state.",
|
|
1583
|
+
message,
|
|
1584
|
+
{
|
|
1585
|
+
entities,
|
|
1586
|
+
next,
|
|
1587
|
+
recovery: "Inspect the target item and choose a lifecycle step that matches its current state."
|
|
1588
|
+
}
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
if (message === "A quest cannot unlock itself." || message === "Unlock would create a cycle.") {
|
|
1592
|
+
return toolFailure(
|
|
1593
|
+
"domain",
|
|
1594
|
+
"constraint_violation",
|
|
1595
|
+
options.summary ?? "The requested dependency change violates unlock rules.",
|
|
1596
|
+
message,
|
|
1597
|
+
{
|
|
1598
|
+
entities,
|
|
1599
|
+
next,
|
|
1600
|
+
recovery: "Remove the invalid dependency or choose a different prerequisite structure."
|
|
1601
|
+
}
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
return toolFailure(
|
|
1605
|
+
"system",
|
|
1606
|
+
"system_error",
|
|
1607
|
+
options.summary ?? "The tool failed unexpectedly.",
|
|
1608
|
+
message,
|
|
1609
|
+
{
|
|
1610
|
+
entities,
|
|
1611
|
+
next,
|
|
1612
|
+
recovery: "Inspect the target and retry with a narrower action if the problem persists."
|
|
1613
|
+
}
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/tools/tool_next.ts
|
|
1618
|
+
function inspectItemNext(target) {
|
|
1619
|
+
return {
|
|
1620
|
+
kind: "inspect_item",
|
|
1621
|
+
message: `Inspect ${target.kind} ${target.id} for full detail.`,
|
|
1622
|
+
tool: "inspect_questlog_item",
|
|
1623
|
+
suggestedInput: {
|
|
1624
|
+
target: {
|
|
1625
|
+
kind: target.kind,
|
|
1626
|
+
id: target.id
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
function reviewViewNext(view, suggestedInput = {}) {
|
|
1632
|
+
return {
|
|
1633
|
+
kind: "review_view",
|
|
1634
|
+
message: `Review the \`${view}\` operational view next.`,
|
|
1635
|
+
tool: "review_questlog",
|
|
1636
|
+
suggestedInput: {
|
|
1637
|
+
view,
|
|
1638
|
+
...suggestedInput
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
function useToolNext(tool, message, suggestedInput = {}) {
|
|
1643
|
+
return {
|
|
1644
|
+
kind: "use_tool",
|
|
1645
|
+
message,
|
|
1646
|
+
tool,
|
|
1647
|
+
suggestedInput
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// src/tools/capture_rumor_tool.ts
|
|
1652
|
+
function captureRumorToolHandler(db, input) {
|
|
1653
|
+
try {
|
|
1654
|
+
const rumor = captureRumor(db, input);
|
|
1655
|
+
const rumorRef = toRumorRef(rumor);
|
|
1656
|
+
return toolSuccess(
|
|
1657
|
+
`Captured rumor \`${rumor.title}\`.`,
|
|
1658
|
+
{ rumor },
|
|
1659
|
+
{
|
|
1660
|
+
entities: [rumorRef],
|
|
1661
|
+
next: [
|
|
1662
|
+
inspectItemNext(rumorRef),
|
|
1663
|
+
useToolNext(
|
|
1664
|
+
shapeWorkToolName,
|
|
1665
|
+
"Settle, dismiss, or reopen this rumor later when its next action is clear.",
|
|
1666
|
+
{
|
|
1667
|
+
action: "settle_rumor",
|
|
1668
|
+
rumorId: rumor.id
|
|
1669
|
+
}
|
|
1670
|
+
)
|
|
1671
|
+
]
|
|
1672
|
+
}
|
|
1673
|
+
);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
return translateToolError(error, { summary: "Could not capture the rumor." });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
var captureRumorTool = defineQuestlogTool({
|
|
1679
|
+
name: captureRumorToolName,
|
|
1680
|
+
description: "Capture uncertain incoming work as a rumor so it can be triaged later without prematurely turning it into committed execution.",
|
|
1681
|
+
whenToUse: "Use this when something matters enough to record but is not yet ready to become a quest or questline.",
|
|
1682
|
+
whenNotToUse: "Do not use this for already-committed work that should be created directly as a quest or questline.",
|
|
1683
|
+
sideEffects: "writes_state",
|
|
1684
|
+
readOnly: false,
|
|
1685
|
+
supportsClarification: false,
|
|
1686
|
+
targetKinds: ["rumor"],
|
|
1687
|
+
inputDescriptions: {
|
|
1688
|
+
title: "Short rumor title describing the incoming signal or possible work.",
|
|
1689
|
+
details: "Optional supporting context that explains why the rumor might matter.",
|
|
1690
|
+
now: "Optional creation timestamp for the rumor."
|
|
1691
|
+
},
|
|
1692
|
+
outputDescription: "Returns the captured rumor, its ref, and follow-up hints for inspecting or later shaping the rumor into real work.",
|
|
1693
|
+
inputSchema: objectSchema(
|
|
1694
|
+
{
|
|
1695
|
+
title: stringSchema("Short rumor title describing the incoming signal or possible work."),
|
|
1696
|
+
details: stringSchema(
|
|
1697
|
+
"Optional supporting context that explains why the rumor might matter."
|
|
1698
|
+
),
|
|
1699
|
+
now: integerSchema("Optional creation timestamp for the rumor.")
|
|
1700
|
+
},
|
|
1701
|
+
["title"],
|
|
1702
|
+
"Capture uncertain incoming work as a rumor."
|
|
1703
|
+
),
|
|
1704
|
+
handler: captureRumorToolHandler
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
// src/questlines/get_questline_or_throw.ts
|
|
1708
|
+
function getQuestlineOrThrow(db, questlineId) {
|
|
1709
|
+
const row = db.prepare("SELECT * FROM questlines WHERE id = ? AND deleted_at IS NULL").get(questlineId);
|
|
1710
|
+
if (!row) {
|
|
1711
|
+
throw new Error(`Questline ${questlineId} was not found.`);
|
|
1712
|
+
}
|
|
1713
|
+
return mapQuestlineRow(row);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/questlines/get_questline_detail.ts
|
|
1717
|
+
function getQuestlineDetail(db, questlineId, now = Date.now()) {
|
|
1718
|
+
const questline = getQuestlineOrThrow(db, questlineId);
|
|
1719
|
+
const stats = queryQuestlineStats(db, now, [questlineId]).get(questlineId);
|
|
1720
|
+
return {
|
|
1721
|
+
...questline,
|
|
1722
|
+
totalQuests: stats?.totalQuests ?? 0,
|
|
1723
|
+
openQuests: stats?.openQuests ?? 0,
|
|
1724
|
+
inProgressQuests: stats?.inProgressQuests ?? 0,
|
|
1725
|
+
doneQuests: stats?.doneQuests ?? 0,
|
|
1726
|
+
abandonedQuests: stats?.abandonedQuests ?? 0,
|
|
1727
|
+
availableQuests: stats?.availableQuests ?? 0,
|
|
1728
|
+
overdueQuests: stats?.overdueQuests ?? 0
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// src/rumors/get_rumor_outputs.ts
|
|
1733
|
+
function getRumorOutputs(db, rumorId) {
|
|
1734
|
+
const questOutputs = db.prepare("SELECT id FROM quests WHERE source_rumor_id = ? AND deleted_at IS NULL").all(rumorId).map((row) => ({ entityKind: "quest", entityId: Number(row.id) }));
|
|
1735
|
+
const questlineOutputs = db.prepare("SELECT id FROM questlines WHERE source_rumor_id = ? AND deleted_at IS NULL").all(rumorId).map((row) => ({ entityKind: "questline", entityId: Number(row.id) }));
|
|
1736
|
+
return [...questOutputs, ...questlineOutputs].sort(
|
|
1737
|
+
(a, b) => a.entityKind === b.entityKind ? a.entityId - b.entityId : a.entityKind === "quest" ? -1 : 1
|
|
1738
|
+
);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/rumors/get_rumor_detail.ts
|
|
1742
|
+
function getRumorDetail(db, rumorId) {
|
|
1743
|
+
const rumor = getRumorOrThrow(db, rumorId);
|
|
1744
|
+
return {
|
|
1745
|
+
...rumor,
|
|
1746
|
+
state: deriveRumorState(rumor),
|
|
1747
|
+
outputs: getRumorOutputs(db, rumorId)
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// src/tools/inspect_questlog_item_tool.ts
|
|
1752
|
+
function compactFields(detail, keys) {
|
|
1753
|
+
const compact = {};
|
|
1754
|
+
for (const key of keys) compact[key] = detail[key];
|
|
1755
|
+
return compact;
|
|
1756
|
+
}
|
|
1757
|
+
function toQuestCompactDetail(detail) {
|
|
1758
|
+
return {
|
|
1759
|
+
...questResolutionFields(detail),
|
|
1760
|
+
...compactFields(detail, [
|
|
1761
|
+
"id",
|
|
1762
|
+
"title",
|
|
1763
|
+
"objective",
|
|
1764
|
+
"state",
|
|
1765
|
+
"markerId",
|
|
1766
|
+
"available",
|
|
1767
|
+
"effectiveDueAt",
|
|
1768
|
+
"tagNames",
|
|
1769
|
+
"questlineId",
|
|
1770
|
+
"sourceRumorId",
|
|
1771
|
+
"spawnedFromRepeatableId"
|
|
1772
|
+
])
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function toQuestlineCompactDetail(detail) {
|
|
1776
|
+
return compactFields(detail, [
|
|
1777
|
+
"id",
|
|
1778
|
+
"title",
|
|
1779
|
+
"description",
|
|
1780
|
+
"dueAt",
|
|
1781
|
+
"archivedAt",
|
|
1782
|
+
"totalQuests",
|
|
1783
|
+
"availableQuests",
|
|
1784
|
+
"overdueQuests",
|
|
1785
|
+
"sourceRumorId"
|
|
1786
|
+
]);
|
|
1787
|
+
}
|
|
1788
|
+
function toRepeatableQuestCompactDetail(detail) {
|
|
1789
|
+
return compactFields(detail, [
|
|
1790
|
+
"id",
|
|
1791
|
+
"title",
|
|
1792
|
+
"objective",
|
|
1793
|
+
"rrule",
|
|
1794
|
+
"anchorAt",
|
|
1795
|
+
"questlineId",
|
|
1796
|
+
"archivedAt",
|
|
1797
|
+
"deletedAt"
|
|
1798
|
+
]);
|
|
1799
|
+
}
|
|
1800
|
+
function toRumorCompactDetail(detail) {
|
|
1801
|
+
return compactFields(detail, ["id", "title", "details", "state", "markerId", "outputs"]);
|
|
1802
|
+
}
|
|
1803
|
+
function inspectQuestlogItemToolHandler(db, input) {
|
|
1804
|
+
const detailLevel = input.detailLevel ?? "compact";
|
|
1805
|
+
try {
|
|
1806
|
+
switch (input.target.kind) {
|
|
1807
|
+
case "quest": {
|
|
1808
|
+
const detail = getQuestDetail(db, input.target.id, input.now ?? Date.now());
|
|
1809
|
+
const entities = [toQuestRef(detail)];
|
|
1810
|
+
const related = [];
|
|
1811
|
+
if (detail.questlineId != null) {
|
|
1812
|
+
related.push({ kind: "questline", id: detail.questlineId });
|
|
1813
|
+
}
|
|
1814
|
+
if (detail.sourceRumorId != null) {
|
|
1815
|
+
related.push({ kind: "rumor", id: detail.sourceRumorId });
|
|
1816
|
+
}
|
|
1817
|
+
if (detail.spawnedByQuestId != null) {
|
|
1818
|
+
related.push({ kind: "quest", id: detail.spawnedByQuestId });
|
|
1819
|
+
}
|
|
1820
|
+
if (detail.spawnedFromRepeatableId != null) {
|
|
1821
|
+
related.push({ kind: "repeatable_quest", id: detail.spawnedFromRepeatableId });
|
|
1822
|
+
}
|
|
1823
|
+
for (const reward of detail.rewards) {
|
|
1824
|
+
related.push(toRewardRef(reward));
|
|
1825
|
+
}
|
|
1826
|
+
return toolSuccess(
|
|
1827
|
+
`Loaded quest \`${detail.title}\`.`,
|
|
1828
|
+
{
|
|
1829
|
+
item: {
|
|
1830
|
+
kind: "quest",
|
|
1831
|
+
detail: detailLevel === "full" ? detail : toQuestCompactDetail(detail)
|
|
1832
|
+
},
|
|
1833
|
+
related,
|
|
1834
|
+
detailLevel
|
|
1835
|
+
},
|
|
1836
|
+
{ entities }
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
case "questline": {
|
|
1840
|
+
const detail = getQuestlineDetail(db, input.target.id, input.now ?? Date.now());
|
|
1841
|
+
const entities = [toQuestlineRef(detail)];
|
|
1842
|
+
const related = detail.sourceRumorId == null ? [] : [{ kind: "rumor", id: detail.sourceRumorId }];
|
|
1843
|
+
return toolSuccess(
|
|
1844
|
+
`Loaded questline \`${detail.title}\`.`,
|
|
1845
|
+
{
|
|
1846
|
+
item: {
|
|
1847
|
+
kind: "questline",
|
|
1848
|
+
detail: detailLevel === "full" ? detail : toQuestlineCompactDetail(detail)
|
|
1849
|
+
},
|
|
1850
|
+
related,
|
|
1851
|
+
detailLevel
|
|
1852
|
+
},
|
|
1853
|
+
{ entities }
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
case "repeatable_quest": {
|
|
1857
|
+
const detail = getRepeatableQuestOrThrow(db, input.target.id);
|
|
1858
|
+
const entities = [toRepeatableQuestRef(detail)];
|
|
1859
|
+
const related = detail.questlineId == null ? [] : [{ kind: "questline", id: detail.questlineId }];
|
|
1860
|
+
return toolSuccess(
|
|
1861
|
+
`Loaded repeatable quest \`${detail.title}\`.`,
|
|
1862
|
+
{
|
|
1863
|
+
item: {
|
|
1864
|
+
kind: "repeatable_quest",
|
|
1865
|
+
detail: detailLevel === "full" ? detail : toRepeatableQuestCompactDetail(detail)
|
|
1866
|
+
},
|
|
1867
|
+
related,
|
|
1868
|
+
detailLevel
|
|
1869
|
+
},
|
|
1870
|
+
{ entities }
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
case "rumor": {
|
|
1874
|
+
const detail = getRumorDetail(db, input.target.id);
|
|
1875
|
+
const entities = [toRumorRef(detail)];
|
|
1876
|
+
const related = detail.outputs.map((output) => ({
|
|
1877
|
+
kind: output.entityKind,
|
|
1878
|
+
id: output.entityId
|
|
1879
|
+
}));
|
|
1880
|
+
return toolSuccess(
|
|
1881
|
+
`Loaded rumor \`${detail.title}\`.`,
|
|
1882
|
+
{
|
|
1883
|
+
item: {
|
|
1884
|
+
kind: "rumor",
|
|
1885
|
+
detail: detailLevel === "full" ? detail : toRumorCompactDetail(detail)
|
|
1886
|
+
},
|
|
1887
|
+
related,
|
|
1888
|
+
detailLevel
|
|
1889
|
+
},
|
|
1890
|
+
{ entities }
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
return translateToolError(error, {
|
|
1896
|
+
summary: `Could not inspect ${input.target.kind} ${input.target.id}.`,
|
|
1897
|
+
entities: [{ kind: input.target.kind, id: input.target.id }]
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
var inspectQuestlogItemTool = defineQuestlogTool({
|
|
1902
|
+
name: inspectQuestlogItemToolName,
|
|
1903
|
+
description: "Inspect one specific questlog item in depth and return its richer structured detail and important related refs.",
|
|
1904
|
+
whenToUse: "Use this when you already know the target item id and want either compact or full detail for one rumor, quest, questline, or repeatable quest.",
|
|
1905
|
+
whenNotToUse: "Do not use this to discover items when the exact target is not known yet.",
|
|
1906
|
+
sideEffects: "none",
|
|
1907
|
+
readOnly: true,
|
|
1908
|
+
supportsClarification: false,
|
|
1909
|
+
targetKinds: ["quest", "questline", "repeatable_quest", "rumor"],
|
|
1910
|
+
inputDescriptions: {
|
|
1911
|
+
target: "The specific item to inspect, identified by its kind and numeric id.",
|
|
1912
|
+
detailLevel: "Use `compact` by default for low-token summaries, or `full` when you need the entire domain detail payload.",
|
|
1913
|
+
now: "Optional evaluation timestamp used when inspecting time-sensitive quest or questline detail."
|
|
1914
|
+
},
|
|
1915
|
+
outputDescription: "Returns one detail payload for the requested item plus compact refs for related downstream or linked items. Compact detail is the default to reduce token load, while full detail preserves the original richer domain shape.",
|
|
1916
|
+
inputSchema: objectSchema(
|
|
1917
|
+
{
|
|
1918
|
+
target: objectSchema(
|
|
1919
|
+
{
|
|
1920
|
+
kind: enumSchema("The kind of item to inspect.", [
|
|
1921
|
+
"quest",
|
|
1922
|
+
"questline",
|
|
1923
|
+
"repeatable_quest",
|
|
1924
|
+
"rumor"
|
|
1925
|
+
]),
|
|
1926
|
+
id: integerSchema("The numeric id of the item to inspect.")
|
|
1927
|
+
},
|
|
1928
|
+
["kind", "id"],
|
|
1929
|
+
"The specific item to inspect."
|
|
1930
|
+
),
|
|
1931
|
+
detailLevel: enumSchema("How much detail to return.", ["compact", "full"]),
|
|
1932
|
+
now: integerSchema("Optional evaluation timestamp for time-sensitive detail.")
|
|
1933
|
+
},
|
|
1934
|
+
["target"],
|
|
1935
|
+
"Inspect one specific questlog item."
|
|
1936
|
+
),
|
|
1937
|
+
handler: inspectQuestlogItemToolHandler
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
// src/repeatable_quests/update_repeatable_quest.ts
|
|
1941
|
+
function updateRepeatableQuest(db, repeatableQuestId, input) {
|
|
1942
|
+
return withTransaction(db, () => {
|
|
1943
|
+
const current = getRepeatableQuestOrThrow(db, repeatableQuestId);
|
|
1944
|
+
const now = resolveNow(input.now);
|
|
1945
|
+
const rrule = input.rrule ?? current.rrule;
|
|
1946
|
+
parseRRule(rrule);
|
|
1947
|
+
db.prepare(
|
|
1948
|
+
`UPDATE repeatable_quests
|
|
1949
|
+
SET questline_id = ?,
|
|
1950
|
+
title = ?,
|
|
1951
|
+
objective = ?,
|
|
1952
|
+
rrule = ?,
|
|
1953
|
+
anchor_at = ?,
|
|
1954
|
+
not_before_offset_seconds = ?,
|
|
1955
|
+
due_offset_seconds = ?,
|
|
1956
|
+
scheduled_start_offset_seconds = ?,
|
|
1957
|
+
scheduled_end_offset_seconds = ?,
|
|
1958
|
+
all_day = ?,
|
|
1959
|
+
estimate_seconds = ?,
|
|
1960
|
+
archived_at = ?,
|
|
1961
|
+
updated_at = ?
|
|
1962
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
1963
|
+
).run(
|
|
1964
|
+
input.questlineId === void 0 ? current.questlineId : input.questlineId,
|
|
1965
|
+
input.title == null ? current.title : assertNonEmpty(input.title, "Repeatable quest title"),
|
|
1966
|
+
input.objective == null ? current.objective : assertNonEmpty(input.objective, "Repeatable quest objective"),
|
|
1967
|
+
rrule,
|
|
1968
|
+
input.anchorAt ?? current.anchorAt,
|
|
1969
|
+
input.notBeforeOffsetSeconds === void 0 ? current.notBeforeOffsetSeconds : assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
|
|
1970
|
+
input.dueOffsetSeconds === void 0 ? current.dueOffsetSeconds : assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
|
|
1971
|
+
input.scheduledStartOffsetSeconds === void 0 ? current.scheduledStartOffsetSeconds : input.scheduledStartOffsetSeconds,
|
|
1972
|
+
input.scheduledEndOffsetSeconds === void 0 ? current.scheduledEndOffsetSeconds : input.scheduledEndOffsetSeconds,
|
|
1973
|
+
input.allDay === void 0 ? current.allDay ? 1 : 0 : input.allDay ? 1 : 0,
|
|
1974
|
+
input.estimateSeconds === void 0 ? current.estimateSeconds : assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
|
|
1975
|
+
input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
|
|
1976
|
+
now,
|
|
1977
|
+
repeatableQuestId
|
|
1978
|
+
);
|
|
1979
|
+
return getRepeatableQuestOrThrow(db, repeatableQuestId);
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// src/repeatable_quests/archive_repeatable_quest.ts
|
|
1984
|
+
function archiveRepeatableQuest(db, repeatableQuestId, archivedAt) {
|
|
1985
|
+
return updateRepeatableQuest(db, repeatableQuestId, { archivedAt: resolveNow(archivedAt) });
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// src/rewards/replace_active_repeatable_quest_rewards.ts
|
|
1989
|
+
function replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now) {
|
|
1990
|
+
const effectiveNow = resolveNow(now);
|
|
1991
|
+
db.prepare(
|
|
1992
|
+
"UPDATE repeatable_quest_rewards SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
|
|
1993
|
+
).run(effectiveNow, repeatableQuestId);
|
|
1994
|
+
for (const reward of rewards) {
|
|
1995
|
+
db.prepare(
|
|
1996
|
+
`INSERT INTO repeatable_quest_rewards (
|
|
1997
|
+
repeatable_quest_id,
|
|
1998
|
+
kind,
|
|
1999
|
+
name,
|
|
2000
|
+
description,
|
|
2001
|
+
quantity,
|
|
2002
|
+
created_at
|
|
2003
|
+
) VALUES (?, ?, ?, ?, ?, ?)`
|
|
2004
|
+
).run(
|
|
2005
|
+
repeatableQuestId,
|
|
2006
|
+
assertNonEmpty(reward.kind, "Reward kind"),
|
|
2007
|
+
assertNonEmpty(reward.name, "Reward name"),
|
|
2008
|
+
reward.description?.trim() || null,
|
|
2009
|
+
assertNonNegativeNumber(reward.quantity, "Reward quantity") ?? null,
|
|
2010
|
+
effectiveNow
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// src/tags/replace_active_repeatable_quest_tags.ts
|
|
2016
|
+
function getOrCreateTagId2(db, name, now) {
|
|
2017
|
+
const normalizedName = normalizeTagName(name);
|
|
2018
|
+
if (!normalizedName) {
|
|
2019
|
+
throw new Error("Tag name must not be empty.");
|
|
2020
|
+
}
|
|
2021
|
+
const existing = db.prepare("SELECT id FROM tags WHERE normalized_name = ? AND deleted_at IS NULL").get(normalizedName);
|
|
2022
|
+
if (existing) {
|
|
2023
|
+
return Number(existing.id);
|
|
2024
|
+
}
|
|
2025
|
+
const created = db.prepare(
|
|
2026
|
+
`INSERT INTO tags (name, normalized_name, created_at)
|
|
2027
|
+
VALUES (?, ?, ?)`
|
|
2028
|
+
).run(name.trim(), normalizedName, now);
|
|
2029
|
+
return Number(created.lastInsertRowid);
|
|
2030
|
+
}
|
|
2031
|
+
function replaceActiveRepeatableQuestTags(db, repeatableQuestId, tagNames, now) {
|
|
2032
|
+
const effectiveNow = resolveNow(now);
|
|
2033
|
+
db.prepare(
|
|
2034
|
+
"UPDATE repeatable_quest_tags SET deleted_at = ? WHERE repeatable_quest_id = ? AND deleted_at IS NULL"
|
|
2035
|
+
).run(effectiveNow, repeatableQuestId);
|
|
2036
|
+
const uniqueNames = [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
|
|
2037
|
+
for (const name of uniqueNames) {
|
|
2038
|
+
const tagId = getOrCreateTagId2(db, name, effectiveNow);
|
|
2039
|
+
db.prepare(
|
|
2040
|
+
`INSERT INTO repeatable_quest_tags (repeatable_quest_id, tag_id, created_at)
|
|
2041
|
+
VALUES (?, ?, ?)`
|
|
2042
|
+
).run(repeatableQuestId, tagId, effectiveNow);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// src/repeatable_quests/create_repeatable_quest.ts
|
|
2047
|
+
function createRepeatableQuest(db, input) {
|
|
2048
|
+
return withTransaction(db, () => {
|
|
2049
|
+
const now = resolveNow(input.now);
|
|
2050
|
+
const title = assertNonEmpty(input.title, "Repeatable quest title");
|
|
2051
|
+
const objective = assertNonEmpty(input.objective, "Repeatable quest objective");
|
|
2052
|
+
parseRRule(input.rrule);
|
|
2053
|
+
if (input.questlineId != null) {
|
|
2054
|
+
assertActiveRowExists(db, "questlines", input.questlineId, "Questline");
|
|
2055
|
+
}
|
|
2056
|
+
const created = db.prepare(
|
|
2057
|
+
`INSERT INTO repeatable_quests (
|
|
2058
|
+
questline_id,
|
|
2059
|
+
title,
|
|
2060
|
+
objective,
|
|
2061
|
+
rrule,
|
|
2062
|
+
anchor_at,
|
|
2063
|
+
not_before_offset_seconds,
|
|
2064
|
+
due_offset_seconds,
|
|
2065
|
+
scheduled_start_offset_seconds,
|
|
2066
|
+
scheduled_end_offset_seconds,
|
|
2067
|
+
all_day,
|
|
2068
|
+
estimate_seconds,
|
|
2069
|
+
created_at,
|
|
2070
|
+
updated_at
|
|
2071
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2072
|
+
).run(
|
|
2073
|
+
input.questlineId ?? null,
|
|
2074
|
+
title,
|
|
2075
|
+
objective,
|
|
2076
|
+
input.rrule,
|
|
2077
|
+
input.anchorAt,
|
|
2078
|
+
assertNonNegativeSeconds(input.notBeforeOffsetSeconds, "Repeatable not-before offset") ?? null,
|
|
2079
|
+
assertNonNegativeSeconds(input.dueOffsetSeconds, "Repeatable due offset") ?? null,
|
|
2080
|
+
input.scheduledStartOffsetSeconds ?? null,
|
|
2081
|
+
input.scheduledEndOffsetSeconds ?? null,
|
|
2082
|
+
input.allDay ? 1 : 0,
|
|
2083
|
+
assertNonNegativeSeconds(input.estimateSeconds, "Repeatable estimate") ?? null,
|
|
2084
|
+
now,
|
|
2085
|
+
now
|
|
2086
|
+
);
|
|
2087
|
+
const repeatableQuestId = Number(created.lastInsertRowid);
|
|
2088
|
+
if (input.tags && input.tags.length > 0) {
|
|
2089
|
+
replaceActiveRepeatableQuestTags(db, repeatableQuestId, input.tags, now);
|
|
2090
|
+
}
|
|
2091
|
+
if (input.rewards && input.rewards.length > 0) {
|
|
2092
|
+
replaceActiveRepeatableQuestRewards(db, repeatableQuestId, input.rewards, now);
|
|
2093
|
+
}
|
|
2094
|
+
return getRepeatableQuestOrThrow(db, repeatableQuestId);
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// src/repeatable_quests/soft_delete_repeatable_quest.ts
|
|
2099
|
+
function softDeleteRepeatableQuest(db, repeatableQuestId, deletedAt) {
|
|
2100
|
+
const now = resolveNow(deletedAt);
|
|
2101
|
+
getRepeatableQuestOrThrow(db, repeatableQuestId);
|
|
2102
|
+
db.prepare(
|
|
2103
|
+
`UPDATE repeatable_quests
|
|
2104
|
+
SET deleted_at = ?, updated_at = ?
|
|
2105
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
2106
|
+
).run(now, now, repeatableQuestId);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// src/rewards/copy_repeatable_quest_rewards_to_quest.ts
|
|
2110
|
+
function copyRepeatableQuestRewardsToQuest(db, repeatableQuestId, questId, now) {
|
|
2111
|
+
const effectiveNow = resolveNow(now);
|
|
2112
|
+
const rows = db.prepare(
|
|
2113
|
+
`SELECT kind, name, description, quantity
|
|
2114
|
+
FROM repeatable_quest_rewards
|
|
2115
|
+
WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
|
|
2116
|
+
).all(repeatableQuestId);
|
|
2117
|
+
for (const row of rows) {
|
|
2118
|
+
db.prepare(
|
|
2119
|
+
`INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
|
|
2120
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2121
|
+
).run(
|
|
2122
|
+
questId,
|
|
2123
|
+
String(row.kind),
|
|
2124
|
+
String(row.name),
|
|
2125
|
+
row.description == null ? null : String(row.description),
|
|
2126
|
+
row.quantity == null ? null : Number(row.quantity),
|
|
2127
|
+
effectiveNow
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/tags/copy_repeatable_quest_tags_to_quest.ts
|
|
2133
|
+
function copyRepeatableQuestTagsToQuest(db, repeatableQuestId, questId, now) {
|
|
2134
|
+
const effectiveNow = resolveNow(now);
|
|
2135
|
+
const rows = db.prepare(
|
|
2136
|
+
`SELECT tag_id
|
|
2137
|
+
FROM repeatable_quest_tags
|
|
2138
|
+
WHERE repeatable_quest_id = ? AND deleted_at IS NULL`
|
|
2139
|
+
).all(repeatableQuestId);
|
|
2140
|
+
for (const row of rows) {
|
|
2141
|
+
db.prepare(
|
|
2142
|
+
`INSERT INTO quest_tags (quest_id, tag_id, created_at)
|
|
2143
|
+
VALUES (?, ?, ?)`
|
|
2144
|
+
).run(questId, Number(row.tag_id), effectiveNow);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// src/repeatable_quests/spawn_due_repeatable_quests.ts
|
|
2149
|
+
function spawnDueRepeatableQuests(db, now) {
|
|
2150
|
+
return withTransaction(db, () => {
|
|
2151
|
+
const anchors = listDueRepeatableQuestAnchors(db, now);
|
|
2152
|
+
const quests = [];
|
|
2153
|
+
for (const anchor of anchors) {
|
|
2154
|
+
const repeatable = getRepeatableQuestOrThrow(db, anchor.repeatableQuestId);
|
|
2155
|
+
const created = db.prepare(
|
|
2156
|
+
`INSERT INTO quests (
|
|
2157
|
+
questline_id,
|
|
2158
|
+
source_rumor_id,
|
|
2159
|
+
spawned_by_quest_id,
|
|
2160
|
+
spawned_from_repeatable_id,
|
|
2161
|
+
spawned_for_at,
|
|
2162
|
+
title,
|
|
2163
|
+
objective,
|
|
2164
|
+
created_at,
|
|
2165
|
+
updated_at,
|
|
2166
|
+
effort_seconds,
|
|
2167
|
+
estimate_seconds,
|
|
2168
|
+
not_before_at,
|
|
2169
|
+
due_at,
|
|
2170
|
+
scheduled_start_at,
|
|
2171
|
+
scheduled_end_at,
|
|
2172
|
+
all_day
|
|
2173
|
+
) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
|
|
2174
|
+
).run(
|
|
2175
|
+
repeatable.questlineId ?? null,
|
|
2176
|
+
repeatable.id,
|
|
2177
|
+
anchor.anchorAt,
|
|
2178
|
+
repeatable.title,
|
|
2179
|
+
repeatable.objective,
|
|
2180
|
+
now,
|
|
2181
|
+
now,
|
|
2182
|
+
repeatable.estimateSeconds ?? null,
|
|
2183
|
+
repeatable.notBeforeOffsetSeconds == null ? null : anchor.anchorAt + repeatable.notBeforeOffsetSeconds * 1e3,
|
|
2184
|
+
repeatable.dueOffsetSeconds == null ? null : anchor.anchorAt + repeatable.dueOffsetSeconds * 1e3,
|
|
2185
|
+
repeatable.scheduledStartOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledStartOffsetSeconds * 1e3,
|
|
2186
|
+
repeatable.scheduledEndOffsetSeconds == null ? null : anchor.anchorAt + repeatable.scheduledEndOffsetSeconds * 1e3,
|
|
2187
|
+
repeatable.allDay ? 1 : 0
|
|
2188
|
+
);
|
|
2189
|
+
const questId = Number(created.lastInsertRowid);
|
|
2190
|
+
copyRepeatableQuestTagsToQuest(db, repeatable.id, questId, now);
|
|
2191
|
+
copyRepeatableQuestRewardsToQuest(db, repeatable.id, questId, now);
|
|
2192
|
+
quests.push(getQuestOrThrow(db, questId));
|
|
2193
|
+
}
|
|
2194
|
+
return quests;
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// src/tools/manage_repeatable_tool.ts
|
|
2199
|
+
function manageRepeatableToolHandler(db, input) {
|
|
2200
|
+
try {
|
|
2201
|
+
switch (input.action) {
|
|
2202
|
+
case "preview_due": {
|
|
2203
|
+
const now = input.now ?? Date.now();
|
|
2204
|
+
const items = listDueRepeatableQuestAnchors(db, now).map(
|
|
2205
|
+
(anchor) => toRepeatableAnchorListItem(
|
|
2206
|
+
anchor,
|
|
2207
|
+
getRepeatableQuestOrThrow(db, anchor.repeatableQuestId)
|
|
2208
|
+
)
|
|
2209
|
+
);
|
|
2210
|
+
return toolSuccess(
|
|
2211
|
+
items.length === 0 ? "No repeatable quest anchors are currently due." : `Found ${items.length} due repeatable anchors.`,
|
|
2212
|
+
{
|
|
2213
|
+
action: input.action,
|
|
2214
|
+
created: [],
|
|
2215
|
+
updated: [],
|
|
2216
|
+
items
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
entities: items.map((item) => ({
|
|
2220
|
+
kind: item.kind,
|
|
2221
|
+
id: item.id,
|
|
2222
|
+
title: item.title,
|
|
2223
|
+
markerId: item.markerId,
|
|
2224
|
+
state: item.state
|
|
2225
|
+
})),
|
|
2226
|
+
warnings: items.length === 0 ? [toolWarning("empty_result", "No repeatable anchors are due right now.")] : void 0
|
|
2227
|
+
}
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
case "spawn_due": {
|
|
2231
|
+
const spawned = spawnDueRepeatableQuests(db, input.now);
|
|
2232
|
+
if (spawned.length === 0) {
|
|
2233
|
+
return toolNoOp(
|
|
2234
|
+
"No repeatable quest anchors were due to spawn.",
|
|
2235
|
+
{
|
|
2236
|
+
action: input.action,
|
|
2237
|
+
created: [],
|
|
2238
|
+
spawnedQuests: []
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
warnings: [toolWarning("empty_result", "There was nothing due to materialize.")]
|
|
2242
|
+
}
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
const spawnedQuests = spawned.map((quest) => getQuestDetail(db, quest.id));
|
|
2246
|
+
return toolSuccess(
|
|
2247
|
+
`Spawned ${spawnedQuests.length} repeatable quest occurrences.`,
|
|
2248
|
+
{
|
|
2249
|
+
action: input.action,
|
|
2250
|
+
created: spawnedQuests.map(toQuestRef),
|
|
2251
|
+
spawnedQuests
|
|
2252
|
+
},
|
|
2253
|
+
{
|
|
2254
|
+
entities: spawnedQuests.map(toQuestRef)
|
|
2255
|
+
}
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
case "create": {
|
|
2259
|
+
const repeatableQuest = createRepeatableQuest(db, input.repeatableQuest);
|
|
2260
|
+
return toolSuccess(
|
|
2261
|
+
`Created repeatable quest \`${repeatableQuest.title}\`.`,
|
|
2262
|
+
{
|
|
2263
|
+
action: input.action,
|
|
2264
|
+
primary: toRepeatableQuestRef(repeatableQuest),
|
|
2265
|
+
created: [toRepeatableQuestRef(repeatableQuest)],
|
|
2266
|
+
repeatableQuest
|
|
2267
|
+
},
|
|
2268
|
+
{
|
|
2269
|
+
entities: [toRepeatableQuestRef(repeatableQuest)]
|
|
2270
|
+
}
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
case "update": {
|
|
2274
|
+
const repeatableQuest = updateRepeatableQuest(db, input.repeatableQuestId, input);
|
|
2275
|
+
return toolSuccess(
|
|
2276
|
+
`Updated repeatable quest \`${repeatableQuest.title}\`.`,
|
|
2277
|
+
{
|
|
2278
|
+
action: input.action,
|
|
2279
|
+
primary: toRepeatableQuestRef(repeatableQuest),
|
|
2280
|
+
updated: [toRepeatableQuestRef(repeatableQuest)],
|
|
2281
|
+
repeatableQuest
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
entities: [toRepeatableQuestRef(repeatableQuest)]
|
|
2285
|
+
}
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
case "archive": {
|
|
2289
|
+
const current = getRepeatableQuestOrThrow(db, input.repeatableQuestId);
|
|
2290
|
+
if (current.archivedAt != null) {
|
|
2291
|
+
return toolNoOp(
|
|
2292
|
+
`Repeatable quest \`${current.title}\` is already archived.`,
|
|
2293
|
+
{
|
|
2294
|
+
action: input.action,
|
|
2295
|
+
primary: toRepeatableQuestRef(current),
|
|
2296
|
+
repeatableQuest: current
|
|
2297
|
+
},
|
|
2298
|
+
{
|
|
2299
|
+
entities: [toRepeatableQuestRef(current)]
|
|
2300
|
+
}
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
const repeatableQuest = archiveRepeatableQuest(
|
|
2304
|
+
db,
|
|
2305
|
+
input.repeatableQuestId,
|
|
2306
|
+
input.archivedAt
|
|
2307
|
+
);
|
|
2308
|
+
return toolSuccess(
|
|
2309
|
+
`Archived repeatable quest \`${repeatableQuest.title}\`.`,
|
|
2310
|
+
{
|
|
2311
|
+
action: input.action,
|
|
2312
|
+
primary: toRepeatableQuestRef(repeatableQuest),
|
|
2313
|
+
updated: [toRepeatableQuestRef(repeatableQuest)],
|
|
2314
|
+
repeatableQuest
|
|
2315
|
+
},
|
|
2316
|
+
{
|
|
2317
|
+
entities: [toRepeatableQuestRef(repeatableQuest)]
|
|
2318
|
+
}
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
return translateToolError(error, {
|
|
2324
|
+
summary: `Could not complete manage-repeatable action \`${input.action}\`.`
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
var manageRepeatableTool = defineQuestlogTool({
|
|
2329
|
+
name: manageRepeatableToolName,
|
|
2330
|
+
description: "Manage recurring work definitions by creating, updating, previewing, spawning, or archiving repeatable quests.",
|
|
2331
|
+
whenToUse: "Use this when the work is a repeatable template or when due recurring anchors need to be previewed or materialized.",
|
|
2332
|
+
whenNotToUse: "Do not use this for one-off concrete quest execution, for inspecting an existing concrete quest, or for hiding a repeatable quest. Use `retire_work` for hiding.",
|
|
2333
|
+
sideEffects: "writes_state",
|
|
2334
|
+
readOnly: false,
|
|
2335
|
+
supportsClarification: false,
|
|
2336
|
+
targetKinds: ["repeatable_quest", "quest"],
|
|
2337
|
+
inputDescriptions: {
|
|
2338
|
+
action: "Which repeatable quest lifecycle action to perform.",
|
|
2339
|
+
repeatableQuest: "The repeatable quest creation input used for the create action.",
|
|
2340
|
+
repeatableQuestId: "The repeatable quest definition being updated or archived.",
|
|
2341
|
+
rrule: "Optional new RRULE that changes future materialization behavior.",
|
|
2342
|
+
anchorAt: "Optional new recurrence anchor timestamp for future materialization.",
|
|
2343
|
+
notBeforeOffsetSeconds: "Optional not-before offset in seconds applied to future spawned quests.",
|
|
2344
|
+
dueOffsetSeconds: "Optional due offset in seconds applied to future spawned quests.",
|
|
2345
|
+
scheduledStartOffsetSeconds: "Optional scheduled-start offset in seconds applied to future spawned quests.",
|
|
2346
|
+
scheduledEndOffsetSeconds: "Optional scheduled-end offset in seconds applied to future spawned quests.",
|
|
2347
|
+
allDay: "Whether future spawned schedule windows should be treated as all-day.",
|
|
2348
|
+
estimateSeconds: "Optional active-effort estimate in seconds for future spawned quests.",
|
|
2349
|
+
title: "Optional new repeatable quest title.",
|
|
2350
|
+
objective: "Optional new repeatable quest objective.",
|
|
2351
|
+
questlineId: "Optional questline that should own the repeatable quest.",
|
|
2352
|
+
archivedAt: "Optional archive timestamp for the archive action.",
|
|
2353
|
+
now: "Evaluation or spawn timestamp for previewing or materializing due anchors."
|
|
2354
|
+
},
|
|
2355
|
+
outputDescription: "Returns the changed repeatable quest, due anchor preview items, or spawned quest details for the selected recurrence action. Empty previews and empty spawns return structured success or no-op results, and repeatable hiding is now intentionally routed through `retire_work`.",
|
|
2356
|
+
inputSchema: objectSchema(
|
|
2357
|
+
{
|
|
2358
|
+
action: enumSchema("Action to perform.", [
|
|
2359
|
+
"archive",
|
|
2360
|
+
"create",
|
|
2361
|
+
"preview_due",
|
|
2362
|
+
"spawn_due",
|
|
2363
|
+
"update"
|
|
2364
|
+
]),
|
|
2365
|
+
repeatableQuestId: integerSchema("Repeatable quest to archive or update."),
|
|
2366
|
+
repeatableQuest: objectSchema(
|
|
2367
|
+
{
|
|
2368
|
+
title: stringSchema("Title for the new repeatable quest."),
|
|
2369
|
+
objective: stringSchema("Objective for the new repeatable quest."),
|
|
2370
|
+
rrule: stringSchema("RRULE describing recurrence."),
|
|
2371
|
+
anchorAt: integerSchema("Recurrence anchor timestamp.")
|
|
2372
|
+
},
|
|
2373
|
+
["title", "objective", "rrule", "anchorAt"]
|
|
2374
|
+
),
|
|
2375
|
+
title: stringSchema("Optional new title for the update action."),
|
|
2376
|
+
objective: stringSchema("Optional new objective for the update action."),
|
|
2377
|
+
questlineId: integerSchema("Optional new questline id."),
|
|
2378
|
+
rrule: stringSchema("Optional new RRULE."),
|
|
2379
|
+
anchorAt: integerSchema("Optional new recurrence anchor timestamp."),
|
|
2380
|
+
notBeforeOffsetSeconds: integerSchema("Optional new not-before offset in seconds."),
|
|
2381
|
+
dueOffsetSeconds: integerSchema("Optional new due offset in seconds."),
|
|
2382
|
+
scheduledStartOffsetSeconds: integerSchema("Optional new scheduled-start offset in seconds."),
|
|
2383
|
+
scheduledEndOffsetSeconds: integerSchema("Optional new scheduled-end offset in seconds."),
|
|
2384
|
+
allDay: booleanSchema("Whether future schedules are all-day."),
|
|
2385
|
+
estimateSeconds: integerSchema("Optional new effort estimate."),
|
|
2386
|
+
archivedAt: integerSchema("Optional archive timestamp."),
|
|
2387
|
+
now: integerSchema("Evaluation or update timestamp.")
|
|
2388
|
+
},
|
|
2389
|
+
["action"],
|
|
2390
|
+
"Manage repeatable quest definitions and due materialization."
|
|
2391
|
+
),
|
|
2392
|
+
handler: manageRepeatableToolHandler
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
// src/questlines/update_questline.ts
|
|
2396
|
+
function updateQuestline(db, questlineId, input) {
|
|
2397
|
+
return withTransaction(db, () => {
|
|
2398
|
+
const current = getQuestlineOrThrow(db, questlineId);
|
|
2399
|
+
const now = resolveNow(input.now);
|
|
2400
|
+
db.prepare(
|
|
2401
|
+
`UPDATE questlines
|
|
2402
|
+
SET title = ?,
|
|
2403
|
+
description = ?,
|
|
2404
|
+
starts_at = ?,
|
|
2405
|
+
due_at = ?,
|
|
2406
|
+
archived_at = ?,
|
|
2407
|
+
updated_at = ?
|
|
2408
|
+
WHERE id = ?`
|
|
2409
|
+
).run(
|
|
2410
|
+
input.title == null ? current.title : assertNonEmpty(input.title, "Questline title"),
|
|
2411
|
+
input.description === void 0 ? current.description : input.description?.trim() || null,
|
|
2412
|
+
input.startsAt === void 0 ? current.startsAt : input.startsAt,
|
|
2413
|
+
input.dueAt === void 0 ? current.dueAt : input.dueAt,
|
|
2414
|
+
input.archivedAt === void 0 ? current.archivedAt : input.archivedAt,
|
|
2415
|
+
now,
|
|
2416
|
+
questlineId
|
|
2417
|
+
);
|
|
2418
|
+
return getQuestlineOrThrow(db, questlineId);
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// src/questlines/archive_questline.ts
|
|
2423
|
+
function archiveQuestline(db, questlineId, archivedAt) {
|
|
2424
|
+
return updateQuestline(db, questlineId, { archivedAt: resolveNow(archivedAt) });
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// src/questlines/create_questline.ts
|
|
2428
|
+
function createQuestline(db, input) {
|
|
2429
|
+
const now = resolveNow(input.now);
|
|
2430
|
+
const title = assertNonEmpty(input.title, "Questline title");
|
|
2431
|
+
if (input.sourceRumorId != null) {
|
|
2432
|
+
assertActiveRowExists(db, "rumors", input.sourceRumorId, "Rumor");
|
|
2433
|
+
}
|
|
2434
|
+
const created = db.prepare(
|
|
2435
|
+
`INSERT INTO questlines (
|
|
2436
|
+
source_rumor_id,
|
|
2437
|
+
title,
|
|
2438
|
+
description,
|
|
2439
|
+
starts_at,
|
|
2440
|
+
due_at,
|
|
2441
|
+
created_at,
|
|
2442
|
+
updated_at
|
|
2443
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
2444
|
+
).run(
|
|
2445
|
+
input.sourceRumorId ?? null,
|
|
2446
|
+
title,
|
|
2447
|
+
input.description?.trim() || null,
|
|
2448
|
+
input.startsAt ?? null,
|
|
2449
|
+
input.dueAt ?? null,
|
|
2450
|
+
now,
|
|
2451
|
+
now
|
|
2452
|
+
);
|
|
2453
|
+
return getQuestlineOrThrow(db, Number(created.lastInsertRowid));
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// src/questlines/detach_quest_from_questline.ts
|
|
2457
|
+
function detachQuestFromQuestline(db, questId, now) {
|
|
2458
|
+
assertActiveRowExists(db, "quests", questId, "Quest");
|
|
2459
|
+
db.prepare(
|
|
2460
|
+
"UPDATE quests SET questline_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
|
|
2461
|
+
).run(resolveNow(now), questId);
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// src/questlines/move_quest_to_questline.ts
|
|
2465
|
+
function moveQuestToQuestline(db, questId, questlineId, now) {
|
|
2466
|
+
assertActiveRowExists(db, "quests", questId, "Quest");
|
|
2467
|
+
assertActiveRowExists(db, "questlines", questlineId, "Questline");
|
|
2468
|
+
db.prepare(
|
|
2469
|
+
"UPDATE quests SET questline_id = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
|
|
2470
|
+
).run(questlineId, resolveNow(now), questId);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
// src/questlines/soft_delete_questline.ts
|
|
2474
|
+
function softDeleteQuestline(db, questlineId, deletedAt) {
|
|
2475
|
+
const now = resolveNow(deletedAt);
|
|
2476
|
+
assertActiveRowExists(db, "questlines", questlineId, "Questline");
|
|
2477
|
+
db.prepare(
|
|
2478
|
+
"UPDATE questlines SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
|
|
2479
|
+
).run(now, now, questlineId);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// src/unlocks/remove_unlock.ts
|
|
2483
|
+
function removeUnlock(db, fromQuestId, toQuestId, now) {
|
|
2484
|
+
db.prepare(
|
|
2485
|
+
`UPDATE quest_unlocks
|
|
2486
|
+
SET deleted_at = ?
|
|
2487
|
+
WHERE from_quest_id = ? AND to_quest_id = ? AND deleted_at IS NULL`
|
|
2488
|
+
).run(resolveNow(now), fromQuestId, toQuestId);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// src/unlocks/replace_unlocks.ts
|
|
2492
|
+
function replaceUnlocks(db, toQuestId, fromQuestIds, now) {
|
|
2493
|
+
withTransaction(db, () => {
|
|
2494
|
+
db.prepare(
|
|
2495
|
+
`UPDATE quest_unlocks
|
|
2496
|
+
SET deleted_at = ?
|
|
2497
|
+
WHERE to_quest_id = ? AND deleted_at IS NULL`
|
|
2498
|
+
).run(resolveNow(now), toQuestId);
|
|
2499
|
+
for (const fromQuestId of [...new Set(fromQuestIds)]) {
|
|
2500
|
+
addUnlock(db, fromQuestId, toQuestId, now);
|
|
2501
|
+
}
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/tools/organize_work_tool.ts
|
|
2506
|
+
function sameQuestlineFields(current, input) {
|
|
2507
|
+
return (input.title === void 0 || input.title === current.title) && (input.description === void 0 || input.description === current.description) && (input.startsAt === void 0 || input.startsAt === current.startsAt) && (input.dueAt === void 0 || input.dueAt === current.dueAt) && (input.archivedAt === void 0 || input.archivedAt === current.archivedAt);
|
|
2508
|
+
}
|
|
2509
|
+
function organizeWorkToolHandler(db, input) {
|
|
2510
|
+
try {
|
|
2511
|
+
switch (input.action) {
|
|
2512
|
+
case "set_questline_fields": {
|
|
2513
|
+
const current = getQuestlineDetail(db, input.questlineId);
|
|
2514
|
+
if (sameQuestlineFields(current, input)) {
|
|
2515
|
+
return toolNoOp(
|
|
2516
|
+
`Questline \`${current.title}\` already has those fields.`,
|
|
2517
|
+
{
|
|
2518
|
+
action: input.action,
|
|
2519
|
+
questline: current
|
|
2520
|
+
},
|
|
2521
|
+
{
|
|
2522
|
+
entities: [toQuestlineRef(current)]
|
|
2523
|
+
}
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
updateQuestline(db, input.questlineId, input);
|
|
2527
|
+
const detail = getQuestlineDetail(db, input.questlineId);
|
|
2528
|
+
return toolSuccess(
|
|
2529
|
+
`Updated questline \`${detail.title}\`.`,
|
|
2530
|
+
{
|
|
2531
|
+
action: input.action,
|
|
2532
|
+
questline: detail
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
entities: [toQuestlineRef(detail)]
|
|
2536
|
+
}
|
|
2537
|
+
);
|
|
2538
|
+
}
|
|
2539
|
+
case "archive_questline": {
|
|
2540
|
+
const current = getQuestlineDetail(db, input.questlineId);
|
|
2541
|
+
if (current.archivedAt != null) {
|
|
2542
|
+
return toolNoOp(
|
|
2543
|
+
`Questline \`${current.title}\` is already archived.`,
|
|
2544
|
+
{
|
|
2545
|
+
action: input.action,
|
|
2546
|
+
questline: current
|
|
2547
|
+
},
|
|
2548
|
+
{
|
|
2549
|
+
entities: [toQuestlineRef(current)]
|
|
2550
|
+
}
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
archiveQuestline(db, input.questlineId, input.archivedAt);
|
|
2554
|
+
const detail = getQuestlineDetail(db, input.questlineId);
|
|
2555
|
+
return toolSuccess(
|
|
2556
|
+
`Archived questline \`${detail.title}\`.`,
|
|
2557
|
+
{
|
|
2558
|
+
action: input.action,
|
|
2559
|
+
questline: detail
|
|
2560
|
+
},
|
|
2561
|
+
{
|
|
2562
|
+
entities: [toQuestlineRef(detail)]
|
|
2563
|
+
}
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
case "add_unlock": {
|
|
2567
|
+
const current = getQuestDetail(db, input.toQuestId);
|
|
2568
|
+
if (current.unlockedByQuestIds.includes(input.fromQuestId)) {
|
|
2569
|
+
return toolNoOp(
|
|
2570
|
+
`Quest \`${current.title}\` is already blocked by that prerequisite.`,
|
|
2571
|
+
{
|
|
2572
|
+
action: input.action,
|
|
2573
|
+
quest: current
|
|
2574
|
+
},
|
|
2575
|
+
{
|
|
2576
|
+
entities: [toQuestRef(current)]
|
|
2577
|
+
}
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
addUnlock(db, input.fromQuestId, input.toQuestId, input.now);
|
|
2581
|
+
const detail = getQuestDetail(db, input.toQuestId);
|
|
2582
|
+
return toolSuccess(
|
|
2583
|
+
`Added an unlock prerequisite to quest \`${detail.title}\`.`,
|
|
2584
|
+
{
|
|
2585
|
+
action: input.action,
|
|
2586
|
+
quest: detail
|
|
2587
|
+
},
|
|
2588
|
+
{
|
|
2589
|
+
entities: [toQuestRef(detail), { kind: "quest", id: input.fromQuestId }]
|
|
2590
|
+
}
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
case "remove_unlock": {
|
|
2594
|
+
const current = getQuestDetail(db, input.toQuestId);
|
|
2595
|
+
if (!current.unlockedByQuestIds.includes(input.fromQuestId)) {
|
|
2596
|
+
return toolNoOp(
|
|
2597
|
+
`Quest \`${current.title}\` is not blocked by that prerequisite.`,
|
|
2598
|
+
{
|
|
2599
|
+
action: input.action,
|
|
2600
|
+
quest: current
|
|
2601
|
+
},
|
|
2602
|
+
{
|
|
2603
|
+
entities: [toQuestRef(current)]
|
|
2604
|
+
}
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2607
|
+
removeUnlock(db, input.fromQuestId, input.toQuestId, input.now);
|
|
2608
|
+
const detail = getQuestDetail(db, input.toQuestId);
|
|
2609
|
+
return toolSuccess(
|
|
2610
|
+
`Removed an unlock prerequisite from quest \`${detail.title}\`.`,
|
|
2611
|
+
{
|
|
2612
|
+
action: input.action,
|
|
2613
|
+
quest: detail
|
|
2614
|
+
},
|
|
2615
|
+
{
|
|
2616
|
+
entities: [toQuestRef(detail), { kind: "quest", id: input.fromQuestId }]
|
|
2617
|
+
}
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
case "replace_unlocks": {
|
|
2621
|
+
const current = getQuestDetail(db, input.toQuestId);
|
|
2622
|
+
const currentIds = [...current.unlockedByQuestIds].sort((a, b) => a - b);
|
|
2623
|
+
const nextIds = [...new Set(input.fromQuestIds)].sort((a, b) => a - b);
|
|
2624
|
+
if (currentIds.length === nextIds.length && currentIds.every((value, index) => value === nextIds[index])) {
|
|
2625
|
+
return toolNoOp(
|
|
2626
|
+
`Quest \`${current.title}\` already has that blocker set.`,
|
|
2627
|
+
{
|
|
2628
|
+
action: input.action,
|
|
2629
|
+
quest: current
|
|
2630
|
+
},
|
|
2631
|
+
{
|
|
2632
|
+
entities: [toQuestRef(current)]
|
|
2633
|
+
}
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
replaceUnlocks(db, input.toQuestId, input.fromQuestIds, input.now);
|
|
2637
|
+
const detail = getQuestDetail(db, input.toQuestId);
|
|
2638
|
+
return toolSuccess(
|
|
2639
|
+
`Replaced the blocker set for quest \`${detail.title}\`.`,
|
|
2640
|
+
{
|
|
2641
|
+
action: input.action,
|
|
2642
|
+
quest: detail
|
|
2643
|
+
},
|
|
2644
|
+
{
|
|
2645
|
+
entities: [
|
|
2646
|
+
toQuestRef(detail),
|
|
2647
|
+
...nextIds.map((id) => ({ kind: "quest", id }))
|
|
2648
|
+
]
|
|
2649
|
+
}
|
|
2650
|
+
);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
return translateToolError(error, {
|
|
2655
|
+
summary: `Could not complete organize-work action \`${input.action}\`.`
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
var organizeWorkTool = defineQuestlogTool({
|
|
2660
|
+
name: organizeWorkToolName,
|
|
2661
|
+
description: "Organize grouping and dependency structure by updating questlines or changing hard unlock prerequisites between concrete quests.",
|
|
2662
|
+
whenToUse: "Use this when work structure or gating logic should change without changing quest execution state.",
|
|
2663
|
+
whenNotToUse: "Do not use this for quest execution, planning-only edits, intake capture, or tag-like classification.",
|
|
2664
|
+
sideEffects: "writes_state",
|
|
2665
|
+
readOnly: false,
|
|
2666
|
+
supportsClarification: false,
|
|
2667
|
+
targetKinds: ["quest", "questline"],
|
|
2668
|
+
inputDescriptions: {
|
|
2669
|
+
action: "Which structural organization action to perform.",
|
|
2670
|
+
questlineId: "The questline to update or archive.",
|
|
2671
|
+
title: "Optional new questline title for field updates.",
|
|
2672
|
+
description: "Optional new questline description for field updates.",
|
|
2673
|
+
startsAt: "Optional questline start timestamp for field updates.",
|
|
2674
|
+
dueAt: "Optional questline due timestamp for field updates.",
|
|
2675
|
+
archivedAt: "Optional archive timestamp for archiving a questline.",
|
|
2676
|
+
toQuestId: "The quest whose incoming blocker set should change.",
|
|
2677
|
+
fromQuestId: "The prerequisite quest for add-unlock or remove-unlock actions.",
|
|
2678
|
+
fromQuestIds: "The full prerequisite quest id set for the replace-unlocks action.",
|
|
2679
|
+
now: "Optional timestamp for unlock changes."
|
|
2680
|
+
},
|
|
2681
|
+
outputDescription: "Returns the updated questline detail or target quest detail after grouping or dependency changes. Repeated equivalent changes return structured no-op results.",
|
|
2682
|
+
inputSchema: objectSchema(
|
|
2683
|
+
{
|
|
2684
|
+
action: enumSchema("Action to perform.", [
|
|
2685
|
+
"add_unlock",
|
|
2686
|
+
"archive_questline",
|
|
2687
|
+
"remove_unlock",
|
|
2688
|
+
"replace_unlocks",
|
|
2689
|
+
"set_questline_fields"
|
|
2690
|
+
]),
|
|
2691
|
+
questlineId: integerSchema("Questline to update or archive."),
|
|
2692
|
+
title: stringSchema("Optional new questline title."),
|
|
2693
|
+
description: stringSchema("Optional new questline description."),
|
|
2694
|
+
startsAt: integerSchema("Optional questline start timestamp."),
|
|
2695
|
+
dueAt: integerSchema("Optional questline due timestamp."),
|
|
2696
|
+
archivedAt: integerSchema("Optional archive timestamp."),
|
|
2697
|
+
toQuestId: integerSchema("Quest whose blockers should change."),
|
|
2698
|
+
fromQuestId: integerSchema("Prerequisite quest id."),
|
|
2699
|
+
fromQuestIds: arraySchema(integerSchema("Quest id."), "Full prerequisite quest id set."),
|
|
2700
|
+
now: integerSchema("Optional timestamp.")
|
|
2701
|
+
},
|
|
2702
|
+
["action"],
|
|
2703
|
+
"Organize questline fields and unlock dependencies."
|
|
2704
|
+
),
|
|
2705
|
+
handler: organizeWorkToolHandler
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
// src/tools/plan_quest_tool.ts
|
|
2709
|
+
function samePlanningState(current, input) {
|
|
2710
|
+
return (input.notBeforeAt === void 0 || input.notBeforeAt === current.notBeforeAt) && (input.dueAt === void 0 || input.dueAt === current.dueAt) && (input.scheduledStartAt === void 0 || input.scheduledStartAt === current.scheduledStartAt) && (input.scheduledEndAt === void 0 || input.scheduledEndAt === current.scheduledEndAt) && (input.allDay === void 0 || input.allDay === current.allDay) && (input.estimateSeconds === void 0 || input.estimateSeconds === current.estimateSeconds);
|
|
2711
|
+
}
|
|
2712
|
+
function planQuestToolHandler(db, input) {
|
|
2713
|
+
try {
|
|
2714
|
+
const current = getQuestDetail(db, input.questId);
|
|
2715
|
+
if (input.action === "revise_objective") {
|
|
2716
|
+
const nextObjective = input.objective.trim();
|
|
2717
|
+
if (nextObjective === current.objective) {
|
|
2718
|
+
return toolNoOp(
|
|
2719
|
+
`Quest \`${current.title}\` already has that objective.`,
|
|
2720
|
+
{
|
|
2721
|
+
action: input.action,
|
|
2722
|
+
quest: current
|
|
2723
|
+
},
|
|
2724
|
+
{
|
|
2725
|
+
entities: [toQuestRef(current)]
|
|
2726
|
+
}
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
reviseQuestObjective(db, input.questId, input.objective, input.now);
|
|
2730
|
+
const detail2 = getQuestDetail(db, input.questId);
|
|
2731
|
+
return toolSuccess(
|
|
2732
|
+
`Revised quest objective for \`${detail2.title}\`.`,
|
|
2733
|
+
{
|
|
2734
|
+
action: input.action,
|
|
2735
|
+
quest: detail2
|
|
2736
|
+
},
|
|
2737
|
+
{
|
|
2738
|
+
entities: [toQuestRef(detail2)]
|
|
2739
|
+
}
|
|
2740
|
+
);
|
|
2741
|
+
}
|
|
2742
|
+
if (samePlanningState(current, input)) {
|
|
2743
|
+
return toolNoOp(
|
|
2744
|
+
`Quest \`${current.title}\` already has that planning state.`,
|
|
2745
|
+
{
|
|
2746
|
+
action: input.action,
|
|
2747
|
+
quest: current
|
|
2748
|
+
},
|
|
2749
|
+
{
|
|
2750
|
+
entities: [toQuestRef(current)]
|
|
2751
|
+
}
|
|
2752
|
+
);
|
|
2753
|
+
}
|
|
2754
|
+
planQuestTime(db, input.questId, input);
|
|
2755
|
+
const detail = getQuestDetail(db, input.questId);
|
|
2756
|
+
return toolSuccess(
|
|
2757
|
+
`Updated planning for quest \`${detail.title}\`.`,
|
|
2758
|
+
{
|
|
2759
|
+
action: input.action,
|
|
2760
|
+
quest: detail
|
|
2761
|
+
},
|
|
2762
|
+
{
|
|
2763
|
+
entities: [toQuestRef(detail)]
|
|
2764
|
+
}
|
|
2765
|
+
);
|
|
2766
|
+
} catch (error) {
|
|
2767
|
+
return translateToolError(error, {
|
|
2768
|
+
summary: `Could not complete plan-quest action \`${input.action}\`.`
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
var planQuestTool = defineQuestlogTool({
|
|
2773
|
+
name: planQuestToolName,
|
|
2774
|
+
description: "Refine a quest by revising its objective before actual start or by changing its temporal planning fields.",
|
|
2775
|
+
whenToUse: "Use this when you need to sharpen what the quest means or how it is timed without starting or resolving it.",
|
|
2776
|
+
whenNotToUse: "Do not use this for lifecycle execution such as start, finish, abandon, or effort logging.",
|
|
2777
|
+
sideEffects: "writes_state",
|
|
2778
|
+
readOnly: false,
|
|
2779
|
+
supportsClarification: false,
|
|
2780
|
+
targetKinds: ["quest"],
|
|
2781
|
+
inputDescriptions: {
|
|
2782
|
+
action: "Whether to revise the quest objective or update the quest timing and estimate fields.",
|
|
2783
|
+
questId: "The concrete quest to refine.",
|
|
2784
|
+
objective: "The new quest objective text for the revise-objective action.",
|
|
2785
|
+
notBeforeAt: "Optional earliest actionable timestamp for the quest.",
|
|
2786
|
+
dueAt: "Optional latest acceptable completion timestamp for the quest.",
|
|
2787
|
+
scheduledStartAt: "Optional planned schedule window start timestamp.",
|
|
2788
|
+
scheduledEndAt: "Optional planned schedule window end timestamp.",
|
|
2789
|
+
allDay: "Whether the scheduled window should be treated as all-day.",
|
|
2790
|
+
estimateSeconds: "Optional estimate of active effort in seconds.",
|
|
2791
|
+
now: "Optional timestamp for updating the quest."
|
|
2792
|
+
},
|
|
2793
|
+
outputDescription: "Returns the updated quest detail after objective or planning changes. If the requested planning state is already present, the tool returns a structured no-op result.",
|
|
2794
|
+
inputSchema: objectSchema(
|
|
2795
|
+
{
|
|
2796
|
+
action: enumSchema("Action to perform.", ["revise_objective", "set_time"]),
|
|
2797
|
+
questId: integerSchema("The quest to refine."),
|
|
2798
|
+
objective: stringSchema("The new quest objective text. Required for revise_objective."),
|
|
2799
|
+
notBeforeAt: integerSchema("Optional earliest actionable timestamp."),
|
|
2800
|
+
dueAt: integerSchema("Optional latest acceptable completion timestamp."),
|
|
2801
|
+
scheduledStartAt: integerSchema("Optional planned schedule window start."),
|
|
2802
|
+
scheduledEndAt: integerSchema("Optional planned schedule window end."),
|
|
2803
|
+
allDay: booleanSchema("Whether the planned schedule window is all-day."),
|
|
2804
|
+
estimateSeconds: integerSchema("Optional active-effort estimate in seconds."),
|
|
2805
|
+
now: integerSchema("Optional update timestamp.")
|
|
2806
|
+
},
|
|
2807
|
+
["action", "questId"],
|
|
2808
|
+
"Refine a quest objective or planning state."
|
|
2809
|
+
),
|
|
2810
|
+
handler: planQuestToolHandler
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
// src/rumors/soft_delete_rumor.ts
|
|
2814
|
+
function softDeleteRumor(db, rumorId, now) {
|
|
2815
|
+
const deletedAt = resolveNow(now);
|
|
2816
|
+
assertActiveRowExists(db, "rumors", rumorId, "Rumor");
|
|
2817
|
+
db.prepare(
|
|
2818
|
+
`UPDATE rumors
|
|
2819
|
+
SET deleted_at = ?, updated_at = ?
|
|
2820
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
2821
|
+
).run(deletedAt, deletedAt, rumorId);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// src/tools/retire_work_tool.ts
|
|
2825
|
+
function readRetireStatus(db, target) {
|
|
2826
|
+
switch (target.kind) {
|
|
2827
|
+
case "rumor": {
|
|
2828
|
+
const row = db.prepare("SELECT title, deleted_at FROM rumors WHERE id = ?").get(target.id);
|
|
2829
|
+
if (!row) {
|
|
2830
|
+
return null;
|
|
2831
|
+
}
|
|
2832
|
+
return {
|
|
2833
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at),
|
|
2834
|
+
title: row.title == null ? null : String(row.title)
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
case "quest": {
|
|
2838
|
+
const row = db.prepare("SELECT title, deleted_at FROM quests WHERE id = ?").get(target.id);
|
|
2839
|
+
if (!row) {
|
|
2840
|
+
return null;
|
|
2841
|
+
}
|
|
2842
|
+
return {
|
|
2843
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at),
|
|
2844
|
+
title: row.title == null ? null : String(row.title)
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
case "questline": {
|
|
2848
|
+
const row = db.prepare("SELECT title, deleted_at FROM questlines WHERE id = ?").get(target.id);
|
|
2849
|
+
if (!row) {
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
return {
|
|
2853
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at),
|
|
2854
|
+
title: row.title == null ? null : String(row.title)
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
case "repeatable_quest": {
|
|
2858
|
+
const row = db.prepare("SELECT title, deleted_at FROM repeatable_quests WHERE id = ?").get(target.id);
|
|
2859
|
+
if (!row) {
|
|
2860
|
+
return null;
|
|
2861
|
+
}
|
|
2862
|
+
return {
|
|
2863
|
+
deletedAt: row.deleted_at == null ? null : Number(row.deleted_at),
|
|
2864
|
+
title: row.title == null ? null : String(row.title)
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
function retireWorkToolHandler(db, input) {
|
|
2870
|
+
try {
|
|
2871
|
+
const status = readRetireStatus(db, input.target);
|
|
2872
|
+
if (status?.deletedAt != null) {
|
|
2873
|
+
return toolNoOp(
|
|
2874
|
+
`${input.target.kind} \`${status.title ?? input.target.id}\` is already hidden.`,
|
|
2875
|
+
{
|
|
2876
|
+
action: input.action,
|
|
2877
|
+
target: input.target
|
|
2878
|
+
},
|
|
2879
|
+
{
|
|
2880
|
+
entities: [
|
|
2881
|
+
{ kind: input.target.kind, id: input.target.id, title: status.title ?? void 0 }
|
|
2882
|
+
]
|
|
2883
|
+
}
|
|
2884
|
+
);
|
|
2885
|
+
}
|
|
2886
|
+
switch (input.target.kind) {
|
|
2887
|
+
case "rumor":
|
|
2888
|
+
softDeleteRumor(db, input.target.id, input.deletedAt);
|
|
2889
|
+
break;
|
|
2890
|
+
case "quest":
|
|
2891
|
+
softDeleteQuest(db, input.target.id, input.deletedAt);
|
|
2892
|
+
break;
|
|
2893
|
+
case "questline":
|
|
2894
|
+
softDeleteQuestline(db, input.target.id, input.deletedAt);
|
|
2895
|
+
break;
|
|
2896
|
+
case "repeatable_quest":
|
|
2897
|
+
softDeleteRepeatableQuest(db, input.target.id, input.deletedAt);
|
|
2898
|
+
break;
|
|
2899
|
+
}
|
|
2900
|
+
return toolSuccess(
|
|
2901
|
+
`Hid ${input.target.kind} ${input.target.id}.`,
|
|
2902
|
+
{
|
|
2903
|
+
action: input.action,
|
|
2904
|
+
target: input.target
|
|
2905
|
+
},
|
|
2906
|
+
{
|
|
2907
|
+
entities: [
|
|
2908
|
+
{ kind: input.target.kind, id: input.target.id, title: status?.title ?? void 0 }
|
|
2909
|
+
]
|
|
2910
|
+
}
|
|
2911
|
+
);
|
|
2912
|
+
} catch (error) {
|
|
2913
|
+
return translateToolError(error, {
|
|
2914
|
+
summary: `Could not hide ${input.target.kind} ${input.target.id}.`
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
var retireWorkTool = defineQuestlogTool({
|
|
2919
|
+
name: retireWorkToolName,
|
|
2920
|
+
description: "Hide an item from normal active operation without rewriting history by soft-deleting a rumor, quest, questline, or repeatable quest.",
|
|
2921
|
+
whenToUse: "Use this when work should disappear from active reads while preserving historical truth.",
|
|
2922
|
+
whenNotToUse: "Do not use this when you mean to archive a questline or repeatable quest while keeping it visible as retired structure.",
|
|
2923
|
+
sideEffects: "writes_state",
|
|
2924
|
+
readOnly: false,
|
|
2925
|
+
supportsClarification: false,
|
|
2926
|
+
targetKinds: ["quest", "questline", "repeatable_quest", "rumor"],
|
|
2927
|
+
inputDescriptions: {
|
|
2928
|
+
action: "The retirement action to perform. This tool currently supports hiding an item.",
|
|
2929
|
+
target: "The rumor, quest, questline, or repeatable quest to hide.",
|
|
2930
|
+
deletedAt: "Optional timestamp to record when the item became hidden."
|
|
2931
|
+
},
|
|
2932
|
+
outputDescription: "Returns a structured success result for the hidden target, or a structured no-op when the target is already hidden.",
|
|
2933
|
+
inputSchema: objectSchema(
|
|
2934
|
+
{
|
|
2935
|
+
action: enumSchema("The retirement action to perform.", ["hide"]),
|
|
2936
|
+
target: objectSchema(
|
|
2937
|
+
{
|
|
2938
|
+
kind: enumSchema("The kind of item to hide.", [
|
|
2939
|
+
"quest",
|
|
2940
|
+
"questline",
|
|
2941
|
+
"repeatable_quest",
|
|
2942
|
+
"rumor"
|
|
2943
|
+
]),
|
|
2944
|
+
id: integerSchema("The numeric id of the item to hide.")
|
|
2945
|
+
},
|
|
2946
|
+
["kind", "id"],
|
|
2947
|
+
"The target to hide."
|
|
2948
|
+
),
|
|
2949
|
+
deletedAt: integerSchema("Optional timestamp to record when the item became hidden.")
|
|
2950
|
+
},
|
|
2951
|
+
["action", "target"],
|
|
2952
|
+
"Hide a questlog item without rewriting history."
|
|
2953
|
+
),
|
|
2954
|
+
handler: retireWorkToolHandler
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
// src/rewards/get_quest_reward_or_throw.ts
|
|
2958
|
+
function getQuestRewardOrThrow(db, rewardId) {
|
|
2959
|
+
const row = db.prepare("SELECT * FROM quest_rewards WHERE id = ? AND deleted_at IS NULL").get(rewardId);
|
|
2960
|
+
if (!row) {
|
|
2961
|
+
throw new Error(`Quest reward ${rewardId} was not found.`);
|
|
2962
|
+
}
|
|
2963
|
+
return mapQuestRewardRow(row);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// src/rewards/add_quest_reward.ts
|
|
2967
|
+
function addQuestReward(db, questId, input) {
|
|
2968
|
+
assertActiveRowExists(db, "quests", questId, "Quest");
|
|
2969
|
+
const now = resolveNow(input.now);
|
|
2970
|
+
const created = db.prepare(
|
|
2971
|
+
`INSERT INTO quest_rewards (quest_id, kind, name, description, quantity, created_at)
|
|
2972
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
2973
|
+
).run(
|
|
2974
|
+
questId,
|
|
2975
|
+
assertNonEmpty(input.kind, "Reward kind"),
|
|
2976
|
+
assertNonEmpty(input.name, "Reward name"),
|
|
2977
|
+
input.description?.trim() || null,
|
|
2978
|
+
assertNonNegativeNumber(input.quantity, "Reward quantity") ?? null,
|
|
2979
|
+
now
|
|
2980
|
+
);
|
|
2981
|
+
return getQuestRewardOrThrow(db, Number(created.lastInsertRowid));
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/rewards/claim_quest_reward.ts
|
|
2985
|
+
function claimQuestReward(db, rewardId, claimedAt) {
|
|
2986
|
+
const reward = getQuestRewardOrThrow(db, rewardId);
|
|
2987
|
+
if (reward.claimedAt != null) {
|
|
2988
|
+
throw new Error("Reward has already been claimed.");
|
|
2989
|
+
}
|
|
2990
|
+
const quest = getQuestOrThrow(db, reward.questId);
|
|
2991
|
+
if (quest.resolvedAt == null || quest.success !== true) {
|
|
2992
|
+
throw new Error("Rewards can only be claimed for successfully resolved quests.");
|
|
2993
|
+
}
|
|
2994
|
+
db.prepare(
|
|
2995
|
+
`UPDATE quest_rewards
|
|
2996
|
+
SET claimed_at = ?
|
|
2997
|
+
WHERE id = ? AND claimed_at IS NULL AND deleted_at IS NULL`
|
|
2998
|
+
).run(resolveNow(claimedAt), rewardId);
|
|
2999
|
+
return getQuestRewardOrThrow(db, rewardId);
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// src/rewards/remove_quest_reward.ts
|
|
3003
|
+
function removeQuestReward(db, rewardId, now) {
|
|
3004
|
+
const current = getQuestRewardOrThrow(db, rewardId);
|
|
3005
|
+
if (current.claimedAt != null) {
|
|
3006
|
+
throw new Error("Claimed rewards cannot be removed.");
|
|
3007
|
+
}
|
|
3008
|
+
db.prepare(
|
|
3009
|
+
`UPDATE quest_rewards
|
|
3010
|
+
SET deleted_at = ?
|
|
3011
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
3012
|
+
).run(resolveNow(now), rewardId);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/rewards/replace_repeatable_quest_rewards.ts
|
|
3016
|
+
function replaceRepeatableQuestRewards(db, repeatableQuestId, rewards, now) {
|
|
3017
|
+
assertActiveRowExists(db, "repeatable_quests", repeatableQuestId, "Repeatable quest");
|
|
3018
|
+
replaceActiveRepeatableQuestRewards(db, repeatableQuestId, rewards, now);
|
|
3019
|
+
db.prepare("UPDATE repeatable_quests SET updated_at = ? WHERE id = ?").run(
|
|
3020
|
+
resolveNow(now),
|
|
3021
|
+
repeatableQuestId
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
// src/rewards/update_quest_reward.ts
|
|
3026
|
+
function updateQuestReward(db, rewardId, input) {
|
|
3027
|
+
const current = getQuestRewardOrThrow(db, rewardId);
|
|
3028
|
+
if (current.claimedAt != null) {
|
|
3029
|
+
throw new Error("Claimed rewards cannot be updated.");
|
|
3030
|
+
}
|
|
3031
|
+
const now = resolveNow(input.now);
|
|
3032
|
+
db.prepare(
|
|
3033
|
+
`UPDATE quest_rewards
|
|
3034
|
+
SET kind = ?, name = ?, description = ?, quantity = ?
|
|
3035
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
3036
|
+
).run(
|
|
3037
|
+
assertNonEmpty(input.kind, "Reward kind"),
|
|
3038
|
+
assertNonEmpty(input.name, "Reward name"),
|
|
3039
|
+
input.description?.trim() || null,
|
|
3040
|
+
assertNonNegativeNumber(input.quantity, "Reward quantity") ?? null,
|
|
3041
|
+
rewardId
|
|
3042
|
+
);
|
|
3043
|
+
db.prepare("UPDATE quests SET updated_at = ? WHERE id = ?").run(now, current.questId);
|
|
3044
|
+
return getQuestRewardOrThrow(db, rewardId);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// src/tools/reward_work_tool.ts
|
|
3048
|
+
function rewardWorkToolHandler(db, input) {
|
|
3049
|
+
try {
|
|
3050
|
+
switch (input.action) {
|
|
3051
|
+
case "add": {
|
|
3052
|
+
const reward = addQuestReward(db, input.target.id, input.reward);
|
|
3053
|
+
return toolSuccess(
|
|
3054
|
+
"Added quest reward.",
|
|
3055
|
+
{
|
|
3056
|
+
action: input.action,
|
|
3057
|
+
target: input.target,
|
|
3058
|
+
reward
|
|
3059
|
+
},
|
|
3060
|
+
{
|
|
3061
|
+
entities: [{ kind: "quest", id: input.target.id }, toRewardRef(reward)]
|
|
3062
|
+
}
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
case "update": {
|
|
3066
|
+
const reward = updateQuestReward(db, input.target.id, input.reward);
|
|
3067
|
+
return toolSuccess(
|
|
3068
|
+
"Updated quest reward.",
|
|
3069
|
+
{
|
|
3070
|
+
action: input.action,
|
|
3071
|
+
target: input.target,
|
|
3072
|
+
reward
|
|
3073
|
+
},
|
|
3074
|
+
{
|
|
3075
|
+
entities: [toRewardRef(reward)]
|
|
3076
|
+
}
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
case "remove": {
|
|
3080
|
+
const current = getQuestRewardOrThrow(db, input.target.id);
|
|
3081
|
+
if (current.claimedAt != null) {
|
|
3082
|
+
return translateToolError(new Error("Claimed rewards cannot be removed."), {
|
|
3083
|
+
summary: "Could not remove the claimed reward.",
|
|
3084
|
+
entities: [toRewardRef(current)]
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
removeQuestReward(db, input.target.id, input.now);
|
|
3088
|
+
return toolSuccess(
|
|
3089
|
+
"Removed quest reward.",
|
|
3090
|
+
{
|
|
3091
|
+
action: input.action,
|
|
3092
|
+
target: input.target
|
|
3093
|
+
},
|
|
3094
|
+
{
|
|
3095
|
+
entities: [toRewardRef(current)]
|
|
3096
|
+
}
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
case "claim": {
|
|
3100
|
+
const current = getQuestRewardOrThrow(db, input.target.id);
|
|
3101
|
+
if (current.claimedAt != null) {
|
|
3102
|
+
return toolNoOp(
|
|
3103
|
+
`Reward \`${current.name}\` is already claimed.`,
|
|
3104
|
+
{
|
|
3105
|
+
action: input.action,
|
|
3106
|
+
target: input.target,
|
|
3107
|
+
reward: current
|
|
3108
|
+
},
|
|
3109
|
+
{
|
|
3110
|
+
entities: [toRewardRef(current)]
|
|
3111
|
+
}
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
const reward = claimQuestReward(db, input.target.id, input.claimedAt);
|
|
3115
|
+
return toolSuccess(
|
|
3116
|
+
"Claimed quest reward.",
|
|
3117
|
+
{
|
|
3118
|
+
action: input.action,
|
|
3119
|
+
target: input.target,
|
|
3120
|
+
reward
|
|
3121
|
+
},
|
|
3122
|
+
{
|
|
3123
|
+
entities: [toRewardRef(reward)]
|
|
3124
|
+
}
|
|
3125
|
+
);
|
|
3126
|
+
}
|
|
3127
|
+
case "replace_repeatable_template": {
|
|
3128
|
+
assertActiveRowExists(db, "repeatable_quests", input.target.id, "Repeatable quest");
|
|
3129
|
+
replaceRepeatableQuestRewards(db, input.target.id, input.rewards, input.now);
|
|
3130
|
+
return toolSuccess(
|
|
3131
|
+
"Replaced repeatable quest future reward templates.",
|
|
3132
|
+
{
|
|
3133
|
+
action: input.action,
|
|
3134
|
+
target: input.target
|
|
3135
|
+
},
|
|
3136
|
+
{
|
|
3137
|
+
entities: [{ kind: "repeatable_quest", id: input.target.id }],
|
|
3138
|
+
warnings: [
|
|
3139
|
+
toolWarning(
|
|
3140
|
+
"future_only_effect",
|
|
3141
|
+
"This change only affects future spawned quests, not historical spawned rewards."
|
|
3142
|
+
)
|
|
3143
|
+
]
|
|
3144
|
+
}
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
return translateToolError(error, {
|
|
3150
|
+
summary: `Could not complete reward-work action \`${input.action}\`.`
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
var rewardWorkTool = defineQuestlogTool({
|
|
3155
|
+
name: rewardWorkToolName,
|
|
3156
|
+
description: "Manage rewards by adding, updating, removing, or claiming quest rewards, or by replacing future reward templates on repeatable quests.",
|
|
3157
|
+
whenToUse: "Use this when a completed or recurring piece of work should grant or update reward information.",
|
|
3158
|
+
whenNotToUse: "Do not use this to finish quests themselves or to model dependencies, classification, or scheduling.",
|
|
3159
|
+
sideEffects: "writes_state",
|
|
3160
|
+
readOnly: false,
|
|
3161
|
+
supportsClarification: false,
|
|
3162
|
+
targetKinds: ["quest", "reward", "repeatable_quest"],
|
|
3163
|
+
inputDescriptions: {
|
|
3164
|
+
action: "Which reward action to perform on the selected target.",
|
|
3165
|
+
target: "The quest, reward, or repeatable quest that the reward action applies to.",
|
|
3166
|
+
reward: "Reward fields used when adding or updating one concrete quest reward.",
|
|
3167
|
+
rewards: "The full future reward template set for a repeatable quest.",
|
|
3168
|
+
claimedAt: "Optional timestamp for claiming a reward.",
|
|
3169
|
+
now: "Optional timestamp for removing a reward or replacing repeatable reward templates."
|
|
3170
|
+
},
|
|
3171
|
+
outputDescription: "Returns the updated or claimed reward when one concrete reward changes, or a structured success result for repeatable future template replacement. Already-claimed rewards return a structured no-op for claim and an error for invalid update or remove attempts.",
|
|
3172
|
+
inputSchema: objectSchema(
|
|
3173
|
+
{
|
|
3174
|
+
action: enumSchema("Action to perform.", [
|
|
3175
|
+
"add",
|
|
3176
|
+
"claim",
|
|
3177
|
+
"remove",
|
|
3178
|
+
"replace_repeatable_template",
|
|
3179
|
+
"update"
|
|
3180
|
+
]),
|
|
3181
|
+
target: objectSchema(
|
|
3182
|
+
{
|
|
3183
|
+
id: integerSchema("Target entity id."),
|
|
3184
|
+
kind: enumSchema("Target kind.", ["quest", "reward", "repeatable_quest"])
|
|
3185
|
+
},
|
|
3186
|
+
["id", "kind"]
|
|
3187
|
+
),
|
|
3188
|
+
reward: objectSchema(
|
|
3189
|
+
{
|
|
3190
|
+
kind: stringSchema("Reward kind."),
|
|
3191
|
+
name: stringSchema("Reward name."),
|
|
3192
|
+
now: integerSchema("Optional update timestamp for the update action.")
|
|
3193
|
+
},
|
|
3194
|
+
[]
|
|
3195
|
+
),
|
|
3196
|
+
rewards: arraySchema(
|
|
3197
|
+
objectSchema(
|
|
3198
|
+
{
|
|
3199
|
+
kind: stringSchema("Reward kind."),
|
|
3200
|
+
name: stringSchema("Reward name.")
|
|
3201
|
+
},
|
|
3202
|
+
["kind", "name"]
|
|
3203
|
+
),
|
|
3204
|
+
"Full future reward template set. Required for replace_repeatable_template."
|
|
3205
|
+
),
|
|
3206
|
+
claimedAt: integerSchema("Optional claim timestamp."),
|
|
3207
|
+
now: integerSchema("Optional timestamp for removal or template replacement.")
|
|
3208
|
+
},
|
|
3209
|
+
["action", "target"],
|
|
3210
|
+
"Manage quest rewards and repeatable reward templates."
|
|
3211
|
+
),
|
|
3212
|
+
handler: rewardWorkToolHandler
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
// src/tools/run_quest_tool.ts
|
|
3216
|
+
function runQuestToolHandler(db, input) {
|
|
3217
|
+
try {
|
|
3218
|
+
const current = getQuestDetail(db, input.questId);
|
|
3219
|
+
if (input.action !== "start" && input.action !== "log_effort") {
|
|
3220
|
+
const outcome = input.outcome;
|
|
3221
|
+
if (typeof outcome !== "string") {
|
|
3222
|
+
return toolNeedsClarification(
|
|
3223
|
+
"missing_required_choice",
|
|
3224
|
+
`What outcome should be recorded for quest \`${current.title}\`?`,
|
|
3225
|
+
["outcome"]
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
switch (input.action) {
|
|
3230
|
+
case "start": {
|
|
3231
|
+
if (current.resolvedAt != null) {
|
|
3232
|
+
return translateToolError(new Error("Resolved quests cannot be started."), {
|
|
3233
|
+
summary: `Could not start quest \`${current.title}\`.`,
|
|
3234
|
+
entities: [toQuestRef(current)]
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
if (current.startedAt != null) {
|
|
3238
|
+
return toolNoOp(
|
|
3239
|
+
`Quest \`${current.title}\` is already started.`,
|
|
3240
|
+
{
|
|
3241
|
+
action: input.action,
|
|
3242
|
+
primary: toQuestRef(current),
|
|
3243
|
+
quest: current
|
|
3244
|
+
},
|
|
3245
|
+
{
|
|
3246
|
+
entities: [toQuestRef(current)]
|
|
3247
|
+
}
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3250
|
+
const blockers = db.prepare(
|
|
3251
|
+
`SELECT blocker.id FROM quest_unlocks prerequisite
|
|
3252
|
+
JOIN quests blocker ON blocker.id = prerequisite.from_quest_id
|
|
3253
|
+
WHERE prerequisite.to_quest_id = ?
|
|
3254
|
+
AND prerequisite.deleted_at IS NULL
|
|
3255
|
+
AND blocker.deleted_at IS NULL
|
|
3256
|
+
AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
|
|
3257
|
+
ORDER BY blocker.id`
|
|
3258
|
+
).all(current.id).map(({ id }) => getQuestDetail(db, id));
|
|
3259
|
+
if (blockers.length && !input.force) {
|
|
3260
|
+
return toolFailure(
|
|
3261
|
+
"domain",
|
|
3262
|
+
"invalid_state",
|
|
3263
|
+
`Quest "${current.title}" is blocked by ${blockers.map((quest) => quest.title).join(", ")}.`,
|
|
3264
|
+
"Finish the blocking prerequisites before starting this quest, or set force to true.",
|
|
3265
|
+
{
|
|
3266
|
+
entities: [toQuestRef(current), ...blockers.map(toQuestRef)],
|
|
3267
|
+
next: blockers.map((quest) => ({
|
|
3268
|
+
...inspectItemNext(toQuestRef(quest)),
|
|
3269
|
+
message: `Inspect and finish blocker "${quest.title}".`
|
|
3270
|
+
}))
|
|
3271
|
+
}
|
|
3272
|
+
);
|
|
3273
|
+
}
|
|
3274
|
+
startQuest(db, input.questId, input.startedAt);
|
|
3275
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3276
|
+
return toolSuccess(
|
|
3277
|
+
`Started quest \`${detail.title}\`.`,
|
|
3278
|
+
{
|
|
3279
|
+
action: input.action,
|
|
3280
|
+
primary: toQuestRef(detail),
|
|
3281
|
+
updated: [toQuestRef(detail)],
|
|
3282
|
+
quest: detail
|
|
3283
|
+
},
|
|
3284
|
+
{
|
|
3285
|
+
entities: [toQuestRef(detail)],
|
|
3286
|
+
warnings: blockers.length ? [
|
|
3287
|
+
{
|
|
3288
|
+
code: "derived_value_used",
|
|
3289
|
+
message: `Forced start with unmet prerequisites: ${blockers.map((quest) => quest.title).join(", ")}.`
|
|
3290
|
+
}
|
|
3291
|
+
] : void 0
|
|
3292
|
+
}
|
|
3293
|
+
);
|
|
3294
|
+
}
|
|
3295
|
+
case "log_effort": {
|
|
3296
|
+
logQuestEffort(db, input.questId, input.effortSeconds, input.now);
|
|
3297
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3298
|
+
return toolSuccess(
|
|
3299
|
+
`Logged effort on quest \`${detail.title}\`.`,
|
|
3300
|
+
{
|
|
3301
|
+
action: input.action,
|
|
3302
|
+
primary: toQuestRef(detail),
|
|
3303
|
+
updated: [toQuestRef(detail)],
|
|
3304
|
+
quest: detail
|
|
3305
|
+
},
|
|
3306
|
+
{
|
|
3307
|
+
entities: [toQuestRef(detail)]
|
|
3308
|
+
}
|
|
3309
|
+
);
|
|
3310
|
+
}
|
|
3311
|
+
case "finish": {
|
|
3312
|
+
finishQuest(db, input.questId, input.outcome, input.resolvedAt);
|
|
3313
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3314
|
+
return toolSuccess(
|
|
3315
|
+
`Finished quest \`${detail.title}\`.`,
|
|
3316
|
+
{
|
|
3317
|
+
action: input.action,
|
|
3318
|
+
primary: toQuestRef(detail),
|
|
3319
|
+
updated: [toQuestRef(detail)],
|
|
3320
|
+
quest: detail
|
|
3321
|
+
},
|
|
3322
|
+
{
|
|
3323
|
+
entities: [toQuestRef(detail)]
|
|
3324
|
+
}
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
case "abandon": {
|
|
3328
|
+
abandonQuest(db, input.questId, input.outcome, input.resolvedAt);
|
|
3329
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3330
|
+
return toolSuccess(
|
|
3331
|
+
`Abandoned quest \`${detail.title}\`.`,
|
|
3332
|
+
{
|
|
3333
|
+
action: input.action,
|
|
3334
|
+
primary: toQuestRef(detail),
|
|
3335
|
+
updated: [toQuestRef(detail)],
|
|
3336
|
+
quest: detail
|
|
3337
|
+
},
|
|
3338
|
+
{
|
|
3339
|
+
entities: [toQuestRef(detail)]
|
|
3340
|
+
}
|
|
3341
|
+
);
|
|
3342
|
+
}
|
|
3343
|
+
case "abandon_and_spawn_followups": {
|
|
3344
|
+
if (input.followups.length === 0) {
|
|
3345
|
+
return toolFailure(
|
|
3346
|
+
"protocol",
|
|
3347
|
+
"invalid_input",
|
|
3348
|
+
"Follow-up quests are required for this action.",
|
|
3349
|
+
"Provide at least one follow-up quest, or use the plain abandon action instead.",
|
|
3350
|
+
{
|
|
3351
|
+
entities: [toQuestRef(current)]
|
|
3352
|
+
}
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
3355
|
+
const result = abandonQuestAndSpawnFollowups(
|
|
3356
|
+
db,
|
|
3357
|
+
input.questId,
|
|
3358
|
+
input.outcome,
|
|
3359
|
+
input.followups,
|
|
3360
|
+
input.resolvedAt
|
|
3361
|
+
);
|
|
3362
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3363
|
+
const followupQuests = result.followupQuests.map((quest) => getQuestDetail(db, quest.id));
|
|
3364
|
+
return toolSuccess(
|
|
3365
|
+
`Abandoned quest \`${detail.title}\` and created follow-up work.`,
|
|
3366
|
+
{
|
|
3367
|
+
action: input.action,
|
|
3368
|
+
primary: toQuestRef(detail),
|
|
3369
|
+
created: followupQuests.map(toQuestRef),
|
|
3370
|
+
updated: [toQuestRef(detail)],
|
|
3371
|
+
quest: detail,
|
|
3372
|
+
followupQuests
|
|
3373
|
+
},
|
|
3374
|
+
{
|
|
3375
|
+
entities: [toQuestRef(detail), ...followupQuests.map(toQuestRef)],
|
|
3376
|
+
next: followupQuests.length === 1 && followupQuests[0] ? [inspectItemNext(toQuestRef(followupQuests[0]))] : void 0
|
|
3377
|
+
}
|
|
3378
|
+
);
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
} catch (error) {
|
|
3382
|
+
return translateToolError(error, {
|
|
3383
|
+
summary: `Could not complete run-quest action \`${input.action}\`.`
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
var runQuestTool = defineQuestlogTool({
|
|
3388
|
+
name: runQuestToolName,
|
|
3389
|
+
description: "Run quest lifecycle actions such as starting, logging effort, finishing, abandoning, or abandoning and spawning follow-up quests atomically.",
|
|
3390
|
+
whenToUse: "Use this for actual execution lifecycle steps after the quest already exists.",
|
|
3391
|
+
whenNotToUse: "Do not use this for work shaping, planning-only edits, tagging, or rewards.",
|
|
3392
|
+
sideEffects: "writes_state",
|
|
3393
|
+
readOnly: false,
|
|
3394
|
+
supportsClarification: false,
|
|
3395
|
+
targetKinds: ["quest"],
|
|
3396
|
+
inputDescriptions: {
|
|
3397
|
+
action: "Which execution lifecycle step to perform on the quest.",
|
|
3398
|
+
questId: "The concrete quest whose execution state should change.",
|
|
3399
|
+
startedAt: "Optional actual start timestamp for the start action.",
|
|
3400
|
+
force: "Start despite unmet unlock prerequisites. Defaults to false.",
|
|
3401
|
+
effortSeconds: "Positive active-effort increment in seconds for the log-effort action.",
|
|
3402
|
+
now: "Optional timestamp for the effort log action.",
|
|
3403
|
+
outcome: "Terminal outcome text for finish or abandon actions.",
|
|
3404
|
+
resolvedAt: "Optional terminal timestamp for finish or abandon actions.",
|
|
3405
|
+
followups: "Follow-up quest creation inputs used only for the abandon-and-spawn-followups action."
|
|
3406
|
+
},
|
|
3407
|
+
outputDescription: "Returns the updated quest detail after the selected lifecycle action, plus purpose-shaped primary, updated, and created refs when follow-up work is spawned. Safe repeated starts return structured no-op results.",
|
|
3408
|
+
inputSchema: objectSchema(
|
|
3409
|
+
{
|
|
3410
|
+
action: enumSchema("Action to perform.", [
|
|
3411
|
+
"start",
|
|
3412
|
+
"log_effort",
|
|
3413
|
+
"finish",
|
|
3414
|
+
"abandon",
|
|
3415
|
+
"abandon_and_spawn_followups"
|
|
3416
|
+
]),
|
|
3417
|
+
questId: integerSchema("Quest whose execution state should change."),
|
|
3418
|
+
startedAt: integerSchema("Optional start timestamp for the start action."),
|
|
3419
|
+
force: booleanSchema("Start despite unmet unlock prerequisites. Defaults to false."),
|
|
3420
|
+
effortSeconds: integerSchema(
|
|
3421
|
+
"Positive active-effort increment in seconds. Required for log_effort."
|
|
3422
|
+
),
|
|
3423
|
+
now: integerSchema("Optional effort-log timestamp."),
|
|
3424
|
+
outcome: stringSchema(
|
|
3425
|
+
"Terminal outcome text. Required for finish, abandon, and abandon_and_spawn_followups."
|
|
3426
|
+
),
|
|
3427
|
+
resolvedAt: integerSchema("Optional resolved timestamp for finish or abandon actions."),
|
|
3428
|
+
followups: arraySchema(
|
|
3429
|
+
objectSchema(
|
|
3430
|
+
{
|
|
3431
|
+
title: stringSchema("Title for a follow-up quest."),
|
|
3432
|
+
objective: stringSchema("Objective for a follow-up quest.")
|
|
3433
|
+
},
|
|
3434
|
+
["title", "objective"]
|
|
3435
|
+
),
|
|
3436
|
+
"Follow-up quests to create atomically. Required for abandon_and_spawn_followups."
|
|
3437
|
+
)
|
|
3438
|
+
},
|
|
3439
|
+
["action", "questId"],
|
|
3440
|
+
"Run quest lifecycle actions."
|
|
3441
|
+
),
|
|
3442
|
+
handler: runQuestToolHandler
|
|
3443
|
+
});
|
|
3444
|
+
|
|
3445
|
+
// src/search_questlog.ts
|
|
3446
|
+
function searchQuestlog(db, query) {
|
|
3447
|
+
const trimmed = query.trim();
|
|
3448
|
+
if (!trimmed) {
|
|
3449
|
+
return [];
|
|
3450
|
+
}
|
|
3451
|
+
const now = Date.now();
|
|
3452
|
+
const questRows = db.prepare(
|
|
3453
|
+
`SELECT q.id,
|
|
3454
|
+
q.title,
|
|
3455
|
+
snippet(quests_fts, 1, '[', ']', '\u2026', 10) AS snippet,
|
|
3456
|
+
q.started_at,
|
|
3457
|
+
q.resolved_at,
|
|
3458
|
+
q.success,
|
|
3459
|
+
q.not_before_at,
|
|
3460
|
+
EXISTS(
|
|
3461
|
+
SELECT 1
|
|
3462
|
+
FROM quest_rewards qr
|
|
3463
|
+
WHERE qr.quest_id = q.id
|
|
3464
|
+
AND qr.deleted_at IS NULL
|
|
3465
|
+
AND qr.claimed_at IS NULL
|
|
3466
|
+
) AS has_unclaimed_rewards,
|
|
3467
|
+
EXISTS(
|
|
3468
|
+
SELECT 1
|
|
3469
|
+
FROM quest_unlocks qu
|
|
3470
|
+
JOIN quests blocker ON blocker.id = qu.from_quest_id
|
|
3471
|
+
WHERE qu.to_quest_id = q.id
|
|
3472
|
+
AND qu.deleted_at IS NULL
|
|
3473
|
+
AND blocker.deleted_at IS NULL
|
|
3474
|
+
AND NOT (blocker.resolved_at IS NOT NULL AND blocker.success = 1)
|
|
3475
|
+
) AS has_blockers
|
|
3476
|
+
FROM quests_fts CROSS JOIN quests q ON q.id = quests_fts.rowid
|
|
3477
|
+
WHERE quests_fts MATCH ? AND q.deleted_at IS NULL`
|
|
3478
|
+
).all(trimmed).map((row) => {
|
|
3479
|
+
const state = deriveQuestState({
|
|
3480
|
+
startedAt: row.started_at == null ? null : Number(row.started_at),
|
|
3481
|
+
resolvedAt: row.resolved_at == null ? null : Number(row.resolved_at),
|
|
3482
|
+
success: row.success == null ? null : Number(row.success) === 1
|
|
3483
|
+
});
|
|
3484
|
+
const notBeforeAt = row.not_before_at == null ? null : Number(row.not_before_at);
|
|
3485
|
+
const blockerCount = Number(row.has_blockers ?? 0);
|
|
3486
|
+
const available = state === "open" && (notBeforeAt == null || notBeforeAt <= now) && blockerCount === 0;
|
|
3487
|
+
return {
|
|
3488
|
+
entityKind: "quest",
|
|
3489
|
+
entityId: Number(row.id),
|
|
3490
|
+
title: String(row.title),
|
|
3491
|
+
snippet: String(row.snippet),
|
|
3492
|
+
markerId: deriveQuestMarkerId({
|
|
3493
|
+
state,
|
|
3494
|
+
available,
|
|
3495
|
+
hasUnclaimedReward: Number(row.has_unclaimed_rewards ?? 0) === 1,
|
|
3496
|
+
notBeforeAt,
|
|
3497
|
+
blockerCount,
|
|
3498
|
+
now
|
|
3499
|
+
})
|
|
3500
|
+
};
|
|
3501
|
+
});
|
|
3502
|
+
const rumorRows = db.prepare(
|
|
3503
|
+
`SELECT r.id,
|
|
3504
|
+
r.title,
|
|
3505
|
+
snippet(rumors_fts, 1, '[', ']', '\u2026', 10) AS snippet,
|
|
3506
|
+
r.settled_at,
|
|
3507
|
+
r.dismissed_at
|
|
3508
|
+
FROM rumors_fts CROSS JOIN rumors r ON r.id = rumors_fts.rowid
|
|
3509
|
+
WHERE rumors_fts MATCH ? AND r.deleted_at IS NULL`
|
|
3510
|
+
).all(trimmed).map((row) => ({
|
|
3511
|
+
entityKind: "rumor",
|
|
3512
|
+
entityId: Number(row.id),
|
|
3513
|
+
title: String(row.title),
|
|
3514
|
+
snippet: String(row.snippet),
|
|
3515
|
+
markerId: deriveRumorMarkerId({
|
|
3516
|
+
state: deriveRumorState({
|
|
3517
|
+
settledAt: row.settled_at == null ? null : Number(row.settled_at),
|
|
3518
|
+
dismissedAt: row.dismissed_at == null ? null : Number(row.dismissed_at)
|
|
3519
|
+
})
|
|
3520
|
+
})
|
|
3521
|
+
}));
|
|
3522
|
+
return [...questRows, ...rumorRows];
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
// src/tools/search_questlog_tool.ts
|
|
3526
|
+
function searchQuestlogToolHandler(db, input) {
|
|
3527
|
+
const query = input.query.trim();
|
|
3528
|
+
if (!query) {
|
|
3529
|
+
return toolFailure(
|
|
3530
|
+
"protocol",
|
|
3531
|
+
"invalid_input",
|
|
3532
|
+
"Search query is required.",
|
|
3533
|
+
"Provide a non-empty search query."
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
if (input.limit != null && input.limit < 1) {
|
|
3537
|
+
return toolFailure(
|
|
3538
|
+
"protocol",
|
|
3539
|
+
"invalid_input",
|
|
3540
|
+
"Search limit must be at least 1.",
|
|
3541
|
+
"Provide a positive limit when you want to cap returned matches."
|
|
3542
|
+
);
|
|
3543
|
+
}
|
|
3544
|
+
const allHits = searchQuestlog(db, query);
|
|
3545
|
+
const filteredHits = input.kinds && input.kinds.length > 0 ? allHits.filter((hit) => input.kinds?.includes(hit.entityKind)) : allHits;
|
|
3546
|
+
const limitedHits = input.limit && input.limit > 0 ? filteredHits.slice(0, input.limit) : filteredHits;
|
|
3547
|
+
const items = limitedHits.map(toSearchListItem);
|
|
3548
|
+
const mode = input.mode ?? "browse";
|
|
3549
|
+
if (mode === "identify_one" && filteredHits.length > 1) {
|
|
3550
|
+
return toolNeedsClarification(
|
|
3551
|
+
"ambiguous_target",
|
|
3552
|
+
`More than one item matches \`${query}\`. Which one did you mean?`,
|
|
3553
|
+
["query"],
|
|
3554
|
+
{
|
|
3555
|
+
entities: items.slice(0, 5).map((item) => ({
|
|
3556
|
+
kind: item.kind,
|
|
3557
|
+
id: item.id,
|
|
3558
|
+
title: item.title,
|
|
3559
|
+
markerId: item.markerId,
|
|
3560
|
+
state: item.state
|
|
3561
|
+
})),
|
|
3562
|
+
options: items.slice(0, 5).map((item) => ({
|
|
3563
|
+
label: `${item.kind}: ${item.title ?? item.id}`,
|
|
3564
|
+
value: item.id
|
|
3565
|
+
}))
|
|
3566
|
+
}
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
const data = {
|
|
3570
|
+
items,
|
|
3571
|
+
returnedCount: items.length,
|
|
3572
|
+
totalCount: filteredHits.length,
|
|
3573
|
+
mode
|
|
3574
|
+
};
|
|
3575
|
+
if (input.kinds && input.kinds.length > 0) {
|
|
3576
|
+
data.appliedKinds = input.kinds;
|
|
3577
|
+
}
|
|
3578
|
+
return toolSuccess(
|
|
3579
|
+
items.length === 0 ? "No matching questlog items were found." : summarizeCount(items.length, "match"),
|
|
3580
|
+
data,
|
|
3581
|
+
{
|
|
3582
|
+
entities: items.map((item) => ({
|
|
3583
|
+
kind: item.kind,
|
|
3584
|
+
id: item.id,
|
|
3585
|
+
title: item.title,
|
|
3586
|
+
markerId: item.markerId,
|
|
3587
|
+
state: item.state
|
|
3588
|
+
})),
|
|
3589
|
+
next: items.length === 1 && items[0] ? [
|
|
3590
|
+
inspectItemNext({
|
|
3591
|
+
kind: items[0].kind,
|
|
3592
|
+
id: items[0].id,
|
|
3593
|
+
title: items[0].title,
|
|
3594
|
+
markerId: items[0].markerId,
|
|
3595
|
+
state: items[0].state
|
|
3596
|
+
})
|
|
3597
|
+
] : void 0,
|
|
3598
|
+
warnings: items.length === 0 ? [toolWarning("empty_result", "No items matched the search query.")] : void 0
|
|
3599
|
+
}
|
|
3600
|
+
);
|
|
3601
|
+
}
|
|
3602
|
+
var searchQuestlogTool = defineQuestlogTool({
|
|
3603
|
+
name: searchQuestlogToolName,
|
|
3604
|
+
description: "Search quests and rumors by text when you need to find an existing item before inspecting or changing it.",
|
|
3605
|
+
whenToUse: "Use this first when the exact quest, rumor, or id is not already known.",
|
|
3606
|
+
whenNotToUse: "Do not use this when you already know the exact item id and want its full detail.",
|
|
3607
|
+
sideEffects: "none",
|
|
3608
|
+
readOnly: true,
|
|
3609
|
+
supportsClarification: true,
|
|
3610
|
+
targetKinds: ["quest", "rumor"],
|
|
3611
|
+
inputDescriptions: {
|
|
3612
|
+
query: "Text to search for in rumor and quest titles and body text.",
|
|
3613
|
+
limit: "Maximum number of matches to return. Leave unset to return all matches.",
|
|
3614
|
+
kinds: "Optional entity kinds to restrict the search to quests, rumors, or both.",
|
|
3615
|
+
mode: "Use `browse` to return matches normally, or `identify_one` to ask for clarification when several matches are plausible."
|
|
3616
|
+
},
|
|
3617
|
+
outputDescription: "Returns compact matching items with ids, titles, snippets, and markers, plus returned and total counts. In identify-one mode, the tool can return structured clarification instead of guessing.",
|
|
3618
|
+
inputSchema: objectSchema(
|
|
3619
|
+
{
|
|
3620
|
+
query: stringSchema("Text to search for in rumor and quest titles and body text."),
|
|
3621
|
+
limit: integerSchema("Maximum number of matches to return."),
|
|
3622
|
+
kinds: arraySchema(
|
|
3623
|
+
enumSchema("Optional kinds to restrict the search to.", ["quest", "rumor"]),
|
|
3624
|
+
"Optional entity kinds to search."
|
|
3625
|
+
),
|
|
3626
|
+
mode: enumSchema(
|
|
3627
|
+
"Whether to browse results normally or ask for clarification when several matches are found.",
|
|
3628
|
+
["browse", "identify_one"]
|
|
3629
|
+
)
|
|
3630
|
+
},
|
|
3631
|
+
["query"],
|
|
3632
|
+
"Search quests and rumors by text."
|
|
3633
|
+
),
|
|
3634
|
+
handler: searchQuestlogToolHandler
|
|
3635
|
+
});
|
|
3636
|
+
|
|
3637
|
+
// src/rumors/dismiss_rumor.ts
|
|
3638
|
+
function dismissRumor(db, rumorId, dismissedAt) {
|
|
3639
|
+
const now = resolveNow(dismissedAt);
|
|
3640
|
+
assertActiveRowExists(db, "rumors", rumorId, "Rumor");
|
|
3641
|
+
db.prepare(
|
|
3642
|
+
`UPDATE rumors
|
|
3643
|
+
SET dismissed_at = ?, settled_at = NULL, updated_at = ?
|
|
3644
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
3645
|
+
).run(now, now, rumorId);
|
|
3646
|
+
return getRumorOrThrow(db, rumorId);
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
// src/rumors/reopen_rumor.ts
|
|
3650
|
+
function reopenRumor(db, rumorId, now) {
|
|
3651
|
+
assertActiveRowExists(db, "rumors", rumorId, "Rumor");
|
|
3652
|
+
const effectiveNow = resolveNow(now);
|
|
3653
|
+
db.prepare(
|
|
3654
|
+
`UPDATE rumors
|
|
3655
|
+
SET settled_at = NULL, dismissed_at = NULL, updated_at = ?
|
|
3656
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
3657
|
+
).run(effectiveNow, rumorId);
|
|
3658
|
+
return getRumorOrThrow(db, rumorId);
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
// src/rumors/settle_rumor.ts
|
|
3662
|
+
function settleRumor(db, rumorId, input) {
|
|
3663
|
+
return withTransaction(db, () => {
|
|
3664
|
+
const rumor = getRumorOrThrow(db, rumorId);
|
|
3665
|
+
const settledAt = input.settledAt ?? Date.now();
|
|
3666
|
+
let questline = null;
|
|
3667
|
+
if (input.questline) {
|
|
3668
|
+
questline = createQuestline(db, {
|
|
3669
|
+
...input.questline,
|
|
3670
|
+
sourceRumorId: rumor.id,
|
|
3671
|
+
now: settledAt
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
const quests = (input.quests ?? []).map(
|
|
3675
|
+
(quest) => createQuest(db, {
|
|
3676
|
+
...quest,
|
|
3677
|
+
sourceRumorId: rumor.id,
|
|
3678
|
+
questlineId: quest.questlineId ?? questline?.id ?? null,
|
|
3679
|
+
now: settledAt
|
|
3680
|
+
})
|
|
3681
|
+
);
|
|
3682
|
+
db.prepare(
|
|
3683
|
+
`UPDATE rumors
|
|
3684
|
+
SET settled_at = ?, updated_at = ?
|
|
3685
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
3686
|
+
).run(settledAt, settledAt, rumorId);
|
|
3687
|
+
return {
|
|
3688
|
+
rumor: getRumorOrThrow(db, rumorId),
|
|
3689
|
+
questline,
|
|
3690
|
+
quests
|
|
3691
|
+
};
|
|
3692
|
+
});
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// src/rumors/update_rumor.ts
|
|
3696
|
+
function updateRumor(db, rumorId, input) {
|
|
3697
|
+
if (input.title == null && input.details == null) {
|
|
3698
|
+
throw new Error("At least one of title or details must be provided to update a rumor.");
|
|
3699
|
+
}
|
|
3700
|
+
const current = getRumorOrThrow(db, rumorId);
|
|
3701
|
+
if (current.settledAt != null) {
|
|
3702
|
+
throw new Error(`Rumor ${rumorId} is already settled and cannot be updated.`);
|
|
3703
|
+
}
|
|
3704
|
+
if (current.dismissedAt != null) {
|
|
3705
|
+
throw new Error(`Rumor ${rumorId} is dismissed and cannot be updated. Reopen it first.`);
|
|
3706
|
+
}
|
|
3707
|
+
const now = resolveNow(input.now);
|
|
3708
|
+
const nextTitle = input.title != null ? assertNonEmpty(input.title, "Rumor title") : current.title;
|
|
3709
|
+
const nextDetails = input.details != null ? input.details.trim() || null : current.details;
|
|
3710
|
+
db.prepare(
|
|
3711
|
+
`UPDATE rumors
|
|
3712
|
+
SET title = ?, details = ?, updated_at = ?
|
|
3713
|
+
WHERE id = ? AND deleted_at IS NULL`
|
|
3714
|
+
).run(nextTitle, nextDetails, now, rumorId);
|
|
3715
|
+
return getRumorOrThrow(db, rumorId);
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
// src/tools/shape_work_tool.ts
|
|
3719
|
+
function shapeWorkToolHandler(db, input) {
|
|
3720
|
+
try {
|
|
3721
|
+
switch (input.action) {
|
|
3722
|
+
case "dismiss_rumor": {
|
|
3723
|
+
const current = getRumorDetail(db, input.rumorId);
|
|
3724
|
+
if (current.state === "dismissed") {
|
|
3725
|
+
return toolNoOp(
|
|
3726
|
+
`Rumor \`${current.title}\` is already dismissed.`,
|
|
3727
|
+
{
|
|
3728
|
+
action: input.action,
|
|
3729
|
+
primary: toRumorRef(current),
|
|
3730
|
+
rumor: current
|
|
3731
|
+
},
|
|
3732
|
+
{
|
|
3733
|
+
entities: [toRumorRef(current)]
|
|
3734
|
+
}
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
const rumor = dismissRumor(db, input.rumorId, input.dismissedAt);
|
|
3738
|
+
return toolSuccess(
|
|
3739
|
+
`Dismissed rumor \`${rumor.title}\`.`,
|
|
3740
|
+
{
|
|
3741
|
+
action: input.action,
|
|
3742
|
+
primary: toRumorRef(rumor),
|
|
3743
|
+
updated: [toRumorRef(rumor)],
|
|
3744
|
+
rumor: getRumorDetail(db, rumor.id)
|
|
3745
|
+
},
|
|
3746
|
+
{
|
|
3747
|
+
entities: [toRumorRef(rumor)]
|
|
3748
|
+
}
|
|
3749
|
+
);
|
|
3750
|
+
}
|
|
3751
|
+
case "reopen_rumor": {
|
|
3752
|
+
const current = getRumorDetail(db, input.rumorId);
|
|
3753
|
+
if (current.state === "open") {
|
|
3754
|
+
return toolNoOp(
|
|
3755
|
+
`Rumor \`${current.title}\` is already open.`,
|
|
3756
|
+
{
|
|
3757
|
+
action: input.action,
|
|
3758
|
+
primary: toRumorRef(current),
|
|
3759
|
+
rumor: current
|
|
3760
|
+
},
|
|
3761
|
+
{
|
|
3762
|
+
entities: [toRumorRef(current)]
|
|
3763
|
+
}
|
|
3764
|
+
);
|
|
3765
|
+
}
|
|
3766
|
+
const rumor = reopenRumor(db, input.rumorId, input.now);
|
|
3767
|
+
const detail = getRumorDetail(db, rumor.id);
|
|
3768
|
+
return toolSuccess(
|
|
3769
|
+
`Reopened rumor \`${detail.title}\`.`,
|
|
3770
|
+
{
|
|
3771
|
+
action: input.action,
|
|
3772
|
+
primary: toRumorRef(detail),
|
|
3773
|
+
updated: [toRumorRef(detail)],
|
|
3774
|
+
rumor: detail
|
|
3775
|
+
},
|
|
3776
|
+
{
|
|
3777
|
+
entities: [toRumorRef(detail)],
|
|
3778
|
+
next: [inspectItemNext(toRumorRef(detail))]
|
|
3779
|
+
}
|
|
3780
|
+
);
|
|
3781
|
+
}
|
|
3782
|
+
case "settle_rumor": {
|
|
3783
|
+
if (!input.questline && (!input.quests || input.quests.length === 0)) {
|
|
3784
|
+
return toolNeedsClarification(
|
|
3785
|
+
"missing_required_choice",
|
|
3786
|
+
"How should this rumor be settled into real work?",
|
|
3787
|
+
["questline", "quests"]
|
|
3788
|
+
);
|
|
3789
|
+
}
|
|
3790
|
+
const settleInput = {};
|
|
3791
|
+
if (input.questline) {
|
|
3792
|
+
settleInput.questline = input.questline;
|
|
3793
|
+
}
|
|
3794
|
+
if (input.quests) {
|
|
3795
|
+
settleInput.quests = input.quests;
|
|
3796
|
+
}
|
|
3797
|
+
if (input.settledAt != null) {
|
|
3798
|
+
settleInput.settledAt = input.settledAt;
|
|
3799
|
+
}
|
|
3800
|
+
const settled = settleRumor(db, input.rumorId, settleInput);
|
|
3801
|
+
const rumorDetail = getRumorDetail(db, settled.rumor.id);
|
|
3802
|
+
const createdQuestline = settled.questline == null ? void 0 : getQuestlineDetail(db, settled.questline.id);
|
|
3803
|
+
const createdQuests = settled.quests.map((quest) => getQuestDetail(db, quest.id));
|
|
3804
|
+
return toolSuccess(
|
|
3805
|
+
`Settled rumor \`${rumorDetail.title}\`.`,
|
|
3806
|
+
{
|
|
3807
|
+
action: input.action,
|
|
3808
|
+
primary: toRumorRef(rumorDetail),
|
|
3809
|
+
created: [
|
|
3810
|
+
...createdQuestline ? [toQuestlineRef(createdQuestline)] : [],
|
|
3811
|
+
...createdQuests.map(toQuestRef)
|
|
3812
|
+
],
|
|
3813
|
+
updated: [toRumorRef(rumorDetail)],
|
|
3814
|
+
rumor: rumorDetail,
|
|
3815
|
+
questline: createdQuestline,
|
|
3816
|
+
quests: createdQuests
|
|
3817
|
+
},
|
|
3818
|
+
{
|
|
3819
|
+
entities: [
|
|
3820
|
+
toRumorRef(rumorDetail),
|
|
3821
|
+
...createdQuestline ? [toQuestlineRef(createdQuestline)] : [],
|
|
3822
|
+
...createdQuests.map(toQuestRef)
|
|
3823
|
+
],
|
|
3824
|
+
next: [reviewViewNext("quests.available")]
|
|
3825
|
+
}
|
|
3826
|
+
);
|
|
3827
|
+
}
|
|
3828
|
+
case "create_quest": {
|
|
3829
|
+
const quest = createQuest(db, input.quest);
|
|
3830
|
+
const detail = getQuestDetail(db, quest.id);
|
|
3831
|
+
return toolSuccess(
|
|
3832
|
+
`Created quest \`${detail.title}\`.`,
|
|
3833
|
+
{
|
|
3834
|
+
action: input.action,
|
|
3835
|
+
primary: toQuestRef(detail),
|
|
3836
|
+
created: [toQuestRef(detail)],
|
|
3837
|
+
quest: detail
|
|
3838
|
+
},
|
|
3839
|
+
{
|
|
3840
|
+
entities: [toQuestRef(detail)],
|
|
3841
|
+
next: [inspectItemNext(toQuestRef(detail))]
|
|
3842
|
+
}
|
|
3843
|
+
);
|
|
3844
|
+
}
|
|
3845
|
+
case "create_questline": {
|
|
3846
|
+
const questline = createQuestline(db, input.questline);
|
|
3847
|
+
const detail = getQuestlineDetail(db, questline.id);
|
|
3848
|
+
return toolSuccess(
|
|
3849
|
+
`Created questline \`${detail.title}\`.`,
|
|
3850
|
+
{
|
|
3851
|
+
action: input.action,
|
|
3852
|
+
primary: toQuestlineRef(detail),
|
|
3853
|
+
created: [toQuestlineRef(detail)],
|
|
3854
|
+
questline: detail
|
|
3855
|
+
},
|
|
3856
|
+
{
|
|
3857
|
+
entities: [toQuestlineRef(detail)]
|
|
3858
|
+
}
|
|
3859
|
+
);
|
|
3860
|
+
}
|
|
3861
|
+
case "attach_quest_to_questline": {
|
|
3862
|
+
const current = getQuestDetail(db, input.questId);
|
|
3863
|
+
if (current.questlineId === input.questlineId) {
|
|
3864
|
+
return toolNoOp(
|
|
3865
|
+
`Quest \`${current.title}\` is already in that questline.`,
|
|
3866
|
+
{
|
|
3867
|
+
action: input.action,
|
|
3868
|
+
primary: toQuestRef(current),
|
|
3869
|
+
quest: current
|
|
3870
|
+
},
|
|
3871
|
+
{
|
|
3872
|
+
entities: [toQuestRef(current)]
|
|
3873
|
+
}
|
|
3874
|
+
);
|
|
3875
|
+
}
|
|
3876
|
+
moveQuestToQuestline(db, input.questId, input.questlineId, input.now);
|
|
3877
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3878
|
+
return toolSuccess(
|
|
3879
|
+
`Attached quest \`${detail.title}\` to questline ${input.questlineId}.`,
|
|
3880
|
+
{
|
|
3881
|
+
action: input.action,
|
|
3882
|
+
primary: toQuestRef(detail),
|
|
3883
|
+
updated: [
|
|
3884
|
+
toQuestRef(detail),
|
|
3885
|
+
toQuestlineRef(getQuestlineDetail(db, input.questlineId))
|
|
3886
|
+
],
|
|
3887
|
+
quest: detail,
|
|
3888
|
+
questline: getQuestlineDetail(db, input.questlineId)
|
|
3889
|
+
},
|
|
3890
|
+
{
|
|
3891
|
+
entities: [toQuestRef(detail), { kind: "questline", id: input.questlineId }]
|
|
3892
|
+
}
|
|
3893
|
+
);
|
|
3894
|
+
}
|
|
3895
|
+
case "detach_quest_from_questline": {
|
|
3896
|
+
const current = getQuestDetail(db, input.questId);
|
|
3897
|
+
if (current.questlineId == null) {
|
|
3898
|
+
return toolNoOp(
|
|
3899
|
+
`Quest \`${current.title}\` is not attached to a questline.`,
|
|
3900
|
+
{
|
|
3901
|
+
action: input.action,
|
|
3902
|
+
primary: toQuestRef(current),
|
|
3903
|
+
quest: current
|
|
3904
|
+
},
|
|
3905
|
+
{
|
|
3906
|
+
entities: [toQuestRef(current)]
|
|
3907
|
+
}
|
|
3908
|
+
);
|
|
3909
|
+
}
|
|
3910
|
+
detachQuestFromQuestline(db, input.questId, input.now);
|
|
3911
|
+
const detail = getQuestDetail(db, input.questId);
|
|
3912
|
+
return toolSuccess(
|
|
3913
|
+
`Detached quest \`${detail.title}\` from its questline.`,
|
|
3914
|
+
{
|
|
3915
|
+
action: input.action,
|
|
3916
|
+
primary: toQuestRef(detail),
|
|
3917
|
+
updated: [toQuestRef(detail)],
|
|
3918
|
+
quest: detail
|
|
3919
|
+
},
|
|
3920
|
+
{
|
|
3921
|
+
entities: [toQuestRef(detail)]
|
|
3922
|
+
}
|
|
3923
|
+
);
|
|
3924
|
+
}
|
|
3925
|
+
case "update_rumor": {
|
|
3926
|
+
updateRumor(db, input.rumorId, {
|
|
3927
|
+
...input.title != null && { title: input.title },
|
|
3928
|
+
...input.details != null && { details: input.details },
|
|
3929
|
+
...input.now != null && { now: input.now }
|
|
3930
|
+
});
|
|
3931
|
+
const detail = getRumorDetail(db, input.rumorId);
|
|
3932
|
+
return toolSuccess(
|
|
3933
|
+
`Updated rumor \`${detail.title}\`.`,
|
|
3934
|
+
{
|
|
3935
|
+
action: input.action,
|
|
3936
|
+
primary: toRumorRef(detail),
|
|
3937
|
+
updated: [toRumorRef(detail)],
|
|
3938
|
+
rumor: detail
|
|
3939
|
+
},
|
|
3940
|
+
{
|
|
3941
|
+
entities: [toRumorRef(detail)],
|
|
3942
|
+
next: [inspectItemNext(toRumorRef(detail))]
|
|
3943
|
+
}
|
|
3944
|
+
);
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
} catch (error) {
|
|
3948
|
+
return translateToolError(error, {
|
|
3949
|
+
summary: `Could not complete shape-work action \`${input.action}\`.`
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
var shapeWorkTool = defineQuestlogTool({
|
|
3954
|
+
name: shapeWorkToolName,
|
|
3955
|
+
description: "Create a quest (with timing, tags, and objective in one call), create a questline, settle a rumor into concrete work, or manage quest-questline membership. Use create_quest for clear commitments \u2014 include tags and timing directly to avoid follow-up calls.",
|
|
3956
|
+
whenToUse: "Use this when deciding what real work exists and how it should be grouped before or around execution. Prefer a single create_quest call with all fields over separate create + plan_quest + tag_work calls.",
|
|
3957
|
+
whenNotToUse: "Do not use this for quest execution steps like starting, finishing, abandoning, or logging effort.",
|
|
3958
|
+
sideEffects: "writes_state",
|
|
3959
|
+
readOnly: false,
|
|
3960
|
+
supportsClarification: true,
|
|
3961
|
+
targetKinds: ["quest", "questline", "rumor"],
|
|
3962
|
+
inputDescriptions: {
|
|
3963
|
+
action: "Which structure-shaping action to perform, such as settling a rumor, creating a quest, or attaching a quest to a questline.",
|
|
3964
|
+
rumorId: "The rumor to dismiss, reopen, or settle when the action targets intake.",
|
|
3965
|
+
questline: "Questline creation data used when creating a questline directly or as part of rumor settlement. Include description for context.",
|
|
3966
|
+
quests: "Concrete quest creation inputs when settling a rumor into real work. Each quest supports the same fields as the quest property \u2014 include timing and tags directly.",
|
|
3967
|
+
settledAt: "Optional settlement timestamp for rumor settlement.",
|
|
3968
|
+
quest: "Quest creation input when directly creating one concrete quest. Include timing fields (dueAt, scheduledStartAt, etc.), tags, and rewards to create a fully specified quest in one call.",
|
|
3969
|
+
questId: "The concrete quest being attached to or detached from a questline.",
|
|
3970
|
+
questlineId: "The questline that should receive the quest for attach actions.",
|
|
3971
|
+
dismissedAt: "Optional dismissal timestamp for dismissing a rumor.",
|
|
3972
|
+
now: "Optional timestamp for reopen, questline membership, or rumor update.",
|
|
3973
|
+
title: "New title for the update_rumor action.",
|
|
3974
|
+
details: "New or enriched context for the update_rumor action. Append evidence as rumors accumulate information across conversations."
|
|
3975
|
+
},
|
|
3976
|
+
outputDescription: "Returns the changed or created rumor, quest, questline, or created quest set for the selected shape-work action, plus purpose-shaped primary and created or updated refs. Repeated safe actions return structured no-op results, and rumor settlement can ask for missing structure instead of failing.",
|
|
3977
|
+
inputSchema: objectSchema(
|
|
3978
|
+
{
|
|
3979
|
+
action: enumSchema("Action to perform.", [
|
|
3980
|
+
"attach_quest_to_questline",
|
|
3981
|
+
"create_quest",
|
|
3982
|
+
"create_questline",
|
|
3983
|
+
"detach_quest_from_questline",
|
|
3984
|
+
"dismiss_rumor",
|
|
3985
|
+
"reopen_rumor",
|
|
3986
|
+
"settle_rumor",
|
|
3987
|
+
"update_rumor"
|
|
3988
|
+
]),
|
|
3989
|
+
questId: integerSchema("Quest to attach or detach."),
|
|
3990
|
+
questlineId: integerSchema("Questline that should receive the quest."),
|
|
3991
|
+
quest: objectSchema(
|
|
3992
|
+
{
|
|
3993
|
+
title: stringSchema("Title for the new quest."),
|
|
3994
|
+
objective: stringSchema("Objective for the new quest."),
|
|
3995
|
+
questlineId: integerSchema("Optional questline to place the quest in."),
|
|
3996
|
+
tags: arraySchema(stringSchema("Tag name."), "Optional tags to apply at creation."),
|
|
3997
|
+
rewards: arraySchema(
|
|
3998
|
+
objectSchema(
|
|
3999
|
+
{
|
|
4000
|
+
kind: stringSchema("Reward kind."),
|
|
4001
|
+
name: stringSchema("Reward name.")
|
|
4002
|
+
},
|
|
4003
|
+
["kind", "name"]
|
|
4004
|
+
),
|
|
4005
|
+
"Optional rewards to attach at creation."
|
|
4006
|
+
),
|
|
4007
|
+
dueAt: integerSchema("Optional latest acceptable completion timestamp."),
|
|
4008
|
+
notBeforeAt: integerSchema("Optional earliest actionable timestamp."),
|
|
4009
|
+
scheduledStartAt: integerSchema("Optional planned schedule window start."),
|
|
4010
|
+
scheduledEndAt: integerSchema("Optional planned schedule window end."),
|
|
4011
|
+
allDay: booleanSchema("Whether the scheduled window is all-day."),
|
|
4012
|
+
estimateSeconds: integerSchema("Optional active-effort estimate in seconds.")
|
|
4013
|
+
},
|
|
4014
|
+
["title", "objective"]
|
|
4015
|
+
),
|
|
4016
|
+
questline: objectSchema(
|
|
4017
|
+
{
|
|
4018
|
+
title: stringSchema("Questline title."),
|
|
4019
|
+
description: stringSchema("Optional questline description.")
|
|
4020
|
+
},
|
|
4021
|
+
[]
|
|
4022
|
+
),
|
|
4023
|
+
rumorId: integerSchema("Rumor to settle, dismiss, or reopen."),
|
|
4024
|
+
dismissedAt: integerSchema("Optional dismissal timestamp."),
|
|
4025
|
+
settledAt: integerSchema("Optional settlement timestamp."),
|
|
4026
|
+
quests: arraySchema(
|
|
4027
|
+
objectSchema(
|
|
4028
|
+
{
|
|
4029
|
+
title: stringSchema("Title for a created quest."),
|
|
4030
|
+
objective: stringSchema("Objective for a created quest."),
|
|
4031
|
+
tags: arraySchema(stringSchema("Tag name."), "Optional tags to apply at creation."),
|
|
4032
|
+
rewards: arraySchema(
|
|
4033
|
+
objectSchema(
|
|
4034
|
+
{
|
|
4035
|
+
kind: stringSchema("Reward kind."),
|
|
4036
|
+
name: stringSchema("Reward name.")
|
|
4037
|
+
},
|
|
4038
|
+
["kind", "name"]
|
|
4039
|
+
),
|
|
4040
|
+
"Optional rewards to attach at creation."
|
|
4041
|
+
),
|
|
4042
|
+
dueAt: integerSchema("Optional latest acceptable completion timestamp."),
|
|
4043
|
+
notBeforeAt: integerSchema("Optional earliest actionable timestamp."),
|
|
4044
|
+
scheduledStartAt: integerSchema("Optional planned schedule window start."),
|
|
4045
|
+
scheduledEndAt: integerSchema("Optional planned schedule window end."),
|
|
4046
|
+
allDay: booleanSchema("Whether the scheduled window is all-day."),
|
|
4047
|
+
estimateSeconds: integerSchema("Optional active-effort estimate in seconds.")
|
|
4048
|
+
},
|
|
4049
|
+
["title", "objective"]
|
|
4050
|
+
),
|
|
4051
|
+
"Quests to create during rumor settlement. Include timing and tags directly."
|
|
4052
|
+
),
|
|
4053
|
+
now: integerSchema("Optional timestamp for membership, reopen, or rumor update."),
|
|
4054
|
+
title: stringSchema("New title for the update_rumor action."),
|
|
4055
|
+
details: stringSchema("New or enriched context text for the update_rumor action.")
|
|
4056
|
+
},
|
|
4057
|
+
["action"],
|
|
4058
|
+
"Create quests with timing and tags, create questlines, settle rumors into work, manage quest-questline membership, or enrich open rumors with new context."
|
|
4059
|
+
),
|
|
4060
|
+
handler: shapeWorkToolHandler
|
|
4061
|
+
});
|
|
4062
|
+
|
|
4063
|
+
// src/tags/replace_quest_tags.ts
|
|
4064
|
+
function replaceQuestTags(db, questId, tagNames, now) {
|
|
4065
|
+
assertActiveRowExists(db, "quests", questId, "Quest");
|
|
4066
|
+
replaceActiveQuestTags(db, questId, tagNames, now);
|
|
4067
|
+
db.prepare("UPDATE quests SET updated_at = ? WHERE id = ?").run(resolveNow(now), questId);
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
// src/tags/replace_repeatable_quest_tags.ts
|
|
4071
|
+
function replaceRepeatableQuestTags(db, repeatableQuestId, tagNames, now) {
|
|
4072
|
+
assertActiveRowExists(db, "repeatable_quests", repeatableQuestId, "Repeatable quest");
|
|
4073
|
+
replaceActiveRepeatableQuestTags(db, repeatableQuestId, tagNames, now);
|
|
4074
|
+
db.prepare("UPDATE repeatable_quests SET updated_at = ? WHERE id = ?").run(
|
|
4075
|
+
resolveNow(now),
|
|
4076
|
+
repeatableQuestId
|
|
4077
|
+
);
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
// src/tags/tag_quest.ts
|
|
4081
|
+
function tagQuest(db, questId, tagNames, now) {
|
|
4082
|
+
assertActiveRowExists(db, "quests", questId, "Quest");
|
|
4083
|
+
const current = db.prepare(
|
|
4084
|
+
`SELECT t.name
|
|
4085
|
+
FROM quest_tags qt
|
|
4086
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
4087
|
+
WHERE qt.quest_id = ? AND qt.deleted_at IS NULL AND t.deleted_at IS NULL`
|
|
4088
|
+
).all(questId).map((row) => String(row.name));
|
|
4089
|
+
replaceActiveQuestTags(db, questId, [...current, ...tagNames], now);
|
|
4090
|
+
db.prepare("UPDATE quests SET updated_at = ? WHERE id = ?").run(resolveNow(now), questId);
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// src/tags/untag_quest.ts
|
|
4094
|
+
function untagQuest(db, questId, tagNames, now) {
|
|
4095
|
+
assertActiveRowExists(db, "quests", questId, "Quest");
|
|
4096
|
+
const removeSet = new Set(tagNames.map((tag) => tag.trim().toLowerCase()));
|
|
4097
|
+
const current = db.prepare(
|
|
4098
|
+
`SELECT t.name
|
|
4099
|
+
FROM quest_tags qt
|
|
4100
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
4101
|
+
WHERE qt.quest_id = ? AND qt.deleted_at IS NULL AND t.deleted_at IS NULL`
|
|
4102
|
+
).all(questId).map((row) => String(row.name)).filter((name) => !removeSet.has(name.trim().toLowerCase()));
|
|
4103
|
+
replaceActiveQuestTags(db, questId, current, now);
|
|
4104
|
+
db.prepare("UPDATE quests SET updated_at = ? WHERE id = ?").run(resolveNow(now), questId);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
// src/tools/tag_work_tool.ts
|
|
4108
|
+
function normalizeTagNames(tagNames) {
|
|
4109
|
+
return [...new Set(tagNames.map((tag) => tag.trim()).filter(Boolean))];
|
|
4110
|
+
}
|
|
4111
|
+
function readQuestTagNames(db, questId) {
|
|
4112
|
+
return db.prepare(
|
|
4113
|
+
`SELECT t.name
|
|
4114
|
+
FROM quest_tags qt
|
|
4115
|
+
JOIN tags t ON t.id = qt.tag_id
|
|
4116
|
+
WHERE qt.quest_id = ? AND qt.deleted_at IS NULL AND t.deleted_at IS NULL
|
|
4117
|
+
ORDER BY t.name ASC`
|
|
4118
|
+
).all(questId).map((row) => String(row.name));
|
|
4119
|
+
}
|
|
4120
|
+
function readRepeatableTagNames(db, repeatableQuestId) {
|
|
4121
|
+
return db.prepare(
|
|
4122
|
+
`SELECT t.name
|
|
4123
|
+
FROM repeatable_quest_tags rqt
|
|
4124
|
+
JOIN tags t ON t.id = rqt.tag_id
|
|
4125
|
+
WHERE rqt.repeatable_quest_id = ? AND rqt.deleted_at IS NULL AND t.deleted_at IS NULL
|
|
4126
|
+
ORDER BY t.name ASC`
|
|
4127
|
+
).all(repeatableQuestId).map((row) => String(row.name));
|
|
4128
|
+
}
|
|
4129
|
+
function tagWorkToolHandler(db, input) {
|
|
4130
|
+
try {
|
|
4131
|
+
const normalized = normalizeTagNames(input.tagNames);
|
|
4132
|
+
if (input.target.kind === "quest") {
|
|
4133
|
+
assertActiveRowExists(db, "quests", input.target.id, "Quest");
|
|
4134
|
+
const current2 = readQuestTagNames(db, input.target.id);
|
|
4135
|
+
const currentSet2 = new Set(current2.map((name) => name.trim().toLowerCase()));
|
|
4136
|
+
const normalizedSet2 = new Set(normalized.map((name) => name.trim().toLowerCase()));
|
|
4137
|
+
if (input.action === "add") {
|
|
4138
|
+
if (normalized.every((name) => currentSet2.has(name.trim().toLowerCase()))) {
|
|
4139
|
+
return toolNoOp(
|
|
4140
|
+
"Those tags are already present on the quest.",
|
|
4141
|
+
{
|
|
4142
|
+
action: input.action,
|
|
4143
|
+
target: input.target,
|
|
4144
|
+
tagNames: current2
|
|
4145
|
+
},
|
|
4146
|
+
{
|
|
4147
|
+
entities: [{ kind: "quest", id: input.target.id }]
|
|
4148
|
+
}
|
|
4149
|
+
);
|
|
4150
|
+
}
|
|
4151
|
+
tagQuest(db, input.target.id, normalized, input.now);
|
|
4152
|
+
} else if (input.action === "remove") {
|
|
4153
|
+
if (normalized.every((name) => !currentSet2.has(name.trim().toLowerCase()))) {
|
|
4154
|
+
return toolNoOp(
|
|
4155
|
+
"None of those tags are present on the quest.",
|
|
4156
|
+
{
|
|
4157
|
+
action: input.action,
|
|
4158
|
+
target: input.target,
|
|
4159
|
+
tagNames: current2
|
|
4160
|
+
},
|
|
4161
|
+
{
|
|
4162
|
+
entities: [{ kind: "quest", id: input.target.id }]
|
|
4163
|
+
}
|
|
4164
|
+
);
|
|
4165
|
+
}
|
|
4166
|
+
untagQuest(db, input.target.id, normalized, input.now);
|
|
4167
|
+
} else {
|
|
4168
|
+
const same2 = currentSet2.size === normalizedSet2.size && [...currentSet2].every((value) => normalizedSet2.has(value));
|
|
4169
|
+
if (same2) {
|
|
4170
|
+
return toolNoOp(
|
|
4171
|
+
"The quest already has exactly that tag set.",
|
|
4172
|
+
{
|
|
4173
|
+
action: input.action,
|
|
4174
|
+
target: input.target,
|
|
4175
|
+
tagNames: current2
|
|
4176
|
+
},
|
|
4177
|
+
{
|
|
4178
|
+
entities: [{ kind: "quest", id: input.target.id }]
|
|
4179
|
+
}
|
|
4180
|
+
);
|
|
4181
|
+
}
|
|
4182
|
+
replaceQuestTags(db, input.target.id, normalized, input.now);
|
|
4183
|
+
}
|
|
4184
|
+
return toolSuccess(
|
|
4185
|
+
"Updated quest tags.",
|
|
4186
|
+
{
|
|
4187
|
+
action: input.action,
|
|
4188
|
+
target: input.target,
|
|
4189
|
+
tagNames: readQuestTagNames(db, input.target.id)
|
|
4190
|
+
},
|
|
4191
|
+
{
|
|
4192
|
+
entities: [{ kind: "quest", id: input.target.id }]
|
|
4193
|
+
}
|
|
4194
|
+
);
|
|
4195
|
+
}
|
|
4196
|
+
if (input.action !== "replace") {
|
|
4197
|
+
return toolFailure(
|
|
4198
|
+
"protocol",
|
|
4199
|
+
"unsupported_target",
|
|
4200
|
+
"Repeatable quest tags only support replacement.",
|
|
4201
|
+
"Use the replace action when changing the future auto-tag template set for a repeatable quest.",
|
|
4202
|
+
{
|
|
4203
|
+
entities: [{ kind: "repeatable_quest", id: input.target.id }]
|
|
4204
|
+
}
|
|
4205
|
+
);
|
|
4206
|
+
}
|
|
4207
|
+
assertActiveRowExists(db, "repeatable_quests", input.target.id, "Repeatable quest");
|
|
4208
|
+
const current = readRepeatableTagNames(db, input.target.id);
|
|
4209
|
+
const currentSet = new Set(current.map((name) => name.trim().toLowerCase()));
|
|
4210
|
+
const normalizedSet = new Set(normalized.map((name) => name.trim().toLowerCase()));
|
|
4211
|
+
const same = currentSet.size === normalizedSet.size && [...currentSet].every((value) => normalizedSet.has(value));
|
|
4212
|
+
if (same) {
|
|
4213
|
+
return toolNoOp(
|
|
4214
|
+
"The repeatable quest already has exactly that future tag template set.",
|
|
4215
|
+
{
|
|
4216
|
+
action: input.action,
|
|
4217
|
+
target: input.target,
|
|
4218
|
+
tagNames: current
|
|
4219
|
+
},
|
|
4220
|
+
{
|
|
4221
|
+
entities: [{ kind: "repeatable_quest", id: input.target.id }]
|
|
4222
|
+
}
|
|
4223
|
+
);
|
|
4224
|
+
}
|
|
4225
|
+
replaceRepeatableQuestTags(db, input.target.id, normalized, input.now);
|
|
4226
|
+
return toolSuccess(
|
|
4227
|
+
"Updated repeatable quest future tags.",
|
|
4228
|
+
{
|
|
4229
|
+
action: input.action,
|
|
4230
|
+
target: input.target,
|
|
4231
|
+
tagNames: readRepeatableTagNames(db, input.target.id)
|
|
4232
|
+
},
|
|
4233
|
+
{
|
|
4234
|
+
entities: [{ kind: "repeatable_quest", id: input.target.id }]
|
|
4235
|
+
}
|
|
4236
|
+
);
|
|
4237
|
+
} catch (error) {
|
|
4238
|
+
return translateToolError(error, {
|
|
4239
|
+
summary: `Could not complete tag-work action \`${input.action}\`.`
|
|
4240
|
+
});
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
var tagWorkTool = defineQuestlogTool({
|
|
4244
|
+
name: tagWorkToolName,
|
|
4245
|
+
description: "Classify work by adding, removing, or replacing tags on quests and by replacing the future tag template set on repeatable quests.",
|
|
4246
|
+
whenToUse: "Use this when you want classification, reporting, or saved-filter semantics rather than lifecycle or dependency semantics.",
|
|
4247
|
+
whenNotToUse: "Do not use tags to model status, time planning, or hard prerequisites.",
|
|
4248
|
+
sideEffects: "writes_state",
|
|
4249
|
+
readOnly: false,
|
|
4250
|
+
supportsClarification: false,
|
|
4251
|
+
targetKinds: ["quest", "repeatable_quest"],
|
|
4252
|
+
inputDescriptions: {
|
|
4253
|
+
action: "Whether to add, remove, or replace tags on the chosen target.",
|
|
4254
|
+
target: "The quest or repeatable quest whose tag set should change.",
|
|
4255
|
+
tagNames: "The tag names to add, remove, or make the full replacement set.",
|
|
4256
|
+
now: "Optional timestamp for the tag mutation."
|
|
4257
|
+
},
|
|
4258
|
+
outputDescription: "Returns the normalized final tag set for the target after the selected tag action. Repeated equivalent changes return structured no-op results.",
|
|
4259
|
+
inputSchema: objectSchema(
|
|
4260
|
+
{
|
|
4261
|
+
action: enumSchema("Action to perform.", ["add", "remove", "replace"]),
|
|
4262
|
+
target: objectSchema(
|
|
4263
|
+
{
|
|
4264
|
+
id: integerSchema("Target entity id."),
|
|
4265
|
+
kind: enumSchema("Target kind.", ["quest", "repeatable_quest"])
|
|
4266
|
+
},
|
|
4267
|
+
["id", "kind"]
|
|
4268
|
+
),
|
|
4269
|
+
tagNames: arraySchema(stringSchema("Tag name."), "Tag names to add, remove, or replace."),
|
|
4270
|
+
now: integerSchema("Optional timestamp for the tag mutation.")
|
|
4271
|
+
},
|
|
4272
|
+
["action", "target", "tagNames"],
|
|
4273
|
+
"Mutate tags on quests or repeatable quests."
|
|
4274
|
+
),
|
|
4275
|
+
handler: tagWorkToolHandler
|
|
4276
|
+
});
|
|
4277
|
+
|
|
4278
|
+
// src/tools/tool_registry.ts
|
|
4279
|
+
var questlogTools = [
|
|
4280
|
+
searchQuestlogTool,
|
|
4281
|
+
reviewQuestlogTool,
|
|
4282
|
+
inspectQuestlogItemTool,
|
|
4283
|
+
captureRumorTool,
|
|
4284
|
+
shapeWorkTool,
|
|
4285
|
+
planQuestTool,
|
|
4286
|
+
runQuestTool,
|
|
4287
|
+
organizeWorkTool,
|
|
4288
|
+
manageRepeatableTool,
|
|
4289
|
+
tagWorkTool,
|
|
4290
|
+
rewardWorkTool,
|
|
4291
|
+
retireWorkTool
|
|
4292
|
+
];
|
|
4293
|
+
|
|
4294
|
+
// src/surface/questlog_surface_tools.ts
|
|
4295
|
+
var questlogSurfaceTools = questlogTools.map(
|
|
4296
|
+
(tool) => ({
|
|
4297
|
+
name: tool.name,
|
|
4298
|
+
description: tool.description,
|
|
4299
|
+
whenToUse: tool.whenToUse,
|
|
4300
|
+
whenNotToUse: tool.whenNotToUse ?? "",
|
|
4301
|
+
readOnly: tool.readOnly,
|
|
4302
|
+
sideEffects: tool.sideEffects,
|
|
4303
|
+
inputSchema: { ...tool.inputSchema, type: "object" },
|
|
4304
|
+
handler: tool.handler
|
|
4305
|
+
})
|
|
4306
|
+
);
|
|
4307
|
+
|
|
4308
|
+
export { getQuestDetail, questlogSurfaceTools, reviewQuestlogTool };
|