@theronap/agnoclast-mcp 0.9.186 → 0.9.187

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.
@@ -0,0 +1,167 @@
1
+ // Custom sources, for an agent — the pure half of create_custom_source / list_custom_sources /
2
+ // set_custom_source_home (server.mjs registers them). Turns tool arguments into the setup API's body and
3
+ // its answers into text an agent can act on. No I/O here, so every rendering has a test.
4
+ //
5
+ // Why these tools exist (Theron, 2026-09-29): "would you have known how to fix the script to connect to
6
+ // the custom connector if you didn't build the system in the first place? Because if not, real users
7
+ // won't be able to make their custom connectors." The agent that wired the first real source needed
8
+ // three things no tool gave it — the source's field mapping, the page it files onto, and a way to see
9
+ // where its items landed. list_custom_sources and list_records page: are those; create_custom_source is
10
+ // the setup itself, which he did not want a person doing alone.
11
+
12
+ /** The home part of a setup/channel body, from tool arguments. undefined = let the server default it. */
13
+ export function homeFromArgs(a, fallbackTitle) {
14
+ if (a.home_page) return { page: String(a.home_page).trim() }
15
+ const setAside = Array.isArray(a.not_any_of) ? a.not_any_of.map((x) => String(x).trim()).filter(Boolean) : []
16
+ const title = a.new_page_title ? String(a.new_page_title).trim() : ''
17
+ if (!title && !setAside.length && !a.new_page_brain) return undefined
18
+ return {
19
+ title: title || fallbackTitle,
20
+ ...(a.new_page_brain ? { brain: String(a.new_page_brain).trim() } : {}),
21
+ ...(a.description ? { description: String(a.description) } : {}),
22
+ ...(setAside.length ? { notAnyOf: setAside } : {}),
23
+ ...(a.reason ? { reason: String(a.reason) } : {}),
24
+ }
25
+ }
26
+
27
+ export function renderMapping(m) {
28
+ const roles = ['id', 'time', 'title', 'body', 'group', 'url']
29
+ const parts = roles.filter((r) => m?.[r]).map((r) => `${r} ← ${m[r]}`)
30
+ if (m?.people?.length) parts.push(`people ← ${m.people.join(', ')}`)
31
+ return parts.length ? parts.join(' · ') : '(none — every item is titled by the source name and identified by its content)'
32
+ }
33
+
34
+ function ago(iso, now) {
35
+ if (!iso) return 'never used'
36
+ const s = Math.max(0, (now - new Date(iso).getTime()) / 1000)
37
+ if (s < 90) return 'used just now'
38
+ if (s < 5400) return `used ${Math.round(s / 60)}m ago`
39
+ if (s < 129600) return `used ${Math.round(s / 3600)}h ago`
40
+ return `used ${Math.round(s / 86400)}d ago`
41
+ }
42
+
43
+ /** GET /api/custom-connectors → text. */
44
+ export function formatSources(connectors, now = Date.now()) {
45
+ if (!connectors?.length) return 'You have no custom sources. create_custom_source sets one up.'
46
+ return connectors.map((c) => {
47
+ const where = c.disabledAt
48
+ ? `OFF since ${String(c.disabledAt).slice(0, 10)} — its key no longer works; everything it sent is kept`
49
+ : c.channel && !c.channel.homeRef
50
+ ? 'files onto a page you can no longer read (you left its brain, or its privacy changed) — point it elsewhere with set_custom_source_home'
51
+ : c.mode === 'channel' && c.channel
52
+ ? `files onto "${c.channel.homeTitle}" (${c.channel.brain}) — ref ${c.channel.homeRef}; read it with list_records page:"${c.channel.homeRef}" source:"custom"`
53
+ : c.channel
54
+ ? `waits in the pile to be sorted, suggested for "${c.channel.homeTitle}" (${c.channel.brain})`
55
+ : 'waits in the pile to be sorted onto the pages each item belongs to'
56
+ return [
57
+ `• ${c.name} [id ${c.id}] ${c.identifier} · ${c.shape} · ${c.purpose}`,
58
+ ` where: ${where}`,
59
+ ` mapping: ${renderMapping(c.mapping)}`,
60
+ ` key: ${c.key ? `${c.key.prefix}… · ${ago(c.key.lastUsedAt, now)}` : 'none live'}`,
61
+ ].join('\n')
62
+ }).join('\n')
63
+ }
64
+
65
+ /** The pages that may already be the home, as a numbered list an agent reads and then judges. */
66
+ export function renderCandidates(cands) {
67
+ return (cands ?? []).map((c, i) => [
68
+ ` ${i + 1}. "${c.title}" — ${c.brain} · ${c.records} item${c.records === 1 ? '' : 's'} on it · ${c.why ?? (c.exact ? 'same name' : 'similar name')}`,
69
+ ` id: ${c.documentId} ref: ${c.ref}`,
70
+ ...(c.snippet ? [` “${c.snippet}”`] : []),
71
+ ].join('\n')).join('\n')
72
+ }
73
+
74
+ /** Why a home was refused, and exactly what to do next. */
75
+ export function renderHomeProblem(p) {
76
+ if (!p) return ''
77
+ if (p.error === 'home_candidates' || p.error === 'reason_required') {
78
+ return [
79
+ p.error === 'reason_required'
80
+ ? 'A new page needs a `reason`: say why none of these pages is the home.'
81
+ : 'These existing pages may already be what this source is about — nothing was created:',
82
+ renderCandidates(p.candidates),
83
+ '',
84
+ 'Read them (read_page ref:"…") and decide WITH the person. Then either:',
85
+ ' • file onto one: home_page:"<its id or ref>" (preferred — one page per subject), or',
86
+ ' • make a new page: new_page_title:"…" + not_any_of:[every id above] + reason:"why none of them is the home".',
87
+ ].join('\n')
88
+ }
89
+ if (p.error === 'home_exists') {
90
+ return `${p.detail ?? 'A page with that name already exists.'}\n${renderCandidates(p.candidates)}\nFile onto it with home_page:"${p.documentId ?? '<its id>'}", or choose a genuinely different name.`
91
+ }
92
+ if (p.error === 'home_ambiguous') return `${p.detail}\n${renderCandidates(p.candidates)}\nPass one of these ids as home_page.`
93
+ if (p.error === 'brain_required' || p.error === 'brain_not_found' || p.error === 'brain_ambiguous') {
94
+ return `Say which brain the new page goes in — new_page_brain: one of ${(p.brains ?? []).map((b) => `"${b.brain}"`).join(', ')}.`
95
+ }
96
+ if (p.error === 'cannot_write_brain') return 'You cannot add to that brain right now (not an active member, or writing is paused).'
97
+ if (p.error === 'home_not_found') return 'No page you can read has that id or ref. Use an id from the candidate list, or a ref read_page printed.'
98
+ return `${p.error}${p.detail ? `: ${p.detail}` : ''}`
99
+ }
100
+
101
+ function renderPreview(pv, problem) {
102
+ if (problem) return `⚠ The sample would be REFUSED as mapped: ${problem}. Fix the mapping (or the sample) before wiring anything.`
103
+ if (!pv) return 'No sample given — pass one real item as `sample` to see how it arrives and to have the mapping filled in.'
104
+ return `The sample will arrive as: "${pv.title}" · ${pv.time ?? 'time: when it arrives'}${pv.id ? ` · id ${pv.id}` : ' · no id — identified by its content'}${pv.people?.length ? ` · people ${pv.people.join(', ')}` : ''}${pv.body ? `\n main text: ${pv.body.length > 200 ? `${pv.body.slice(0, 199)}…` : pv.body}` : ' · (no main text mapped)'}`
105
+ }
106
+
107
+ /** POST /api/custom-connectors with dryRun → text. */
108
+ export function formatDryRun(r) {
109
+ const home = r.mode === 'ingest'
110
+ ? 'Items would wait in the pile, to be sorted onto the pages each belongs to.'
111
+ : r.home
112
+ ? r.home.created
113
+ ? `Items would file onto a NEW page "${r.home.title}" in ${r.home.brain} — no existing page matched its name or description.`
114
+ : `Items would file onto the existing page "${r.home.title}" (${r.home.brain}) — ref ${r.home.ref}.`
115
+ : renderHomeProblem(r.homeProblem)
116
+ return [
117
+ 'DRY RUN — nothing was created.',
118
+ `Mapping${r.mappingSuggested ? ' (suggested from your sample — check it)' : ''}: ${renderMapping(r.mapping)}`,
119
+ renderPreview(r.preview, r.sampleProblem),
120
+ '',
121
+ home,
122
+ ].join('\n')
123
+ }
124
+
125
+ /** POST /api/custom-connectors (created) → text. The key is in it exactly once. */
126
+ export function formatCreated(r, keyFileHint) {
127
+ const c = r.connector
128
+ const home = r.channel
129
+ ? `Files onto ${r.channel.homeCreated ? 'a NEW page' : 'your existing page'} "${r.channel.homeTitle}" (${r.channel.brain}) — ref ${r.channel.homeRef}.`
130
+ : r.channelError
131
+ ? `⚠ Created, but NOT filing onto a page yet — ${renderHomeProblem(r.channelError)}\nFix it with set_custom_source_home; until then items wait in the pile.`
132
+ : 'Items wait in the pile, to be sorted onto the pages each belongs to.'
133
+ // The generated script embeds the key; an agent copies what it is shown into files, so show it reading
134
+ // the key from where it was told to store it instead.
135
+ const python = (r.snippets?.python ?? '').split(r.key).join('<YOUR KEY — read it from the key file or env var>')
136
+ return [
137
+ `Created custom source "${c.name}" — ${c.identifier} (id ${c.id}).`,
138
+ home,
139
+ '',
140
+ 'KEY — shown ONCE, never again:',
141
+ ` ${r.key}`,
142
+ `Store it now, outside any repo: ${keyFileHint(c.slug)} (chmod 600), or an environment variable. Never commit it or paste it into a chat or a page. It can only ADD items to this one source; if it leaks, rotate it on the Data sources page.`,
143
+ '',
144
+ `Endpoint: POST ${r.endpoint} Authorization: Bearer <key> body: { "items": [ … ] } — up to 100 items per call, 64 KB each, 30 calls a minute.`,
145
+ `Mapping: ${renderMapping(r.mapping)}`,
146
+ renderPreview(r.preview, r.sampleProblem),
147
+ '',
148
+ 'Python (read the key from where you stored it):',
149
+ python,
150
+ '',
151
+ 'Next: send the real sample, then confirm it landed —',
152
+ r.channel ? ` list_records page:"${r.channel.homeRef}" source:"custom"` : ' list_records source:"custom"',
153
+ 'Each item in the push response is filed / pending / duplicate (already received) / rejected (with the reason). Re-sending an unchanged item is safe; a changed one is kept as a new version.',
154
+ ].join('\n')
155
+ }
156
+
157
+ /** Find a source by id or name in a list_custom_sources answer. */
158
+ export function pickSource(connectors, ref) {
159
+ const want = String(ref ?? '').trim().toLowerCase()
160
+ const live = (connectors ?? []).filter((c) => !c.disabledAt)
161
+ const byId = live.find((c) => c.id.toLowerCase() === want || c.identifier.toLowerCase() === want || c.slug?.toLowerCase() === want)
162
+ if (byId) return { ok: true, source: byId }
163
+ const byName = live.filter((c) => c.name.toLowerCase() === want)
164
+ if (byName.length === 1) return { ok: true, source: byName[0] }
165
+ if (byName.length > 1) return { ok: false, message: `${byName.length} sources are named "${ref}" — pass one's id:\n${formatSources(byName)}` }
166
+ return { ok: false, message: `No live custom source is called "${ref}". Yours:\n${formatSources(live)}` }
167
+ }
package/lib/server.mjs CHANGED
@@ -19,6 +19,7 @@ import { repoFullNameFrom, workingFilesFrom } from './capture.mjs'
19
19
  import { findTranscript, createWorkedFilesTracker } from './working_files.mjs'
