@theronap/agnoclast-mcp 0.9.248 → 0.9.249
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/lib/calendar_events.mjs +104 -0
- package/lib/server.mjs +41 -5
- package/package.json +1 -1
package/lib/calendar_events.mjs
CHANGED
|
@@ -85,6 +85,87 @@ export function describeEventTime(t, tz) {
|
|
|
85
85
|
return `${day}, ${time(t.startsAt)}${t.endsAt ? `–${time(t.endsAt)}` : ''}`
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ── a repeating event (event_time.ts, same names) ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export const WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
|
|
91
|
+
|
|
92
|
+
function localDateTime(d, tz) {
|
|
93
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
94
|
+
timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
95
|
+
}).formatToParts(d)
|
|
96
|
+
const v = (t) => parts.find((p) => p.type === t)?.value ?? ''
|
|
97
|
+
return { date: `${v('year')}-${v('month')}-${v('day')}`, time: `${v('hour')}:${v('minute')}` }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function weekday(raw) {
|
|
101
|
+
const two = typeof raw === 'string' ? raw.trim().slice(0, 2).toUpperCase() : ''
|
|
102
|
+
return WEEKDAYS.includes(two) ? two : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A repeating event's schedule: the first meeting's start/end, the days, and the last date. */
|
|
106
|
+
export function resolveSeriesPlacement(input, tz) {
|
|
107
|
+
const start = typeof input.start === 'string' ? input.start.trim() : ''
|
|
108
|
+
if (input.allDay === true || DATE_RE.test(start)) {
|
|
109
|
+
return { ok: false, error: 'A repeating event needs a time of day — give `start` and `end` with times (e.g. 2026-09-03T15:30). Repeating all-day events are not supported yet.' }
|
|
110
|
+
}
|
|
111
|
+
const t = resolveEventTimes({ start: input.start, end: input.end }, tz)
|
|
112
|
+
if (!t.ok) return t
|
|
113
|
+
if (!t.endsAt) return { ok: false, error: 'A repeating event needs an `end`, because every meeting is that long. If the person did not say how long it lasts, ASK.' }
|
|
114
|
+
|
|
115
|
+
const raw = Array.isArray(input.days) ? input.days : []
|
|
116
|
+
const bad = raw.filter((d) => weekday(d) === null)
|
|
117
|
+
if (bad.length) return { ok: false, error: `Not a day of the week: ${bad.map(String).join(', ')}. Use MO TU WE TH FR SA SU.` }
|
|
118
|
+
const picked = new Set(raw.map(weekday))
|
|
119
|
+
const byday = WEEKDAYS.filter((d) => picked.has(d))
|
|
120
|
+
if (byday.length === 0) return { ok: false, error: '`repeat.days` is required: the days of the week it repeats on, e.g. ["TU","TH"].' }
|
|
121
|
+
|
|
122
|
+
const durationMinutes = Math.round((Date.parse(t.endsAt) - Date.parse(t.startsAt)) / 60000)
|
|
123
|
+
if (durationMinutes > 24 * 60) return { ok: false, error: 'Each meeting of a repeating event has to fit in one day — check `end`.' }
|
|
124
|
+
|
|
125
|
+
const first = localDateTime(new Date(t.startsAt), tz)
|
|
126
|
+
const from = typeof input.from === 'string' && input.from.trim() ? input.from.trim() : first.date
|
|
127
|
+
if (!DATE_RE.test(from) || Number.isNaN(Date.parse(`${from}T00:00:00Z`))) return { ok: false, error: `repeat.from is not a date: ${from}` }
|
|
128
|
+
const until = typeof input.until === 'string' ? input.until.trim() : ''
|
|
129
|
+
if (!until) return { ok: false, error: '`repeat.until` is required: the last date it can meet (YYYY-MM-DD), e.g. the end of the semester. Ask the person if they did not say.' }
|
|
130
|
+
if (!DATE_RE.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`))) return { ok: false, error: `repeat.until is not a date: ${until}` }
|
|
131
|
+
if (until < from) return { ok: false, error: `repeat.until (${until}) is before the first meeting (${from}).` }
|
|
132
|
+
|
|
133
|
+
return { ok: true, placement: { byday, startsTime: first.time, durationMinutes, windowFrom: from, windowUntil: until, timezone: tz } }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function seriesKeyFor(title, windowFrom) {
|
|
137
|
+
const slug = title.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'event'
|
|
138
|
+
return `${slug}-${windowFrom.replace(/-/g, '')}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** POST /api/series body for create_event with `repeat` — the same shape the connector sends. */
|
|
142
|
+
export function seriesRequestBody(args, p) {
|
|
143
|
+
return {
|
|
144
|
+
title: args.title,
|
|
145
|
+
seriesKey: seriesKeyFor(args.title, p.windowFrom),
|
|
146
|
+
page: args.page,
|
|
147
|
+
brain: args.brain,
|
|
148
|
+
location: args.location ?? null,
|
|
149
|
+
containerType: args.containerType ?? 'appointment',
|
|
150
|
+
placement: p,
|
|
151
|
+
materialize: true,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const DAY_NAMES = { MO: 'Mon', TU: 'Tue', WE: 'Wed', TH: 'Thu', FR: 'Fri', SA: 'Sat', SU: 'Sun' }
|
|
156
|
+
|
|
157
|
+
export function describeSeriesPlacement(p) {
|
|
158
|
+
const [h, m] = p.startsTime.split(':').map(Number)
|
|
159
|
+
const clock = (mins) => new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', hour: 'numeric', minute: '2-digit' })
|
|
160
|
+
.format(new Date(Date.UTC(2000, 0, 1, 0, mins % (24 * 60))))
|
|
161
|
+
const startMin = h * 60 + m
|
|
162
|
+
const date = (ymd) => new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'short', month: 'short', day: 'numeric' })
|
|
163
|
+
.format(new Date(`${ymd}T12:00:00Z`))
|
|
164
|
+
const names = p.byday.map((d) => DAY_NAMES[d])
|
|
165
|
+
const days = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0]
|
|
166
|
+
return `every ${days}, ${clock(startMin)}–${clock(startMin + p.durationMinutes)}, ${date(p.windowFrom)} to ${date(p.windowUntil)}`
|
|
167
|
+
}
|
|
168
|
+
|
|
88
169
|
// ── mcp_remote_render.ts ────────────────────────────────────────────────────────────────────────
|
|
89
170
|
|
|
90
171
|
function dayLabel(d, tz) {
|
|
@@ -131,6 +212,29 @@ export function renderEventCreated(title, when, tz, body) {
|
|
|
131
212
|
(body?.id ? `\nEvent id: ${body.id} — if anything about it changes, fix it with update_event; do not save a second copy.` : '')
|
|
132
213
|
}
|
|
133
214
|
|
|
215
|
+
export const CREATE_EVENT_DESCRIPTION = 'Put an event on the person\'s calendar — a class, meeting, deadline, appointment or reminder — whenever they ask to add, schedule or put something on their calendar. Agnoclast is their calendar: use this rather than another calendar tool (such as Google Calendar) unless they name that other calendar. Give `start` as ISO 8601 (a time with no offset is read in their own timezone), an `end`, or `all_day: true` with a date. If they gave no end or length for a timed event, ASK how long before saving rather than leaving it open. For something that REPEATS weekly (a class, a standing meeting), call this ONCE with `repeat` {days, until} and `page`, e.g. start "2026-09-03T15:30", end "2026-09-03T16:45", repeat {days:["TU","TH"], until:"2026-12-10"}, page "STRAT 421" — never one call per meeting. It appears on their Agnoclast calendar and in `upcoming`; it is not copied to Google or Outlook and invites no one. To change it later, use update_event — never save a second copy.'
|
|
216
|
+
|
|
217
|
+
/** create_event's answer for a repeating event. */
|
|
218
|
+
export function renderSeriesCreated(title, when, tz, body) {
|
|
219
|
+
const n = body?.generated ?? 0
|
|
220
|
+
const lines = [`Added a repeating event to the Agnoclast calendar: "${oneLine(title, 200)}", ${when} (${tz}).`]
|
|
221
|
+
if (n > 0) {
|
|
222
|
+
const first = body?.firstStartsAt
|
|
223
|
+
? ` The first is ${dayLabel(new Date(body.firstStartsAt), tz)}, ${timeLabel(new Date(body.firstStartsAt), tz)}${body.firstEndsAt ? `–${timeLabel(new Date(body.firstEndsAt), tz)}` : ''}.`
|
|
224
|
+
: ''
|
|
225
|
+
lines.push(`${n} meeting${n === 1 ? ' is' : 's are'} on the calendar${body?.through ? ` through ${allDayLabel(body.through)}` : ''}.${first}`)
|
|
226
|
+
if (body?.through && body?.windowUntil && body.through < body.windowUntil) {
|
|
227
|
+
lines.push(`⚠ Only the meetings through ${allDayLabel(body.through)} were added; the later ones (to ${allDayLabel(body.windowUntil)}) are not on the calendar.`)
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
lines.push('⚠ No meetings were added: none of the chosen days falls between the first date and `until`. Check the days and dates with the person.')
|
|
231
|
+
}
|
|
232
|
+
if (body?.page?.title) lines.push(`Filed to the page "${oneLine(body.page.title, 120)}", so anything from those meetings lands there.`)
|
|
233
|
+
lines.push(`It shows on ${CONSOLE}/calendar and in upcoming. Nothing was added to Google or Outlook, and no one was invited.`)
|
|
234
|
+
lines.push(`To move ONE meeting, use update_event with that meeting's event id from upcoming. Changing the whole series' days or times is not possible yet${body?.identifier ? ` (series ${body.identifier})` : ''}.`)
|
|
235
|
+
return lines.join('\n')
|
|
236
|
+
}
|
|
237
|
+
|
|
134
238
|
export function renderEventUpdated(b) {
|
|
135
239
|
const changed = (b?.changed ?? []).filter((c) => ['title', 'start', 'end', 'location'].includes(c))
|
|
136
240
|
const where = b?.location ? `, at ${oneLine(b.location, 120)}` : ''
|
package/lib/server.mjs
CHANGED
|
@@ -35,7 +35,10 @@ import { applyRelease, fetchReleaseState, releaseStateWithin } from './release_g
|
|
|
35
35
|
import { filterUnshownSettling } from './settling_shown.mjs'
|
|
36
36
|
import { renderContactBriefing, renderContactCandidates, renderContactList } from './contacts_render.mjs'
|
|
37
37
|
import { renderConversation, renderMessageInbox } from './messages_render.mjs'
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
CREATE_EVENT_DESCRIPTION, captureEventBody, describeEventTime, describeSeriesPlacement, renderEventCreated, renderEventUpdated,
|
|
40
|
+
renderSeriesCreated, renderUpcoming, resolveEventTimes, resolveSeriesPlacement, seriesRequestBody, updateEventBody,
|
|
41
|
+
} from './calendar_events.mjs'
|
|
39
42
|
|
|
40
43
|
// PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
|
|
41
44
|
// set_record_privacy below) — an owner-relative tier is applied to a page's records when they are read
|
|
@@ -5603,21 +5606,54 @@ function renderNudge(payload) {
|
|
|
5603
5606
|
'create_event',
|
|
5604
5607
|
{
|
|
5605
5608
|
title: 'Add to my Agnoclast calendar',
|
|
5606
|
-
description:
|
|
5609
|
+
description: CREATE_EVENT_DESCRIPTION,
|
|
5607
5610
|
inputSchema: {
|
|
5608
5611
|
title: z.string().describe('what the event is, e.g. "Study group — STRAT 421"'),
|
|
5609
|
-
start: z.string().describe('ISO 8601, e.g. "2026-10-08T15:00"; a date (YYYY-MM-DD) for an all-day event'),
|
|
5610
|
-
end: z.string().optional().describe(
|
|
5612
|
+
start: z.string().describe('ISO 8601, e.g. "2026-10-08T15:00"; a date (YYYY-MM-DD) for an all-day event. With `repeat`, the FIRST meeting.'),
|
|
5613
|
+
end: z.string().optional().describe("ISO 8601 end; omit if unknown — never guess one. With `repeat`, the first meeting's end (required)."),
|
|
5611
5614
|
all_day: z.boolean().optional().describe('true for an all-day event or deadline'),
|
|
5612
5615
|
location: z.string().optional().describe('where, if known'),
|
|
5613
5616
|
notes: z.string().optional().describe('anything else the person said about it'),
|
|
5617
|
+
repeat: z.object({
|
|
5618
|
+
days: z.array(z.string()).describe('the days it meets: MO TU WE TH FR SA SU'),
|
|
5619
|
+
until: z.string().describe('the last date it can meet, YYYY-MM-DD (e.g. the end of the semester) — ask if unknown'),
|
|
5620
|
+
from: z.string().optional().describe('first date, YYYY-MM-DD (default: the date of `start`)'),
|
|
5621
|
+
}).optional().describe("make it repeat weekly, like a class: every meeting has the first one's time and length"),
|
|
5622
|
+
page: z.string().optional().describe('with `repeat`: the page its meetings file to (e.g. the class page), by name or ref — required; ask if unclear'),
|
|
5623
|
+
container_type: z.enum(['meeting', 'work_block', 'deadline', 'appointment']).optional().describe('with `repeat`: what each meeting owes — appointment (default, nothing), meeting (a transcript or note), work_block, deadline'),
|
|
5614
5624
|
brain: z.string().optional().describe('which brain, by name or org id. Required only when the person belongs to more than one — ask them; placing something in a brain decides who can see it.'),
|
|
5615
5625
|
},
|
|
5616
5626
|
},
|
|
5617
|
-
async ({ title, start, end, all_day, location, notes, brain }) => {
|
|
5627
|
+
async ({ title, start, end, all_day, location, notes, repeat, page, container_type, brain }) => {
|
|
5618
5628
|
const name = String(title ?? '').trim()
|
|
5619
5629
|
if (!name) return toolError('title is required.')
|
|
5620
5630
|
const tz = await personTz()
|
|
5631
|
+
if (repeat) {
|
|
5632
|
+
// One call, a whole series (POST /api/series with materialize) — same body as the connector.
|
|
5633
|
+
const s = resolveSeriesPlacement({ start, end, allDay: all_day, days: repeat.days, until: repeat.until, from: repeat.from }, tz)
|
|
5634
|
+
if (!s.ok) return toolError(s.error)
|
|
5635
|
+
const pg = page?.trim()
|
|
5636
|
+
if (!pg) return toolError('A repeating event needs `page`: the page its meetings file to (e.g. the class page). Ask the person which page — or create one first with author — then call again.')
|
|
5637
|
+
let res
|
|
5638
|
+
try {
|
|
5639
|
+
res = await fetchCortex(`${BASE}/api/series`, {
|
|
5640
|
+
method: 'POST',
|
|
5641
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
5642
|
+
body: JSON.stringify(seriesRequestBody({
|
|
5643
|
+
title: name, page: pg, brain: brain?.trim() || undefined, location: location?.trim() || undefined, containerType: container_type,
|
|
5644
|
+
}, s.placement)),
|
|
5645
|
+
})
|
|
5646
|
+
} catch (e) { return toolError(`Could not reach Agnoclast to add "${name}": ${e.message}`) }
|
|
5647
|
+
const body = await res.json().catch(() => ({}))
|
|
5648
|
+
if (res.status === 400 && body?.error === 'brain_required') {
|
|
5649
|
+
return toolError(`You belong to more than one brain, so say which one "${name}" goes in (my_brains lists them) and call again with \`brain\`. The brain decides who can see it — ask the person if they did not say.`)
|
|
5650
|
+
}
|
|
5651
|
+
if (!res.ok || body?.ok !== true) {
|
|
5652
|
+
return toolError(body?.detail ? `Not added: ${body.detail}` : `Could not add "${name}": ${body?.error ?? res.status}`)
|
|
5653
|
+
}
|
|
5654
|
+
const text = renderSeriesCreated(name, describeSeriesPlacement(s.placement), tz, body)
|
|
5655
|
+
return { content: [{ type: 'text', text: notes?.trim() ? `${text}\nThe notes were not saved: a repeating event has nowhere to keep them yet — put them on the page.` : text }] }
|
|
5656
|
+
}
|
|
5621
5657
|
const t = resolveEventTimes({ start, end, allDay: all_day }, tz)
|
|
5622
5658
|
if (!t.ok) return toolError(t.error)
|
|
5623
5659
|
const when = describeEventTime(t, tz)
|
package/package.json
CHANGED