@theronap/agnoclast-mcp 0.9.232 โ 0.9.234
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/locked_tools.mjs +102 -0
- package/lib/server.mjs +38 -4
- package/lib/settling_shown.mjs +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// AGNOCLAST LITE โ LOCKED TOOLS on the stdio server (Theron, 2026-10-13 3:19 PM MDT: "What if we stopped the
|
|
2
|
+
// agent from using the tools but we did allow them to see that the tools are there as something that would be
|
|
3
|
+
// open to them if they either used the trial or paid for the plan?").
|
|
4
|
+
//
|
|
5
|
+
// The list, the ๐ line and every answer live on the server (web/lib/engine/locked_tools.ts) so the claude.ai
|
|
6
|
+
// connector and this server can never disagree. This module only:
|
|
7
|
+
// ยท asks once at start-up whether this account is locked (GET /api/lite/locked), and marks those tools'
|
|
8
|
+
// descriptions with the server's ๐ line before the first tools/list goes out โ the rest of each
|
|
9
|
+
// description is left exactly as written;
|
|
10
|
+
// ยท for a call to a locked tool, asks the server for the answer (POST /api/lite/locked) and returns it
|
|
11
|
+
// INSTEAD of running the tool. If the server now says "not locked" (the trial started mid-session), the
|
|
12
|
+
// tool runs as usual.
|
|
13
|
+
// ยท FAILS CLOSED when it cannot know (Codex review, 2026-10-13): if start-up could not learn the account's
|
|
14
|
+
// state, every tool in REGULAR_ONLY_TOOLS asks per call, and a call whose check cannot be made runs
|
|
15
|
+
// nothing and says so. A definite "not locked" (Regular) at start-up costs nothing per call.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The fallback list, used only when start-up could not ask the server. The same names as the server's
|
|
19
|
+
* LOCKED_TOOLS (web/lib/engine/locked_tools.ts) that exist here โ web/lib/engine/trial.test.ts pins the two.
|
|
20
|
+
*/
|
|
21
|
+
export const REGULAR_ONLY_TOOLS = [
|
|
22
|
+
'list_records', 'read_record', 'timeline_pull', 'record_status', 'set_record_notes', 'set_record_privacy',
|
|
23
|
+
'relabel_record_people', 'supersede_record', 'move_record', 'contain_record', 'pending_records', 'claim_record',
|
|
24
|
+
'route_record', 'unroute_record', 'request_file', 'get_file', 'create_custom_source', 'search_spam_email',
|
|
25
|
+
'gmail_modify', 'draft_email_reply', 'request_send_email', 'send_imessage', 'create_brain', 'add_to_brain',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** What a locked tool answers when the check itself could not be made. Nothing was run. */
|
|
29
|
+
export const LOCK_CHECK_FAILED =
|
|
30
|
+
'Not run: this tool is part of the Agnoclast Regular plan, and Agnoclast did not answer whether this account can use it right now, ' +
|
|
31
|
+
'so nothing was done. Try again in a moment.'
|
|
32
|
+
|
|
33
|
+
/** Start-up budget: the MCP handshake must not wait on this for long. */
|
|
34
|
+
export const LOCK_FETCH_TIMEOUT_MS = 2500
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* GET /api/lite/locked โ { known: true, locked: false } (Regular, or a server that predates locked tools: a
|
|
38
|
+
* 404) | { known: true, locked: true, tools, lockLine } | { known: false, locked: false } (no answer). Never throws.
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchLockState(base, token, fetchImpl) {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetchImpl(`${base}/api/lite/locked`, { headers: { Authorization: `Bearer ${token}` } }, { skipAutofile: true })
|
|
43
|
+
if (res.status === 404) return { known: true, locked: false }
|
|
44
|
+
if (!res.ok) return { known: false, locked: false }
|
|
45
|
+
const j = await res.json()
|
|
46
|
+
if (j?.locked === false) return { known: true, locked: false }
|
|
47
|
+
if (j?.locked !== true || !Array.isArray(j.tools) || typeof j.lockLine !== 'string') return { known: false, locked: false }
|
|
48
|
+
return { known: true, locked: true, tools: j.tools.filter((t) => typeof t === 'string'), lockLine: j.lockLine }
|
|
49
|
+
} catch {
|
|
50
|
+
return { known: false, locked: false }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The lock state, or "unknown" if it did not arrive within the budget. */
|
|
55
|
+
export async function lockStateWithin(promise, ms = LOCK_FETCH_TIMEOUT_MS) {
|
|
56
|
+
let timer
|
|
57
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ known: false, locked: false }), ms) })
|
|
58
|
+
try { return await Promise.race([promise, timeout]) } finally { clearTimeout(timer) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which registered tools must ask before they run, marking the locked ones' descriptions (lockLine + the
|
|
63
|
+
* original). Locked โ the server's list. Unknown โ REGULAR_ONLY_TOOLS, unmarked (we do not know they are
|
|
64
|
+
* locked; each call asks). Regular โ none. `handles` maps a name to { handle, description }.
|
|
65
|
+
*/
|
|
66
|
+
export function applyLock(handles, lock) {
|
|
67
|
+
const checked = new Set()
|
|
68
|
+
if (lock?.known === false) {
|
|
69
|
+
for (const name of REGULAR_ONLY_TOOLS) if (handles.has(name)) checked.add(name)
|
|
70
|
+
return checked
|
|
71
|
+
}
|
|
72
|
+
if (!lock?.locked) return checked
|
|
73
|
+
for (const name of lock.tools) {
|
|
74
|
+
const h = handles.get(name)
|
|
75
|
+
if (!h) continue
|
|
76
|
+
h.handle.update({ description: `${lock.lockLine}${h.description ?? ''}` })
|
|
77
|
+
checked.add(name)
|
|
78
|
+
}
|
|
79
|
+
return checked
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* POST /api/lite/locked { tool, args } โ { run: true } to run the tool as usual, or { run: false, text } to
|
|
84
|
+
* answer instead. A check that cannot be made answers LOCK_CHECK_FAILED: nothing runs. Never throws.
|
|
85
|
+
*/
|
|
86
|
+
export async function lockedAnswerFor(base, token, tool, args, fetchImpl) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetchImpl(`${base}/api/lite/locked`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ tool, args: args && typeof args === 'object' && !Array.isArray(args) ? args : {} }),
|
|
92
|
+
}, { skipAutofile: true })
|
|
93
|
+
if (res.status === 404) return { run: true } // a server that predates locked tools locks nothing
|
|
94
|
+
if (!res.ok) return { run: false, text: LOCK_CHECK_FAILED }
|
|
95
|
+
const j = await res.json()
|
|
96
|
+
if (j?.locked === false) return { run: true }
|
|
97
|
+
if (j?.locked === true && typeof j.text === 'string') return { run: false, text: j.text }
|
|
98
|
+
return { run: false, text: LOCK_CHECK_FAILED }
|
|
99
|
+
} catch {
|
|
100
|
+
return { run: false, text: LOCK_CHECK_FAILED }
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -30,6 +30,8 @@ import { renderRecord, READ_RECORD_DEFAULT_CHARS, READ_RECORD_PARTS } from './re
|
|
|
30
30
|
import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
|
|
31
31
|
import { formatSeriesRefusal, formatSeriesUpdated } from './series_render.mjs'
|
|
32
32
|
import { formatSends } from './sends_render.mjs'
|
|
33
|
+
import { applyLock, fetchLockState, lockedAnswerFor, lockStateWithin } from './locked_tools.mjs'
|
|
34
|
+
import { filterUnshownSettling } from './settling_shown.mjs'
|
|
33
35
|
|
|
34
36
|
// PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
|
|
35
37
|
// set_record_privacy below) โ an owner-relative tier is applied to a page's records when they are read
|
|
@@ -387,11 +389,30 @@ export async function runServer(version) {
|
|
|
387
389
|
}
|
|
388
390
|
|
|
389
391
|
const server = new McpServer({ name: 'cortex', version })
|
|
392
|
+
// AGNOCLAST LITE โ locked tools (lib/locked_tools.mjs; Theron, 2026-10-13 3:19 PM). Asked now, in parallel
|
|
393
|
+
// with registering ~120 tools, and awaited (bounded) just before the handshake, so the first tools/list a
|
|
394
|
+
// Lite account's AI sees already marks the Regular-only tools ๐. Regular accounts: nothing changes.
|
|
395
|
+
const lockP = fetchLockState(BASE, TOKEN, fetchCortex)
|
|
396
|
+
const toolHandles = new Map()
|
|
397
|
+
let lockedTools = new Set()
|
|
390
398
|
// ADR-0070: every tool runs with its NAME in async context, so an outage fetchCortex auto-files is
|
|
391
399
|
// attributed to the tool that hit it rather than guessed from a URL shared by several tools.
|
|
392
400
|
const registerTool = server.registerTool.bind(server)
|
|
393
|
-
server.registerTool = (name, config, handler) =>
|
|
394
|
-
|
|
401
|
+
server.registerTool = (name, config, handler) => {
|
|
402
|
+
const takesArgs = !!config?.inputSchema
|
|
403
|
+
const handle = registerTool(name, config, (...args) => runWithTool(name, async () => {
|
|
404
|
+
// A LOCKED tool runs nothing: the server's answer says what it would have done and how Regular is
|
|
405
|
+
// reached. "Not locked any more" (the trial started mid-session) โ the tool runs as usual; a check that
|
|
406
|
+
// cannot be made โ nothing runs (fails closed).
|
|
407
|
+
if (lockedTools.has(name)) {
|
|
408
|
+
const check = await lockedAnswerFor(BASE, TOKEN, name, takesArgs ? args[0] : {}, fetchCortex)
|
|
409
|
+
if (!check.run) return { content: [{ type: 'text', text: check.text }] }
|
|
410
|
+
}
|
|
411
|
+
return handler(...args)
|
|
412
|
+
}))
|
|
413
|
+
toolHandles.set(name, { handle, description: config?.description })
|
|
414
|
+
return handle
|
|
415
|
+
}
|
|
395
416
|
|
|
396
417
|
|
|
397
418
|
// โโ THE PRE-CLAIM NUDGE, rendered โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -430,9 +451,18 @@ function renderArrival(payload) {
|
|
|
430
451
|
` โ \`staged_records\` lists them. Not offered to you, just counted; \`ignore_arrivals\` if this is noise right now.`
|
|
431
452
|
}
|
|
432
453
|
|
|
454
|
+
// ASK ONCE, THEN ONLY WHAT IS NEW (Theron, 2026-10-14). This block rode on EVERY tool reply, unchanged,
|
|
455
|
+
// and an offer that can be ignored on every call is ignored on every call โ measured that day: 869
|
|
456
|
+
// pending, 0 claimed, while the block was printed to every session. Now a unit is shown to this process
|
|
457
|
+
// (one MCP server = one session) the first time only, framed as a decision owed before other work.
|
|
458
|
+
// Units every session passes on are escalated to the PERSON at the next session start
|
|
459
|
+
// (web/lib/engine/orphan_escalation.ts), so a unit dropped here is never lost โ it changes audience.
|
|
460
|
+
const settlingShown = new Set()
|
|
461
|
+
|
|
433
462
|
function renderSettling(payload) {
|
|
434
463
|
const feed = payload?.settling
|
|
435
|
-
const
|
|
464
|
+
const firstSight = settlingShown.size === 0
|
|
465
|
+
const offers = filterUnshownSettling(Array.isArray(feed?.offers) ? feed.offers : [], settlingShown)
|
|
436
466
|
if (!offers.length) return ''
|
|
437
467
|
const total = Number(feed?.total ?? offers.length)
|
|
438
468
|
|
|
@@ -463,7 +493,10 @@ function renderSettling(payload) {
|
|
|
463
493
|
? `\nโ โ ${maxHolders} live sessions of yours hold a matching identifier, so these are offered to all of them. \`intake_claim\` takes a lease โ claim before you work, not after, or two sessions judge the same arrival.`
|
|
464
494
|
: ''
|
|
465
495
|
|
|
466
|
-
|
|
496
|
+
const head = firstSight
|
|
497
|
+
? `\n\nโก ARRIVALS SETTLING โ DECIDE EACH LINE BEFORE CONTINUING: \`intake_claim\` it if it belongs to this session's work, otherwise \`not_mine\`. You are asked once; when every session has passed, the person is told at the next session start.\n`
|
|
498
|
+
: `\n\nโก NEW ARRIVALS SETTLING โ decide each: \`intake_claim\` or \`not_mine\`:\n`
|
|
499
|
+
return `${head}${lines.join('\n')}\n` +
|
|
467
500
|
`โ These are unjudged ARRIVALS, not records. Since automatic materialization was deleted, nothing files them for you.\n` +
|
|
468
501
|
`โ \`intake_look <id>\` reads the full body without claiming it โ looking is free, non-exclusive and logged, so look rather than guess from the headline.\n` +
|
|
469
502
|
`โ Then \`intake_claim {intakeItemIds: ["<id>"]}\` to take a lease on THAT unit (the array is required; without an id you claim the head of the queue instead), then \`intake_materialize\` into the brain and pages it belongs to โ or \`intake_discard\`, or \`intake_defer\` with a question when only the owner can decide.\n` +
|
|
@@ -6710,5 +6743,6 @@ function renderNudge(payload) {
|
|
|
6710
6743
|
},
|
|
6711
6744
|
)
|
|
6712
6745
|
|
|
6746
|
+
lockedTools = applyLock(toolHandles, await lockStateWithin(lockP))
|
|
6713
6747
|
await server.connect(new StdioServerTransport())
|
|
6714
6748
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Ask-once filter for the โก ARRIVALS SETTLING block (2026-10-14). Pure, so it is testable without
|
|
2
|
+
// importing server.mjs. An offer is fresh if ANY id it stands for has not been shown to this session;
|
|
3
|
+
// a text conversation counts as one offer carrying many ids, so one new text re-raises its chat.
|
|
4
|
+
export function filterUnshownSettling(offers, shown) {
|
|
5
|
+
const idsOf = (x) => (x.conversation?.ids?.length ? x.conversation.ids : [x.intakeItemId])
|
|
6
|
+
const fresh = offers.filter((x) => idsOf(x).some((id) => !shown.has(id)))
|
|
7
|
+
for (const x of fresh) for (const id of idsOf(x)) shown.add(id)
|
|
8
|
+
return fresh
|
|
9
|
+
}
|
package/package.json
CHANGED