20
20
  import { spawnObligationWorker } from './obligations_worker.mjs'
21
21
  import { renderObligations } from './obligations_render.mjs'
22
+ import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
22
23
 
23
24
  // Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
24
25
  // tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
@@ -3886,9 +3887,10 @@ function renderNudge(payload) {
3886
3887
  role: z.enum(['member', 'manager', 'owner']).optional().describe("their role in this brain (default 'member'). NOTE: only owner/manager can see records scoped to people below them"),
3887
3888
  brain: z.string().optional().describe('which brain, by name or org id. Optional when planning; REQUIRED with execute:true. Pass the org id when a name matches more than one of your brains'),
3888
3889
  execute: z.boolean().optional().describe('default false. false = report the plan only. true = actually add them — not undoable through the API, and requires an explicit brain'),
3890
+ inherit_page_id: z.string().optional().describe("an existing PERSON page in that brain that becomes their user page (ADR-0023). The plan lists candidates; ask the user which, or omit for a new page"),
3889
3891
  },
3890
3892
  },
3891
- async ({ email, name, title, role, brain, execute }) => {
3893
+ async ({ email, name, title, role, brain, execute, inherit_page_id }) => {
3892
3894
  const addr = (email ?? '').trim().toLowerCase()
3893
3895
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
3894
3896
 
@@ -3980,7 +3982,7 @@ function renderNudge(payload) {
3980
3982
  done = await fetchCortex(`${BASE}/api/invite`, {
3981
3983
  method: 'POST',
3982
3984
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
3983
- body: JSON.stringify({ ...payload, brain: target.orgId }),
3985
+ body: JSON.stringify({ ...payload, brain: target.orgId, ...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}) }),
3984
3986
  })
3985
3987
  } catch (e) {
3986
3988
  return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
@@ -3998,15 +4000,36 @@ function renderNudge(payload) {
3998
4000
  const how = r.existingAccount
3999
4001
  ? 'They already had an Agnoclast account, so they keep their current login and simply gain this brain.'
4000
4002
  : `A new account was created. Temporary password: ${r.password} — they must change it on first sign-in.`
4001
- return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
4003
+ const page = r.userPage?.status
4004
+ ? `\nTheir page in ${target.name}: ${r.userPage.status}${r.userPage.error ? ` (${r.userPage.error})` : ''}.`
4005
+ : ''
4006
+ return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}${page}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
4002
4007
  }
