@theronap/agnoclast-mcp 0.9.186 → 0.9.188
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/diagnose.mjs +32 -4
- package/lib/report_spool.mjs +92 -14
- package/lib/server.mjs +184 -9
- 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/diagnose.mjs
CHANGED
|
@@ -485,7 +485,8 @@ async function fetchCortexOnce(url, opts = {}, policy = {}) {
|
|
|
485
485
|
// app answered in JSON, the server already knows about (Sentry, §7), and a 429 is our own limiter
|
|
486
486
|
// telling this seat to slow down — filing about it would be the seat arguing with the limiter.
|
|
487
487
|
//
|
|
488
|
-
// connect / DNS
|
|
488
|
+
// connect / DNS → outage (failure_class 'connect')
|
|
489
|
+
// timeout → outage (failure_class 'timeout' — it ANSWERED too slowly, it did not refuse)
|
|
489
490
|
// non-JSON 4xx/5xx (not 401, not 429) → outage (failure_class 'infra' — firewall / protection)
|
|
490
491
|
// 503 → outage (failure_class 'refused', or 'infra' when non-JSON)
|
|
491
492
|
// 429, JSON app errors, 401 → never
|
|
@@ -501,11 +502,19 @@ const toolContext = new AsyncLocalStorage()
|
|
|
501
502
|
/** Run `fn` with `tool` as the name any outage filed during it is attributed to (server.mjs wraps every tool). */
|
|
502
503
|
export const runWithTool = (tool, fn) => toolContext.run(tool, fn)
|
|
503
504
|
|
|
504
|
-
// For callers outside a tool call (hooks, doctor). Unknown paths are 'other' — the server
|
|
505
|
+
// For callers outside a tool call (hooks, doctor, the heartbeat). Unknown paths are 'other' — the server
|
|
506
|
+
// coerces too.
|
|
507
|
+
//
|
|
508
|
+
// ⚠ 'other' IS WHERE A REPORT GOES TO BE UNREADABLE. The first ×3 `other` timeout (2026-09-28) took an
|
|
509
|
+
// agent a transcript-and-log dig to identify as the session-ping heartbeat, because every call made
|
|
510
|
+
// outside a tool — the heartbeat, capture, the arrivals hook, skill sync — landed in the same bucket.
|
|
511
|
+
// Each now carries a name the server's KNOWN_TOOLS accepts (web/lib/engine/bug_reports.ts).
|
|
505
512
|
const PATH_TOOLS = {
|
|
506
513
|
'mcp-context': 'my_context', 'session-context': 'session_context', 'grep': 'grep', 'search': 'search_org',
|
|
507
514
|
'brain/page': 'read_page', 'brain/author': 'author', 'brain/edit-page': 'edit_page', 'records/capture': 'capture_record',
|
|
508
515
|
'intake/claim': 'intake_claim', 'intake/materialize': 'intake_materialize', 'staged': 'staged_records', 'brains': 'my_brains',
|
|
516
|
+
'session-ping': 'session_ping', 'session-signals': 'session_signals', 'skills': 'skills',
|
|
517
|
+
'ingest': 'capture', 'node-types': 'capture', 'obligations/candidates': 'capture',
|
|
509
518
|
}
|
|
510
519
|
|
|
511
520
|
/** PURE. The tool an outage on `url` belongs to: the running tool if known, else a path mapping, else 'other'. */
|
|
@@ -528,7 +537,9 @@ export function outageReport(outcome, { tool, requestId = null, version = CLIENT
|
|
|
528
537
|
const base = { kind: 'outage', tool: tool ?? 'other', request_id: requestId ?? null, client_version: version ?? null }
|
|
529
538
|
if (outcome.thrownClass) {
|
|
530
539
|
if (outcome.thrownClass === 'connect') return { ...base, status: null, error_code: null, failure_class: 'connect' }
|
|
531
|
-
|
|
540
|
+
// A timeout is its own class: the server was reached and was SLOW. Filing it as 'connect' told the
|
|
541
|
+
// reader "could not reach" about a request that may well have completed after we gave up.
|
|
542
|
+
if (outcome.thrownClass === 'timeout') return { ...base, status: null, error_code: 'timeout', failure_class: 'timeout' }
|
|
532
543
|
return null
|
|
533
544
|
}
|
|
534
545
|
const { status, contentType } = outcome
|
|
@@ -606,20 +617,36 @@ function triggerFlush(url, authorization) {
|
|
|
606
617
|
/** Tests: await the in-flight flush, if any. */
|
|
607
618
|
export const pendingFlush = () => flushing
|
|
608
619
|
|
|
620
|
+
// ⚠ A HEARTBEAT IS A NOISY OUTAGE SENSOR. session-ping fires every 60s per open session, best-effort,
|
|
621
|
+
// never retried after a timeout — so one slow request on one instance filed an outage, and two MCP
|
|
622
|
+
// servers flushing the same entry counted it twice (2026-09-28: ×3 reports for two failures, laptop
|
|
623
|
+
// awake and online both times). A missed beat is harmless by design; the next one refreshes presence.
|
|
624
|
+
// So a heartbeat files only when it has failed this many times IN A ROW — a real outage does that, a
|
|
625
|
+
// blip does not.
|
|
626
|
+
export const HEARTBEAT_OUTAGE_AFTER = 3
|
|
627
|
+
/** PURE. The fetchCortex policy for a heartbeat that has already failed `consecutiveFailures` times in a row. */
|
|
628
|
+
export function heartbeatPolicy(consecutiveFailures) {
|
|
629
|
+
return { fileOutage: consecutiveFailures + 1 >= HEARTBEAT_OUTAGE_AFTER }
|
|
630
|
+
}
|
|
631
|
+
|
|
609
632
|
/**
|
|
610
633
|
* fetch with the D9 retry policy (see fetchCortexOnce), plus the ADR-0070 outage hook. Returns the
|
|
611
634
|
* Response (possibly a non-OK one, after retries are exhausted or refused); throws a clear "Could not
|
|
612
635
|
* reach" error when no response was ever obtained.
|
|
613
636
|
*
|
|
614
637
|
* policy.skipAutofile never auto-file this call's failure, and never flush from it (the report POSTs).
|
|
638
|
+
* policy.fileOutage false = do not file THIS call's failure, but DO still flush the spool on success.
|
|
639
|
+
* Distinct from skipAutofile on purpose: the heartbeat is the call most likely to
|
|
640
|
+
* be the next 2xx after an outage, so it must keep carrying the spool out.
|
|
615
641
|
*/
|
|
616
642
|
export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
617
643
|
const hook = !policy.skipAutofile && autofileEnabled() && authOf(opts)
|
|
644
|
+
const file = hook && policy.fileOutage !== false
|
|
618
645
|
let res
|
|
619
646
|
try {
|
|
620
647
|
res = await fetchCortexOnce(url, opts, policy)
|
|
621
648
|
} catch (e) {
|
|
622
|
-
if (
|
|
649
|
+
if (file && e?.retryClass) {
|
|
623
650
|
const r = outageReport({ thrownClass: e.retryClass }, { tool: toolForUrl(url) })
|
|
624
651
|
// Awaited, not fire-and-forget: hook processes are short-lived and would exit before a detached
|
|
625
652
|
// probe answered. Bounded by REACHABILITY_TIMEOUT_MS, and only on a path that is already failing.
|
|
@@ -629,6 +656,7 @@ export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
|
629
656
|
}
|
|
630
657
|
if (!hook) return res
|
|
631
658
|
if (res.ok) { void triggerFlush(url, authOf(opts)); return res }
|
|
659
|
+
if (!file) return res
|
|
632
660
|
try {
|
|
633
661
|
const contentType = res.headers.get('content-type')
|
|
634
662
|
let errorCode = null
|
package/lib/report_spool.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// ⚠ STRUCTURED FIELDS ONLY. Nothing here ever holds an error MESSAGE — classify()'s text carries brain
|
|
20
20
|
// names and page titles. Only an interactive, user-approved report_issue may carry summary/expected.
|
|
21
21
|
|
|
22
|
-
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
22
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, openSync, closeSync } from 'node:fs'
|
|
23
23
|
import { homedir, tmpdir } from 'node:os'
|
|
24
24
|
import { join } from 'node:path'
|
|
25
25
|
import { createHash, randomBytes } from 'node:crypto'
|
|
@@ -81,6 +81,32 @@ export const readDropped = (dir = spoolDir(), now = Date.now()) => ledgerRead(di
|
|
|
81
81
|
|
|
82
82
|
const dropRow = (e, reason, at) => ({ at, kind: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1, reason })
|
|
83
83
|
|
|
84
|
+
// ⚠ ONE WRITER PER KEY AT A TIME. spoolReport, the flush claim and releaseClaim each READ an entry and
|
|
85
|
+
// then WRITE it. Without a lock, a spoolReport that read `<key>.json` just before a flush renamed it
|
|
86
|
+
// away would write the old count back into a fresh file — re-sending what had just been sent — and a
|
|
87
|
+
// releaseClaim racing a spoolReport could overwrite a repeat (Codex review of the claim design,
|
|
88
|
+
// 2026-09-29). An O_EXCL lock file per key serialises those three; the POST itself stays OUTSIDE it,
|
|
89
|
+
// so a slow server never holds a writer up. A lock older than LOCK_STALE_MS belongs to a dead process.
|
|
90
|
+
const LOCK_STALE_MS = 5_000
|
|
91
|
+
const LOCK_WAIT_MS = 250
|
|
92
|
+
function withKeyLock(dir, key, fn) {
|
|
93
|
+
const lock = join(dir, `_lock.${key}`)
|
|
94
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
95
|
+
for (;;) {
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
98
|
+
closeSync(openSync(lock, 'wx', 0o600))
|
|
99
|
+
break
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (e?.code !== 'EEXIST') return undefined
|
|
102
|
+
try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue } } catch { continue }
|
|
103
|
+
if (Date.now() > deadline) return undefined // contended: the caller skips, it never blocks a session
|
|
104
|
+
const until = Date.now() + 5; while (Date.now() < until) { /* brief spin: these sections are sub-ms */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try { return fn() } finally { try { unlinkSync(lock) } catch { /* already gone */ } }
|
|
108
|
+
}
|
|
109
|
+
|
|
84
110
|
/**
|
|
85
111
|
* Spool one report. A repeat of the same signature increments `count`; a NEW signature past the cap
|
|
86
112
|
* evicts the oldest (by last_seen) into the drop ledger. Returns the entry written, or null. Never throws.
|
|
@@ -88,6 +114,12 @@ const dropRow = (e, reason, at) => ({ at, kind: e.kind, tool: e.tool, status: e.
|
|
|
88
114
|
export function spoolReport(report, { dir = spoolDir(), now = Date.now() } = {}) {
|
|
89
115
|
try {
|
|
90
116
|
const key = reportKey(report)
|
|
117
|
+
return withKeyLock(dir, key, () => spoolLocked(report, key, dir, now)) ?? null
|
|
118
|
+
} catch { return null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function spoolLocked(report, key, dir, now) {
|
|
122
|
+
try {
|
|
91
123
|
const cur = readJson(join(dir, `${key}.json`), null)
|
|
92
124
|
const entry = cur?.key
|
|
93
125
|
? { ...cur, ...report, key, rev: randomBytes(6).toString('hex'), count: (cur.count ?? 1) + (report.count ?? 1), last_seen: now }
|
|
@@ -120,31 +152,77 @@ export function wireBody(e) {
|
|
|
120
152
|
return body
|
|
121
153
|
}
|
|
122
154
|
|
|
155
|
+
// ⚠ CLAIM BEFORE SENDING. Every MCP server and hook process flushes after its own next 2xx, and nothing
|
|
156
|
+
// stopped two of them reading the same entry and both POSTing it — the server dedupes onto one open row
|
|
157
|
+
// per signature and ADDS the count, so one failure was reported twice. Measured 2026-09-28: two POSTs of
|
|
158
|
+
// one entry 183 ms apart, which is how two heartbeat timeouts became "×3". A rename is atomic, so of any
|
|
159
|
+
// number of concurrent flushes exactly one takes a given entry; the rest skip it. While it is claimed, a
|
|
160
|
+
// repeat of the same failure lands in a FRESH <key>.json, so nothing spooled mid-send is lost or re-sent.
|
|
161
|
+
const CLAIM_MARK = '.json.sending.'
|
|
162
|
+
export const STALE_CLAIM_MS = 10 * 60_000
|
|
163
|
+
|
|
164
|
+
/** Put a claimed entry back (a send that must be retried, or a claim its process abandoned), merging any
|
|
165
|
+
* repeats spooled while it was out. Never throws. */
|
|
166
|
+
function releaseClaim(dir, claimedPath, entry, { attempted }) {
|
|
167
|
+
withKeyLock(dir, entry.key, () => releaseLocked(dir, claimedPath, entry, { attempted }))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function releaseLocked(dir, claimedPath, entry, { attempted }) {
|
|
171
|
+
try {
|
|
172
|
+
const file = join(dir, `${entry.key}.json`)
|
|
173
|
+
const fresh = readJson(file, null)
|
|
174
|
+
const attempts = (entry.attempts ?? 0) + (attempted ? 1 : 0)
|
|
175
|
+
const merged = fresh?.key
|
|
176
|
+
? { ...fresh, count: (fresh.count ?? 1) + (entry.count ?? 1), first_seen: Math.min(fresh.first_seen ?? Infinity, entry.first_seen ?? Infinity), attempts }
|
|
177
|
+
: { ...entry, attempts }
|
|
178
|
+
atomicWrite(dir, `${entry.key}.json`, merged)
|
|
179
|
+
unlinkSync(claimedPath)
|
|
180
|
+
} catch { /* the claim file stays and is recovered as stale */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Claims older than STALE_CLAIM_MS belong to a process that died mid-send: return them to the spool. */
|
|
184
|
+
function recoverStaleClaims(dir, now) {
|
|
185
|
+
let names
|
|
186
|
+
try { names = readdirSync(dir) } catch { return }
|
|
187
|
+
for (const n of names) {
|
|
188
|
+
if (!n.includes(CLAIM_MARK)) continue
|
|
189
|
+
const p = join(dir, n)
|
|
190
|
+
try {
|
|
191
|
+
if (now - statSync(p).mtimeMs < STALE_CLAIM_MS) continue
|
|
192
|
+
const e = readJson(p, null)
|
|
193
|
+
if (e?.key) releaseClaim(dir, p, e, { attempted: false })
|
|
194
|
+
else unlinkSync(p)
|
|
195
|
+
} catch { /* raced with another recoverer */ }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
123
199
|
/**
|
|
124
|
-
* Send every entry via `postFn(body)` → { status } (or throws). 2xx removes it (
|
|
125
|
-
*
|
|
126
|
-
*
|
|
200
|
+
* Send every entry via `postFn(body)` → { status } (or throws). 2xx removes it; a final 4xx (not 429)
|
|
201
|
+
* moves it to the drop ledger; 429/5xx/network put it back with attempts+1. Each entry is CLAIMED first
|
|
202
|
+
* (see CLAIM_MARK), so concurrent flushes never send one entry twice. Returns { sent, kept, dropped }.
|
|
127
203
|
*/
|
|
128
204
|
export async function flush(postFn, { dir = spoolDir(), now = Date.now } = {}) {
|
|
129
205
|
const out = { sent: 0, kept: 0, dropped: 0 }
|
|
206
|
+
recoverStaleClaims(dir, now())
|
|
130
207
|
for (const e of readEntries(dir)) {
|
|
131
|
-
let status = 0
|
|
132
|
-
try { status = Number((await postFn(wireBody(e)))?.status) || 0 } catch { status = 0 }
|
|
133
208
|
const file = join(dir, `${e.key}.json`)
|
|
134
|
-
const
|
|
209
|
+
const claimed = `${file}${CLAIM_MARK.slice('.json'.length)}${process.pid}.${randomBytes(3).toString('hex')}`
|
|
210
|
+
const took = withKeyLock(dir, e.key, () => { try { renameSync(file, claimed); return true } catch { return false } })
|
|
211
|
+
if (!took) continue // another flush took it, or a writer holds the key right now — next flush gets it
|
|
212
|
+
const mine = readJson(claimed, null) ?? e // what we actually own — repeats may have landed since readEntries
|
|
213
|
+
let status = 0
|
|
214
|
+
try { status = Number((await postFn(wireBody(mine)))?.status) || 0 } catch { status = 0 }
|
|
135
215
|
if (status >= 200 && status < 300) {
|
|
136
216
|
out.sent++
|
|
137
|
-
ledgerAppend(dir, '_filed.json', { at: now(), kind:
|
|
138
|
-
|
|
139
|
-
atomicWrite(dir, `${e.key}.json`, { ...cur, count: cur.count - (e.count ?? 1) })
|
|
140
|
-
} else { try { unlinkSync(file) } catch { /* raced */ } }
|
|
217
|
+
ledgerAppend(dir, '_filed.json', { at: now(), kind: mine.kind, tool: mine.tool, status: mine.status ?? null, count: mine.count ?? 1 }, now())
|
|
218
|
+
try { unlinkSync(claimed) } catch { /* raced */ }
|
|
141
219
|
} else if (status >= 400 && status < 500 && status !== 429) {
|
|
142
220
|
out.dropped++
|
|
143
|
-
try { unlinkSync(
|
|
144
|
-
ledgerAppend(dir, '_dropped.json', dropRow(
|
|
221
|
+
try { unlinkSync(claimed) } catch { /* raced */ }
|
|
222
|
+
ledgerAppend(dir, '_dropped.json', dropRow(mine, `rejected ${status}`, now()), now())
|
|
145
223
|
} else {
|
|
146
224
|
out.kept++
|
|
147
|
-
|
|
225
|
+
releaseClaim(dir, claimed, mine, { attempted: true })
|
|
148
226
|
}
|
|
149
227
|
}
|
|
150
228
|
return out
|
package/lib/server.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
|
|
|
5
5
|
import { homedir } from 'os'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
|
-
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool } from './diagnose.mjs'
|
|
8
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
|
|
9
9
|
import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
|
|
10
10
|
import { folderClaimLine } from './folder_claims.mjs'
|
|
11
11
|
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
@@ -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,
|
|
@@ -252,9 +253,12 @@ export async function runServer(version) {
|
|
|
252
253
|
function currentAuthored() {
|
|
253
254
|
try { return workedFiles ? workedFiles.authored(100) : [] } catch { return [] }
|
|
254
255
|
}
|
|
256
|
+
// Consecutive heartbeat failures. A lone slow beat is not an outage (see HEARTBEAT_OUTAGE_AFTER);
|
|
257
|
+
// three in a row is, and that beat files through fetchCortex's normal path, probe included.
|
|
258
|
+
let pingFailures = 0
|
|
255
259
|
async function pingSession() {
|
|
256
260
|
try {
|
|
257
|
-
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
261
|
+
const res = await fetchCortex(`${BASE}/api/session-ping`, {
|
|
258
262
|
method: 'POST',
|
|
259
263
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
260
264
|
// mcpVersion (ADR-0033 T9): the fleet currently has NO way to report which client build a
|
|
@@ -285,8 +289,10 @@ export async function runServer(version) {
|
|
|
285
289
|
// YOURS; a shared changed file is not (see authoredInCall in working_files.mjs).
|
|
286
290
|
authored: currentAuthored(),
|
|
287
291
|
}),
|
|
288
|
-
})
|
|
289
|
-
|
|
292
|
+
}, heartbeatPolicy(pingFailures))
|
|
293
|
+
pingFailures = res.ok ? 0 : pingFailures + 1
|
|
294
|
+
try { await res.body?.cancel() } catch { /* nothing to drain */ }
|
|
295
|
+
} catch { pingFailures++ /* best-effort heartbeat — never disrupt the session */ }
|
|
290
296
|
}
|
|
291
297
|
void pingSession()
|
|
292
298
|
const _hb = setInterval(() => void pingSession(), 60_000)
|
|
@@ -3886,9 +3892,10 @@ function renderNudge(payload) {
|
|
|
3886
3892
|
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
3893
|
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
3894
|
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'),
|
|
3895
|
+
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
3896
|
},
|
|
3890
3897
|
},
|
|
3891
|
-
async ({ email, name, title, role, brain, execute }) => {
|
|
3898
|
+
async ({ email, name, title, role, brain, execute, inherit_page_id }) => {
|
|
3892
3899
|
const addr = (email ?? '').trim().toLowerCase()
|
|
3893
3900
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
|
|
3894
3901
|
|
|
@@ -3980,7 +3987,7 @@ function renderNudge(payload) {
|
|
|
3980
3987
|
done = await fetchCortex(`${BASE}/api/invite`, {
|
|
3981
3988
|
method: 'POST',
|
|
3982
3989
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
3983
|
-
body: JSON.stringify({ ...payload, brain: target.orgId }),
|
|
3990
|
+
body: JSON.stringify({ ...payload, brain: target.orgId, ...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}) }),
|
|
3984
3991
|
})
|
|
3985
3992
|
} catch (e) {
|
|
3986
3993
|
return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
|
|
@@ -3998,15 +4005,36 @@ function renderNudge(payload) {
|
|
|
3998
4005
|
const how = r.existingAccount
|
|
3999
4006
|
? 'They already had an Agnoclast account, so they keep their current login and simply gain this brain.'
|
|
4000
4007
|
: `A new account was created. Temporary password: ${r.password} — they must change it on first sign-in.`
|
|
4001
|
-
|
|
4008
|
+
const page = r.userPage?.status
|
|
4009
|
+
? `\nTheir page in ${target.name}: ${r.userPage.status}${r.userPage.error ? ` (${r.userPage.error})` : ''}.`
|
|
4010
|
+
: ''
|
|
4011
|
+
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
4012
|
}
|
|
4003
4013
|
|
|
4014
|
+
// ADR-0023: does this brain already have a page about them? The inviter decides — list, never pick.
|
|
4015
|
+
let pageLines = []
|
|
4016
|
+
try {
|
|
4017
|
+
const cr = await fetchCortex(`${BASE}/api/invite/candidates`, {
|
|
4018
|
+
method: 'POST',
|
|
4019
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4020
|
+
body: JSON.stringify({ brain: target.orgId, name: payload.name, email: payload.email }),
|
|
4021
|
+
})
|
|
4022
|
+
if (cr.ok) {
|
|
4023
|
+
const { candidates = [], suggested = null } = await cr.json()
|
|
4024
|
+
pageLines = candidates.length
|
|
4025
|
+
? ['', '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:',
|
|
4026
|
+
...candidates.map((c) => ` - "${c.title}" [${c.documentId}] matched by ${c.matchedBy.join('+')}${c.documentId === suggested ? ' (likely them)' : ''}`)]
|
|
4027
|
+
: ['', 'No existing page about them in this brain — they get a new one.']
|
|
4028
|
+
}
|
|
4029
|
+
} catch { /* the plan still stands without the lookup */ }
|
|
4030
|
+
|
|
4004
4031
|
const text = [
|
|
4005
4032
|
`Ready to add ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}.`,
|
|
4006
4033
|
'',
|
|
4007
4034
|
` brain ${target.name} [${target.orgId}]`,
|
|
4008
4035
|
` your role ${target.role} — you may add people here`,
|
|
4009
4036
|
` manager you [${target.userId}] (they are placed under you)`,
|
|
4037
|
+
...pageLines,
|
|
4010
4038
|
'',
|
|
4011
4039
|
'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
|
|
4012
4040
|
`or do it yourself at ${BASE}/?invite=1 with these values:`,
|
|
@@ -4113,7 +4141,7 @@ function renderNudge(payload) {
|
|
|
4113
4141
|
'list_records',
|
|
4114
4142
|
{
|
|
4115
4143
|
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
|
|
4144
|
+
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
4145
|
inputSchema: {
|
|
4118
4146
|
type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
|
|
4119
4147
|
project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
|
|
@@ -4124,11 +4152,13 @@ function renderNudge(payload) {
|
|
|
4124
4152
|
source: z.string().optional().describe("a record source, e.g. 'custom' (custom sources), 'github', 'email', 'imessage', 'meeting'"),
|
|
4125
4153
|
order: z.enum(['newest', 'oldest']).optional().describe('newest first (default), or oldest first to read a series in sequence'),
|
|
4126
4154
|
offset: z.number().optional().describe('skip this many records — page through more than 50 by calling again with offset 50, 100…'),
|
|
4155
|
+
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
4156
|
},
|
|
4128
4157
|
},
|
|
4129
|
-
async ({ type, project, since_days, limit, session, page, source, order, offset }) => {
|
|
4158
|
+
async ({ type, project, since_days, limit, session, page, source, order, offset, match }) => {
|
|
4130
4159
|
const qs = new URLSearchParams()
|
|
4131
4160
|
if (page) qs.set('page', page)
|
|
4161
|
+
if (match) qs.set('match', match)
|
|
4132
4162
|
if (source) qs.set('source', source)
|
|
4133
4163
|
if (order) qs.set('order', order)
|
|
4134
4164
|
if (offset != null) qs.set('offset', String(offset))
|
|
@@ -4152,6 +4182,151 @@ function renderNudge(payload) {
|
|
|
4152
4182
|
},
|
|
4153
4183
|
)
|
|
4154
4184
|
|
|
4185
|
+
// ── custom sources: set up, list, re-home (Theron, 2026-09-29) ─────────────────────────────────
|
|
4186
|
+
// "I don't think it's that good of an idea to let the user do the whole setup without having an agent
|
|
4187
|
+
// involved." The agent can read the system being connected, describe what its data is ABOUT, and judge
|
|
4188
|
+
// which existing page is its home — and the server holds it to that: a new page is refused while any
|
|
4189
|
+
// page that may already be about it has not been set aside by id. Formatting: custom_sources.mjs.
|
|
4190
|
+
const homeArgs = {
|
|
4191
|
+
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'),
|
|
4192
|
+
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'),
|
|
4193
|
+
new_page_brain: z.string().optional().describe('the brain for a new page, when you are in more than one'),
|
|
4194
|
+
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'),
|
|
4195
|
+
reason: z.string().optional().describe('why none of those pages is the home — kept in the new page\'s history'),
|
|
4196
|
+
}
|
|
4197
|
+
const cannot = async (what, res) => {
|
|
4198
|
+
const body = await res.text()
|
|
4199
|
+
let j = null
|
|
4200
|
+
try { j = JSON.parse(body) } catch { /* not JSON */ }
|
|
4201
|
+
if (j && (res.status === 409 || res.status === 400 || res.status === 404 || res.status === 403) && (j.error || j.message)) {
|
|
4202
|
+
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)}`)
|
|
4203
|
+
}
|
|
4204
|
+
return toolError(`${what}: ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
server.registerTool(
|
|
4208
|
+
'list_custom_sources',
|
|
4209
|
+
{
|
|
4210
|
+
title: 'List your custom sources (with their field mappings)',
|
|
4211
|
+
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).',
|
|
4212
|
+
inputSchema: {},
|
|
4213
|
+
},
|
|
4214
|
+
async () => {
|
|
4215
|
+
let res
|
|
4216
|
+
try {
|
|
4217
|
+
res = await fetchCortex(`${BASE}/api/custom-connectors`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4218
|
+
} catch (e) {
|
|
4219
|
+
return toolError(`Could not list custom sources: ${e.message}`)
|
|
4220
|
+
}
|
|
4221
|
+
if (!res.ok) return cannot('Could not list custom sources', res)
|
|
4222
|
+
const j = await res.json()
|
|
4223
|
+
return { content: [{ type: 'text', text: formatSources(j.connectors) }] }
|
|
4224
|
+
},
|
|
4225
|
+
)
|
|
4226
|
+
|
|
4227
|
+
server.registerTool(
|
|
4228
|
+
'create_custom_source',
|
|
4229
|
+
{
|
|
4230
|
+
title: 'Set up a custom source (dry run first)',
|
|
4231
|
+
description: [
|
|
4232
|
+
'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:',
|
|
4233
|
+
'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.',
|
|
4234
|
+
'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.',
|
|
4235
|
+
'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.',
|
|
4236
|
+
'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.',
|
|
4237
|
+
'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".',
|
|
4238
|
+
'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',
|
|
4239
|
+
].join('\n'),
|
|
4240
|
+
inputSchema: {
|
|
4241
|
+
name: z.string().describe('what the source is called, e.g. "Reader Engine" or "HaloITSM tickets"'),
|
|
4242
|
+
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'),
|
|
4243
|
+
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'),
|
|
4244
|
+
purpose: z.enum(['communication', 'work', 'reference', 'metrics']).describe('sets defaults only'),
|
|
4245
|
+
sample: z.record(z.string(), z.any()).optional().describe('ONE real item, exactly as the system will send it (a JSON object)'),
|
|
4246
|
+
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'),
|
|
4247
|
+
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'),
|
|
4248
|
+
...homeArgs,
|
|
4249
|
+
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'),
|
|
4250
|
+
},
|
|
4251
|
+
},
|
|
4252
|
+
async (a) => {
|
|
4253
|
+
const home = homeFromArgs(a, a.name)
|
|
4254
|
+
const body = {
|
|
4255
|
+
name: a.name, description: a.description, shape: a.shape, purpose: a.purpose,
|
|
4256
|
+
...(a.sample ? { sample: a.sample } : {}), ...(a.mapping ? { mapping: a.mapping } : {}),
|
|
4257
|
+
...(a.mode ? { mode: a.mode } : {}), ...(home ? { home } : {}), ...(a.dry_run ? { dryRun: true } : {}),
|
|
4258
|
+
}
|
|
4259
|
+
let res
|
|
4260
|
+
try {
|
|
4261
|
+
res = await fetchCortex(`${BASE}/api/custom-connectors`, {
|
|
4262
|
+
method: 'POST',
|
|
4263
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4264
|
+
body: JSON.stringify(body),
|
|
4265
|
+
// A dry run writes nothing, so it may be retried; a real create is a POST and never is.
|
|
4266
|
+
}, a.dry_run ? { idempotent: true } : {})
|
|
4267
|
+
} catch (e) {
|
|
4268
|
+
return toolError(`Could not set up the source: ${e.message}`)
|
|
4269
|
+
}
|
|
4270
|
+
if (!res.ok) return cannot(a.dry_run ? 'Dry run refused' : 'Not created', res)
|
|
4271
|
+
const j = await res.json()
|
|
4272
|
+
const text = j.dryRun ? formatDryRun(j) : formatCreated(j, (slug) => `~/.agnoclast/keys/${slug}`)
|
|
4273
|
+
return { content: [{ type: 'text', text }] }
|
|
4274
|
+
},
|
|
4275
|
+
)
|
|
4276
|
+
|
|
4277
|
+
server.registerTool(
|
|
4278
|
+
'set_custom_source_home',
|
|
4279
|
+
{
|
|
4280
|
+
title: 'Change where a custom source files',
|
|
4281
|
+
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.',
|
|
4282
|
+
inputSchema: {
|
|
4283
|
+
source: z.string().describe('the source — its id or its name, as list_custom_sources shows'),
|
|
4284
|
+
...homeArgs,
|
|
4285
|
+
description: z.string().optional().describe('for a new page: what the source is about, searched to find pages that may already be its home'),
|
|
4286
|
+
mode: z.enum(['channel', 'ingest']).optional().describe('ingest = stop filing onto a page (every new item goes to the pile)'),
|
|
4287
|
+
},
|
|
4288
|
+
},
|
|
4289
|
+
async (a) => {
|
|
4290
|
+
let list
|
|
4291
|
+
try {
|
|
4292
|
+
const res = await fetchCortex(`${BASE}/api/custom-connectors`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4293
|
+
if (!res.ok) return cannot('Could not read your custom sources', res)
|
|
4294
|
+
list = (await res.json()).connectors
|
|
4295
|
+
} catch (e) {
|
|
4296
|
+
return toolError(`Could not read your custom sources: ${e.message}`)
|
|
4297
|
+
}
|
|
4298
|
+
const picked = pickSource(list, a.source)
|
|
4299
|
+
if (!picked.ok) return toolError(picked.message)
|
|
4300
|
+
const src = picked.source
|
|
4301
|
+
const url = `${BASE}/api/custom-connectors/${encodeURIComponent(src.id)}/channel`
|
|
4302
|
+
let res
|
|
4303
|
+
try {
|
|
4304
|
+
if (a.mode === 'ingest') {
|
|
4305
|
+
res = await fetchCortex(url, { method: 'DELETE', headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
4306
|
+
if (!res.ok) return cannot('Not changed', res)
|
|
4307
|
+
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.` }] }
|
|
4308
|
+
}
|
|
4309
|
+
const home = homeFromArgs(a, src.name)
|
|
4310
|
+
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.')
|
|
4311
|
+
res = await fetchCortex(url, {
|
|
4312
|
+
method: 'PUT',
|
|
4313
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4314
|
+
body: JSON.stringify({ home }),
|
|
4315
|
+
})
|
|
4316
|
+
} catch (e) {
|
|
4317
|
+
return toolError(`Not changed: ${e.message}`)
|
|
4318
|
+
}
|
|
4319
|
+
if (!res.ok) return cannot('Not changed', res)
|
|
4320
|
+
const j = await res.json()
|
|
4321
|
+
const ch = j.channel
|
|
4322
|
+
return { content: [{ type: 'text', text: [
|
|
4323
|
+
`${src.name} now files onto ${ch.homeCreated ? 'a NEW page' : ''} "${ch.homeTitle}" (${ch.brain}) — ref ${ch.homeRef}.`.replace('onto "', 'onto "'),
|
|
4324
|
+
`${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.' : ''}`,
|
|
4325
|
+
`Read it: list_records page:"${ch.homeRef}" source:"custom"`,
|
|
4326
|
+
].join('\n') }] }
|
|
4327
|
+
},
|
|
4328
|
+
)
|
|
4329
|
+
|
|
4155
4330
|
server.registerTool(
|
|
4156
4331
|
'my_day',
|
|
4157
4332
|
{
|
package/package.json
CHANGED