@theronap/agnoclast-mcp 0.9.238 → 0.9.240

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.
@@ -59,6 +59,8 @@ import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
59
59
  // Enough to hold a session's own recent output in full; small enough that the notice is READ rather
60
60
  // than spilled to a file, which is what happened at 49 KB.
61
61
  const MAX_YOURS = 25
62
+ /** Assigned groups listed per prompt; the server hands out at most 5 new groups per prompt anyway. */
63
+ const MAX_ASSIGNED = 8
62
64
 
63
65
  // A title is somebody else's text — anyone can send a calendar invite — and this prints into a
64
66
  // session's context. One line, bounded, and quoted so it reads as the name of a thing.
@@ -176,6 +178,21 @@ export function formatNotice(signals) {
176
178
  const total = Number(signals?.settling?.total ?? offers.length)
177
179
  const delta = Number(signals?.arrival?.count ?? 0)
178
180
 
181
+ // ⭐ ADR-0083 §2.3 — arrivals ASSIGNED to this session. Not an offer: this session is their one owner, and
182
+ // it is told at a prompt boundary so it handles them before the user's request. A conversation stays with
183
+ // this session for its lifetime, so its later texts come here too.
184
+ const assigned = Array.isArray(signals?.assigned) ? signals.assigned : []
185
+ if (assigned.length) {
186
+ const n = assigned.reduce((t, g) => t + (Number(g?.count) || 1), 0)
187
+ lines.push(`📥 ${n} arrival${n === 1 ? ' is' : 's are'} ASSIGNED TO THIS SESSION — you are ${n === 1 ? 'its' : 'their'} only owner. Handle ${n === 1 ? 'it' : 'them'} before the user's request, then answer the user:`)
188
+ for (const g of assigned.slice(0, MAX_ASSIGNED)) {
189
+ const many = Number(g?.count) > 1 ? ` ×${g.count}` : ''
190
+ const ids = (g?.ids ?? []).slice(0, 3).join(', ') + ((g?.ids ?? []).length > 3 ? ', …' : '')
191
+ lines.push(` ${g?.source ?? '?'}${many} — ${g?.title ?? '(unreadable)'} · ${g?.hoursAgo ?? '?'}h · ids: ${ids}`)
192
+ }
193
+ if (assigned.length > MAX_ASSIGNED) lines.push(` … and ${assigned.length - MAX_ASSIGNED} more groups`)
194
+ lines.push(' `intake_claim {intakeItemIds}` → `intake_materialize` (or `intake_discard` for junk) · `not_mine` hands one to the next session · only what only the user can decide → `intake_defer`.')
195
+ }
179
196
  if (delta) {
180
197
  lines.push(`⚡ ${delta} new arrival${delta === 1 ? '' : 's'} in the pile since this session was last told.`)
181
198
  }
@@ -0,0 +1,114 @@
1
+ // `my_contacts` and `contact` — AGNOCLAST'S OWN CONTACT BOOK (ADR-0084; Theron, 2026-10-17).
2
+ //
3
+ // Rendered from GET /api/contacts (or POST /api/contacts/search) and POST /api/contacts/briefing. The
4
+ // book is the caller's alone: every person page they can read, in every brain they are in, merged into
5
+ // one contact when pages share a phone or email the caller can see. A same-name match is NEVER a merge.
6
+ //
7
+ // The contact LIST (app and website) shows names and identifiers only. `contact` is the AI's view: the
8
+ // identifiers AND every page of theirs, told apart by brain, plus the ONE page an outreach is filed on.
9
+
10
+ /** Pure. `(801) 555-0123` for a US number; anything else as stored. */
11
+ export function displayPhone(e164) {
12
+ const m = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(String(e164 ?? ''))
13
+ return m ? `(${m[1]}) ${m[2]}-${m[3]}` : String(e164 ?? '')
14
+ }
15
+
16
+ /** Pure. One identifier, saying who can see it. */
17
+ export function identifierLine(i) {
18
+ const v = i.kind === 'phone' ? displayPhone(i.value) : i.value
19
+ const who = i.shared ? '' : i.mine ? ' (private — only you)' : ''
20
+ return `${v}${who}`
21
+ }
22
+
23
+ /** Pure. Where reaching out to this contact files the record. */
24
+ export function fileToLine(fileTo) {
25
+ if (!fileTo) return ''
26
+ if (fileTo.ok) {
27
+ const why = fileTo.why === 'context' ? 'the brain you are acting in' : 'the only brain they are in'
28
+ return `files to ${fileTo.page.brain} → "${fileTo.page.title}" (${why})`
29
+ }
30
+ if (fileTo.reason === 'ambiguous') return `files to: ASK which brain — ${fileTo.options.map((o) => o.brain).join(' or ')}`
31
+ return 'no page to file on'
32
+ }
33
+
34
+ function reachLine(reach, ringing) {
35
+ if (!reach) return ''
36
+ const bits = []
37
+ if (ringing && reach.call === true) bits.push('Call')
38
+ else if (ringing && reach.call === null) bits.push('Call? (not checked)')
39
+ if (reach.text) bits.push('Text an invite')
40
+ if (reach.email) bits.push('Email')
41
+ return bits.length ? bits.join(' · ') : 'no way to reach them yet'
42
+ }
43
+
44
+ /** Pure. The contact list. */
45
+ export function renderContactList(out, { query } = {}) {
46
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : []
47
+ const brains = Array.isArray(out?.brains) ? out.brains.map((b) => b.brain) : []
48
+ const lines = []
49
+ if (!contacts.length) {
50
+ lines.push(query
51
+ ? `No contact of yours matches "${query}". Your book is every person page you can read${brains.length ? ` in ${brains.join(', ')}` : ''}.`
52
+ : 'Your contact book is empty: no person page you can read has been written yet.')
53
+ } else {
54
+ const total = typeof out.total === 'number' ? out.total : contacts.length
55
+ lines.push(`${total} contact(s)${query ? ` matching "${query}"` : ''}${total > contacts.length ? `, showing ${contacts.length}` : ''} — person pages you can read${brains.length ? ` in ${brains.join(', ')}` : ''}, merged on a shared phone/email:`)
56
+ for (const c of contacts) {
57
+ const ids = (c.identifiers ?? []).map(identifierLine)
58
+ const where = [...new Set((c.pages ?? []).map((p) => p.brain))]
59
+ const parts = [`· ${c.name}`]
60
+ if (ids.length) parts.push(ids.join(' · '))
61
+ parts.push(`in ${where.join(', ')}${(c.pages ?? []).length > 1 ? ` (${c.pages.length} pages)` : ''}`)
62
+ const reach = reachLine(c.reach, out.ringing === true)
63
+ if (reach) parts.push(reach)
64
+ parts.push(fileToLine(c.fileTo))
65
+ lines.push(parts.filter(Boolean).join(' — '))
66
+ lines.push(` page: ${c.id}`)
67
+ }
68
+ }
69
+ const sug = Array.isArray(out?.suggestions) ? out.suggestions : []
70
+ if (sug.length) {
71
+ const nameOf = new Map(contacts.map((c) => [c.id, c]))
72
+ lines.push('', 'SAME NAME, NOT MERGED — no phone or email in common. Ask the person before treating them as one:')
73
+ for (const s of sug) {
74
+ const [a, b] = s.contactIds.map((id) => nameOf.get(id))
75
+ const label = (c, id) => (c ? `${c.name} (${[...new Set(c.pages.map((p) => p.brain))].join(', ')})` : `page ${id}`)
76
+ lines.push(` ${label(a, s.contactIds[0])} / ${label(b, s.contactIds[1])}`)
77
+ }
78
+ }
79
+ for (const e of out?.errors ?? []) lines.push(`⚠ ${e.brain}: could not be read (${e.error}) — its contacts are missing above.`)
80
+ lines.push('', '— Read one with `contact` (page: …) before reaching out: it returns their pages too. A phone number is for showing and "Text an invite" only; a call reaches an account by its email.')
81
+ return lines.join('\n')
82
+ }
83
+
84
+ /** Pure. The AI's briefing on one contact. */
85
+ export function renderContactBriefing(out) {
86
+ const c = out?.contact
87
+ if (!c) return 'No such contact.'
88
+ const lines = [`${c.name}`]
89
+ const phones = (c.identifiers ?? []).filter((i) => i.kind === 'phone')
90
+ const emails = (c.identifiers ?? []).filter((i) => i.kind === 'email')
91
+ lines.push(phones.length ? `Phone: ${phones.map(identifierLine).join(' · ')}` : 'Phone: none you can see')
92
+ lines.push(emails.length ? `Email: ${emails.map(identifierLine).join(' · ')}` : 'Email: none you can see')
93
+ const pages = Array.isArray(out.pages) ? out.pages : []
94
+ const brains = [...new Set(pages.map((p) => p.brain))]
95
+ lines.push(`Reaching out: ${fileToLine(out.fileTo)}`)
96
+ if (out.fileTo && !out.fileTo.ok && out.fileTo.reason === 'ambiguous') {
97
+ lines.push(' → ask the person which brain this is for (or pass `brain`). File the record on that ONE page, not on all of them.')
98
+ }
99
+ lines.push('', pages.length > 1
100
+ ? `${pages.length} pages in ${brains.length} brain(s) — the same person (they share a phone or email), kept as separate pages. Say which brain a fact came from:`
101
+ : 'Their page:')
102
+ for (const p of pages) {
103
+ lines.push('', `## ${p.brain} — "${p.title}" [ref: ${p.ref} · tier: ${p.tier}]`)
104
+ for (const s of p.sections ?? []) lines.push(`### ${s.heading}`, s.body ?? '')
105
+ }
106
+ return lines.join('\n')
107
+ }
108
+
109
+ /** Pure. A name that fits several contacts. */
110
+ export function renderContactCandidates(candidates, name) {
111
+ const list = Array.isArray(candidates) ? candidates : []
112
+ return [`"${name}" fits ${list.length} contacts — pass \`page\` for the one you mean:`,
113
+ ...list.map((c) => ` · ${c.name} (${(c.brains ?? []).join(', ')}) — page: ${c.id}`)].join('\n')
114
+ }
package/lib/server.mjs CHANGED
@@ -32,6 +32,7 @@ import { formatSeriesRefusal, formatSeriesUpdated } from './series_render.mjs'
32
32
  import { formatSends } from './sends_render.mjs'
33
33
  import { applyLock, fetchLockState, lockedAnswerFor, lockStateWithin } from './locked_tools.mjs'
34
34
  import { filterUnshownSettling } from './settling_shown.mjs'
35
+ import { renderContactBriefing, renderContactCandidates, renderContactList } from './contacts_render.mjs'
35
36
 
36
37
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
37
38
  // set_record_privacy below) — an owner-relative tier is applied to a page's records when they are read
@@ -4165,6 +4166,76 @@ function renderNudge(payload) {
4165
4166
  },
4166
4167
  )
