@theronap/agnoclast-mcp 0.9.247 → 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.
@@ -90,6 +90,12 @@ export function formatPressing(pressing) {
90
90
  const when = !Number.isFinite(m) ? 'Starting soon' : m > 0 ? `In ${mins(m)}` : m === 0 ? 'Starting now' : `Started ${mins(-m)} ago`
91
91
  const where = p.location ? ` · ${String(p.location).replace(/\s+/g, ' ').trim().slice(0, 80)}` : ''
92
92
  lines.push(`⏰ ${when}: ${named(p.title)}${p.at ? ` — ${p.at}` : ''}${where}`)
93
+ } else if (p?.kind === 'record_offer') {
94
+ // Theron, 2026-10-20: "offer to record for me right as an event is starting". An OFFER: ask, and
95
+ // only point them at it — a session cannot hear the room.
96
+ const m = Number(p.minutes)
97
+ const when = Number.isFinite(m) && m < 0 ? `started ${mins(-m)} ago` : 'is starting now'
98
+ lines.push(`🎙 ${named(p.title)} ${when} — OFFER TO RECORD IT: ask if they want it recorded; if yes, Record now in Agnoclast's meeting pill (desktop app or web console) captures it.`)
93
99
  } else if (p?.kind === 'due_soon') {
94
100
  const m = Number(p.minutes)
95
101
  // ⚠ A record may already have discharged this. Then the session must ASK: telling someone they
@@ -165,7 +171,7 @@ export function pressingAcks(pressing) {
165
171
  const handoff = p.kind === 'asked' || p.kind === 'given' || p.kind === 'declined' || p.kind === 'lapsed'
166
172
  || p.kind === 'meeting_missing' || p.kind === 'meeting_waiting'
167
173
  const rollup = (p.kind === 'due_today' || p.kind === 'work_now') && Number(p.count) > 0
168
- if (p.kind === 'event' || p.kind === 'due_soon' || handoff || rollup) {
174
+ if (p.kind === 'event' || p.kind === 'record_offer' || p.kind === 'due_soon' || handoff || rollup) {
169
175
  out.push({ subject: p.subject, bucket: p.bucket })
170
176
  }
171
177
  }
@@ -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 { captureEventBody, describeEventTime, renderEventCreated, renderEventUpdated, renderUpcoming, resolveEventTimes, updateEventBody } from './calendar_events.mjs'
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: "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. 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.",
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('ISO 8601 end; omit if unknown — never guess one'),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.247",
3
+ "version": "0.9.249",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {