@theronap/agnoclast-mcp 0.9.225 → 0.9.227
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/diagnose.mjs +88 -1
- package/lib/server.mjs +61 -3
- package/package.json +1 -1
package/lib/diagnose.mjs
CHANGED
|
@@ -579,6 +579,78 @@ let reachabilityProbe = async () => {
|
|
|
579
579
|
}
|
|
580
580
|
/** Tests: replace the internet-reachability probe. Returns the previous one. */
|
|
581
581
|
export function setReachabilityProbe(fn) { const prev = reachabilityProbe; reachabilityProbe = fn; return prev }
|
|
582
|
+
/** Is the internet itself reachable right now? Never throws. The heartbeat asks this on EVERY failed beat. */
|
|
583
|
+
export async function internetReachable() {
|
|
584
|
+
try { return Boolean(await reachabilityProbe()) } catch { return false }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ⚠ A LAPTOP THAT SLEPT LOOKS LIKE A SERVER THAT TIMED OUT. Measured 2026-10-08 on Theron's Mac
|
|
588
|
+
// (obligation f7eccd9d): 20 session_ping outages filed 16:55–17:26 MDT while the lid was shut and the Mac
|
|
589
|
+
// DarkWoke every 1-3 minutes on a dying battery, then 2 more after a 2-minute Wi-Fi drop at 20:39. Production
|
|
590
|
+
// answered every request it received. The 1.1.1.1 probe above did not catch it: during a DarkWake, and the
|
|
591
|
+
// moment the network is back, the probe passes, while a request that was in flight when the lid closed
|
|
592
|
+
// "times out" on wake because its 15 s budget ran out while the lid was shut.
|
|
593
|
+
//
|
|
594
|
+
// THE TELL IS LATENESS, NOT A CLOCK. A frozen process runs no timers, so a timer that should have fired
|
|
595
|
+
// every WATCH_TICK_MS fires late, and late is measurable on whichever clock counts the sleep:
|
|
596
|
+
// * macOS: performance.now() is mach_CONTINUOUS_time, which COUNTS sleep (libuv's darwin uv__hrtime;
|
|
597
|
+
// measured on Theron's Mac 2026-10-10, Node 24.11/libuv 1.51 and Node 26.4/libuv 1.52: hrtime matched
|
|
598
|
+
// mach_continuous_time to 0.0 s and sat 11.69 h ahead of mach_absolute_time). So the monotonic gap
|
|
599
|
+
// between ticks is the sleep itself.
|
|
600
|
+
// * Linux: CLOCK_MONOTONIC STOPS in suspend, so the monotonic gap stays small and the wall clock runs ahead.
|
|
601
|
+
// ⚠ #1453's first version compared wall to monotonic only, which is the Linux half: inert on every Mac,
|
|
602
|
+
// the one platform that filed these. Codex caught it on review; never assume which half a platform is.
|
|
603
|
+
// Accepted costs (Codex, both passes on #1453), each at worst one skipped report or one reset beat, against
|
|
604
|
+
// 20 false outages per closed lid:
|
|
605
|
+
// * an awake clock STEPPED forward > SLEEP_GAP_MS (a manual change; NTP slews) reads as a freeze;
|
|
606
|
+
// * an awake event loop stalled > WATCH_TICK_MS + SLEEP_GAP_MS reads as one too (its timers fired late,
|
|
607
|
+
// so a timeout inside it does not measure Agnoclast either);
|
|
608
|
+
// * a Mac sleep SHORTER than that is not caught; the request still had most of its 15 s budget awake.
|
|
609
|
+
export const SLEEP_GAP_MS = 5_000
|
|
610
|
+
export const WATCH_TICK_MS = 2_000
|
|
611
|
+
let clocks = { wall: () => Date.now(), mono: () => globalThis.performance.now() }
|
|
612
|
+
let watch = { last: null, freezes: 0, timer: null }
|
|
613
|
+
|
|
614
|
+
/** PURE. Was the process frozen between two consecutive readings of both clocks? */
|
|
615
|
+
export function frozeBetween(prev, now, gapMs = SLEEP_GAP_MS, tickMs = WATCH_TICK_MS) {
|
|
616
|
+
if (!prev || !now) return false
|
|
617
|
+
const mono = now.mono - prev.mono
|
|
618
|
+
const wall = now.wall - prev.wall
|
|
619
|
+
return mono > tickMs + gapMs // no timer ran for this long: sleep on macOS (or a stalled loop)
|
|
620
|
+
|| wall - mono > gapMs // the wall clock ran ahead while monotonic time stood still: Linux suspend
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function observe() {
|
|
624
|
+
const now = { wall: clocks.wall(), mono: clocks.mono() }
|
|
625
|
+
if (frozeBetween(watch.last, now)) watch.freezes++
|
|
626
|
+
watch.last = now
|
|
627
|
+
return now
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Tests: replace the two clocks, and start the watch over. Returns the previous pair. */
|
|
631
|
+
export function setClocks(c) {
|
|
632
|
+
const prev = clocks
|
|
633
|
+
clocks = c
|
|
634
|
+
if (watch.timer) clearInterval(watch.timer)
|
|
635
|
+
watch = { last: null, freezes: 0, timer: null }
|
|
636
|
+
return prev
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* A reading for sleptBetween. The first one starts the watch (unref'd, so a short-lived hook still exits).
|
|
641
|
+
* It also checks the gap itself, because on wake the failing request's callback can run before the overdue tick.
|
|
642
|
+
*/
|
|
643
|
+
export function clockMark() {
|
|
644
|
+
if (!watch.timer) { watch.timer = setInterval(observe, WATCH_TICK_MS); watch.timer.unref?.() }
|
|
645
|
+
const now = observe()
|
|
646
|
+
return { wall: now.wall, mono: now.mono, freezes: watch.freezes }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** PURE. Was the process frozen at any point between two clockMark()s? */
|
|
650
|
+
export function sleptBetween(a, b) {
|
|
651
|
+
if (!a || !b) return false
|
|
652
|
+
return (b.freezes ?? 0) > (a.freezes ?? 0)
|
|
653
|
+
}
|
|
582
654
|
|
|
583
655
|
/** Auto-filing is off under `bun test` unless the test points the spool somewhere (never the real home). */
|
|
584
656
|
function autofileEnabled(env = process.env) {
|
|
@@ -636,6 +708,19 @@ export function heartbeatPolicy(consecutiveFailures) {
|
|
|
636
708
|
return { fileOutage: consecutiveFailures + 1 >= HEARTBEAT_OUTAGE_AFTER }
|
|
637
709
|
}
|
|
638
710
|
|
|
711
|
+
/**
|
|
712
|
+
* PURE. The heartbeat's consecutive-failure count after one beat. Only a failure that says something about
|
|
713
|
+
* AGNOCLAST counts: a beat that spanned a sleep, or failed while the internet itself was unreachable, resets
|
|
714
|
+
* the streak instead (2026-10-08, obligation f7eccd9d). A RESPONSE proves the network worked, so a non-OK
|
|
715
|
+
* response counts unless the machine slept.
|
|
716
|
+
*/
|
|
717
|
+
export function nextPingFailures(prev, { ok, slept = false, internetUp = true }) {
|
|
718
|
+
if (ok) return 0
|
|
719
|
+
if (slept) return 0
|
|
720
|
+
if (internetUp === false) return 0
|
|
721
|
+
return prev + 1
|
|
722
|
+
}
|
|
723
|
+
|
|
639
724
|
/**
|
|
640
725
|
* fetch with the D9 retry policy (see fetchCortexOnce), plus the ADR-0070 outage hook. Returns the
|
|
641
726
|
* Response (possibly a non-OK one, after retries are exhausted or refused); throws a clear "Could not
|
|
@@ -649,11 +734,13 @@ export function heartbeatPolicy(consecutiveFailures) {
|
|
|
649
734
|
export async function fetchCortex(url, opts = {}, policy = {}) {
|
|
650
735
|
const hook = !policy.skipAutofile && autofileEnabled() && authOf(opts)
|
|
651
736
|
const file = hook && policy.fileOutage !== false
|
|
737
|
+
const started = clockMark()
|
|
652
738
|
let res
|
|
653
739
|
try {
|
|
654
740
|
res = await fetchCortexOnce(url, opts, policy)
|
|
655
741
|
} catch (e) {
|
|
656
|
-
|
|
742
|
+
// A request the machine slept through is not an outage (see SLEEP_GAP_MS): file nothing, probe nothing.
|
|
743
|
+
if (file && e?.retryClass && !sleptBetween(started, clockMark())) {
|
|
657
744
|
const r = outageReport({ thrownClass: e.retryClass }, { tool: toolForUrl(url) })
|
|
658
745
|
// Awaited, not fire-and-forget: hook processes are short-lived and would exit before a detached
|
|
659
746
|
// probe answered. Bounded by REACHABILITY_TIMEOUT_MS, and only on a path that is already failing.
|
package/lib/server.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from 'os'
|
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
8
|
import { seatChargeRefusal, seatChargeQuestion } from './seat_charge.mjs'
|
|
9
|
-
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
|
|
9
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy, nextPingFailures, clockMark, sleptBetween, internetReachable } 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
12
|
import { renderFolderClaimOffers, renderIdentifierResolution, renderUnroutedPathStamps } from './identifier_routes.mjs'
|
|
@@ -281,8 +281,14 @@ export async function runServer(version) {
|
|
|
281
281
|
}
|
|
282
282
|
// Consecutive heartbeat failures. A lone slow beat is not an outage (see HEARTBEAT_OUTAGE_AFTER);
|
|
283
283
|
// three in a row is, and that beat files through fetchCortex's normal path, probe included.
|
|
284
|
+
// ⚠ Only failures that say something about Agnoclast count (nextPingFailures, 2026-10-08): a beat the
|
|
285
|
+
// laptop slept through, a sleep between beats, or a failure while the internet itself is down resets it.
|
|
284
286
|
let pingFailures = 0
|
|
287
|
+
let lastBeat = null
|
|
285
288
|
async function pingSession() {
|
|
289
|
+
const started = clockMark()
|
|
290
|
+
if (lastBeat && sleptBetween(lastBeat, started)) pingFailures = 0 // the lid closed since the last beat
|
|
291
|
+
lastBeat = started
|
|
286
292
|
try {
|
|
287
293
|
const res = await fetchCortex(`${BASE}/api/session-ping`, {
|
|
288
294
|
method: 'POST',
|
|
@@ -316,9 +322,18 @@ export async function runServer(version) {
|
|
|
316
322
|
authored: currentAuthored(),
|
|
317
323
|
}),
|
|
318
324
|
}, heartbeatPolicy(pingFailures))
|
|
319
|
-
|
|
325
|
+
// A response proves the network worked; it still does not count if the machine slept through it.
|
|
326
|
+
pingFailures = nextPingFailures(pingFailures, { ok: res.ok, slept: sleptBetween(started, clockMark()) })
|
|
320
327
|
try { await res.body?.cancel() } catch { /* nothing to drain */ }
|
|
321
|
-
} catch {
|
|
328
|
+
} catch {
|
|
329
|
+
// best-effort heartbeat — never disrupt the session. A thrown connect/timeout counts only when the
|
|
330
|
+
// machine stayed awake AND the internet answers: otherwise the fault is the lid or the Wi-Fi.
|
|
331
|
+
try {
|
|
332
|
+
const slept = sleptBetween(started, clockMark())
|
|
333
|
+
const internetUp = slept ? true : await internetReachable()
|
|
334
|
+
pingFailures = nextPingFailures(pingFailures, { ok: false, slept, internetUp })
|
|
335
|
+
} catch { pingFailures = 0 }
|
|
336
|
+
}
|
|
322
337
|
}
|
|
323
338
|
void pingSession()
|
|
324
339
|
const _hb = setInterval(() => void pingSession(), 60_000)
|
|
@@ -4685,6 +4700,49 @@ function renderNudge(payload) {
|
|
|
4685
4700
|
},
|
|
4686
4701
|
)
|
|
4687
4702
|
|
|
4703
|
+
// relabel_record_people — put a real name where a text record names its person by a bare phone number
|
|
4704
|
+
// (records filed before desktop 0.1.40 could not read Contacts). Owner-only; labels, never identifiers or
|
|
4705
|
+
// message bodies; dry run unless apply: true; the server keeps each before-state in record history.
|
|
4706
|
+
server.registerTool(
|
|
4707
|
+
'relabel_record_people',
|
|
4708
|
+
{
|
|
4709
|
+
title: 'Put a name on texts that show a bare phone number',
|
|
4710
|
+
description:
|
|
4711
|
+
'On YOUR OWN text-message records, replace a bare phone number standing as a person\'s name ("1 message with ' +
|
|
4712
|
+
'+18015550100") with the name you give: the summary, the thread label and the "who:" speaker prefix of each ' +
|
|
4713
|
+
'message. Never changes the number itself as an identifier, message wording, or who can see the record. ' +
|
|
4714
|
+
'Dry run by default (reports how many records would change); pass apply: true to write. Safe to repeat.',
|
|
4715
|
+
inputSchema: {
|
|
4716
|
+
mappings: z.array(z.object({
|
|
4717
|
+
phone: z.string().describe('E.164, e.g. +18015550100 (a "phone:" prefix is fine)'),
|
|
4718
|
+
name: z.string().describe('the display name to show instead'),
|
|
4719
|
+
})).min(1).max(50).describe('one or more number → name pairs'),
|
|
4720
|
+
apply: z.boolean().optional().describe('true = write; omitted/false = dry run'),
|
|
4721
|
+
},
|
|
4722
|
+
},
|
|
4723
|
+
async ({ mappings, apply }) => {
|
|
4724
|
+
let res
|
|
4725
|
+
try {
|
|
4726
|
+
res = await fetchCortex(`${BASE}/api/records/relabel-people`, {
|
|
4727
|
+
method: 'POST',
|
|
4728
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4729
|
+
body: JSON.stringify({ mappings, dry_run: apply !== true }),
|
|
4730
|
+
})
|
|
4731
|
+
} catch (e) {
|
|
4732
|
+
return toolError(`Could not reach Agnoclast: ${e.message}`)
|
|
4733
|
+
}
|
|
4734
|
+
const body = await res.json().catch(() => ({}))
|
|
4735
|
+
if (res.status === 404 && !body?.error) return toolError('This server does not relabel records yet. Nothing was written.')
|
|
4736
|
+
if (res.status === 422) return toolError(`Not done: ${body?.detail ?? 'the mappings were refused'}.`)
|
|
4737
|
+
if (!res.ok || !body?.ok) return toolError(`Could not relabel: HTTP ${res.status} ${JSON.stringify(body)}`)
|
|
4738
|
+
const ids = (body.recordIds ?? []).join(', ')
|
|
4739
|
+
const text = body.dryRun
|
|
4740
|
+
? `Dry run: ${body.changed} of your text records would be relabelled (${body.matched} mention the number${body.matched === 1 ? '' : 's'}).${ids ? ` Records: ${ids}.` : ''} Nothing was written; pass apply: true to write.`
|
|
4741
|
+
: `Relabelled ${body.changed} of your text records.${ids ? ` Records: ${ids}.` : ''} Each before-state is kept in record history.`
|
|
4742
|
+
return { content: [{ type: 'text', text }] }
|
|
4743
|
+
},
|
|
4744
|
+
)
|
|
4745
|
+
|
|
4688
4746
|
// ── the questions agents used to answer with SQL (Agnoclast page "Agent SQL gap", 2026-10-07) ──────
|
|
4689
4747
|
// Theron, 2026-10-07: "any information that I have felt the need to get about my own brains, but was
|
|
4690
4748
|
// not able to do it with Agnoclast tools … we either need to make tools for that, or …". Measured the
|
package/package.json
CHANGED