4003
4008
 
4009
+ // ADR-0023: does this brain already have a page about them? The inviter decides — list, never pick.
4010
+ let pageLines = []
4011
+ try {
4012
+ const cr = await fetchCortex(`${BASE}/api/invite/candidates`, {
4013
+ method: 'POST',
4014
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4015
+ body: JSON.stringify({ brain: target.orgId, name: payload.name, email: payload.email }),
4016
+ })
4017
+ if (cr.ok) {
4018
+ const { candidates = [], suggested = null } = await cr.json()
4019
+ pageLines = candidates.length
4020
+ ? ['', 'Existing pages in this brain that may be them — ask the user whether one should become their page (pass inherit_page_id), or omit it for a new page:',
4021
+ ...candidates.map((c) => ` - "${c.title}" [${c.documentId}] matched by ${c.matchedBy.join('+')}${c.documentId === suggested ? ' (likely them)' : ''}`)]
4022
+ : ['', 'No existing page about them in this brain — they get a new one.']
4023
+ }
4024
+ } catch { /* the plan still stands without the lookup */ }
4025
+
4004
4026
  const text = [
4005
4027
  `Ready to add ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}.`,
4006
4028
  '',
4007
4029
  ` brain ${target.name} [${target.orgId}]`,
4008
4030
  ` your role ${target.role} — you may add people here`,
4009
4031
  ` manager you [${target.userId}] (they are placed under you)`,
4032
+ ...pageLines,
4010
4033
  '',
4011
4034
  'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
4012
4035
  `or do it yourself at ${BASE}/?invite=1 with these values:`,
@@ -4113,7 +4136,7 @@ function renderNudge(payload) {
4113
4136
  'list_records',
4114
4137
  {
4115
4138
  title: 'List activity records (filtered)',
4116
- description: 'List recent activity records you can see, filtered by type, project, page, source and recency. RLS-scoped. Use to enumerate "what happened on project X this week", "recent meetings", or EVERYTHING FILED ON A PAGE — pass `page` (its name or ref) to read a channel\'s items or a project page\'s records, with order:"oldest" to read a series in sequence and `offset` to page past 50. Returns each record\'s FULL summary + id — never a truncated preview, so what you get back is the whole record you are permitted to read (use an id with request_file to ask its owner for the original file).',
4139
+ description: 'List recent activity records you can see, filtered by type, project, page, source and recency. RLS-scoped. Use to enumerate "what happened on project X this week", "recent meetings", or EVERYTHING FILED ON A PAGE — pass `page` (its name or ref) to read a channel\'s items or a project page\'s records, with order:"oldest" to read a series in sequence, `offset` to page past 50, and `match` to narrow to records mentioning something. A long result holds whole records back and tells you the offset to resume from. Returns each record\'s FULL summary + id — never a truncated preview, so what you get back is the whole record you are permitted to read (use an id with request_file to ask its owner for the original file).',
4117
4140
  inputSchema: {
4118
4141
  type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
4119
4142
  project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
@@ -4124,11 +4147,13 @@ function renderNudge(payload) {
4124
4147
  source: z.string().optional().describe("a record source, e.g. 'custom' (custom sources), 'github', 'email', 'imessage', 'meeting'"),
4125
4148
  order: z.enum(['newest', 'oldest']).optional().describe('newest first (default), or oldest first to read a series in sequence'),
4126
4149
  offset: z.number().optional().describe('skip this many records — page through more than 50 by calling again with offset 50, 100…'),
4150
+ match: z.string().optional().describe('only records whose title or text contains this (case-insensitive, literal) — e.g. one book on a channel that carries several, one ticket in a queue'),
4127
4151
  },
4128
4152
  },
4129
- async ({ type, project, since_days, limit, session, page, source, order, offset }) => {
4153
+ async ({ type, project, since_days, limit, session, page, source, order, offset, match }) => {
4130
4154
  const qs = new URLSearchParams()
4131
4155
  if (page) qs.set('page', page)
4156
+ if (match) qs.set('match', match)
4132
4157
  if (source) qs.set('source', source)
4133
4158
  if (order) qs.set('order', order)
4134
4159
  if (offset != null) qs.set('offset', String(offset))
@@ -4152,6 +4177,151 @@ function renderNudge(payload) {
4152
4177
  },
4153
4178
  )
4154
4179
 
4180
+ // ── custom sources: set up, list, re-home (Theron, 2026-09-29) ─────────────────────────────────
4181
+ // "I don't think it's that good of an idea to let the user do the whole setup without having an agent
4182
+ // involved." The agent can read the system being connected, describe what its data is ABOUT, and judge
4183
+ // which existing page is its home — and the server holds it to that: a new page is refused while any
4184
+ // page that may already be about it has not been set aside by id. Formatting: custom_sources.mjs.
4185
+ const homeArgs = {
4186
+ home_page: z.string().optional().describe('file onto this EXISTING page — an id from a candidate list, or a ref read_page printed. Preferred: one page per subject'),
4187
+ new_page_title: z.string().optional().describe('make a NEW page with this title instead. Refused while any existing page may be about the same thing, until each is listed in not_any_of'),
4188
+ new_page_brain: z.string().optional().describe('the brain for a new page, when you are in more than one'),
4189
+ not_any_of: z.array(z.string()).optional().describe('the ids of EVERY candidate page you looked at and set aside — required to make a new page when candidates exist'),
4190
+ reason: z.string().optional().describe('why none of those pages is the home — kept in the new page\'s history'),
4191
+ }
4192
+ const cannot = async (what, res) => {
4193
+ const body = await res.text()
4194
+ let j = null
4195
+ try { j = JSON.parse(body) } catch { /* not JSON */ }
4196
+ if (j && (res.status === 409 || res.status === 400 || res.status === 404 || res.status === 403) && (j.error || j.message)) {
4197
+ return toolError(`${what}: ${j.candidates || j.brains || String(j.error).startsWith('home') || j.error === 'reason_required' || j.error === 'cannot_write_brain' ? renderHomeProblem(j) : (j.message ?? j.detail ?? j.error)}`)
4198
+ }
4199
+ return toolError(`${what}: ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
4200
+ }
4201
+
4202
+ server.registerTool(
4203
+ 'list_custom_sources',
4204
+ {
4205
+ title: 'List your custom sources (with their field mappings)',
4206
+ description: 'Your CUSTOM SOURCES — systems that push data into Agnoclast with a write-only ingest key (a script\'s output, a ticketing tool, a CRM, a dashboard). For each: its id, the page it files onto (with that page\'s ref), its FIELD MAPPING — which field of a pushed item is its id, title, main text, time — its key prefix and when the key was last used. Read this BEFORE writing or fixing anything that pushes to a source: the script\'s items must match the mapping. The key itself is never shown again (create_custom_source shows it once; the Data sources page rotates it).',
4207
+ inputSchema: {},
4208
+ },
4209
+ async () => {
4210
+ let res
4211
+ try {
4212
+ res = await fetchCortex(`${BASE}/api/custom-connectors`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4213
+ } catch (e) {
4214
+ return toolError(`Could not list custom sources: ${e.message}`)
4215
+ }
4216
+ if (!res.ok) return cannot('Could not list custom sources', res)
4217
+ const j = await res.json()
4218
+ return { content: [{ type: 'text', text: formatSources(j.connectors) }] }
4219
+ },
4220
+ )
4221
+
4222
+ server.registerTool(
4223
+ 'create_custom_source',
4224
+ {
4225
+ title: 'Set up a custom source (dry run first)',
4226
+ description: [
4227
+ 'Set up a CUSTOM SOURCE — something Agnoclast has no connector for (a script\'s output, a ticketing tool, a CRM, a dashboard) — so it can push items with a write-only key. Do it WITH the person:',
4228
+ '1. Find ONE REAL item the system produces — read its code, its output files or an export. Never invent one: a made-up sample becomes a real record the moment it is sent.',
4229
+ '2. Call with dry_run:true, name, shape, description and that sample. You get the field mapping (suggested from the sample when you pass none), how the sample will arrive, and where items would file. Pages that may ALREADY be about this — the same name, a similar name, or pages whose text is about it — are listed: read them (read_page) and decide with the person.',
4230
+ '3. Call again without dry_run. Prefer home_page:<id or ref> to file onto a page that exists. A new page (new_page_title) is refused until not_any_of lists every candidate and reason says why none fits — a slightly different name is not enough to get a duplicate.',
4231
+ '4. The KEY is in the reply ONCE. Store it outside any repo (a key file, chmod 600, or an env var); never commit it or paste it anywhere.',
4232
+ '5. Wire the system to POST items, send the real sample, and confirm it landed with list_records page:<the home\'s ref> source:"custom".',
4233
+ 'mode:"ingest" sends each item to the pile to be sorted instead — for mixed data that belongs on many pages. Reference: https://agnoclast.com/docs/custom-sources',
4234
+ ].join('\n'),
4235
+ inputSchema: {
4236
+ name: z.string().describe('what the source is called, e.g. "Reader Engine" or "HaloITSM tickets"'),
4237
+ description: z.string().describe('what this data is ABOUT, in a sentence or two — its subject, not its format (e.g. "chapter-by-chapter results of my reader-engine simulation, where AI reader personas read my novels"). Searched against every page you can read to find the one it belongs on'),
4238
+ shape: z.enum(['message', 'event', 'document', 'record']).describe('message = emails, chats, replies · event = ticket changes, alerts, CRM updates · document = notes, transcripts, reports · record = rows, metrics, anything else'),
4239
+ purpose: z.enum(['communication', 'work', 'reference', 'metrics']).describe('sets defaults only'),
4240
+ sample: z.record(z.string(), z.any()).optional().describe('ONE real item, exactly as the system will send it (a JSON object)'),
4241
+ mapping: z.record(z.string(), z.any()).optional().describe('which field plays which role, as dot paths into one item: { id, time, title, body, group, url, people: [..] }. Omit to have it suggested from the sample'),
4242
+ mode: z.enum(['channel', 'ingest']).optional().describe('channel (default) = every item files onto one page as it arrives · ingest = each item waits in the pile to be sorted'),
4243
+ ...homeArgs,
4244
+ dry_run: z.boolean().optional().describe('true = show the mapping, the sample preview and the home (or the candidate pages) and create NOTHING. Do this first'),
4245
+ },
4246
+ },
4247
+ async (a) => {
4248
+ const home = homeFromArgs(a, a.name)
4249
+ const body = {
4250
+ name: a.name, description: a.description, shape: a.shape, purpose: a.purpose,
4251
+ ...(a.sample ? { sample: a.sample } : {}), ...(a.mapping ? { mapping: a.mapping } : {}),
4252
+ ...(a.mode ? { mode: a.mode } : {}), ...(home ? { home } : {}), ...(a.dry_run ? { dryRun: true } : {}),
4253
+ }
4254
+ let res
4255
+ try {
4256
+ res = await fetchCortex(`${BASE}/api/custom-connectors`, {
4257
+ method: 'POST',
4258
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4259
+ body: JSON.stringify(body),
4260
+ // A dry run writes nothing, so it may be retried; a real create is a POST and never is.
4261
+ }, a.dry_run ? { idempotent: true } : {})
4262
+ } catch (e) {
4263
+ return toolError(`Could not set up the source: ${e.message}`)
4264
+ }
4265
+ if (!res.ok) return cannot(a.dry_run ? 'Dry run refused' : 'Not created', res)
4266
+ const j = await res.json()
4267
+ const text = j.dryRun ? formatDryRun(j) : formatCreated(j, (slug) => `~/.agnoclast/keys/${slug}`)
4268
+ return { content: [{ type: 'text', text }] }
4269
+ },
4270
+ )
4271
+
4272
+ server.registerTool(
4273
+ 'set_custom_source_home',
4274
+ {
4275
+ title: 'Change where a custom source files',
4276
+ description: 'Point one of your custom sources at a different page, or stop filing it onto a page. home_page files onto an existing page (an id or a read_page ref) — items it already sent INTO THAT PAGE\'S BRAIN are attached there too (items in another brain stay put; move_record moves them), and the old page stops receiving new ones (what is already on it stays). new_page_title makes a new page, under the same rule as create_custom_source: refused until every page that may already be about it is listed in not_any_of with a reason. mode:"ingest" stops filing onto a page: new items wait in the pile to be sorted.',
4277
+ inputSchema: {
4278
+ source: z.string().describe('the source — its id or its name, as list_custom_sources shows'),
4279
+ ...homeArgs,
4280
+ description: z.string().optional().describe('for a new page: what the source is about, searched to find pages that may already be its home'),
4281
+ mode: z.enum(['channel', 'ingest']).optional().describe('ingest = stop filing onto a page (every new item goes to the pile)'),
4282
+ },
4283
+ },
4284
+ async (a) => {
4285
+ let list
4286
+ try {
4287
+ const res = await fetchCortex(`${BASE}/api/custom-connectors`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4288
+ if (!res.ok) return cannot('Could not read your custom sources', res)
4289
+ list = (await res.json()).connectors
4290
+ } catch (e) {
4291
+ return toolError(`Could not read your custom sources: ${e.message}`)
4292
+ }
4293
+ const picked = pickSource(list, a.source)
4294
+ if (!picked.ok) return toolError(picked.message)
4295
+ const src = picked.source
4296
+ const url = `${BASE}/api/custom-connectors/${encodeURIComponent(src.id)}/channel`
4297
+ let res
4298
+ try {
4299
+ if (a.mode === 'ingest') {
4300
+ res = await fetchCortex(url, { method: 'DELETE', headers: { Authorization: `Bearer ${TOKEN}` } })
4301
+ if (!res.ok) return cannot('Not changed', res)
4302
+ return { content: [{ type: 'text', text: `${src.name} no longer files onto a page: new items wait in the pile to be sorted. Everything already filed stays where it is.` }] }
4303
+ }
4304
+ const home = homeFromArgs(a, src.name)
4305
+ if (!home) return toolError('Say where it should file: home_page (an existing page) or new_page_title — or mode:"ingest" to stop filing onto a page.')
4306
+ res = await fetchCortex(url, {
4307
+ method: 'PUT',
4308
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4309
+ body: JSON.stringify({ home }),
4310
+ })
4311
+ } catch (e) {
4312
+ return toolError(`Not changed: ${e.message}`)
4313
+ }
4314
+ if (!res.ok) return cannot('Not changed', res)
4315
+ const j = await res.json()
4316
+ const ch = j.channel
4317
+ return { content: [{ type: 'text', text: [
4318
+ `${src.name} now files onto ${ch.homeCreated ? 'a NEW page' : ''} "${ch.homeTitle}" (${ch.brain}) — ref ${ch.homeRef}.`.replace('onto "', 'onto "'),
4319
+ `${j.attachedExisting ?? 0} item(s) it had already sent were attached there.${j.releasedPreviousHome ? ' The previous page no longer receives new items; what is on it stays.' : ''}`,
4320
+ `Read it: list_records page:"${ch.homeRef}" source:"custom"`,
4321
+ ].join('\n') }] }
4322
+ },
4323
+ )
4324
+
4155
4325
  server.registerTool(
4156
4326
  'my_day',
4157
4327
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.186",
3
+ "version": "0.9.187",
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": {