@theronap/agnoclast-mcp 0.9.258 → 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` : ''
package/lib/server.mjs CHANGED
@@ -1225,12 +1225,13 @@ function renderNudge(payload) {
1225
1225
  description:
1226
1226
  'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make. ' +
1227
1227
  'YOU ARE THE JUDGE of every unit you claim (ADR-0062): nothing is published until you materialize, and materializing is when it becomes visible. Each unit carries a `proposal` — the brain its signals point to and, for that brain, the pages it would attach to, the page that would govern it, and the `tier` it would be born with — computed without writing anything. Decide the brain, the pages (confirm, drop or add), which page should govern (the page whose reasons for restricting its audience fit the content, not a person page), and obligations. Pages only tighten a tier; if a record should be LOOSER than `tierReason.baseline`, that is the owner\'s call — intake_defer with the question. ' +
1228
- 'view "compact" (trial, only where the server enables it — otherwise you get the full response): the judge rules, your brains, the lease and the cleanup count come ONCE per response in `rules`/`brains`, not on every unit; each unit carries its full content, only the proposed brain\'s pages as one line each, and up front what intake_materialize would refuse it for (`materialize.otherHomes`, `materialize.folderQuestions`). Read `rules` before judging.',
1228
+ 'DEFAULT VIEW IS "compact" where the server enables it (otherwise the server answers with the full response, whatever you ask): the judge rules, your brains, the lease and the cleanup count come ONCE per response in `rules`/`brains`, not on every unit; the proposal is flattened onto each unit — `brain`, the proposed brain\'s `pages` as one line each, `governing`, `tier` (and `baseline` when it differs) — or `candidates` when no brain is proposed; each unit carries its full content, and up front what intake_materialize would refuse it for (`materialize.otherHomes`, `materialize.folderQuestions`). Read `rules` before judging. Pass view "full" for the per-unit `proposal` object with every brain\'s placement.',
1229
1229
  inputSchema: {
1230
1230
  claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
1231
1231
  view: z.enum(['full', 'compact']).optional().describe(
1232
- 'default "full". "compact" sends the rules once per response instead of on every unit, and only the proposed ' +
1233
- 'brain\'s placement. Honoured only where the server enables it; otherwise the response is the full one.',
1232
+ 'default "compact": the rules once per response instead of on every unit, and only the proposed ' +
1233
+ 'brain\'s placement. Honoured only where the server enables it; otherwise the response is the full one. ' +
1234
+ '"full" asks for the full per-unit `proposal` (every brain\'s placement, the judge text on each unit).',
1234
1235
  ),
1235
1236
  limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
1236
1237
  source: z.string().optional().describe('claim only the oldest units of ONE source — e.g. "imessage", "meeting", "email", "github", "claude-code". Reaches texts without first claiming every older GitHub/session unit ahead of them. Ignored when intakeItemIds is given.'),
@@ -1278,8 +1279,11 @@ function renderNudge(payload) {
1278
1279
  // claim into a no-op.
1279
1280
  ...(intakeItemIds ? { intakeItemIds } : {}),
1280
1281
  ...(source ? { source } : {}),
1281
- // Sent only when asked for: a default claim's request body is exactly what it was.
1282
- ...(view === 'compact' ? { view } : {}),
1282
+ // Compact by DEFAULT (Theron, 2026-10-23, after a watched real-filing trial: 26 units, 25 filed
1283
+ // correctly, 0 refusals, ~3.9K vs ~10K chars per unit). Always sent, so an explicit "full" is
1284
+ // honoured. The server still gates compact on RELEASE_FLAGS.compact_claim — an account outside
1285
+ // that cohort gets the full response even though this asks for compact.
1286
+ view: view ?? 'compact',
1283
1287
  }),
1284
1288
  })
1285
1289
  if (!res.ok) {
@@ -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.258",
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": {