@theronap/agnoclast-mcp 0.9.248 → 0.9.250
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 +53 -7
- 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
|
|
@@ -1948,7 +1951,7 @@ function renderNudge(payload) {
|
|
|
1948
1951
|
{
|
|
1949
1952
|
title: 'Ask to send an email from the user\'s mailbox (Gmail or Outlook)',
|
|
1950
1953
|
description:
|
|
1951
|
-
'Send an email from the USER\'S OWN connected mailbox, Gmail or Outlook. By default this does NOT send immediately: it creates a request that WAITS for the user to approve it in Agnoclast, and it goes out at once only if their own send rule allows it. The message is frozen when the request is made, so write the final wording. Always show the user what you are about to send and get their agreement first. Returns either an approval link to relay to them, or confirmation that it already went. ⚠ NEVER call this twice for the same email — it is not idempotent, and a retry can send twice.',
|
|
1954
|
+
'Send an email from the USER\'S OWN connected mailbox, Gmail or Outlook. By default this does NOT send immediately: it creates a request that WAITS for the user to approve it in Agnoclast, and it goes out at once only if their own send rule allows it. The message is frozen when the request is made, so write the final wording. Always show the user what you are about to send and get their agreement first. Returns either an approval link to relay to them, or confirmation that it already went. ⚠ NEVER call this twice for the same email — it is not idempotent, and a retry can send twice. IF YOU ARE AN AI AGENT WITH YOUR OWN IDENTITY (a member agent): you send from YOUR OWN address, labelled as an AI agent of your owner, and one of your overseers approves unless their rule for you allows it; a line saying an AI wrote it is added for you. Pass from: \'overseer\' only if an overseer has opened their mailbox to you.',
|
|
1952
1955
|
inputSchema: {
|
|
1953
1956
|
to: z.array(z.string()).min(1).describe('recipient email addresses'),
|
|
1954
1957
|
subject: z.string().min(1).describe('the subject line'),
|
|
@@ -1958,9 +1961,11 @@ function renderNudge(payload) {
|
|
|
1958
1961
|
replyToRecordId: z.string().optional().describe('reply in the thread of this Agnoclast email record'),
|
|
1959
1962
|
replyToMessageId: z.string().optional().describe('reply in the thread of this message id (Gmail or Outlook)'),
|
|
1960
1963
|
account: z.string().email().optional().describe('which connected mailbox (Gmail or Outlook) to send from, when the user has more than one'),
|
|
1964
|
+
from: z.enum(['own', 'overseer']).optional().describe("AI member agents only: 'own' (default) = your own address; 'overseer' = the mailbox an overseer opened to you"),
|
|
1965
|
+
brain: z.string().optional().describe('AI member agents only: which brain this email is for, when you are in more than one'),
|
|
1961
1966
|
},
|
|
1962
1967
|
},
|
|
1963
|
-
async ({ to, subject, text, cc, bcc, replyToRecordId, replyToMessageId, account }) => {
|
|
1968
|
+
async ({ to, subject, text, cc, bcc, replyToRecordId, replyToMessageId, account, from, brain }) => {
|
|
1964
1969
|
let res
|
|
1965
1970
|
try {
|
|
1966
1971
|
res = await fetchCortex(`${BASE}/api/gmail/send-requests`, {
|
|
@@ -1972,6 +1977,8 @@ function renderNudge(payload) {
|
|
|
1972
1977
|
...(replyToRecordId ? { replyToRecordId } : {}),
|
|
1973
1978
|
...(replyToMessageId ? { replyToMessageId } : {}),
|
|
1974
1979
|
...(account ? { account } : {}),
|
|
1980
|
+
...(from ? { from } : {}),
|
|
1981
|
+
...(brain ? { brain } : {}),
|
|
1975
1982
|
}),
|
|
1976
1983
|
}, { idempotent: false, retries: 0 })
|
|
1977
1984
|
} catch (e) {
|
|
@@ -1988,6 +1995,12 @@ function renderNudge(payload) {
|
|
|
1988
1995
|
const reviewUrl = body?.reviewUrl
|
|
1989
1996
|
const acct = body?.request?.account ?? 'your mailbox'
|
|
1990
1997
|
const n = body?.request?.recipientCount ?? to.length
|
|
1998
|
+
// ADR-0092: a member agent's request waits for one of its OVERSEERS, not for "you".
|
|
1999
|
+
if (reviewUrl && body?.waitingFor) {
|
|
2000
|
+
return { content: [{ type: 'text', text:
|
|
2001
|
+
`Email request created from ${acct} to ${n} recipient${n === 1 ? '' : 's'}. It is NOT sent yet: it waits for ` +
|
|
2002
|
+
`${body.waitingFor} to approve it in Agnoclast (${reviewUrl}). Do not send it again.` }] }
|
|
2003
|
+
}
|
|
1991
2004
|
if (reviewUrl) {
|
|
1992
2005
|
// Relayed VERBATIM — this exact sentence is the contract the create route records.
|
|
1993
2006
|
return { content: [{ type: 'text', text:
|
|
@@ -5603,21 +5616,54 @@ function renderNudge(payload) {
|
|
|
5603
5616
|
'create_event',
|
|
5604
5617
|
{
|
|
5605
5618
|
title: 'Add to my Agnoclast calendar',
|
|
5606
|
-
description:
|
|
5619
|
+
description: CREATE_EVENT_DESCRIPTION,
|
|
5607
5620
|
inputSchema: {
|
|
5608
5621
|
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(
|
|
5622
|
+
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.'),
|
|
5623
|
+
end: z.string().optional().describe("ISO 8601 end; omit if unknown — never guess one. With `repeat`, the first meeting's end (required)."),
|
|
5611
5624
|
all_day: z.boolean().optional().describe('true for an all-day event or deadline'),
|
|
5612
5625
|
location: z.string().optional().describe('where, if known'),
|
|
5613
5626
|
notes: z.string().optional().describe('anything else the person said about it'),
|
|
5627
|
+
repeat: z.object({
|
|
5628
|
+
days: z.array(z.string()).describe('the days it meets: MO TU WE TH FR SA SU'),
|
|
5629
|
+
until: z.string().describe('the last date it can meet, YYYY-MM-DD (e.g. the end of the semester) — ask if unknown'),
|
|
5630
|
+
from: z.string().optional().describe('first date, YYYY-MM-DD (default: the date of `start`)'),
|
|
5631
|
+
}).optional().describe("make it repeat weekly, like a class: every meeting has the first one's time and length"),
|
|
5632
|
+
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'),
|
|
5633
|
+
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
5634
|
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
5635
|
},
|
|
5616
5636
|
},
|
|
5617
|
-
async ({ title, start, end, all_day, location, notes, brain }) => {
|
|
5637
|
+
async ({ title, start, end, all_day, location, notes, repeat, page, container_type, brain }) => {
|
|
5618
5638
|
const name = String(title ?? '').trim()
|
|
5619
5639
|
if (!name) return toolError('title is required.')
|
|
5620
5640
|
const tz = await personTz()
|
|
5641
|
+
if (repeat) {
|
|
5642
|
+
// One call, a whole series (POST /api/series with materialize) — same body as the connector.
|
|
5643
|
+
const s = resolveSeriesPlacement({ start, end, allDay: all_day, days: repeat.days, until: repeat.until, from: repeat.from }, tz)
|
|
5644
|
+
if (!s.ok) return toolError(s.error)
|
|
5645
|
+
const pg = page?.trim()
|
|
5646
|
+
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.')
|
|
5647
|
+
let res
|
|
5648
|
+
try {
|
|
5649
|
+
res = await fetchCortex(`${BASE}/api/series`, {
|
|
5650
|
+
method: 'POST',
|
|
5651
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
5652
|
+
body: JSON.stringify(seriesRequestBody({
|
|
5653
|
+
title: name, page: pg, brain: brain?.trim() || undefined, location: location?.trim() || undefined, containerType: container_type,
|
|
5654
|
+
}, s.placement)),
|
|
5655
|
+
})
|
|
5656
|
+
} catch (e) { return toolError(`Could not reach Agnoclast to add "${name}": ${e.message}`) }
|
|
5657
|
+
const body = await res.json().catch(() => ({}))
|
|
5658
|
+
if (res.status === 400 && body?.error === 'brain_required') {
|
|
5659
|
+
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.`)
|
|
5660
|
+
}
|
|
5661
|
+
if (!res.ok || body?.ok !== true) {
|
|
5662
|
+
return toolError(body?.detail ? `Not added: ${body.detail}` : `Could not add "${name}": ${body?.error ?? res.status}`)
|
|
5663
|
+
}
|
|
5664
|
+
const text = renderSeriesCreated(name, describeSeriesPlacement(s.placement), tz, body)
|
|
5665
|
+
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 }] }
|
|
5666
|
+
}
|
|
5621
5667
|
const t = resolveEventTimes({ start, end, allDay: all_day }, tz)
|
|
5622
5668
|
if (!t.ok) return toolError(t.error)
|
|
5623
5669
|
const when = describeEventTime(t, tz)
|
package/package.json
CHANGED