@theronap/agnoclast-mcp 0.9.209 โ 0.9.211
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/identifier_routes.mjs +81 -0
- package/lib/imessage_send.mjs +70 -80
- package/lib/server.mjs +35 -26
- package/package.json +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// WHERE AN IDENTIFIER ROUTES, AND WHAT A FILING SESSION MAY CLAIM โ pure renderers (ADR-0075, 2026-10-05).
|
|
2
|
+
//
|
|
3
|
+
// ๐ด THE LINE THIS REPLACES TOLD A SESSION SOMETHING FALSE. `read_page "file:โฆ"` printed "Home: X" for
|
|
4
|
+
// the page whose BODY carried the [[stamp]], and for an unclaimed identifier said "author its page and
|
|
5
|
+
// stamp [[id]] on it (that page becomes the home)". Since ADR-0026 a body stamp routes nothing โ the
|
|
6
|
+
// router reads page_routing_identifiers only โ so following that advice produced a page the router never
|
|
7
|
+
// sends anything to, and the session was told it worked. That is the 2026-10-04 V4 email-surface
|
|
8
|
+
// incident. The server now answers `homes` from CLAIMS and moves stamp pages to `mentionedOn`; this
|
|
9
|
+
// prints the two apart and gives `set_routing_identifier` as the fix.
|
|
10
|
+
//
|
|
11
|
+
// Pure so CI can execute what each state prints.
|
|
12
|
+
|
|
13
|
+
const q = (s) => `"${s}"`
|
|
14
|
+
const pageLabel = (h) => `${q(h.title)} (${h.kind}, ${h.tier})`
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The read_page identifier view, as lines. Handles the pre-ADR-0075 server too (no `mentionedOn`):
|
|
18
|
+
* there `homes` are stamp pages, so they are printed as mentions, never as where anything routes.
|
|
19
|
+
*/
|
|
20
|
+
export function renderIdentifierResolution(out, { expand = false } = {}) {
|
|
21
|
+
const id = out.identifier
|
|
22
|
+
const lines = [`# ${id} (identifier โ a join key, not a page)`]
|
|
23
|
+
const claimAware = Array.isArray(out.mentionedOn)
|
|
24
|
+
const homes = claimAware ? (out.homes ?? []) : []
|
|
25
|
+
const mentions = claimAware ? out.mentionedOn : (out.homes ?? [])
|
|
26
|
+
const catchAll = claimAware ? (out.catchAll ?? []) : []
|
|
27
|
+
const hidden = claimAware ? Number(out.hiddenClaims ?? 0) : 0
|
|
28
|
+
|
|
29
|
+
if (!claimAware) {
|
|
30
|
+
lines.push('This server cannot say where it ROUTES (it predates ADR-0075); the pages below only mention it, and a mention routes nothing. `my_routing_claims` lists the claims that do.')
|
|
31
|
+
} else if (homes.length === 1 && out.status !== 'duplicate') {
|
|
32
|
+
const h = homes[0]
|
|
33
|
+
const how = h.via === id ? 'claims it' : `claims the folder \`${h.via}\` above it`
|
|
34
|
+
lines.push(`Routes to: ${pageLabel(h)} โ it ${how}. \`read_page ${q(h.title)}\` for the page.`)
|
|
35
|
+
} else if (homes.length >= 1) {
|
|
36
|
+
lines.push(out.status === 'duplicate'
|
|
37
|
+
? `โ More than one page claims this at the same depth โ a record carrying it is filed under all of them (and STAGES if they sit in different brains). Settle which one it belongs to:`
|
|
38
|
+
: `Routes to ${homes.length} pages, deepest claim first โ a record carrying it is filed under each:`)
|
|
39
|
+
for (const h of homes) lines.push(` - ${pageLabel(h)} via \`${h.via}\`${h.brain ? ` ยท ${h.brain}` : ''}`)
|
|
40
|
+
}
|
|
41
|
+
if (hidden > 0) {
|
|
42
|
+
lines.push(`${hidden} claim${hidden === 1 ? '' : 's'} on page${hidden === 1 ? '' : 's'} you cannot open โ ${hidden === 1 ? 'it routes' : 'they route'} all the same.`)
|
|
43
|
+
}
|
|
44
|
+
if (claimAware && homes.length === 0 && hidden === 0) {
|
|
45
|
+
const fallback = catchAll.length
|
|
46
|
+
? ` Records carrying it are filed only under the repo catch-all: ${catchAll.map((h) => q(h.title)).join(', ')} (via \`${catchAll[0].via}\`).`
|
|
47
|
+
: ''
|
|
48
|
+
lines.push(
|
|
49
|
+
`No page claims this, so it routes nowhere on its own.${fallback} If a page is ABOUT it, claim it there: ` +
|
|
50
|
+
`\`set_routing_identifier\` with identifier ${q(id)}${id.startsWith('file:') && !id.endsWith('/') ? ' (or the deepest folder above it, with its trailing slash)' : ''}. ` +
|
|
51
|
+
'A [[stamp]] in a page body does NOT make that page its home.',
|
|
52
|
+
)
|
|
53
|
+
} else if (catchAll.length) {
|
|
54
|
+
lines.push(`Also filed under the repo catch-all: ${catchAll.map((h) => q(h.title)).join(', ')} (via \`${catchAll[0].via}\`).`)
|
|
55
|
+
}
|
|
56
|
+
if (mentions.length) {
|
|
57
|
+
lines.push(`Mentioned on (does not route): ${mentions.map((h) => q(h.title)).join(', ')}`)
|
|
58
|
+
}
|
|
59
|
+
const ev = out.events ?? { visibleCount: 0, recent: [] }
|
|
60
|
+
lines.push(`ยท ${ev.visibleCount} tagged timeline event${ev.visibleCount === 1 ? '' : 's'} visible to you${ev.recent?.length ? ':' : expand ? '.' : ' โ pass expand: true to list recent ones.'}`)
|
|
61
|
+
for (const e of ev.recent ?? []) lines.push(` - ${String(e.occurredAt).slice(0, 10)} ยท ${e.source} ยท ${e.summary}`)
|
|
62
|
+
return lines
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** intake_materialize: the folder-claim offers, as lines. [] when the server sent none (or is older). */
|
|
66
|
+
export function renderFolderClaimOffers(offers) {
|
|
67
|
+
if (!Array.isArray(offers) || offers.length === 0) return []
|
|
68
|
+
const lines = ['Folder claim offered (nothing was claimed โ decide, do not just accept):']
|
|
69
|
+
for (const o of offers) if (o && typeof o.offer === 'string') lines.push(` - ${o.offer}`)
|
|
70
|
+
return lines.length > 1 ? lines : []
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** author / edit_page: [[file:โฆ]] stamps the write left that route nothing to this page, as lines. */
|
|
74
|
+
export function renderUnroutedPathStamps(body) {
|
|
75
|
+
const stamps = Array.isArray(body?.unroutedPathStamps) ? body.unroutedPathStamps : []
|
|
76
|
+
if (!stamps.length) return []
|
|
77
|
+
const lines = [`โ ${stamps.length} [[file:โฆ]] stamp${stamps.length === 1 ? '' : 's'} on this page route${stamps.length === 1 ? 's' : ''} nothing here: ${stamps.map((s) => `\`${s.identifier}\``).join(', ')}.`]
|
|
78
|
+
if (typeof body.unroutedPathStampsNote === 'string') lines.push(body.unroutedPathStampsNote)
|
|
79
|
+
for (const s of stamps) if (typeof s.fix === 'string') lines.push(` - ${s.fix}`)
|
|
80
|
+
return lines
|
|
81
|
+
}
|
package/lib/imessage_send.mjs
CHANGED
|
@@ -1,88 +1,78 @@
|
|
|
1
|
-
// send_imessage โ
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
1
|
+
// send_imessage โ ask to send a text from the user's own Mac, the same way request_send_email asks to
|
|
2
|
+
// send an email (ADR-0076).
|
|
3
|
+
//
|
|
4
|
+
// WHAT CHANGED, AND WHY. This tool used to text directly: osascript โ Messages.app on whatever machine
|
|
5
|
+
// the MCP ran on, gated by CORTEX_IMESSAGE_SEND_ALLOWLIST and an out-of-band confirm secret. No
|
|
6
|
+
// approval screen, no history, no "did it go out?", and nothing written to Agnoclast โ while email had
|
|
7
|
+
// all of it. Theron, 2026-10-05: "my texts are not being handled in the same way that emails are
|
|
8
|
+
// handled, and that seems like a failure."
|
|
9
|
+
//
|
|
10
|
+
// Now it creates a request on agnoclast.com (POST /api/texts/send-requests). The request waits on
|
|
11
|
+
// /email โ Waiting for the user's OK, unless their send rule โ the SAME rule email uses, set only on
|
|
12
|
+
// the web โ lets it through. Once approved, the user's Mac (the Agnoclast desktop app) sends it through
|
|
13
|
+
// Messages and reports back. So:
|
|
14
|
+
// * this process never runs osascript and never sends anything itself;
|
|
15
|
+
// * the env allowlist and the confirm secret are gone โ the web send rule replaced both, and an agent
|
|
16
|
+
// cannot loosen it;
|
|
17
|
+
// * without send:true it is still a draft preview, exactly as before, so existing callers that rely on
|
|
18
|
+
// draft-by-default see no change in that path.
|
|
19
|
+
//
|
|
20
|
+
// โ THE POST IS NOT IDEMPOTENT. Never retried: a 5xx may mean the request was created.
|
|
11
21
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
set targetMessage to item 2 of argv
|
|
16
|
-
tell application "Messages"
|
|
17
|
-
set targetService to 1st account whose service type = iMessage
|
|
18
|
-
set targetBuddy to participant targetId of targetService
|
|
19
|
-
send targetMessage to targetBuddy
|
|
20
|
-
end tell
|
|
21
|
-
end run`
|
|
22
|
-
|
|
23
|
-
export function phoneKey(raw) {
|
|
24
|
-
const d = String(raw).replace(/[^0-9]/g, '')
|
|
25
|
-
return d.length < 7 ? null : d.slice(-10)
|
|
26
|
-
}
|
|
27
|
-
function handleKey(h) {
|
|
28
|
-
return h.includes('@') ? h.trim().toLowerCase() : phoneKey(h)
|
|
29
|
-
}
|
|
30
|
-
export function loadSendAllowlist(raw = process.env.CORTEX_IMESSAGE_SEND_ALLOWLIST ?? '') {
|
|
31
|
-
const set = new Set()
|
|
32
|
-
for (const item of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
33
|
-
set.add(item.includes('@') ? item.toLowerCase() : (phoneKey(item) ?? item))
|
|
34
|
-
}
|
|
35
|
-
return set
|
|
22
|
+
/** PURE. The draft preview โ unchanged in spirit from the old tool. */
|
|
23
|
+
export function draftText({ recipient, message }) {
|
|
24
|
+
return `๐ DRAFT (not sent) โ to ${recipient}:\n${message}\n\n(Call send_imessage again with send:true to ask the user to approve it.)`
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function sendViaOsascript(recipient, message) {
|
|
57
|
-
return new Promise((resolve) => {
|
|
58
|
-
let err = ''
|
|
59
|
-
let proc
|
|
60
|
-
try {
|
|
61
|
-
proc = spawn('osascript', ['-', String(recipient), String(message)], { stdio: ['pipe', 'ignore', 'pipe'] })
|
|
62
|
-
} catch (e) {
|
|
63
|
-
resolve({ ok: false, error: String(e) }); return
|
|
27
|
+
/**
|
|
28
|
+
* PURE. The server's answer โ what the agent is told. Mirrors request_send_email's contract exactly:
|
|
29
|
+
* `reviewUrl` PRESENT means pending, and the hand-off sentence is relayed verbatim; ABSENT means the
|
|
30
|
+
* user's send rule already approved it, and there is nothing to approve.
|
|
31
|
+
*
|
|
32
|
+
* Returns { text, isError }.
|
|
33
|
+
*/
|
|
34
|
+
export function textRequestReply(status, body, recipient) {
|
|
35
|
+
const msg = typeof body?.error === 'string' ? body.error : null
|
|
36
|
+
if (status === 201 || (status >= 200 && status < 300)) {
|
|
37
|
+
const reviewUrl = body?.reviewUrl
|
|
38
|
+
if (reviewUrl) {
|
|
39
|
+
return { isError: false, text:
|
|
40
|
+
`Text request created to ${recipient}. It is NOT sent yet. ` +
|
|
41
|
+
`Tell the user, in these words: It's waiting for your OK: ${reviewUrl}` }
|
|
64
42
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
43
|
+
const state = body?.request?.state ?? 'approved'
|
|
44
|
+
return { isError: false, text:
|
|
45
|
+
`Approved by the user's send rule โ nothing to approve (state: ${state}). Their Mac sends it through ` +
|
|
46
|
+
`Messages the next time it checks in, usually within a minute. Do not offer an approval link, and do ` +
|
|
47
|
+
`not send it again; the user can see how it went on Email โ History in Agnoclast.` }
|
|
48
|
+
}
|
|
49
|
+
if (body?.code === 'no_text_sender') {
|
|
50
|
+
return { isError: true, text: `${msg} Nothing was stored, so nothing will be sent.` }
|
|
51
|
+
}
|
|
52
|
+
if (status >= 500) {
|
|
53
|
+
return { isError: true, text:
|
|
54
|
+
`${msg ?? `Agnoclast could not finish that (${status}).`} โ The request MAY have been created anyway. ` +
|
|
55
|
+
`Do NOT call send_imessage again โ ask the user to check Email in Agnoclast.` }
|
|
56
|
+
}
|
|
57
|
+
return { isError: true, text: msg ?? `send_imessage failed (${status}).` }
|
|
71
58
|
}
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Tool entry. `request` is the injected POST (fetchCortex with retries off, in server.mjs), so this
|
|
62
|
+
* stays testable without a network.
|
|
63
|
+
*/
|
|
64
|
+
export async function runSendImessage({ recipient, message, send }, request) {
|
|
65
|
+
if (!recipient || !String(recipient).trim()) return { isError: true, text: 'Name who to text: a phone number or an iMessage email address.' }
|
|
66
|
+
if (!message || !String(message).trim()) return { isError: true, text: 'The text is empty.' }
|
|
67
|
+
if (send !== true) return { isError: false, text: draftText({ recipient, message }) }
|
|
68
|
+
let res
|
|
69
|
+
try {
|
|
70
|
+
res = await request({ to: String(recipient).trim(), text: String(message) })
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { isError: true, text:
|
|
73
|
+
`Could not confirm the text request: ${e?.message ?? e} โ It may have been created anyway. ` +
|
|
74
|
+
`Do NOT call send_imessage again โ ask the user to check Email in Agnoclast.` }
|
|
85
75
|
}
|
|
86
|
-
const
|
|
87
|
-
return
|
|
76
|
+
const body = await res.json().catch(() => null)
|
|
77
|
+
return textRequestReply(res.status, body, recipient)
|
|
88
78
|
}
|
package/lib/server.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { seatChargeRefusal, seatChargeQuestion } from './seat_charge.mjs'
|
|
|
9
9
|
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
|
|
10
10
|
import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
|
|
11
11
|
import { folderClaimLine } from './folder_claims.mjs'
|
|
12
|
+
import { renderFolderClaimOffers, renderIdentifierResolution, renderUnroutedPathStamps } from './identifier_routes.mjs'
|
|
12
13
|
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
13
14
|
import { runSendImessage } from './imessage_send.mjs'
|
|
14
15
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
@@ -1343,6 +1344,9 @@ function renderNudge(payload) {
|
|
|
1343
1344
|
// null/undefined no container was ever requested (say nothing)
|
|
1344
1345
|
// containerSkipped is the only one that needs a warning: it means a container WAS asked for and
|
|
1345
1346
|
// did not happen, which is exactly the silence this whole path exists to remove.
|
|
1347
|
+
// ADR-0075 โ the deepest unclaimed folder this unit sits in, offered for the feature page it was
|
|
1348
|
+
// filed onto. Never claimed by the server; the session decides.
|
|
1349
|
+
lines.push(...renderFolderClaimOffers(body?.folderClaimOffers))
|
|
1346
1350
|
if (body?.containerSkipped) {
|
|
1347
1351
|
lines.push(`โ CONTAINER NOT APPLIED โ ${body.containerSkipped}. The record exists but is not inside the event you named; place it with contain_record or leave it out.`)
|
|
1348
1352
|
} else if (body?.contained === true) {
|
|
@@ -1874,7 +1878,7 @@ function renderNudge(payload) {
|
|
|
1874
1878
|
description:
|
|
1875
1879
|
'READ the full authored wiki page for one node (project/person/org/you) by its canonical name โ every section you can see. This is how you READ a node; `grep` only LOCATES pages (snippets + their [[links]]), it does not read them. Navigate like a researcher: read the page you need, then FOLLOW its inline [[links]] by calling read_page on each linked name โ keep following while the linked pages stay relevant, stop when they do not. You decide how deep to go. Returns only what you are permitted to see.',
|
|
1876
1880
|
inputSchema: {
|
|
1877
|
-
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben", or a [[link]] target) โ identifier links ([[repo:owner/name]]) resolve to
|
|
1881
|
+
name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben", or a [[link]] target) โ identifier links ([[repo:owner/name]], [[file:owner/name:path]]) resolve to the page(s) that CLAIM them โ where the router actually files records โ plus the pages that merely mention them, and a visible-event count'),
|
|
1878
1882
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind โ a PREFERENCE, not a filter. The lookup matches every kind by title and only prefers this one when several share a name, so omitting it never hides a person/org/user page. Pass it to disambiguate a name that exists as more than one kind.'),
|
|
1879
1883
|
expand: z.boolean().optional().describe('identifier names only: also list recent visible timeline events for this identifier (default: home + count)'),
|
|
1880
1884
|
history: z.boolean().optional().describe('node names only: return the node\'s TIMELINE (events joined via its [[repo:โฆ]] stamps, reverse-chron, viewer-visible) instead of the page body. The page is the present; this is the history.'),
|
|
@@ -1962,18 +1966,8 @@ function renderNudge(payload) {
|
|
|
1962
1966
|
const r = await fetchCortex(`${BASE}/api/identifier/resolve?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1963
1967
|
if (r.ok) {
|
|
1964
1968
|
const out = await r.json()
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
lines.push(`No authored home carries this stamp yet โ data but no home. If you know what this identifies, author its page and stamp [[${out.identifier}]] on it (that page becomes the home).`)
|
|
1968
|
-
} else if (out.status === 'healthy') {
|
|
1969
|
-
const h = out.homes[0]
|
|
1970
|
-
lines.push(`Home: "${h.title}" (${h.kind}, ${h.tier}) โ \`read_page "${h.title}"\` for the full page.`)
|
|
1971
|
-
} else {
|
|
1972
|
-
lines.push(`โ ${out.homes.length} pages carry this stamp โ possible duplicate homes, consider reconciling:`)
|
|
1973
|
-
for (const h of out.homes) lines.push(` - "${h.title}" (${h.kind}, ${h.tier})`)
|
|
1974
|
-
}
|
|
1975
|
-
lines.push(`ยท ${out.events.visibleCount} tagged timeline event${out.events.visibleCount === 1 ? '' : 's'} visible to you${out.events.recent.length ? ':' : expand ? '.' : ' โ pass expand: true to list recent ones.'}`)
|
|
1976
|
-
for (const e of out.events.recent) lines.push(` - ${String(e.occurredAt).slice(0, 10)} ยท ${e.source} ยท ${e.summary}`)
|
|
1969
|
+
// ADR-0075: `homes` are CLAIMS (where the router files it); stamp pages are `mentionedOn`.
|
|
1970
|
+
const lines = renderIdentifierResolution(out, { expand })
|
|
1977
1971
|
// KWA-28 โ an identifier node is VIRTUAL: derived per read, zero stored rows, so there is no
|
|
1978
1972
|
// as_of to fetch. The item says exactly what to do in that case: "where the object is derived
|
|
1979
1973
|
// per-read, stamp the read itself." Note this as-of means something DIFFERENT from every
|
|
@@ -3988,7 +3982,10 @@ function renderNudge(payload) {
|
|
|
3988
3982
|
const undatedNote = out?.undated === true
|
|
3989
3983
|
? `\nโ This section now carries no explicit calendar date. A reader can see WHEN the text was written but not when the claim was TRUE. If it asserts a status, add the date inline โ you still hold the context. The edit already landed; this is advisory.`
|
|
3990
3984
|
: ''
|
|
3991
|
-
|
|
3985
|
+
// ADR-0075 โ a [[file:โฆ]] stamp this edit wrote that routes nothing here, with the claim that would.
|
|
3986
|
+
const unroutedLines = renderUnroutedPathStamps(out)
|
|
3987
|
+
const unrouted = unroutedLines.length ? `\n${unroutedLines.join('\n')}` : ''
|
|
3988
|
+
return { content: [{ type: 'text', text: `Edited "${name}" (${out.brain} ยท ${out.tier} tier) ยง ${out.heading}. Only that passage changed; every other section is byte-identical. New version: ${out.version}${red}${undatedNote}${unrouted}` }] }
|
|
3992
3989
|
},
|
|
3993
3990
|
)
|
|
3994
3991
|
|
|
@@ -5872,23 +5869,32 @@ function renderNudge(payload) {
|
|
|
5872
5869
|
},
|
|
5873
5870
|
)
|
|
5874
5871
|
|
|
5875
|
-
// send_imessage โ
|
|
5876
|
-
//
|
|
5872
|
+
// send_imessage โ ask to send a text from the user's own Mac (ADR-0076). It no longer texts from this
|
|
5873
|
+
// machine: it creates a request on Agnoclast that waits on /email โ Waiting for the user's OK (or their
|
|
5874
|
+
// web send rule approves it), and the user's Mac โ the desktop app, through Messages โ sends it and
|
|
5875
|
+
// reports back. The same contract as request_send_email: reviewUrl PRESENT = pending, relayed verbatim.
|
|
5876
|
+
//
|
|
5877
|
+
// โ NOT IDEMPOTENT: `idempotent: false, retries: 0`, exactly as request_send_email, so a widened
|
|
5878
|
+
// fetchCortex default can never make sending a text retriable.
|
|
5877
5879
|
server.registerTool(
|
|
5878
5880
|
'send_imessage',
|
|
5879
5881
|
{
|
|
5880
|
-
title: '
|
|
5881
|
-
description: '
|
|
5882
|
+
title: 'Ask to send a text (iMessage) from the user\'s Mac',
|
|
5883
|
+
description: 'Text someone from the USER\'S OWN Mac, through Messages. Without send:true this only returns a draft preview. With send:true it creates a request that WAITS for the user to approve it in Agnoclast (Email โ Waiting), unless their own send rule allows it; once approved, their Mac sends it within about a minute. The text is frozen when the request is made, so write the final wording, and show the user what you are about to send first. Returns an approval link to relay, or confirmation that their rule approved it. โ NEVER call it twice with send:true for the same text โ it is not idempotent.',
|
|
5882
5884
|
inputSchema: {
|
|
5883
|
-
recipient: z.string().describe('phone number (e.g. +18015551234) or iMessage email'),
|
|
5884
|
-
message: z.string().describe('the
|
|
5885
|
-
send: z.boolean().optional().describe('
|
|
5886
|
-
confirm: z.string().optional().describe('
|
|
5885
|
+
recipient: z.string().describe('one phone number (e.g. +18015551234, or (801) 555-1234 for a US number) or iMessage email address'),
|
|
5886
|
+
message: z.string().describe('the text to send'),
|
|
5887
|
+
send: z.boolean().optional().describe('true to ask the user to approve sending it; omit or false for a draft preview only'),
|
|
5888
|
+
confirm: z.string().optional().describe('NO LONGER USED โ approval happens in Agnoclast now. Ignored.'),
|
|
5887
5889
|
},
|
|
5888
5890
|
},
|
|
5889
|
-
async ({ recipient, message, send
|
|
5890
|
-
const
|
|
5891
|
-
|
|
5891
|
+
async ({ recipient, message, send }) => {
|
|
5892
|
+
const r = await runSendImessage({ recipient, message, send }, (body) => fetchCortex(`${BASE}/api/texts/send-requests`, {
|
|
5893
|
+
method: 'POST',
|
|
5894
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
5895
|
+
body: JSON.stringify(body),
|
|
5896
|
+
}, { idempotent: false, retries: 0 }))
|
|
5897
|
+
return r.isError ? toolError(r.text) : { content: [{ type: 'text', text: r.text }] }
|
|
5892
5898
|
},
|
|
5893
5899
|
)
|
|
5894
5900
|
|
|
@@ -6067,8 +6073,11 @@ function renderNudge(payload) {
|
|
|
6067
6073
|
` can read the page. Re-send with identity_claim_ack: true only if the subject is publishing` +
|
|
6068
6074
|
` their own contact detail; otherwise use a confidential section or set_routing_identifier.`
|
|
6069
6075
|
: ''
|
|
6076
|
+
// ADR-0075 โ [[file:โฆ]] stamps that route nothing here, each with the claim that would. Advisory.
|
|
6077
|
+
const unroutedLines = renderUnroutedPathStamps(out)
|
|
6078
|
+
const unrouted = unroutedLines.length ? `\n${unroutedLines.join('\n')}` : ''
|
|
6070
6079
|
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
6071
|
-
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}${undated}${identityRefused}`
|
|
6080
|
+
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}${undated}${identityRefused}${unrouted}`
|
|
6072
6081
|
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
|
|
6073
6082
|
return { content: [{ type: 'text', text: note }] }
|
|
6074
6083
|
},
|
package/package.json
CHANGED