4167
4168
 
4169
+ // ⭐ AGNOCLAST'S OWN CONTACT BOOK (ADR-0084; Theron, 2026-10-17). The person's book is every person page
4170
+ // they can read, merged on a shared phone/email; `contact` is the briefing an AI reads before reaching out.
4171
+ server.registerTool(
4172
+ 'my_contacts',
4173
+ {
4174
+ title: 'Your contacts — person pages you can read, merged',
4175
+ description: 'The person\'s OWN Agnoclast contact book: every person page they can read, in every brain they are in. Pages in several brains that share a phone or email are ONE contact (same person, pages kept separate); a same-NAME match is listed as a question, never merged. Shows each contact\'s phone numbers and emails (marked when private to the person), the brains they are in, Call / Text an invite / Email, and where reaching out would be filed. Page text is not included — call `contact` for that before you reach out. Pass `query` to search by name, email or digits of a number. A phone number never rings anyone: a call reaches an Agnoclast account by its email.',
4176
+ inputSchema: {
4177
+ query: z.string().optional().describe('a name, an email address, or digits of a phone number'),
4178
+ brain: z.string().optional().describe('the brain you are acting in (name or id) — decides where outreach files for a contact in several brains'),
4179
+ limit: z.number().optional().describe('max contacts (default 50, max 200)'),
4180
+ offset: z.number().optional().describe('skip this many (paging)'),
4181
+ reach: z.boolean().optional().describe('also check who can be CALLED (uses the person\'s call-lookup budget; default false)'),
4182
+ },
4183
+ },
4184
+ async ({ query, brain, limit, offset, reach }) => {
4185
+ const q = typeof query === 'string' ? query.trim() : ''
4186
+ let res
4187
+ try {
4188
+ res = q
4189
+ ? await fetchCortex(`${BASE}/api/contacts/search`, {
4190
+ method: 'POST',
4191
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4192
+ body: JSON.stringify({ q, brain, limit, offset, reach: reach === true }),
4193
+ })
4194
+ : await fetchCortex(`${BASE}/api/contacts?${new URLSearchParams({
4195
+ ...(brain ? { brain } : {}), ...(limit ? { limit: String(limit) } : {}), ...(offset ? { offset: String(offset) } : {}),
4196
+ ...(reach ? { reach: '1' } : {}),
4197
+ })}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4198
+ } catch (e) {
4199
+ return toolError(`Could not read your contacts: ${e.message}`)
4200
+ }
4201
+ const out = await res.json().catch(() => null)
4202
+ if (res.status === 404) return toolError('This Agnoclast server has no contact book yet (it predates ADR-0084). Use grep / read_page for person pages.')
4203
+ if (!res.ok) return toolError(`Could not read your contacts: ${out?.error ?? res.status}`)
4204
+ return { content: [{ type: 'text', text: renderContactList(out, { query: q }) }] }
4205
+ },
4206
+ )
4207
+
4208
+ server.registerTool(
4209
+ 'contact',
4210
+ {
4211
+ title: 'One contact — identifiers AND their pages, before you reach out',
4212
+ description: 'Brief yourself on one contact before a call, invite or email: their phone numbers and emails the person may see, EVERY person page of theirs the person can read (one per brain, labelled — the same person, kept separate, so say which brain a fact came from), and the ONE page the outreach should be filed on. If they are in several brains and none is obvious it says ASK: ask the person which brain this is for, then file the record on that one page only. Address them by `page` (from my_contacts, or a person page ref) or by `name`.',
4213
+ inputSchema: {
4214
+ page: z.string().optional().describe('a page id from my_contacts, or a person page ref from read_page'),
4215
+ name: z.string().optional().describe('their name, when you have no page id'),
4216
+ brain: z.string().optional().describe('the brain you are acting in (name or id), if any'),
4217
+ },
4218
+ },
4219
+ async ({ page, name, brain }) => {
4220
+ if (!page && !name) return toolError('Pass page or name')
4221
+ let res
4222
+ try {
4223
+ res = await fetchCortex(`${BASE}/api/contacts/briefing`, {
4224
+ method: 'POST',
4225
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4226
+ body: JSON.stringify({ page, name, brain }),
4227
+ })
4228
+ } catch (e) {
4229
+ return toolError(`Could not read that contact: ${e.message}`)
4230
+ }
4231
+ const out = await res.json().catch(() => null)
4232
+ if (res.status === 409 && out?.error === 'ambiguous') return { content: [{ type: 'text', text: renderContactCandidates(out.candidates, name ?? page) }] }
4233
+ if (res.status === 404 && out?.error === 'not_found') return { content: [{ type: 'text', text: `No contact of yours matches "${name ?? page}". my_contacts lists them; a person you have never written a page for is not a contact yet.` }] }
4234
+ if (!res.ok) return toolError(`Could not read that contact: ${out?.error ?? res.status}`)
4235
+ return { content: [{ type: 'text', text: renderContactBriefing(out) }] }
4236
+ },
4237
+ )
4238
+
4168
4239
  server.registerTool(
4169
4240
  'set_summary',
4170
4241
  {
@@ -5516,23 +5587,27 @@ function renderNudge(payload) {
5516
5587
  {
5517
5588
  title: 'Claim a routing identifier on a page',
5518
5589
  description: 'Declare that a page is a Gate 4 attach HOME for an identifier (repo:owner/name or file:owner/name:path). Body [[repo:…]] stamps are navigation only and do NOT drive attach — use this instead. Prefer the narrowest id: mother pages own repo:; feature pages own file: paths. Do not copy every body mention into a routing claim. ' +
5519
- 'PRIVATE CONTACT CLAIMS (ADR-0079): on a PERSON page you may claim `phone:<number>` (normalized to E.164, US default — `phone:(801) 636-7505` stores `phone:+18016367505`) or `email:<address>` (lowercased). These are visible ONLY to you — never in the page body, grep, or to other brain members — and they make your captured texts / mail from that contact propose that person page. This is the right place for a contact\'s number; never put it in page text.',
5590
+ 'CONTACT IDENTIFIERS (ADR-0079, ADR-0084): on a PERSON page you may claim `phone:<number>` (E.164, US default — `phone:(801) 636-7505` stores `phone:+18016367505`) or `email:<address>`. A NEW one is SHARED with everyone who can read that page, like a shared CRM (Theron, 2026-10-17), unless you pass `private: true` — then only you see it. Pass `private` explicitly to change one you added before; without it an existing claim keeps its visibility. Either way it is never in the page body or grep, and it files only YOUR captured texts/mail onto the page. This is the right place for a contact\'s number; never put it in page text.',
5520
5591
  inputSchema: {
5521
5592
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
5522
5593
  name: z.string().optional().describe('page title (or pass ref)'),
5523
5594
  ref: z.string().optional().describe('node ref from read_page — prefer over name when available'),
5524
5595
  brain: z.string().optional().describe('brain label when the title is ambiguous across brains'),
5525
- identifier: z.string().describe('canonical identifier, e.g. repo:theronap/cortex, file:theronap/cortex:web/lib/engine/github_intake.ts, or on a person page phone:+18015550123 / email:someone@example.com (private to you)'),
5596
+ identifier: z.string().describe('canonical identifier, e.g. repo:theronap/cortex, file:theronap/cortex:web/lib/engine/github_intake.ts, or on a person page phone:+18015550123 / email:someone@example.com'),
5597
+ private: z.boolean().optional().describe('person-page phone:/email: only. true = only you see it; false = everyone who can read the page. Omit for a new one to be shared and an existing one to stay as it is.'),
5526
5598
  },
5527
5599
  },
5528
- async ({ kind, name, ref, brain, identifier }) => {
5600
+ async ({ kind, name, ref, brain, identifier, private: isPrivate }) => {
5529
5601
  if (!name && !ref) return toolError('Pass name or ref')
5602
+ // ADR-0084: a person page's phone/email is shared with its readers unless the person says private.
5603
+ const contactClaim = kind === 'person' && /^(phone|email):/i.test(String(identifier).trim())
5604
+ const sharing = contactClaim ? { shared: isPrivate !== true, sharing_explicit: typeof isPrivate === 'boolean' } : {}
5530
5605
  let res
5531
5606
  try {
5532
5607
  res = await fetchCortex(`${BASE}/api/brain/routing-identifiers`, {
5533
5608
  method: 'POST',
5534
5609
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
5535
- body: JSON.stringify({ kind, name, ref, brain, identifier }),
5610
+ body: JSON.stringify({ kind, name, ref, brain, identifier, ...sharing }),
5536
5611
  })
5537
5612
  } catch (e) {
5538
5613
  return toolError(`Could not set routing identifier: ${e.message}`)
@@ -5558,9 +5633,12 @@ function renderNudge(payload) {
5558
5633
  ? ` ${healed} existing record(s) ALREADY attached to this page as a result.`
5559
5634
  : ' 0 existing records attached — either nothing carries this identifier yet, or records do but belong to another owner (an identity claim only attaches your own).'
5560
5635
  const isContact = /^(phone|email):/.test(String(out?.set?.[0] ?? identifier).toLowerCase())
5561
- const privacyLine = isContact
5562
- ? ' This contact claim is PRIVATE to you: no other brain member can see it, and it is never rendered in the page. Your captured texts/mail from this contact will now propose this page.'
5563
- : ''
5636
+ // Say what the SERVER did: one older than ADR-0084 ignores `shared` and keeps every claim private.
5637
+ const privacyLine = !isContact
5638
+ ? ''
5639
+ : out?.shared === true
5640
+ ? ` Everyone who can read this page can see and use this ${kind === 'person' ? 'contact detail' : 'identifier'} (shared; pass private: true to make a new one yours alone). It is never rendered in the page text, and it files only YOUR captured texts/mail here.`
5641
+ : ' This contact claim is PRIVATE to you: no other brain member can see it, and it is never rendered in the page. Your captured texts/mail from this contact will now propose this page.'
5564
5642
  return { content: [{ type: 'text', text: `Routing identifier set on document ${out?.documentId ?? '?'}: ${set}.${healedLine} Future matching events will attach here too (body mentions alone will not).${privacyLine}` }] }
5565
5643
  },
5566
5644
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.238",
3
+ "version": "0.9.240",
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": {