@theronap/agnoclast-mcp 0.9.259 → 0.9.260

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.
@@ -3,6 +3,7 @@ import { homedir } from 'os'
3
3
  import { join } from 'path'
4
4
  import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
5
5
  import { repoFullNameFrom } from './capture.mjs'
6
+ import { compactBlocks } from './session_start_compact.mjs'
6
7
 
7
8
  // The private third copy of this lived here until 2026-08-19. doctor.mjs:14 warned that a third
8
9
  // copy is how a machine ends up connected to one command and 'no token found' to another; it also
@@ -136,7 +137,12 @@ export async function runSnapshotContext() {
136
137
 
137
138
  // The action blocks go to STDOUT, which a SessionStart hook injects into the session. Before the
138
139
  // "logged" line so a truncated preview shows what needs doing rather than a file path.
139
- const blocks = actionableBlocks(context)
140
+ //
141
+ // COMPACTED (2026-10-23, session_start_compact.mjs): one line per block with its count and the tool
142
+ // that lists it, instead of every block whole — this stdout is re-read on every turn, and one block
143
+ // alone listed ~8 KB of subjects and ids. The full text is in the file named at the end. Onboarding
144
+ // and the day-one welcome still print whole. actionableBlocks stays as the selection rule's record.
145
+ const blocks = compactBlocks(context, { fullTextPath: join(dir, 'latest.md') })
140
146
  if (blocks) out(blocks + '\n')
141
147
 
142
148
  out(`Agnoclast: logged startup context → ${file}`)
package/lib/doctor.mjs CHANGED
@@ -6,6 +6,7 @@ import { renderRenameNotice } from './rename_notice.mjs'
6
6
  import { detectSurface, renderSurface } from './surface.mjs'
7
7
  import { queueDir, queueStatus, renderQueueNotice } from './capture_queue.mjs'
8
8
  import { pendingNotice, renderReportNotice } from './report_spool.mjs'
9
+ import { compactNotice } from './session_start_compact.mjs'
9
10
 
10
11
  /** ADR-0070 §5: issue reports filed in the BACKGROUND since the last session start. A background
11
12
  * auto-file had no turn to tell anyone in, so this is where the person hears about it. Consumes the
@@ -79,8 +80,13 @@ async function statusLine(out, { capturesStuck = false } = {}) {
79
80
  // about the TOKEN, and every reader takes it as a fact about their WORK. Printing both would
80
81
  // leave the reassurance that caused four days of silent loss sitting directly above the
81
82
  // warning that contradicts it.
83
+ //
84
+ // COMPACTED (2026-10-23, session_start_compact.mjs): one line per notice category, with its count
85
+ // and the tool that lists it. The server stacks several notices into this message, each with its
86
+ // items inline; that text is re-read on every turn of the session, so the items stay one call away.
82
87
  if (r.captureNotice?.message) {
83
- out(`Agnoclast: ⚠ ${r.captureNotice.message}`)
88
+ const compact = compactNotice(r.captureNotice.message) || `⚠ ${r.captureNotice.message}`
89
+ for (const line of compact.split('\n')) out(`Agnoclast: ${line}`)
84
90
  return
85
91
  }
86
92
  const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
@@ -0,0 +1,186 @@
1
+ // SESSION-START COMPACTION (Theron, 2026-10-23).
2
+ //
3
+ // The SessionStart hooks (`status`, `snapshot-context`) print into the session, and Claude Code
4
+ // re-reads that text on EVERY turn. Measured on Theron's seat the day this was written: ~20.8 KB per
5
+ // session start, of which ~8 KB was one block listing 19 email subjects and 101 arrival ids inline, and
6
+ // ~1.9 KB was the status notice listing arrivals and per-source remedies. None of it was lost by being
7
+ // listed ONCE as a count — every list it carried is one tool call away, and the full text is saved to
8
+ // ~/.cortex/context-snapshots/latest.md on every start.
9
+ //
10
+ // THE RULE: every notice CATEGORY becomes one line with its count and the tool that lists it. No
11
+ // category is dropped (a new server block with no mapping still gets its own line, from its heading);
12
+ // only the inline items go. Two kinds of block pass through whole, because they are instructions for
13
+ // a NEW person rather than a backlog: the guided-onboarding first steps (the server sends it only while
14
+ // that person's plan is active — onboarding.ts firstStepsContextFor) and the day-one welcome.
15
+ //
16
+ // PURE: no I/O. context_log.mjs and doctor.mjs feed it what the server returned.
17
+
18
+ /** Blocks printed whole. Matched by ASCII substring of the heading (the welcome heading ends in an emoji). */
19
+ export const PASS_THROUGH_MARKERS = ['First steps this person chose', 'Welcome to Agnoclast']
20
+
21
+ /** The two static orientation blocks: never printed (unchanged from actionableBlocks). */
22
+ export const ORIENTATION_MARKERS = ['Your place in the org', 'This is a POINTER']
23
+
24
+ /**
25
+ * Heading substring -> what to call to see the items. First match wins. A block with no entry falls
26
+ * back to the first backticked tool name in its own body, so a block added later still names a tool.
27
+ */
28
+ export const BLOCK_TOOLS = [
29
+ ['Gate 5', '`gate5_status`'],
30
+ ['Gate 2', '`gate2_status`'],
31
+ ['Attribution is being dropped', '`gate2_status`'],
32
+ ['Open product issues', '/ops'],
33
+ ['Pages waiting on you as steward', '`transfer_page_ownership`'],
34
+ ['Name a successor', '`set_successor`'],
35
+ ['Widening PROPOSED', '`my_retier_notices`'],
36
+ ['re-tiered by a page demotion', '`my_retier_notices`'],
37
+ ['File requests awaiting', '`file_requests`'],
38
+ ['Cleanup queue', '`intake_claim {claimKind:"cleanup"}`'],
39
+ ['Records waiting for a home', '`pending_records`'],
40
+ ['Folders in ', '`set_routing_identifier`'],
41
+ ['Sources nothing has claimed', '`set_routing_identifier`'],
42
+ ]
43
+
44
+ /** Bold sub-counts inside a block that are separate queues with their own verb (mcp_context.ts). */
45
+ const SUB_TOOLS = [
46
+ [/arrivals? not yet in any brain/i, '`staged_records`'],
47
+ [/asking THIS session/i, '`intake_changes`'],
48
+ [/arrived in the last 24h/i, '`pending_records`'],
49
+ ]
50
+
51
+ const MAX_LINE = 240
52
+
53
+ function cap(s, n = MAX_LINE) {
54
+ return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s
55
+ }
56
+
57
+ function splitBlocks(context) {
58
+ const blocks = []
59
+ let cur = null
60
+ for (const line of context.split('\n')) {
61
+ if (line.startsWith('## ')) {
62
+ if (cur) blocks.push(cur)
63
+ cur = { heading: line.slice(3).trim(), lines: [line] }
64
+ } else if (cur) {
65
+ cur.lines.push(line)
66
+ }
67
+ // lines before the first "## " are the title and the clock: orientation, not action
68
+ }
69
+ if (cur) blocks.push(cur)
70
+ return blocks
71
+ }
72
+
73
+ function firstBacktickedTool(body) {
74
+ const m = body.match(/`([a-z][a-z0-9]*_[a-z0-9_]+)\b[^`]*`/)
75
+ return m ? '`' + m[1] + '`' : null
76
+ }
77
+
78
+ function toolFor(heading, body) {
79
+ for (const [needle, tool] of BLOCK_TOOLS) if (heading.includes(needle)) return tool
80
+ return firstBacktickedTool(body)
81
+ }
82
+
83
+ /** "**141 arrivals not yet in any brain — 19 of them…**" -> "141 arrivals not yet in any brain". */
84
+ function subCounts(lines) {
85
+ const out = []
86
+ for (const l of lines) {
87
+ const m = l.match(/^\s*-\s+\*\*(\d[^*]*)\*\*/)
88
+ if (!m) continue
89
+ const text = m[1].split(/\s+—\s+|\.\s|\.$|:$/)[0].trim()
90
+ const sub = SUB_TOOLS.find(([re]) => re.test(text))
91
+ out.push(` - ${cap(text, 120)}${sub ? `: ${sub[1]}` : ''}`)
92
+ }
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * PURE. The session-start text for the /api/mcp-context body: pass-through blocks whole, then one line
98
+ * per remaining action block (plus one per bold sub-queue inside it). '' when there is nothing to act on.
99
+ * `fullTextPath` is where the untrimmed context was saved, named once at the end.
100
+ */
101
+ export function compactBlocks(context, { fullTextPath } = {}) {
102
+ if (typeof context !== 'string' || !context) return ''
103
+ const whole = []
104
+ const lines = []
105
+ for (const b of splitBlocks(context)) {
106
+ if (ORIENTATION_MARKERS.some((m) => b.heading.includes(m))) continue
107
+ if (PASS_THROUGH_MARKERS.some((m) => b.heading.includes(m))) {
108
+ const text = b.lines.join('\n').replace(/\s+$/, '')
109
+ if (text) whole.push(text)
110
+ continue
111
+ }
112
+ const body = b.lines.slice(1).join('\n')
113
+ const tool = toolFor(b.heading, body)
114
+ const urgent = /TELL THE USER/.test(body) ? ' — tell the user' : ''
115
+ lines.push(`- ${cap(b.heading)}${urgent}${tool ? `: ${tool}` : ''}`)
116
+ lines.push(...subCounts(b.lines.slice(1)))
117
+ }
118
+ const parts = [...whole]
119
+ if (lines.length) {
120
+ parts.push(
121
+ '## Agnoclast — waiting on you (one line per queue; call the tool named to see the items)\n' +
122
+ lines.join('\n') +
123
+ (fullTextPath ? `\nFull text of every block: ${fullTextPath} (or call \`my_context\`).` : ''),
124
+ )
125
+ }
126
+ return parts.join('\n\n')
127
+ }
128
+
129
+ // ── The `status` notice (captureNotice.message, server-rendered and stacked by mcp-context's noticesFor) ──
130
+
131
+ function sectionsOf(message) {
132
+ const out = []
133
+ for (const raw of message.split('\n')) {
134
+ if (!raw.trim()) continue
135
+ if (/^\s/.test(raw) && out.length) out[out.length - 1].sub.push(raw.trim())
136
+ else out.push({ head: raw.replace(/^⚠\s*/, '').trim(), sub: [] })
137
+ }
138
+ return out
139
+ }
140
+
141
+ function fixUrl(sub) {
142
+ for (const s of sub) {
143
+ const m = s.match(/^Fix:.*?(https?:\/\/\S+)/)
144
+ if (m) return m[1]
145
+ }
146
+ return null
147
+ }
148
+
149
+ function compactSection({ head, sub }) {
150
+ let m
151
+ if ((m = head.match(/^(\d+) arrivals? no session took/))) {
152
+ const n = Number(m[1])
153
+ return `⚠ ${n} arrival${n === 1 ? '' : 's'} every session passed on — tell the user: \`intake_changes\``
154
+ }
155
+ if ((m = head.match(/^(\d+) captures are NOT being saved/))) {
156
+ const per = sub
157
+ .map((s) => s.match(/^·\s*(\d+ .+?)(?:\s+\(oldest [^)]*\))?\s+—/))
158
+ .filter(Boolean)
159
+ .map((x) => x[1])
160
+ return `⚠ ${m[1]} captures are NOT being saved (no brain)${per.length ? ` — ${per.join(', ')}` : ''}: \`staged_records\``
161
+ }
162
+ if ((m = head.match(/^(\d+ \S+ captures? (?:is|are) NOT being saved(?: \(oldest [^)]*\))?)/))) {
163
+ const gated = /claims an identifier/.test(head)
164
+ return `⚠ ${m[1]}${gated ? ' — held on an unclaimed identifier' : ''}: \`staged_records\``
165
+ }
166
+ if ((m = head.match(/^(Mailbox \S+ is NOT syncing — [^.]+)/))) {
167
+ const url = fixUrl(sub)
168
+ return `⚠ ${m[1]}${url ? ` — reconnect at ${url}` : ''}`
169
+ }
170
+ if ((m = head.match(/^(\d+ mailboxes are NOT syncing)/))) {
171
+ const accts = sub.map((s) => s.match(/^·\s*(\S+)/)).filter(Boolean).map((x) => x[1])
172
+ const url = fixUrl(sub)
173
+ return `⚠ ${m[1]}${accts.length ? ` (${accts.join(', ')})` : ''}${url ? ` — reconnect at ${url}` : ''}`
174
+ }
175
+ if ((m = head.match(/^(Your Mac \(.+?\) has not checked in for .+? — .+?NOT being captured\.)/))) {
176
+ return `⚠ ${m[1]} Tell the user: open Agnoclast on that Mac.`
177
+ }
178
+ // Unknown shape: its first line, capped. Never dropped.
179
+ return `⚠ ${cap(head)}`
180
+ }
181
+
182
+ /** PURE. One line per notice category in a server captureNotice message. '' for nothing. */
183
+ export function compactNotice(message) {
184
+ if (typeof message !== 'string' || !message.trim()) return ''
185
+ return sectionsOf(message).map(compactSection).join('\n')
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.259",
3
+ "version": "0.9.260",
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": {