@theronap/agnoclast-mcp 0.9.194 → 0.9.196
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/seat_charge.mjs +36 -0
- package/lib/server.mjs +58 -11
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A brain that pays for its members' seats (ADR-0072) refuses a member-add until the NEW monthly
|
|
2
|
+
// total has been confirmed: POST /api/invite answers 409 with
|
|
3
|
+
// { code: 'seat_charge_unconfirmed', seatMonthlyTotal, seats, error }.
|
|
4
|
+
// The confirmation is a recurring charge on the brain owner's card, so the tool must be able to tell
|
|
5
|
+
// this apart from every other refusal and put the question to the person instead of reporting an
|
|
6
|
+
// error. Pure, so it can be tested without a server.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {number} status HTTP status of the refused add
|
|
10
|
+
* @param {string} raw the response body, unparsed
|
|
11
|
+
* @returns {{ total: number, seats: number | null } | null} null for any other refusal
|
|
12
|
+
*/
|
|
13
|
+
export function seatChargeRefusal(status, raw) {
|
|
14
|
+
if (status !== 409) return null
|
|
15
|
+
let body
|
|
16
|
+
try { body = JSON.parse(raw) } catch { return null }
|
|
17
|
+
if (!body || body.code !== 'seat_charge_unconfirmed') return null
|
|
18
|
+
const total = body.seatMonthlyTotal
|
|
19
|
+
// No usable number means there is nothing a person could confirm; fall through to the plain error
|
|
20
|
+
// rather than ask them to agree to "$undefined/month".
|
|
21
|
+
if (typeof total !== 'number' || !Number.isFinite(total) || total < 0) return null
|
|
22
|
+
const seats = typeof body.seats === 'number' && Number.isFinite(body.seats) ? body.seats : null
|
|
23
|
+
return { total, seats }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the caller is told. `confirmed` is the total it passed, if any — then that total was stale. */
|
|
27
|
+
export function seatChargeQuestion({ brainName, email, total, seats, confirmed }) {
|
|
28
|
+
return [
|
|
29
|
+
`${brainName} pays for its members' seats, so adding ${email} changes what its owner is charged.`,
|
|
30
|
+
`New total: $${total}/month${seats ? ` for ${seats} ${seats === 1 ? 'seat' : 'seats'}` : ''} ($10/month for each member), charged to the brain's owner.`,
|
|
31
|
+
...(typeof confirmed === 'number' ? [`The total you confirmed ($${confirmed}) is not the current one — the membership changed in between.`] : []),
|
|
32
|
+
'',
|
|
33
|
+
'NOTHING HAS HAPPENED: nobody was added and nothing was charged.',
|
|
34
|
+
`Ask the user whether to accept $${total}/month. Only if they say yes, re-run with the same arguments plus confirm_seat_total: ${total}. Do not confirm it on their behalf.`,
|
|
35
|
+
].join('\n')
|
|
36
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -5,6 +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 { seatChargeRefusal, seatChargeQuestion } from './seat_charge.mjs'
|
|
8
9
|
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
|
|
9
10
|
import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
|
|
10
11
|
import { folderClaimLine } from './folder_claims.mjs'
|
|
@@ -554,15 +555,20 @@ function renderNudge(payload) {
|
|
|
554
555
|
summary: z.string().describe('a summary that stands ON ITS OWN. It must not mention, allude to, or imply that other segments exist — "the rest of this session covered personal projects" leaks in prose exactly what splitting was meant to contain'),
|
|
555
556
|
title: z.string().optional(),
|
|
556
557
|
project: z.string().optional(),
|
|
558
|
+
pages: z.array(z.string()).optional().describe('pages IN THAT BRAIN this half belongs on — each a page `ref:` as read_page shows it'),
|
|
557
559
|
})).optional().describe('SPLIT the log, one entry per brain. Use whenever a session touched work belonging to different brains: a session is a container of time, not a topic, and every single-brain answer is wrong — filing it all in the org brain exposes personal work to colleagues, filing it all in the personal one denies the org its record, and summarizing half silently drops the other half. Max ONE segment per brain (they share this session\'s dedupe key, so two aimed at the same brain would overwrite each other). When a chunk is ambiguous, put it in the MORE PRIVATE brain — a misfile there is private, a misfile the other way is visible to everyone in the org.'),
|
|
558
560
|
project: z.string().optional().describe('project key/name this session worked in'),
|
|
559
561
|
title: z.string().optional().describe('short title for the session'),
|
|
562
|
+
pages: z.array(z.string()).optional().describe('pages this log belongs on — each a page `ref:` as read_page shows it (the project it was about, the decision it bears on). The pages this session WROTE are attached automatically; name the ones it was ABOUT but did not edit. REQUIRED IN EFFECT when you log on behalf of a DIFFERENT session (an explicit sessionId): only a session filing its own log is let through without them. The single-brain form; with `segments`, put pages on each segment'),
|
|
560
563
|
sessionId: z.string().optional().describe('the Claude Code session id — DEFAULTS to the current session, so omit it unless you are logging on behalf of another one. It is shared by every segment and the only thing pairing them, and it is also the join to the pages this session wrote (page_revisions.session_key); without it the record is unattributable forever, since the fallback dedupe key is a timestamp. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
|
|
561
564
|
brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
|
|
562
565
|
privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. A record is normally read by whoever can read the page it is filed under. Pass "confidential" when the summary names confidential work (a candidate evaluation, a security finding) so it is yours alone from the start — safer than letting it land page-visible and locking it after, which leaves it readable in between. Pass "scoped" to lock it to you and your managers. OMIT it for ordinary work: the record then follows its page, which is what makes it visible to the people working on that page with you.'),
|
|
563
566
|
},
|
|
564
567
|
},
|
|
565
|
-
async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
|
|
568
|
+
async ({ summary, segments, project, title, sessionId, brain, privacy, pages }) => {
|
|
569
|
+
// read_page prints `ref: <uuid>`; a caller who pastes the label with it must not get a 400.
|
|
570
|
+
const pageRefs = (list) => (Array.isArray(list) ? list : [])
|
|
571
|
+
.map((r) => String(r).trim().replace(/^ref:\s*/i, '')).filter(Boolean)
|
|
566
572
|
// Default the session id from the environment when the caller omits it.
|
|
567
573
|
//
|
|
568
574
|
// `sessionId` is optional and the model routinely does not pass it, so the record lands with a
|
|
@@ -587,8 +593,8 @@ function renderNudge(payload) {
|
|
|
587
593
|
// A session is a container of TIME, not a topic (ADR-0029 step 4). Normalize to a list of
|
|
588
594
|
// segments; the single-brain call is just a one-segment list.
|
|
589
595
|
const list = Array.isArray(segments) && segments.length
|
|
590
|
-
? segments.map((s) => ({ brain: s.brain, summary: s.summary, title: s.title ?? title, project: s.project ?? project }))
|
|
591
|
-
: (summary ? [{ brain, summary, title, project }] : null)
|
|
596
|
+
? segments.map((s) => ({ brain: s.brain, summary: s.summary, title: s.title ?? title, project: s.project ?? project, pages: pageRefs(s.pages) }))
|
|
597
|
+
: (summary ? [{ brain, summary, title, project, pages: pageRefs(pages) }] : null)
|
|
592
598
|
if (!list) {
|
|
593
599
|
return toolError('Pass `summary` (single brain) or a non-empty `segments` array (one entry per brain).')
|
|
594
600
|
}
|
|
@@ -721,6 +727,12 @@ function renderNudge(payload) {
|
|
|
721
727
|
body: JSON.stringify({
|
|
722
728
|
intakeItemId: j.intakeItemId,
|
|
723
729
|
orgId,
|
|
730
|
+
// Pages the caller named. The pages this session WROTE need no naming: the server
|
|
731
|
+
// attaches those by session key. Sent only when given, so a plain close-out keeps
|
|
732
|
+
// the exact body it always had.
|
|
733
|
+
...(seg.pages.length
|
|
734
|
+
? { documentIds: seg.pages, attachmentMeta: seg.pages.map(() => ({ origin: 'session' })) }
|
|
735
|
+
: {}),
|
|
724
736
|
record: {
|
|
725
737
|
title: seg.title,
|
|
726
738
|
summary: seg.summary,
|
|
@@ -731,16 +743,29 @@ function renderNudge(payload) {
|
|
|
731
743
|
})
|
|
732
744
|
} catch (e) {
|
|
733
745
|
failures++
|
|
734
|
-
lines.push(`✗ ${where}: intake ${j.intakeItemId}
|
|
746
|
+
lines.push(`✗ ${where}: the curated log was NOT written (intake ${j.intakeItemId}) — ${e.message}`)
|
|
735
747
|
continue
|
|
736
748
|
}
|
|
737
749
|
const mj = await m.json().catch(() => ({}))
|
|
738
750
|
if (!m.ok || !mj.recordId) {
|
|
739
751
|
failures++
|
|
740
|
-
|
|
752
|
+
// 🔴 THE OLD WORDING ("not materialized") WAS A LIE HALF THE TIME (2026-10-02). When this session's unit is
|
|
753
|
+
// already a record — filed mid-session — the unit IS materialized; what failed is writing
|
|
754
|
+
// the curated summary onto it. Say what did not happen, and what would make it happen.
|
|
755
|
+
//
|
|
756
|
+
// The other-homes refusal (ADR-0051) is answerable from here: name a page. A server that
|
|
757
|
+
// exempts a session's own close-out never sends it for one; it still does when logging on
|
|
758
|
+
// behalf of ANOTHER session, which is a judgment about someone else's work.
|
|
759
|
+
const why = mj.error === 'other_homes_unchecked'
|
|
760
|
+
? 'the server asks what pages this log belongs on. Re-run with pages:["<page ref>", …] — the project it was about, the decision it bears on (read_page prints each page\'s ref)'
|
|
761
|
+
: (mj.error ?? m.status)
|
|
762
|
+
lines.push(`✗ ${where}: the curated log was NOT written (intake ${j.intakeItemId}) — ${why}`)
|
|
741
763
|
continue
|
|
742
764
|
}
|
|
743
|
-
|
|
765
|
+
// `duplicate` with `updated: false` is ingest saying this session's unit had already been
|
|
766
|
+
// acted on — so the record existed and this call replaced its title and summary.
|
|
767
|
+
const amended = j.duplicate === true && j.updated === false
|
|
768
|
+
lines.push(`✓ ${where}: record ${mj.recordId}${amended ? ' (replaced the summary on this session\'s existing record)' : ''}`)
|
|
744
769
|
continue
|
|
745
770
|
}
|
|
746
771
|
|
|
@@ -3900,7 +3925,7 @@ function renderNudge(payload) {
|
|
|
3900
3925
|
'add_to_brain',
|
|
3901
3926
|
{
|
|
3902
3927
|
title: 'Add someone to one of your brains',
|
|
3903
|
-
description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. Use when asked to invite/add someone to a brain.",
|
|
3928
|
+
description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. IF THE BRAIN PAYS FOR ITS MEMBERS' SEATS, adding someone raises its owner's monthly charge: the first execute is refused with the new total, you ASK THE USER whether to accept that charge, and only on a yes re-run with confirm_seat_total set to that number. Use when asked to invite/add someone to a brain.",
|
|
3904
3929
|
inputSchema: {
|
|
3905
3930
|
email: z.string().describe("the person's email address — the login their Agnoclast account is (or will be) on"),
|
|
3906
3931
|
name: z.string().optional().describe('their full name; falls back to the email local-part'),
|
|
@@ -3909,9 +3934,10 @@ function renderNudge(payload) {
|
|
|
3909
3934
|
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'),
|
|
3910
3935
|
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'),
|
|
3911
3936
|
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"),
|
|
3937
|
+
confirm_seat_total: z.number().optional().describe("ONLY after an execute was refused because this brain pays for its members' seats: the new monthly total in dollars that the refusal named, and ONLY once the user has said yes to that charge in this conversation. Never guess it, never pass it on a first attempt — it puts a recurring charge on the brain owner's card"),
|
|
3912
3938
|
},
|
|
3913
3939
|
},
|
|
3914
|
-
async ({ email, name, title, role, brain, execute, inherit_page_id }) => {
|
|
3940
|
+
async ({ email, name, title, role, brain, execute, inherit_page_id, confirm_seat_total }) => {
|
|
3915
3941
|
const addr = (email ?? '').trim().toLowerCase()
|
|
3916
3942
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
|
|
3917
3943
|
|
|
@@ -4003,13 +4029,29 @@ function renderNudge(payload) {
|
|
|
4003
4029
|
done = await fetchCortex(`${BASE}/api/invite`, {
|
|
4004
4030
|
method: 'POST',
|
|
4005
4031
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4006
|
-
body: JSON.stringify({
|
|
4032
|
+
body: JSON.stringify({
|
|
4033
|
+
...payload,
|
|
4034
|
+
brain: target.orgId,
|
|
4035
|
+
...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}),
|
|
4036
|
+
// ADR-0072 §3.5. Sent only when the caller passed it: the server compares it with the
|
|
4037
|
+
// total THIS add produces and refuses anything else, so a stale or invented number
|
|
4038
|
+
// cannot charge anyone.
|
|
4039
|
+
...(typeof confirm_seat_total === 'number' ? { confirmSeatTotal: confirm_seat_total } : {}),
|
|
4040
|
+
}),
|
|
4007
4041
|
})
|
|
4008
4042
|
} catch (e) {
|
|
4009
4043
|
return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
|
|
4010
4044
|
}
|
|
4011
4045
|
if (!done.ok) {
|
|
4012
|
-
const
|
|
4046
|
+
const raw = await done.text()
|
|
4047
|
+
// A brain that pays for its members' seats refuses the add until the new monthly total is
|
|
4048
|
+
// confirmed (ADR-0072 §3.5). That is not a failure to report and move on from — it is a
|
|
4049
|
+
// question only the person can answer, because the answer is a charge on the owner's card.
|
|
4050
|
+
const seat = seatChargeRefusal(done.status, raw)
|
|
4051
|
+
if (seat) {
|
|
4052
|
+
return toolError(seatChargeQuestion({ brainName: target.name, email: payload.email, total: seat.total, seats: seat.seats, confirmed: confirm_seat_total }))
|
|
4053
|
+
}
|
|
4054
|
+
const d = classify(done.status, done.headers.get('content-type'), raw, done.headers.get('x-vercel-id'))
|
|
4013
4055
|
return toolError(`Could not add ${payload.email} to ${target.name}: ${d.message}`)
|
|
4014
4056
|
}
|
|
4015
4057
|
const r = await done.json().catch(() => ({}))
|
|
@@ -4024,7 +4066,10 @@ function renderNudge(payload) {
|
|
|
4024
4066
|
const page = r.userPage?.status
|
|
4025
4067
|
? `\nTheir page in ${target.name}: ${r.userPage.status}${r.userPage.error ? ` (${r.userPage.error})` : ''}.`
|
|
4026
4068
|
: ''
|
|
4027
|
-
|
|
4069
|
+
// What the seat sync did, in the server's words: the new monthly total, or that the seat
|
|
4070
|
+
// could NOT be charged and the brain is not paying for them yet.
|
|
4071
|
+
const seatLine = typeof r.seatNote === 'string' && r.seatNote ? `\n${r.seatNote}` : ''
|
|
4072
|
+
return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}${page}${seatLine}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
|
|
4028
4073
|
}
|
|
4029
4074
|
|
|
4030
4075
|
// ADR-0023: does this brain already have a page about them? The inviter decides — list, never pick.
|
|
@@ -4052,6 +4097,8 @@ function renderNudge(payload) {
|
|
|
4052
4097
|
` manager you [${target.userId}] (they are placed under you)`,
|
|
4053
4098
|
...pageLines,
|
|
4054
4099
|
'',
|
|
4100
|
+
"If this brain pays for its members' seats, the first execute is refused with the new monthly total; ask the user before confirming it.",
|
|
4101
|
+
'',
|
|
4055
4102
|
'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
|
|
4056
4103
|
`or do it yourself at ${BASE}/?invite=1 with these values:`,
|
|
4057
4104
|
'',
|
package/package.json
CHANGED