@tiongcreative/assistant-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/package.json +64 -4
- package/src/apps/client-manager.js +623 -0
- package/src/apps/due-again.js +384 -0
- package/src/apps/gift-keeper.js +427 -0
- package/src/apps/lists-and-notes.js +166 -0
- package/src/connection.js +115 -0
- package/src/dates.js +18 -0
- package/src/index.js +116 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Due Again: recurring commitments (renewals, maintenance, appointments).
|
|
2
|
+
// Talks to the app's local API with the per-launch token it writes while
|
|
3
|
+
// "Allow assistant access" is on (due-again/server/assistant.js).
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { appConnection, apiCall, notAvailable } from '../connection.js';
|
|
6
|
+
import { daysBetween, todayLocal } from '../dates.js';
|
|
7
|
+
|
|
8
|
+
export const app = {
|
|
9
|
+
id: 'due-again',
|
|
10
|
+
name: 'Due Again',
|
|
11
|
+
bundleName: 'Due Again', // what `open -a` is given
|
|
12
|
+
dataDir: 'due-again',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// --- shaping ---
|
|
16
|
+
|
|
17
|
+
function status(item, today, dueSoonDays) {
|
|
18
|
+
if (item.archived) return 'archived';
|
|
19
|
+
if (!item.nextDue) return item.recurrence?.type === 'once' ? 'done' : 'unscheduled';
|
|
20
|
+
const days = daysBetween(today, item.nextDue);
|
|
21
|
+
if (days < 0) return 'overdue';
|
|
22
|
+
if (days === 0) return 'due-today';
|
|
23
|
+
if (days <= dueSoonDays) return 'due-soon';
|
|
24
|
+
return 'upcoming';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarise(item, categories, today, dueSoonDays) {
|
|
28
|
+
const days = item.nextDue ? daysBetween(today, item.nextDue) : null;
|
|
29
|
+
return {
|
|
30
|
+
id: item.id,
|
|
31
|
+
title: item.title,
|
|
32
|
+
category: categories.get(item.categoryId) ?? item.categoryId,
|
|
33
|
+
status: status(item, today, dueSoonDays),
|
|
34
|
+
nextDue: item.nextDue,
|
|
35
|
+
daysUntilDue: days,
|
|
36
|
+
recurrence: item.recurrence,
|
|
37
|
+
expectedCost: item.expectedCost,
|
|
38
|
+
currency: item.currency ?? null,
|
|
39
|
+
costVaries: item.costVaries ?? false,
|
|
40
|
+
notes: item.notes || undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadContext(conn) {
|
|
45
|
+
const [items, categories, settings] = await Promise.all([
|
|
46
|
+
apiCall(conn, 'GET', '/api/items'),
|
|
47
|
+
apiCall(conn, 'GET', '/api/categories'),
|
|
48
|
+
apiCall(conn, 'GET', '/api/settings'),
|
|
49
|
+
]);
|
|
50
|
+
return {
|
|
51
|
+
items,
|
|
52
|
+
categories: new Map(categories.map((c) => [c.id, c.name])),
|
|
53
|
+
dueSoonDays: settings.dueSoonDays ?? 14,
|
|
54
|
+
today: todayLocal(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- tools ---
|
|
59
|
+
|
|
60
|
+
export const tools = [
|
|
61
|
+
{
|
|
62
|
+
name: 'due_again_items',
|
|
63
|
+
description:
|
|
64
|
+
'List items in Due Again (recurring commitments such as renewals, insurance, maintenance and appointments) with their status and next due date. Filter by status or look ahead a number of days.',
|
|
65
|
+
input: z.object({
|
|
66
|
+
status: z
|
|
67
|
+
.enum(['overdue', 'due-today', 'due-soon', 'upcoming', 'unscheduled', 'done', 'archived'])
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Only items with this status'),
|
|
70
|
+
withinDays: z
|
|
71
|
+
.number()
|
|
72
|
+
.int()
|
|
73
|
+
.min(0)
|
|
74
|
+
.max(730)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('Only items due within this many days (overdue items included)'),
|
|
77
|
+
search: z.string().max(100).optional().describe('Case-insensitive match on the title'),
|
|
78
|
+
}),
|
|
79
|
+
async run(input) {
|
|
80
|
+
const conn = await appConnection(app);
|
|
81
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
82
|
+
const ctx = await loadContext(conn);
|
|
83
|
+
let list = ctx.items.map((i) => summarise(i, ctx.categories, ctx.today, ctx.dueSoonDays));
|
|
84
|
+
if (input.status) list = list.filter((i) => i.status === input.status);
|
|
85
|
+
else list = list.filter((i) => i.status !== 'archived');
|
|
86
|
+
if (input.withinDays !== undefined) {
|
|
87
|
+
list = list.filter((i) => i.daysUntilDue !== null && i.daysUntilDue <= input.withinDays);
|
|
88
|
+
}
|
|
89
|
+
if (input.search) {
|
|
90
|
+
const q = input.search.toLowerCase();
|
|
91
|
+
list = list.filter((i) => i.title.toLowerCase().includes(q));
|
|
92
|
+
}
|
|
93
|
+
list.sort((a, b) => (a.daysUntilDue ?? Infinity) - (b.daysUntilDue ?? Infinity));
|
|
94
|
+
return { app: app.name, today: ctx.today, count: list.length, items: list };
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'due_again_history',
|
|
99
|
+
description:
|
|
100
|
+
'The completion history of one Due Again item: when it was done, skipped or rescheduled, and what it cost.',
|
|
101
|
+
input: z.object({
|
|
102
|
+
itemId: z.string().min(1),
|
|
103
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
104
|
+
}),
|
|
105
|
+
async run(input) {
|
|
106
|
+
const conn = await appConnection(app);
|
|
107
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
108
|
+
const item = await apiCall(conn, 'GET', `/api/items/${encodeURIComponent(input.itemId)}`);
|
|
109
|
+
const history = await apiCall(
|
|
110
|
+
conn,
|
|
111
|
+
'GET',
|
|
112
|
+
`/api/items/${encodeURIComponent(input.itemId)}/occurrences`,
|
|
113
|
+
);
|
|
114
|
+
const live = history.filter((o) => !o.voided && o.outcome !== 'voided');
|
|
115
|
+
return {
|
|
116
|
+
app: app.name,
|
|
117
|
+
item: { id: item.id, title: item.title, nextDue: item.nextDue },
|
|
118
|
+
history: live.slice(-input.limit).reverse(),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'due_again_complete',
|
|
124
|
+
description:
|
|
125
|
+
'Record that a Due Again item was done. Due Again then works out the next due date from its schedule. Use the item id from due_again_items. Date defaults to today and cannot be in the future.',
|
|
126
|
+
input: z.object({
|
|
127
|
+
itemId: z.string().min(1),
|
|
128
|
+
date: z
|
|
129
|
+
.string()
|
|
130
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
131
|
+
.optional()
|
|
132
|
+
.describe('The day it was done, YYYY-MM-DD. Defaults to today.'),
|
|
133
|
+
cost: z.number().min(0).nullable().optional().describe('What it cost, if known'),
|
|
134
|
+
note: z.string().max(500).optional(),
|
|
135
|
+
}),
|
|
136
|
+
write: true,
|
|
137
|
+
async run(input) {
|
|
138
|
+
const conn = await appConnection(app);
|
|
139
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
140
|
+
const body = {
|
|
141
|
+
date: input.date ?? todayLocal(),
|
|
142
|
+
cost: input.cost ?? null,
|
|
143
|
+
note: input.note ?? '',
|
|
144
|
+
};
|
|
145
|
+
const result = await apiCall(
|
|
146
|
+
conn,
|
|
147
|
+
'POST',
|
|
148
|
+
`/api/items/${encodeURIComponent(input.itemId)}/complete`,
|
|
149
|
+
body,
|
|
150
|
+
);
|
|
151
|
+
return {
|
|
152
|
+
app: app.name,
|
|
153
|
+
changed: 'completed',
|
|
154
|
+
item: { id: result.item.id, title: result.item.title, nextDue: result.item.nextDue },
|
|
155
|
+
occurrence: result.occurrence,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'due_again_add_item',
|
|
161
|
+
description:
|
|
162
|
+
'Add a new item to Due Again: something that comes due on a date, once or on a schedule (a renewal, a bill, maintenance, an appointment). Give the category by name; if it does not match, the reply lists the categories to choose from. Reminders are days before the due date.',
|
|
163
|
+
input: z.object({
|
|
164
|
+
title: z.string().trim().min(1).max(200),
|
|
165
|
+
category: z.string().min(1).max(50).describe('Category name, e.g. Home, Car, Insurance'),
|
|
166
|
+
nextDue: z
|
|
167
|
+
.string()
|
|
168
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
169
|
+
.describe('When it is next due, YYYY-MM-DD'),
|
|
170
|
+
repeat: z
|
|
171
|
+
.enum(['once', 'annual', 'days', 'weeks', 'months', 'years'])
|
|
172
|
+
.default('once')
|
|
173
|
+
.describe('How it repeats; days/weeks/months/years need an interval'),
|
|
174
|
+
interval: z
|
|
175
|
+
.number()
|
|
176
|
+
.int()
|
|
177
|
+
.min(1)
|
|
178
|
+
.max(999)
|
|
179
|
+
.optional()
|
|
180
|
+
.describe('Every N days/weeks/months/years'),
|
|
181
|
+
calculateFrom: z
|
|
182
|
+
.enum(['scheduled', 'completion'])
|
|
183
|
+
.default('scheduled')
|
|
184
|
+
.describe('Whether the next date follows the schedule or the day it was actually done'),
|
|
185
|
+
expectedCost: z.number().min(0).nullable().optional(),
|
|
186
|
+
costVaries: z.boolean().optional().describe('The cost is an estimate that changes each time'),
|
|
187
|
+
reminders: z.array(z.number().int().min(0).max(3650)).max(10).optional(),
|
|
188
|
+
description: z.string().max(2000).optional(),
|
|
189
|
+
notes: z.string().max(10000).optional(),
|
|
190
|
+
}),
|
|
191
|
+
write: true,
|
|
192
|
+
async run(input) {
|
|
193
|
+
const conn = await appConnection(app);
|
|
194
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
195
|
+
const categories = await apiCall(conn, 'GET', '/api/categories');
|
|
196
|
+
const wanted = input.category.trim().toLowerCase();
|
|
197
|
+
const category = categories.find((c) => c.name.toLowerCase() === wanted);
|
|
198
|
+
if (!category) {
|
|
199
|
+
return {
|
|
200
|
+
app: app.name,
|
|
201
|
+
added: false,
|
|
202
|
+
message: `No category called "${input.category}". Choose one of these and try again.`,
|
|
203
|
+
categories: categories.map((c) => c.name),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (['days', 'weeks', 'months', 'years'].includes(input.repeat) && !input.interval) {
|
|
207
|
+
return {
|
|
208
|
+
app: app.name,
|
|
209
|
+
added: false,
|
|
210
|
+
message: `Repeat "${input.repeat}" needs an interval, e.g. every 3 months.`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const recurrence =
|
|
214
|
+
input.repeat === 'once' || input.repeat === 'annual'
|
|
215
|
+
? { type: input.repeat }
|
|
216
|
+
: { type: input.repeat, interval: input.interval };
|
|
217
|
+
const body = {
|
|
218
|
+
title: input.title,
|
|
219
|
+
categoryId: category.id,
|
|
220
|
+
recurrence,
|
|
221
|
+
nextDue: input.nextDue,
|
|
222
|
+
calculateFrom: input.calculateFrom,
|
|
223
|
+
...(input.expectedCost !== undefined ? { expectedCost: input.expectedCost } : {}),
|
|
224
|
+
...(input.costVaries !== undefined ? { costVaries: input.costVaries } : {}),
|
|
225
|
+
...(input.reminders ? { reminders: input.reminders } : {}),
|
|
226
|
+
...(input.description ? { description: input.description } : {}),
|
|
227
|
+
...(input.notes ? { notes: input.notes } : {}),
|
|
228
|
+
};
|
|
229
|
+
const item = await apiCall(conn, 'POST', '/api/items', body);
|
|
230
|
+
return {
|
|
231
|
+
app: app.name,
|
|
232
|
+
added: true,
|
|
233
|
+
item: {
|
|
234
|
+
id: item.id,
|
|
235
|
+
title: item.title,
|
|
236
|
+
category: category.name,
|
|
237
|
+
nextDue: item.nextDue,
|
|
238
|
+
recurrence: item.recurrence,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'due_again_update_item',
|
|
245
|
+
description:
|
|
246
|
+
'Change a Due Again item: move its next due date, rename it, change the expected cost, notes or category. Use the item id from due_again_items. Only the fields given change. To record that it was done use due_again_complete; to skip this occurrence use due_again_skip.',
|
|
247
|
+
input: z.object({
|
|
248
|
+
itemId: z.string().min(1),
|
|
249
|
+
nextDue: z
|
|
250
|
+
.string()
|
|
251
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('New next due date, YYYY-MM-DD'),
|
|
254
|
+
title: z.string().trim().min(1).max(200).optional(),
|
|
255
|
+
expectedCost: z.number().min(0).nullable().optional(),
|
|
256
|
+
costVaries: z.boolean().optional(),
|
|
257
|
+
notes: z.string().max(10000).optional(),
|
|
258
|
+
description: z.string().max(2000).optional(),
|
|
259
|
+
category: z.string().max(50).optional().describe('Category name'),
|
|
260
|
+
}),
|
|
261
|
+
write: true,
|
|
262
|
+
async run(input) {
|
|
263
|
+
const conn = await appConnection(app);
|
|
264
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
265
|
+
const { itemId, category, ...changes } = input;
|
|
266
|
+
if (category) {
|
|
267
|
+
const categories = await apiCall(conn, 'GET', '/api/categories');
|
|
268
|
+
const match = categories.find(
|
|
269
|
+
(c) => c.name.toLowerCase() === category.trim().toLowerCase(),
|
|
270
|
+
);
|
|
271
|
+
if (!match) {
|
|
272
|
+
return {
|
|
273
|
+
app: app.name,
|
|
274
|
+
changed: false,
|
|
275
|
+
message: `No category called "${category}".`,
|
|
276
|
+
categories: categories.map((c) => c.name),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
changes.categoryId = match.id;
|
|
280
|
+
}
|
|
281
|
+
if (Object.keys(changes).length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
app: app.name,
|
|
284
|
+
changed: false,
|
|
285
|
+
message: 'Nothing to change: give a date, title, cost, notes, description or category.',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const item = await apiCall(conn, 'PUT', `/api/items/${encodeURIComponent(itemId)}`, changes);
|
|
289
|
+
const ctx = await loadContext(conn);
|
|
290
|
+
return {
|
|
291
|
+
app: app.name,
|
|
292
|
+
changed: true,
|
|
293
|
+
item: summarise(item, ctx.categories, ctx.today, ctx.dueSoonDays),
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: 'due_again_skip',
|
|
299
|
+
description:
|
|
300
|
+
'Skip the current occurrence of a Due Again item without recording it as done; the next due date moves on by its schedule. Use the item id from due_again_items.',
|
|
301
|
+
input: z.object({
|
|
302
|
+
itemId: z.string().min(1),
|
|
303
|
+
date: z
|
|
304
|
+
.string()
|
|
305
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
306
|
+
.optional()
|
|
307
|
+
.describe('The day it was skipped, defaults to today'),
|
|
308
|
+
note: z.string().max(500).optional(),
|
|
309
|
+
}),
|
|
310
|
+
write: true,
|
|
311
|
+
async run(input) {
|
|
312
|
+
const conn = await appConnection(app);
|
|
313
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
314
|
+
const result = await apiCall(
|
|
315
|
+
conn,
|
|
316
|
+
'POST',
|
|
317
|
+
`/api/items/${encodeURIComponent(input.itemId)}/skip`,
|
|
318
|
+
{
|
|
319
|
+
date: input.date ?? todayLocal(),
|
|
320
|
+
note: input.note ?? '',
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
app: app.name,
|
|
325
|
+
changed: 'skipped',
|
|
326
|
+
item: { id: result.item.id, title: result.item.title, nextDue: result.item.nextDue },
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: 'due_again_reschedule',
|
|
332
|
+
description:
|
|
333
|
+
'Move just the current occurrence of a Due Again item to another date, keeping its series as it is. For a permanent change of schedule use due_again_update_item.',
|
|
334
|
+
input: z.object({
|
|
335
|
+
itemId: z.string().min(1),
|
|
336
|
+
date: z
|
|
337
|
+
.string()
|
|
338
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
339
|
+
.describe('The new date for this occurrence'),
|
|
340
|
+
note: z.string().max(500).optional(),
|
|
341
|
+
}),
|
|
342
|
+
write: true,
|
|
343
|
+
async run(input) {
|
|
344
|
+
const conn = await appConnection(app);
|
|
345
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
346
|
+
const result = await apiCall(
|
|
347
|
+
conn,
|
|
348
|
+
'POST',
|
|
349
|
+
`/api/items/${encodeURIComponent(input.itemId)}/reschedule`,
|
|
350
|
+
{
|
|
351
|
+
date: input.date,
|
|
352
|
+
note: input.note ?? '',
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
app: app.name,
|
|
357
|
+
changed: 'rescheduled',
|
|
358
|
+
item: { id: result.item.id, title: result.item.title, nextDue: result.item.nextDue },
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
/** Lines for the cross-app "what's on" view. */
|
|
365
|
+
export async function attention(days) {
|
|
366
|
+
const conn = await appConnection(app);
|
|
367
|
+
if (!conn.ok) return { app: app.name, available: false, reason: conn.reason, items: [] };
|
|
368
|
+
const ctx = await loadContext(conn);
|
|
369
|
+
const items = ctx.items
|
|
370
|
+
.map((i) => summarise(i, ctx.categories, ctx.today, ctx.dueSoonDays))
|
|
371
|
+
.filter((i) => i.daysUntilDue !== null && i.daysUntilDue <= days && i.status !== 'archived')
|
|
372
|
+
.sort((a, b) => a.daysUntilDue - b.daysUntilDue)
|
|
373
|
+
.map((i) => ({
|
|
374
|
+
app: app.name,
|
|
375
|
+
id: i.id,
|
|
376
|
+
title: i.title,
|
|
377
|
+
date: i.nextDue,
|
|
378
|
+
daysUntilDue: i.daysUntilDue,
|
|
379
|
+
status: i.status,
|
|
380
|
+
expectedCost: i.expectedCost,
|
|
381
|
+
currency: i.currency,
|
|
382
|
+
}));
|
|
383
|
+
return { app: app.name, available: true, items };
|
|
384
|
+
}
|