@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.
@@ -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 / timeout → outage (failure_class 'connect')
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 coerces too.
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
- if (outcome.thrownClass === 'timeout') return { ...base, status: null, error_code: 'timeout', failure_class: 'connect' }
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 (hook && e?.retryClass) {
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
@@ -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 (any repeats spooled
125
- * meanwhile stay); a final 4xx (not 429) moves it to the drop ledger; 429/5xx/network keep it with
126
- * attempts+1. Returns { sent, kept, dropped }.
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 cur = readJson(file, null)
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: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1 }, now())
138
- if (cur?.rev && cur.rev !== e.rev && (cur.count ?? 1) > (e.count ?? 1)) {
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(file) } catch { /* raced */ }
144
- ledgerAppend(dir, '_dropped.json', dropRow(e, `rejected ${status}`, now()), now())
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
- if (cur?.key) atomicWrite(dir, `${e.key}.json`, { ...cur, attempts: (cur.attempts ?? 0) + 1 })
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
- } catch { /* best-effort heartbeat — never disrupt the session */ }
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
- 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.` }] }
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 and `offset` to page past 50. Returns each record\'s FULL summary + id — never a truncated preview, so what you get back is the whole record you are permitted to read (use an id with request_file to ask its owner for the original file).',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.186",
3
+ "version": "0.9.188",
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": {