@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.
- package/lib/context_log.mjs +7 -1
- package/lib/doctor.mjs +7 -1
- package/lib/session_start_compact.mjs +186 -0
- package/package.json +1 -1
package/lib/context_log.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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