@theronap/cortex-mcp 0.9.126 → 0.9.128

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.
Files changed (2) hide show
  1. package/lib/server.mjs +225 -0
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -1993,6 +1993,231 @@ function renderNudge(payload) {
1993
1993
  },
1994
1994
  )
1995
1995
 
1996
+ server.registerTool(
1997
+ 'obligations_from_calendar',
1998
+ {
1999
+ title: 'Meetings that ended and were never written up',
2000
+ description: 'Turn meetings that have already ENDED into "capture what came out of this" obligations. ⚠ THE OBLIGATION IS NOT "ATTEND" — your calendar already does that, and putting every scheduled meeting into your obligations list would bury it (90 of 120 calendar records are recurring birthdays). The gap this fills is real and documented: meeting substance lands wherever the session that heard about it happened to be working, and a walkthrough with no note at all is the normal outcome. ⚠ DRY-RUN BY DEFAULT — it reports what it WOULD create, including every meeting it skipped and why, and writes nothing until apply: true. Excluded on purpose: meetings with no attendee but you (a birthday or a solo hold owes nobody anything), all-day markers, cancelled events, anything with no recorded end, and anything outside the lookback window — no backfill, because obligations nobody will ever action are how a list stops being read. Each one is discharged by a captured transcript ARRIVING, never by one being absent.',
2001
+ inputSchema: {
2002
+ lookback_days: z.number().optional().describe('how far back to sweep (default 14, max 90)'),
2003
+ apply: z.boolean().optional().describe('false/absent = dry run (default). true actually creates them.'),
2004
+ },
2005
+ },
2006
+ async ({ lookback_days, apply }) => {
2007
+ let res
2008
+ try {
2009
+ res = await fetchCortex(`${BASE}/api/obligations/from-calendar`, {
2010
+ method: 'POST',
2011
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2012
+ body: JSON.stringify({ lookbackDays: lookback_days, apply: apply === true }),
2013
+ })
2014
+ } catch (e) { return toolError(`Could not reach the producer: ${e.message}`) }
2015
+ const out = await res.json().catch(() => null)
2016
+ if (!res.ok) return toolError(`Could not run: ${out?.error ?? res.status}`)
2017
+
2018
+ const cands = out?.candidates ?? []
2019
+ const eligible = cands.filter((c) => !c.skipped)
2020
+ const lines = []
2021
+ if (out.mode === 'dry-run') {
2022
+ lines.push(`DRY RUN \u2014 nothing written. ${out.scanned} meeting(s) scanned, ${eligible.length} would become obligations.`)
2023
+ } else {
2024
+ lines.push(`${out.created} obligation(s) created from ${out.scanned} meeting(s) scanned.`)
2025
+ }
2026
+ if (eligible.length) {
2027
+ lines.push('', 'Would track:')
2028
+ for (const c of eligible.slice(0, 15)) {
2029
+ lines.push(` ${c.endedAt.slice(0, 10)} ${c.title ?? '(untitled)'} \u2014 ${c.attendees.length} attendee(s)`)
2030
+ }
2031
+ }
2032
+ // \u26a0 THE SKIPS ARE PRINTED, NOT SUMMARISED AWAY. A producer that silently drops most of its
2033
+ // input reads as "there was nothing to do", which is indistinguishable from a broken filter.
2034
+ const skipped = cands.filter((c) => c.skipped)
2035
+ if (skipped.length) {
2036
+ const byReason = {}
2037
+ for (const c of skipped) byReason[c.skipped] = (byReason[c.skipped] ?? 0) + 1
2038
+ lines.push('', `Skipped ${skipped.length}:`)
2039
+ for (const [reason, n] of Object.entries(byReason).sort((a, b) => b[1] - a[1])) {
2040
+ lines.push(` ${n} ${reason}`)
2041
+ }
2042
+ }
2043
+ if (out.mode === 'dry-run' && eligible.length) lines.push('', 'Re-run with apply: true to create them.')
2044
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2045
+ },
2046
+ )
2047
+
2048
+ server.registerTool(
2049
+ 'my_obligations',
2050
+ {
2051
+ title: 'What I owe',
2052
+ description: 'What you owe — every open obligation across your brains, soonest-due first. The answer to "what am I on the hook for", which until now was spread across four unrelated mechanisms (red-links, the unclaimed backlog, "Open threads" prose in ~367 page sections, and session logs) with no way to query any of them as work. ⚠ READ THE EVIDENCE LINE. An obligation may carry records that MIGHT mean it is already done — a reply from the person you were waiting on, a merged PR. Evidence never closes anything, because passive signals only ever observe successes and failure emits silence, so treating them as proof would close work that never happened. It is there so you ASK "a reply came in — is this done?" instead of telling someone they still owe something they finished last week. Nagging about finished work is the expensive error: it teaches the reader the whole channel is noise.',
2053
+ inputSchema: {
2054
+ closed: z.boolean().optional().describe('true = also show discharged/cancelled ones'),
2055
+ limit: z.number().optional().describe('default 50'),
2056
+ },
2057
+ },
2058
+ async ({ closed, limit }) => {
2059
+ let res
2060
+ try {
2061
+ const qs = new URLSearchParams()
2062
+ if (closed) qs.set('closed', 'true')
2063
+ if (limit) qs.set('limit', String(limit))
2064
+ res = await fetchCortex(`${BASE}/api/obligations?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
2065
+ } catch (e) { return toolError(`Could not reach obligations: ${e.message}`) }
2066
+ const out = await res.json().catch(() => null)
2067
+ if (!res.ok) return toolError(`Could not list: ${out?.error ?? res.status}`)
2068
+ const obs = out?.obligations ?? []
2069
+ if (!obs.length) return { content: [{ type: 'text', text: 'Nothing open.' }] }
2070
+
2071
+ const now = Date.now()
2072
+ const lines = [`${obs.length} open:`, '']
2073
+ for (const o of obs) {
2074
+ const overdue = o.dueAt && new Date(o.dueAt).getTime() <= now
2075
+ const when = o.dueAt ? `${overdue ? 'OVERDUE ' : 'due '}${o.dueAt.slice(0, 10)}` : 'no deadline'
2076
+ lines.push(`${o.id}`)
2077
+ lines.push(` ${o.subject} \u2014 ${when}${o.state === 'snoozed' ? ' (snooze expired)' : ''}`)
2078
+ // \u26a0 EVIDENCE IS RENDERED AS A QUESTION, NEVER AS A VERDICT. The phrasing is the feature:
2079
+ // "is this done?" costs the reader a second, "you still owe this" about finished work costs
2080
+ // the channel its credibility.
2081
+ if (o.evidence?.length) {
2082
+ lines.push(` \u2753 ${o.evidence.length} record(s) suggest this may already be done \u2014 check, then resolve_obligation:`)
2083
+ for (const e of o.evidence.slice(0, 3)) {
2084
+ lines.push(` ${e.occurredAt.slice(0, 10)} ${e.title ?? '(untitled)'}${e.viaIdentifier ? ` [${e.viaIdentifier}]` : ''}`)
2085
+ }
2086
+ }
2087
+ lines.push('')
2088
+ }
2089
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2090
+ },
2091
+ )
2092
+
2093
+ server.registerTool(
2094
+ 'track_obligation',
2095
+ {
2096
+ title: 'Record something you owe',
2097
+ description: "Record something you owe: a subject, optionally when it comes due, optionally whose reply would discharge it. ⚠ RECORDING IS NOT REMINDING — this stores an obligation and schedules nothing; nothing will nudge you about it. ⚠ NEVER INVENT A DUE DATE. Omit due_at when there genuinely is no deadline (a standing obligation); a guessed date is indistinguishable from a real one the moment it is stored, and it is the direct route to nagging about something that was never actually late. `anchor_identifier` is the useful part: give it the counterparty's address as `email:someone@example.com` and any record arriving from them is automatically offered as evidence that this may be done — computed when the record lands, not by a scanner.",
2098
+ inputSchema: {
2099
+ subject: z.string().describe('what is owed, in your own words'),
2100
+ due_at: z.string().optional().describe('ISO 8601. OMIT when there is no real deadline — never guess one.'),
2101
+ anchor_identifier: z.string().optional().describe('e.g. "email:ben@example.com" — records from them become evidence this may be done'),
2102
+ source_record_id: z.string().optional().describe('the record this came from, if any'),
2103
+ brain: z.string().optional().describe('required only if you belong to more than one brain'),
2104
+ },
2105
+ },
2106
+ async ({ subject, due_at, anchor_identifier, source_record_id, brain }) => {
2107
+ let res
2108
+ try {
2109
+ res = await fetchCortex(`${BASE}/api/obligations`, {
2110
+ method: 'POST',
2111
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2112
+ body: JSON.stringify({ subject, dueAt: due_at, anchorIdentifier: anchor_identifier, sourceRecordId: source_record_id, brain }),
2113
+ })
2114
+ } catch (e) { return toolError(`Could not reach obligations: ${e.message}`) }
2115
+ const out = await res.json().catch(() => null)
2116
+ if (!res.ok) return toolError(`Could not record: ${out?.detail ?? out?.error ?? res.status}`)
2117
+ const note = anchor_identifier
2118
+ ? `\nRecords carrying ${anchor_identifier} will be offered as evidence this is done. Nothing will nudge you.`
2119
+ : '\nNothing will nudge you \u2014 ask with my_obligations.'
2120
+ return { content: [{ type: 'text', text: `Recorded: ${subject}${due_at ? ` (due ${due_at.slice(0, 10)})` : ' (no deadline)'}\n${out.id}${note}` }] }
2121
+ },
2122
+ )
2123
+
2124
+ server.registerTool(
2125
+ 'resolve_obligation',
2126
+ {
2127
+ title: 'Close, cancel or defer an obligation',
2128
+ description: 'Close, cancel or defer an obligation. `discharged` = it happened. `cancelled` = it stopped mattering. These are DIFFERENT FACTS and both require a `resolution` saying how it ended — an obligation closed with no reason is a row nobody can reconstruct later, and "stopped mattering" is the one that gets lost first. `snoozed` needs `snoozed_until`; a snooze with no deadline is a permanent silent drop wearing a deferral\'s clothes, and it is refused. An expired snooze returns to my_obligations on its own.',
2129
+ inputSchema: {
2130
+ obligation_id: z.string().describe('from my_obligations'),
2131
+ state: z.enum(['discharged', 'cancelled', 'snoozed']).describe('discharged = it happened; cancelled = it stopped mattering; snoozed = ask me later'),
2132
+ resolution: z.string().optional().describe('REQUIRED for discharged/cancelled — how it ended'),
2133
+ snoozed_until: z.string().optional().describe('REQUIRED for snoozed — ISO 8601'),
2134
+ },
2135
+ },
2136
+ async ({ obligation_id, state, resolution, snoozed_until }) => {
2137
+ let res
2138
+ try {
2139
+ res = await fetchCortex(`${BASE}/api/obligations/resolve`, {
2140
+ method: 'POST',
2141
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2142
+ body: JSON.stringify({ obligationId: obligation_id, state, resolution, snoozedUntil: snoozed_until }),
2143
+ })
2144
+ } catch (e) { return toolError(`Could not reach obligations: ${e.message}`) }
2145
+ const out = await res.json().catch(() => null)
2146
+ if (!res.ok) return toolError(`Could not resolve: ${out?.error ?? res.status}`)
2147
+ return { content: [{ type: 'text', text: state === 'snoozed' ? `Snoozed until ${snoozed_until}. It comes back on its own.` : `Marked ${state}: ${resolution}` }] }
2148
+ },
2149
+ )
2150
+
2151
+ server.registerTool(
2152
+ 'capture_meeting',
2153
+ {
2154
+ title: 'Capture a meeting transcript, joined to its calendar event',
2155
+ description: "Capture a meeting transcript you were given — a paste, an export, notes — as a real record joined to the calendar event it came from. ⚠ CALL IT TWICE. Without `calendar_record_id` it WRITES NOTHING and returns candidate calendar events near that time; you pick one and call again. Overlap alone is not an answer — 90 of 120 calendar records are recurring birthdays and an all-day event overlaps its whole day, so each candidate carries `caveats` saying why it might be wrong. Read them before choosing. WHY THE JOIN MATTERS: a pasted transcript has no attendee list, so on its own it is reachable from nobody. The calendar event's attendee list is authoritative, and the captured meeting inherits it — that is what makes the transcript findable from the people who were in it. The brain follows from the matched event; you never pick one. Attendees who have no page come back as `unclaimed` — each is a person in the room nobody has authored, and authoring one reaches every past event they were in, not just this meeting. Meetings are stored `scoped`, never org-wide.",
2156
+ inputSchema: {
2157
+ occurred_at: z.string().describe('when the meeting STARTED, ISO 8601. Required for both the proposal and the capture.'),
2158
+ ends_at: z.string().optional().describe('when it ended, ISO 8601. Omit and the calendar event\'s end is inherited. NEVER guess one — unknown must stay unknown.'),
2159
+ calendar_record_id: z.string().optional().describe('the chosen candidate from a previous call. ABSENT = propose only, nothing is written.'),
2160
+ transcript: z.string().optional().describe('the raw transcript. Summarized server-side; secrets are redacted before storage.'),
2161
+ summary: z.string().optional().describe('your own summary. If given it is stored as-is and the transcript is not sent for summarization.'),
2162
+ title: z.string().optional().describe('defaults to the calendar event title.'),
2163
+ participants: z.array(z.string()).optional().describe('extra attendee emails ADDED to the inherited list — someone who joined but was not invited.'),
2164
+ },
2165
+ },
2166
+ async ({ occurred_at, ends_at, calendar_record_id, transcript, summary, title, participants }) => {
2167
+ let res
2168
+ try {
2169
+ res = await fetchCortex(`${BASE}/api/meetings/capture`, {
2170
+ method: 'POST',
2171
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2172
+ body: JSON.stringify({
2173
+ occurredAt: occurred_at, endsAt: ends_at, calendarRecordId: calendar_record_id,
2174
+ transcript, summary, title, participants,
2175
+ }),
2176
+ })
2177
+ } catch (e) {
2178
+ return toolError(`Could not reach the capture endpoint: ${e.message}`)
2179
+ }
2180
+ const out = await res.json().catch(() => null)
2181
+ if (!res.ok) return toolError(`Could not capture: ${out?.detail ?? out?.error ?? res.status}`)
2182
+ if (!out) return toolError('The server returned no body.')
2183
+
2184
+ const lines = []
2185
+ if (!out.captured) {
2186
+ // The proposal half. Caveats are printed WITH each candidate rather than summarized, because
2187
+ // the whole point is that the nearest event is often the wrong one.
2188
+ if (!out.matches?.length) {
2189
+ lines.push('No calendar event overlaps that time — nothing was written.')
2190
+ lines.push(out.detail ?? '')
2191
+ } else {
2192
+ lines.push(`${out.matches.length} candidate event(s). NOTHING WAS WRITTEN — re-call with calendar_record_id.`, '')
2193
+ for (const m of out.matches) {
2194
+ lines.push(`${m.recordId}`)
2195
+ lines.push(` ${m.title ?? '(untitled)'} — ${m.occurredAt}${m.endsAt ? ` → ${m.endsAt}` : ''}`)
2196
+ lines.push(` ${m.attendeeCount} attendee identifier(s) to inherit`)
2197
+ for (const c of m.caveats ?? []) lines.push(` \u26a0 ${c}`)
2198
+ lines.push('')
2199
+ }
2200
+ }
2201
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2202
+ }
2203
+
2204
+ lines.push(`Captured as record ${out.recordId} (scoped).`)
2205
+ lines.push(` ${out.occurredAt}${out.endsAt ? ` \u2192 ${out.endsAt}` : ' \u2014 no end recorded'}`)
2206
+ if (out.extentDropped) lines.push(` \u26a0 the end was REFUSED (${out.extentDropped}) and stored as unknown`)
2207
+ const p = out.participants ?? { claimed: [], unclaimed: [] }
2208
+ if (p.claimed?.length) {
2209
+ lines.push('', 'Reaches these pages:')
2210
+ for (const c of p.claimed) lines.push(` ${c.title} (${c.identifier})`)
2211
+ }
2212
+ if (p.unclaimed?.length) {
2213
+ lines.push('', `\u26a0 ${p.unclaimed.length} attendee(s) with NO page — this meeting is not reachable from them:`)
2214
+ for (const u of p.unclaimed) lines.push(` ${u}`)
2215
+ lines.push('Author a page and claim the address, and every past event they were in reaches it too.')
2216
+ }
2217
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2218
+ },
2219
+ )
2220
+
1996
2221
  server.registerTool(
1997
2222
  'place_staged_record',
1998
2223
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.126",
3
+ "version": "0.9.128",
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": {