@theronap/agnoclast-mcp 0.9.185 → 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/redact.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  // [pattern, replacement]. Order matters: most specific Anthropic forms first so a
11
11
  // login token reads as [REDACTED:anthropic-oauth], not the generic key bucket.
12
12
  const PATTERNS = [
13
+ // Agnoclast's own write-only ingest keys (custom sources): agn_in_ + 43 base64url characters.
14
+ [/agn_in_[A-Za-z0-9_-]{40,}/g, '[REDACTED:agnoclast-ingest-key]'],
13
15
  [/sk-ant-oat\d{2}-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-oauth]'],
14
16
  [/sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-key]'],
15
17
  [/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic]'],
package/lib/server.mjs CHANGED
@@ -19,11 +19,32 @@ 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,
25
26
  // and turn that into an actionable prompt. Wording lives in ./red_link_triage.mjs (pure + tested).
26
27
  // Returns '' on any error so a miss never gets worse.
28
+ /**
29
+ * A page name held by pages in more than one brain: list them, each with the ref that picks it. The page
30
+ * read already returns every match (matches[] with brain + ref), so this reuses it instead of leaving the
31
+ * caller at a bare 409. Never throws — a failure falls back to the generic advice.
32
+ */
33
+ async function ambiguousPageHint(base, token, kind, name) {
34
+ const generic = `"${name}" names a page in more than one of your brains. Call read_page "${name}" to see each one with its ref, then pass that ref.`
35
+ try {
36
+ const res = await fetchCortex(`${base}/api/brain/page?kind=${kind}&key=${encodeURIComponent(name)}`, { headers: { Authorization: `Bearer ${token}` } })
37
+ if (!res.ok) return generic
38
+ const page = await res.json()
39
+ const matches = Array.isArray(page?.matches) ? page.matches.filter((m) => m.ref) : []
40
+ if (!matches.length) return generic
41
+ return [`"${name}" names a page in ${matches.length} of your brains — pass the ref of the one you mean:`,
42
+ ...matches.map((m) => ` - "${m.title ?? name}" in ${m.brain ?? 'a brain'} — ref: ${m.ref}`)].join('\n')
43
+ } catch {
44
+ return generic
45
+ }
46
+ }
47
+
27
48
  async function redLinkTriage(BASE, TOKEN, name) {
28
49
  try {
29
50
  const r = await fetchCortex(`${BASE}/api/brain/red-link?name=${encodeURIComponent(name)}`, {
@@ -1810,16 +1831,20 @@ function renderNudge(payload) {
1810
1831
  expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
1811
1832
  history: z.boolean().optional().describe('node names only: return the node\'s TIMELINE (events joined via its [[repo:…]] stamps, reverse-chron, viewer-visible) instead of the page body. The page is the present; this is the history.'),
1812
1833
  version: z.string().optional().describe('read a HISTORICAL version of this page instead of the current one: a rev_no (e.g. "3") or a content_hash from page_history. Use page_history first to see the versions, then rollback_page to restore one.'),
1834
+ ref: z.string().optional().describe('the page\'s ref (read_page prints it as `ref:`). Addresses exactly one page, so use it when a name exists in more than one brain — history and version reads refuse an ambiguous name and list the refs to choose from.'),
1813
1835
  },
1814
1836
  },
1815
- async ({ name, kind, expand, history, version }) => {
1837
+ async ({ name, kind, expand, history, version, ref }) => {
1816
1838
  const k = kind ?? 'project'
1839
+ // A ref addresses exactly one page; a name can match one page in each of several brains.
1840
+ const address = (extra = {}) => new URLSearchParams(ref ? { ref, ...extra } : { kind: k, key: name, ...extra })
1817
1841
  // PAGE HISTORY (Part A): a specific version reads one historical body, resolved within this page's
1818
1842
  // own history. Not for identifier-shaped names (those are join keys, handled below).
1819
1843
  if (version && !/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
1820
1844
  try {
1821
- const qs = new URLSearchParams({ kind: k, key: name, version })
1845
+ const qs = address({ version })
1822
1846
  const vr = await fetchCortex(`${BASE}/api/brain/page?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
1847
+ if (vr.status === 409) return { content: [{ type: 'text', text: await ambiguousPageHint(BASE, TOKEN, k, name) }] }
1823
1848
  if (vr.status === 404) return { content: [{ type: 'text', text: `No version "${version}" for "${name}" (or you can't see it). Use page_history "${name}" to list its versions.` }] }
1824
1849
  if (!vr.ok) {
1825
1850
  const d = classify(vr.status, vr.headers.get('content-type'), await vr.text(), vr.headers.get('x-vercel-id'))
@@ -1834,9 +1859,12 @@ function renderNudge(payload) {
1834
1859
  // PER-NODE TIMELINE (slice 4): history = the projection over the node's identifier stamps.
1835
1860
  if (history && !/^[a-z][a-z0-9_-]*:.+/i.test(name)) {
1836
1861
  try {
1837
- const qs = new URLSearchParams({ kind: k, key: name })
1862
+ const qs = address()
1838
1863
  const r = await fetchCortex(`${BASE}/api/node/timeline?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
1839
1864
  if (r.status === 404) return { content: [{ type: 'text', text: `No ${k} named "${name}" — cannot project a timeline.` }] }
1865
+ // Two brains hold a page by this name. Say which, with the ref for each, rather than a bare 409 —
1866
+ // "Agnoclast API 409: ambiguous_page_name" was a dead end (Reader Engine, 2026-09-29).
1867
+ if (r.status === 409) return { content: [{ type: 'text', text: await ambiguousPageHint(BASE, TOKEN, k, name) }] }
1840
1868
  if (!r.ok) {
1841
1869
  const d = classify(r.status, r.headers.get('content-type'), await r.text(), r.headers.get('x-vercel-id'))
1842
1870
  return toolError(`Could not read the timeline for "${name}": ${d.message}`)
@@ -1868,6 +1896,8 @@ function renderNudge(payload) {
1868
1896
  }
1869
1897
  for (const e of t.events) lines.push(`- ${String(e.occurredAt).slice(0, 10)} · ${e.source} · ${e.summary}`)
1870
1898
  if (!t.events.length) lines.push('(no events visible to you yet on these joins)')
1899
+ // The timeline is a list of headlines. The records themselves — every word — are one call away.
1900
+ else lines.push(`Full text of the records on this page: list_records page:"${t.ref ?? ref ?? name}" (order:"oldest" reads them in sequence; offset pages past 50).`)
1871
1901
  if (t.siblings?.length) lines.push(`Sibling homes (share a stamp — bridges, not history): ${t.siblings.map((s) => `"${s.title}"`).join(', ')}`)
1872
1902
  return { content: [{ type: 'text', text: lines.join('\n') }] }
1873
1903
  } catch (e) {
@@ -1915,7 +1945,7 @@ function renderNudge(payload) {
1915
1945
  }
1916
1946
  let res
1917
1947
  try {
1918
- res = await fetchCortex(`${BASE}/api/brain/page?kind=${k}&key=${encodeURIComponent(name)}`,
1948
+ res = await fetchCortex(`${BASE}/api/brain/page?${address()}`,
1919
1949
  { headers: { Authorization: `Bearer ${TOKEN}` } })
1920
1950
  } catch (e) {
1921
1951
  return toolError(`Could not read "${name}": ${e.message}`)
@@ -3857,9 +3887,10 @@ function renderNudge(payload) {
3857
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"),
3858
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'),
3859
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"),
3860
3891
  },
3861
3892
  },
3862
- async ({ email, name, title, role, brain, execute }) => {
3893
+ async ({ email, name, title, role, brain, execute, inherit_page_id }) => {
3863
3894
  const addr = (email ?? '').trim().toLowerCase()
3864
3895
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
3865
3896
 
@@ -3951,7 +3982,7 @@ function renderNudge(payload) {
3951
3982
  done = await fetchCortex(`${BASE}/api/invite`, {
3952
3983
  method: 'POST',
3953
3984
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
3954
- body: JSON.stringify({ ...payload, brain: target.orgId }),
3985
+ body: JSON.stringify({ ...payload, brain: target.orgId, ...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}) }),
3955
3986
  })
3956
3987
  } catch (e) {
3957
3988
  return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
@@ -3969,15 +4000,36 @@ function renderNudge(payload) {
3969
4000
  const how = r.existingAccount
3970
4001
  ? 'They already had an Agnoclast account, so they keep their current login and simply gain this brain.'
3971
4002
  : `A new account was created. Temporary password: ${r.password} — they must change it on first sign-in.`
3972
- 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.` }] }
3973
4007
  }
3974
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
+
3975
4026
  const text = [
3976
4027
  `Ready to add ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}.`,
3977
4028
  '',
3978
4029
  ` brain ${target.name} [${target.orgId}]`,
3979
4030
  ` your role ${target.role} — you may add people here`,
3980
4031
  ` manager you [${target.userId}] (they are placed under you)`,
4032
+ ...pageLines,
3981
4033
  '',
3982
4034
  'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
3983
4035
  `or do it yourself at ${BASE}/?invite=1 with these values:`,
@@ -4084,17 +4136,27 @@ function renderNudge(payload) {
4084
4136
  'list_records',
4085
4137
  {
4086
4138
  title: 'List activity records (filtered)',
4087
- description: 'List recent activity records you can see, filtered by type, project, and recency. RLS-scoped. Use to enumerate "what happened on project X this week" or "recent meetings". 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).',
4088
4140
  inputSchema: {
4089
4141
  type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
4090
4142
  project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
4091
4143
  since_days: z.number().optional().describe('only records from the last N days'),
4092
4144
  limit: z.number().optional().describe('max rows (1-50, default 20)'),
4093
4145
  session: z.string().optional().describe('a session id — returns every record that session produced, INCLUDING the separate halves of a log that was split across brains. Segmented logs share a sessionId and never link to each other, so this is the only way to reassemble one. RLS-scoped: you get the halves you are cleared for and cannot tell whether others exist'),
4146
+ page: z.string().optional().describe('a page — its name or its ref (from read_page) — to list the records filed on it. A name held by pages in several brains lists them all, each row tagged with its brain; pass the ref to pick one.'),
4147
+ source: z.string().optional().describe("a record source, e.g. 'custom' (custom sources), 'github', 'email', 'imessage', 'meeting'"),
4148
+ order: z.enum(['newest', 'oldest']).optional().describe('newest first (default), or oldest first to read a series in sequence'),
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'),
4094
4151
  },
4095
4152
  },
4096
- async ({ type, project, since_days, limit, session }) => {
4153
+ async ({ type, project, since_days, limit, session, page, source, order, offset, match }) => {
4097
4154
  const qs = new URLSearchParams()
4155
+ if (page) qs.set('page', page)
4156
+ if (match) qs.set('match', match)
4157
+ if (source) qs.set('source', source)
4158
+ if (order) qs.set('order', order)
4159
+ if (offset != null) qs.set('offset', String(offset))
4098
4160
  if (type) qs.set('type', type)
4099
4161
  if (project) qs.set('project', project)
4100
4162
  if (since_days != null) qs.set('since_days', String(since_days))
@@ -4115,6 +4177,151 @@ function renderNudge(payload) {
4115
4177
  },
4116
4178
  )
4117
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
+
4118
4325
  server.registerTool(
4119
4326
  'my_day',
4120
4327
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.185",
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": {