@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.
- package/lib/custom_sources.mjs +167 -0
- package/lib/server.mjs +175 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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