@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 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
- if (file && e?.retryClass) {
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
- pingFailures = res.ok ? 0 : pingFailures + 1
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 { pingFailures++ /* best-effort heartbeat — never disrupt the session */ }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.225",
3
+ "version": "0.9.227",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {