@theronap/agnoclast-mcp 0.9.203 → 0.9.205
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/bin/cortex-mcp.mjs +5 -5
- package/lib/diagnose.mjs +1 -1
- package/lib/member_remove_render.mjs +29 -0
- package/lib/server.mjs +33 -0
- package/package.json +1 -1
- package/lib/resolve.mjs +0 -153
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -188,11 +188,11 @@ if (cmd === 'login') {
|
|
|
188
188
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
189
189
|
await closeFetch()
|
|
190
190
|
} else if (cmd === 'resolve') {
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
// RETIRED 2026-10-02 (Theron: entity dedup "completely failed to merge duplicates"). The server's
|
|
192
|
+
// /api/resolve-candidates and /api/resolve-apply are gone with it. Kept as a no-op, like
|
|
193
|
+
// `materialize`, because a cron line written before this release still runs `resolve`, and an
|
|
194
|
+
// unrecognised subcommand falls through to starting the MCP server, which never exits.
|
|
195
|
+
process.stderr.write('cortex-mcp: `resolve` was retired 2026-10-02 — entity dedup is no longer part of Agnoclast. Nothing to do; remove the cron line that runs it.\n')
|
|
196
196
|
} else if (cmd === 'materialize') {
|
|
197
197
|
// EXCISED 2026-07-02 (legacy-materializer incident): the digest pipeline deleted live-authored
|
|
198
198
|
// pages. Pages come from live authoring now; this stub keeps old hook invocations harmless.
|
package/lib/diagnose.mjs
CHANGED
|
@@ -518,7 +518,7 @@ const PATH_TOOLS = {
|
|
|
518
518
|
// Named 2026-10-02 (obligation bbb5a8b5): these hook and cron endpoints were filing as `other`, which the
|
|
519
519
|
// server cannot trace. cli_paths_named.test.mjs now fails on any endpoint the CLI calls without a name.
|
|
520
520
|
'ingest/route': 'capture', 'staged/promote': 'capture', 'pressing/ack': 'arrivals_notice',
|
|
521
|
-
'
|
|
521
|
+
'timeline/graphify': 'graphify_sync',
|
|
522
522
|
'brain/capture-default': 'set_capture_brain',
|
|
523
523
|
}
|
|
524
524
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// PURE: what remove_member prints (2026-10-02). Out of server.mjs so a test can reach it.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ THE FIRST ANSWER IS A QUESTION FOR THE PERSON, NOT AN ERROR. Removing someone from a brain takes
|
|
4
|
+
// access away from a real person, so the server changes nothing until it is called again with the token
|
|
5
|
+
// for exactly the picture shown: who, from which brain, and where every one of their pages goes.
|
|
6
|
+
//
|
|
7
|
+
// Names, emails and brain names are other people's text: one line each, bounded.
|
|
8
|
+
const one = (s, n = 160) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n)
|
|
9
|
+
|
|
10
|
+
export function renderRemovalQuestion(out) {
|
|
11
|
+
const p = out?.preview ?? {}
|
|
12
|
+
const lines = [
|
|
13
|
+
`NOT REMOVED — this needs the person's yes.${out?.stale ? ' (The picture changed since the last confirmation; this is the current one.)' : ''}`,
|
|
14
|
+
`Remove ${one(p.name, 80)} (${one(p.email, 120)}) from the brain "${one(p.brain, 80)}"?`,
|
|
15
|
+
'They lose access to this brain only. Their login, their other brains and everything they wrote stay.',
|
|
16
|
+
]
|
|
17
|
+
for (const s of Array.isArray(p.summary) ? p.summary : []) lines.push(one(s, 400))
|
|
18
|
+
lines.push(`Tell the person exactly this and ask. Only if they say yes: remove_member again with confirm: "${one(out?.confirm_token, 40)}".`)
|
|
19
|
+
return lines.join('\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderRemoved(out, person) {
|
|
23
|
+
const name = one(out?.displayName || person, 80)
|
|
24
|
+
const lines = [out?.alreadyInactive
|
|
25
|
+
? `${name} was already removed from this brain — nothing more to do.`
|
|
26
|
+
: `Removed ${name} from "${one(out?.orgName, 80)}". They can no longer open anything in it.`]
|
|
27
|
+
for (const w of Array.isArray(out?.warnings) ? out.warnings : []) lines.push(`- ${one(w, 400)}`)
|
|
28
|
+
return lines.join('\n')
|
|
29
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { repoFullNameFrom, workingFilesFrom } from './capture.mjs'
|
|
|
20
20
|
import { findTranscript, createWorkedFilesTracker } from './working_files.mjs'
|
|
21
21
|
import { spawnObligationWorker } from './obligations_worker.mjs'
|
|
22
22
|
import { renderObligations, renderPageObligations, tierReads, who } from './obligations_render.mjs'
|
|
23
|
+
import { renderRemovalQuestion, renderRemoved } from './member_remove_render.mjs'
|
|
23
24
|
import { renderSettings } from './settings_render.mjs'
|
|
24
25
|
import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
|
|
25
26
|
|
|
@@ -3033,6 +3034,38 @@ function renderNudge(payload) {
|
|
|
3033
3034
|
},
|
|
3034
3035
|
)
|
|
3035
3036
|
|
|
3037
|
+
// 2026-10-02. Removal was built in August with this tool planned and never wired, so an assistant could
|
|
3038
|
+
// add people to a brain but not remove them. Two calls, always: the first changes nothing.
|
|
3039
|
+
server.registerTool(
|
|
3040
|
+
'remove_member',
|
|
3041
|
+
{
|
|
3042
|
+
title: 'Remove someone from a brain',
|
|
3043
|
+
description: "Remove a member from one of the person's brains — ONLY when the person asked you to. It takes access away from a real person, so the FIRST call changes nothing: it answers with who would be removed, from which brain, and where every one of their pages would go, plus a confirm token. Tell the person exactly that and ask. Only if they say yes, call again with `confirm` set to that token. Never pass a confirm token the person has not just approved. Removal is soft and per brain: the person keeps their login, their other brains and everything they wrote; their pages go to a steward (usually their manager), and their own profile becomes a person page. Only an owner or admin of the brain can remove anyone, never themselves, and never the last owner. Use an email address when a name could match two people.",
|
|
3044
|
+
inputSchema: {
|
|
3045
|
+
person: z.string().describe("who to remove: their email address, or their name as it appears in the brain"),
|
|
3046
|
+
brain: z.string().optional().describe('which brain — required if the person belongs to more than one'),
|
|
3047
|
+
confirm: z.string().optional().describe('ONLY after the person has seen the removal and said yes: the confirm token from the first call'),
|
|
3048
|
+
},
|
|
3049
|
+
},
|
|
3050
|
+
async ({ person, brain, confirm }) => {
|
|
3051
|
+
let res
|
|
3052
|
+
try {
|
|
3053
|
+
res = await fetchCortex(`${BASE}/api/members/remove`, {
|
|
3054
|
+
method: 'POST',
|
|
3055
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
3056
|
+
body: JSON.stringify({ person, brain, confirm }),
|
|
3057
|
+
})
|
|
3058
|
+
} catch (e) { return toolError(`Could not reach Agnoclast: ${e.message}`) }
|
|
3059
|
+
const out = await res.json().catch(() => null)
|
|
3060
|
+
if (res.status === 409 && out?.error === 'confirm_removal') {
|
|
3061
|
+
// Not an error: a person has to decide. Said as the question it is.
|
|
3062
|
+
return { content: [{ type: 'text', text: renderRemovalQuestion(out) }] }
|
|
3063
|
+
}
|
|
3064
|
+
if (!res.ok) return toolError(`Not removed: ${out?.detail ?? out?.error ?? res.status}`)
|
|
3065
|
+
return { content: [{ type: 'text', text: renderRemoved(out, person) }] }
|
|
3066
|
+
},
|
|
3067
|
+
)
|
|
3068
|
+
|
|
3036
3069
|
server.registerTool(
|
|
3037
3070
|
'set_obligation_tier',
|
|
3038
3071
|
{
|
package/package.json
CHANGED
package/lib/resolve.mjs
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'child_process'
|
|
2
|
-
import { fetchCortex, resolveBase, resolveEnvToken, classify } from './diagnose.mjs'
|
|
3
|
-
import { edgeSafeEnv } from './edge_extract.mjs'
|
|
4
|
-
|
|
5
|
-
// `cortex-mcp resolve` — the JUDGE half of entity identity dedup (Grey harvest). The server FLAGS the
|
|
6
|
-
// fuzzy band of near-duplicate non-person entities (GET /api/resolve-candidates); this command judges
|
|
7
|
-
// each pair LOCALLY via `claude -p` (the engine preference — the server never calls the metered API)
|
|
8
|
-
// and pushes decisions back (POST /api/resolve-apply): confident-same → entity_merges, else → rejected
|
|
9
|
-
// so the pair never re-flags. Conservative by construction. Always exits cleanly.
|
|
10
|
-
|
|
11
|
-
// Which brains to sweep. BOTH endpoints require the brain to be NAMED (ADR-0022 requireBrain —
|
|
12
|
-
// answering a scoped read out of an ARBITRARY brain is the defect that whole class exists to
|
|
13
|
-
// prevent). Until 2026-08-09 this command named none, so a multi-brain caller got 409 on step 1 and
|
|
14
|
-
// the dedup sweep did nothing at all, silently, forever.
|
|
15
|
-
//
|
|
16
|
-
// Naming ONE brain would have been the smaller fix and the wrong one: `resolve` is a MAINTENANCE
|
|
17
|
-
// SWEEP over the user's entities, so doing one brain and reporting success is ADR-0022's other
|
|
18
|
-
// failure — "silently truncating a result set and presenting it as complete". With no --brain we
|
|
19
|
-
// enumerate the caller's brains and sweep EACH, naming it explicitly. Not a guess: every request
|
|
20
|
-
// still names exactly one brain, and all of them actually get done.
|
|
21
|
-
//
|
|
22
|
-
// A sole-brain caller sees today's behaviour: one pass, no flag, no prompt.
|
|
23
|
-
export async function brainsToSweep(base, token, wanted, deps = {}) {
|
|
24
|
-
const fetchFn = deps.fetchCortex ?? fetchCortex
|
|
25
|
-
if (wanted) return [{ orgId: wanted, name: wanted }] // explicit: pass through (name or org id)
|
|
26
|
-
const res = await fetchFn(`${base}/api/brains`, { headers: { Authorization: `Bearer ${token}` } })
|
|
27
|
-
if (!res.ok) {
|
|
28
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
29
|
-
throw new Error(`could not list your brains — ${d.message}`)
|
|
30
|
-
}
|
|
31
|
-
const j = await res.json().catch(() => ({}))
|
|
32
|
-
const brains = Array.isArray(j.brains) ? j.brains : []
|
|
33
|
-
// Carry the ORG ID, never the name: brain names are NOT unique (this account holds two called
|
|
34
|
-
// "Personal"), and a duplicate name comes back as ambiguous_brain (409).
|
|
35
|
-
return brains.filter((b) => b?.orgId).map((b) => ({ orgId: b.orgId, name: b.name ?? b.orgId }))
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function runResolve(argv = []) {
|
|
39
|
-
// recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
|
|
40
|
-
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
41
|
-
const token = resolveEnvToken().token
|
|
42
|
-
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
43
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
44
|
-
|
|
45
|
-
const bIdx = argv.indexOf('--brain')
|
|
46
|
-
const wanted = bIdx === -1 ? null : argv[bIdx + 1]
|
|
47
|
-
if (bIdx !== -1 && (!wanted || wanted.startsWith('-'))) {
|
|
48
|
-
process.stderr.write('Usage: resolve [--brain <name-or-org-id>]\n'); return
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
let targets
|
|
52
|
-
try {
|
|
53
|
-
targets = await brainsToSweep(base, token, wanted)
|
|
54
|
-
} catch (e) { process.stderr.write(`cortex: resolve — ${e.message}\n`); return }
|
|
55
|
-
if (!targets.length) { process.stderr.write('cortex: no brains to sweep\n'); return }
|
|
56
|
-
|
|
57
|
-
for (const t of targets) {
|
|
58
|
-
// Label each line with the brain when sweeping several: an unlabelled "merged 3" cannot be acted
|
|
59
|
-
// on, because you cannot tell WHERE three entities just merged.
|
|
60
|
-
await resolveOneBrain(base, token, t, targets.length > 1)
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function resolveOneBrain(base, token, brain, labelled) {
|
|
65
|
-
const tag = labelled ? `[${brain.name}] ` : ''
|
|
66
|
-
const qs = `?brain=${encodeURIComponent(brain.orgId)}`
|
|
67
|
-
|
|
68
|
-
// 1. pull the flagged candidate pairs
|
|
69
|
-
let candidates = []
|
|
70
|
-
try {
|
|
71
|
-
const res = await fetchCortex(`${base}/api/resolve-candidates${qs}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
72
|
-
if (!res.ok) {
|
|
73
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
74
|
-
process.stderr.write(`cortex: ${tag}resolve-candidates failed — ${d.message}\n`); return
|
|
75
|
-
}
|
|
76
|
-
const j = await res.json().catch(() => ({}))
|
|
77
|
-
candidates = Array.isArray(j.candidates) ? j.candidates : []
|
|
78
|
-
} catch (e) { process.stderr.write(`cortex: ${tag}resolve fetch failed — ${e.message}\n`); return }
|
|
79
|
-
|
|
80
|
-
if (!candidates.length) { process.stderr.write(`cortex: ${tag}no duplicate candidates to judge\n`); return }
|
|
81
|
-
process.stderr.write(`cortex: ${tag}judging ${candidates.length} candidate pair(s) locally…\n`)
|
|
82
|
-
|
|
83
|
-
// 2. judge locally on the subscription
|
|
84
|
-
const decisions = judgeCandidates(candidates)
|
|
85
|
-
if (decisions === null) { process.stderr.write(`cortex: ${tag}judge unavailable (is \`claude\` on PATH?) — skipping\n`); return }
|
|
86
|
-
|
|
87
|
-
// 3. apply — SAME brain the candidates came from, or the merges land in the wrong graph
|
|
88
|
-
try {
|
|
89
|
-
const res = await fetchCortex(`${base}/api/resolve-apply${qs}`, {
|
|
90
|
-
method: 'POST',
|
|
91
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
92
|
-
body: JSON.stringify({ decisions }),
|
|
93
|
-
})
|
|
94
|
-
if (res.ok) {
|
|
95
|
-
const j = await res.json().catch(() => ({}))
|
|
96
|
-
process.stderr.write(`cortex: ${tag}dedup — merged ${j.merged ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
|
|
97
|
-
} else {
|
|
98
|
-
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
99
|
-
process.stderr.write(`cortex: ${tag}resolve-apply failed — ${d.message}\n`)
|
|
100
|
-
}
|
|
101
|
-
} catch (e) { process.stderr.write(`cortex: ${tag}resolve-apply failed — ${e.message}\n`) }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ONE `claude --print` call judges every pair. Returns DedupDecision[] for the apply endpoint, or null
|
|
105
|
-
// if the CLI is unavailable. Every judged pair (merge AND non-merge) is returned so non-merges get
|
|
106
|
-
// recorded as rejected and never re-flag.
|
|
107
|
-
function judgeCandidates(candidates) {
|
|
108
|
-
const list = candidates.map((c, i) =>
|
|
109
|
-
`[${i}] kind=${c.kind}\n` +
|
|
110
|
-
` A: "${c.aName}" (e.g. record: ${c.aSample ?? 'n/a'}; ${c.aMentions} mentions)\n` +
|
|
111
|
-
` B: "${c.bName}" (e.g. record: ${c.bSample ?? 'n/a'}; ${c.bMentions} mentions)\n` +
|
|
112
|
-
` shared records: ${c.sharedRecords}; name similarity: ${Number(c.sim).toFixed(2)}`,
|
|
113
|
-
).join('\n')
|
|
114
|
-
const prompt =
|
|
115
|
-
'You are deduplicating an organization knowledge graph. For each candidate pair below, decide ' +
|
|
116
|
-
'whether A and B are the SAME real-world entity (a spelling/abbreviation/variant of ONE thing) or ' +
|
|
117
|
-
'DIFFERENT things that merely have similar names. Be CONSERVATIVE: only merge when confident; when ' +
|
|
118
|
-
'in doubt, do NOT merge. Same kind only.\n' +
|
|
119
|
-
'Return ONLY a minified JSON array — one object per pair: ' +
|
|
120
|
-
'{"i":<index>,"merge":<true|false>,"confidence":<0..1>,"reason":"<short>"}.\n\n' + list
|
|
121
|
-
try {
|
|
122
|
-
const r = spawnSync(
|
|
123
|
-
'claude',
|
|
124
|
-
['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
|
|
125
|
-
{ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024 },
|
|
126
|
-
)
|
|
127
|
-
if (r.status !== 0 || !r.stdout) return null
|
|
128
|
-
return parseDecisions(r.stdout, candidates)
|
|
129
|
-
} catch {
|
|
130
|
-
return null
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function parseDecisions(out, candidates) {
|
|
135
|
-
const start = out.indexOf('[')
|
|
136
|
-
const end = out.lastIndexOf(']')
|
|
137
|
-
if (start < 0 || end <= start) return []
|
|
138
|
-
let arr
|
|
139
|
-
try { arr = JSON.parse(out.slice(start, end + 1)) } catch { return [] }
|
|
140
|
-
if (!Array.isArray(arr)) return []
|
|
141
|
-
const decisions = []
|
|
142
|
-
for (const d of arr) {
|
|
143
|
-
const c = candidates[Number(d?.i)]
|
|
144
|
-
if (!c) continue
|
|
145
|
-
decisions.push({
|
|
146
|
-
aId: c.aId, bId: c.bId, kind: c.kind,
|
|
147
|
-
merge: !!d.merge,
|
|
148
|
-
confidence: Number(d.confidence) || 0,
|
|
149
|
-
reason: typeof d.reason === 'string' ? d.reason.slice(0, 200) : undefined,
|
|
150
|
-
})
|
|
151
|
-
}
|
|
152
|
-
return decisions
|
|
153
|
-
}
|