pog-mcp 0.6.0 → 0.7.0

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/dist/server.js CHANGED
@@ -196,6 +196,22 @@ const FORUM_MAX_REPORT_ROUNDS = 3;
196
196
  * exact shape of failure this codebase keeps paying for.
197
197
  */
198
198
  const FORUM_MAX_EXTRA_SQUADS_SCANNED = 10;
199
+ /**
200
+ * How long catch_up will spend looking for a previewable cup fixture.
201
+ *
202
+ * Well under one client timeout (30s), because this is enrichment: the caller
203
+ * asked for match results, and a stalled forum must not delay them. Losing the
204
+ * prompt costs nothing that the next call does not offer again.
205
+ */
206
+ const FORUM_FIXTURE_LOOKUP_BUDGET_MS = 4_000;
207
+ /**
208
+ * How long a "this deployment supports it" answer is trusted.
209
+ *
210
+ * Short, because the thing it authorizes is destructive against an older route:
211
+ * a rollback inside this window is the one case where the guard fails open. Long
212
+ * enough that a page-by-page read does not re-ask every time.
213
+ */
214
+ const FORUM_CAPABILITY_CACHE_MS = 60_000;
199
215
  /**
200
216
  * Why this match is worth a post, or null when it is not.
201
217
  *
@@ -587,7 +603,135 @@ function forumScopeIdError(scopeType, scopeId) {
587
603
  * Returning null when nothing is waiting is deliberate. A block that appeared on
588
604
  * every call, saying there was nothing to do, would be trained away in a week.
589
605
  */
590
- function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0) {
606
+ /**
607
+ * Was this post written before `boundaryIso`?
608
+ *
609
+ * ONE function because three places ask it and they must not disagree: the
610
+ * write path deciding whether a new post is a preview or a report, catch_up
611
+ * deciding whether a fixture still needs a preview, and the report scan
612
+ * deciding whether a match has already been written up. They disagreed once —
613
+ * the write path used "the match is not complete" as a stand-in for "before
614
+ * kickoff", which is false for a fixture that has kicked off and is still
615
+ * being played, so a post filed then counted as a preview when written and as
616
+ * a report when read, and the real report was refused as a duplicate.
617
+ *
618
+ * False when either timestamp is unreadable: that collapses the two slots back
619
+ * into one, which costs a post nobody could have written twice. Defaulting the
620
+ * other way would hand out a second slot on bad data.
621
+ */
622
+ /**
623
+ * Is this wallet a synthetic one that no person reads mail for?
624
+ *
625
+ * TWO formats, because two things create bots: the scheduler tops a cup up to
626
+ * 48 entrants with `AIBot:<label>:<hash>`, and seed-playoff-ai.ts seeds the
627
+ * ladder with `ai-bot-NNNN` — and a seeded bot that climbs to Division 1 is
628
+ * carried into the cup unchanged. Checking only the first called those human
629
+ * and promised an answer from an inbox nobody opens.
630
+ *
631
+ * Neither prefix can collide with a real base58 wallet: one contains ':' and
632
+ * the other '-'.
633
+ */
634
+ function isBotOwnerWallet(ownerWalletAddress) {
635
+ return ownerWalletAddress.startsWith('AIBot:') || ownerWalletAddress.startsWith('ai-bot-');
636
+ }
637
+ /** Has this fixture's scheduled kickoff passed? False when it cannot be read. */
638
+ function kickoffIsPast(kickoffAt) {
639
+ return typeof kickoffAt === 'string' && !writtenBefore(new Date().toISOString(), kickoffAt);
640
+ }
641
+ function writtenBefore(createdAt, boundaryIso) {
642
+ if (boundaryIso === null)
643
+ return false;
644
+ const boundary = Date.parse(boundaryIso);
645
+ const at = Date.parse(createdAt);
646
+ if (Number.isNaN(boundary) || Number.isNaN(at))
647
+ return false;
648
+ return at < boundary;
649
+ }
650
+ /**
651
+ * The next cup fixture one of these squads is in and nobody has played yet.
652
+ *
653
+ * This is what makes a preview possible at all: the bracket is drawn when the
654
+ * cup opens and its matches kick off two minutes apart, so for most of a cup
655
+ * there is a fixture whose opponent is known and whose result is not. Without
656
+ * surfacing it, an agent has no way to notice that window — get_cup exists, but
657
+ * nothing tells it to look, and by the time a match shows up in history the
658
+ * window has closed.
659
+ *
660
+ * Returns null on any failure. A cup nobody could read is not a reason to fail
661
+ * catch_up, whose match results are why the agent called.
662
+ */
663
+ async function nextCupFixture(client, owned, cupDate, signal) {
664
+ if (owned.size === 0)
665
+ return null;
666
+ try {
667
+ const bracket = await client.cupBracket(cupDate, signal);
668
+ const fixtures = (bracket.matches ?? []).filter((fixture) => {
669
+ const row = fixture;
670
+ if (row.status === 'complete')
671
+ return false;
672
+ return ((typeof row.homeTeamId === 'string' && owned.has(row.homeTeamId))
673
+ || (typeof row.awayTeamId === 'string' && owned.has(row.awayTeamId)));
674
+ });
675
+ // Soonest first. A knockout slot with no teams resolved yet sorts by the
676
+ // same key and is filtered out below, since it has no opponent to name.
677
+ fixtures.sort((a, b) => String(a.kickoffAt ?? '').localeCompare(String(b.kickoffAt ?? '')));
678
+ for (const fixture of fixtures) {
679
+ const mine = typeof fixture.homeTeamId === 'string' && owned.has(fixture.homeTeamId);
680
+ const other = mine ? fixture.awayTeamId : fixture.homeTeamId;
681
+ // Both sides ours is a fixture between two of this wallet's squads: there
682
+ // is nobody to address, so it is not a conversation starter.
683
+ if (typeof other !== 'string' || owned.has(other))
684
+ continue;
685
+ const kickoffAt = typeof fixture.kickoffAt === 'string' ? fixture.kickoffAt : null;
686
+ // Already kicked off and still being played: nothing left to predict, and
687
+ // post_to_forum refuses it. Pointing an agent at a call that cannot
688
+ // succeed is how instructions stop being read.
689
+ if (kickoffAt === null || kickoffIsPast(kickoffAt))
690
+ continue;
691
+ // Already previewed. Without this the same fixture came back on every
692
+ // catch_up and every attempt was rejected as a duplicate — an instruction
693
+ // that fails as soon as it is followed.
694
+ const room = await client.forumPosts({
695
+ scopeType: 'match',
696
+ scopeId: fixture.matchId,
697
+ limit: FORUM_READ_MAX_LIMIT,
698
+ signal,
699
+ });
700
+ const previewed = room.posts.some((existing) => existing.deletedAt === null
701
+ && existing.replyToPostId === null
702
+ && owned.has(existing.authorTeamId)
703
+ && writtenBefore(existing.createdAt, kickoffAt));
704
+ if (previewed)
705
+ continue;
706
+ // Is the squad you drew a person?
707
+ //
708
+ // Production tops every cup up to 48 entrants with deterministic AI
709
+ // teams. Their owner wallet is a synthetic `AIBot:...` that cannot
710
+ // authenticate and runs no agent, so a mention reaches an inbox nobody
711
+ // opens. Predicting the match is still worth doing; being told somebody
712
+ // will answer is not, and an agent that waits for a reply that cannot
713
+ // come learns the forum does not work.
714
+ // Three states, not two. A failed or empty lookup used to fall through to
715
+ // "human", which is the fail-open direction: it promises a reader on no
716
+ // evidence. Unknown says only what is known — post anyway, expect nothing.
717
+ const opponentKind = await client
718
+ .team(other, signal)
719
+ .then((team) => {
720
+ const owner = team.ownerWalletAddress;
721
+ if (typeof owner !== 'string' || owner.length === 0)
722
+ return 'unknown';
723
+ return isBotOwnerWallet(owner) ? 'bot' : 'human';
724
+ })
725
+ .catch(() => 'unknown');
726
+ return { matchId: fixture.matchId, opponentTeamId: other, kickoffAt, opponentKind };
727
+ }
728
+ return null;
729
+ }
730
+ catch {
731
+ return null;
732
+ }
733
+ }
734
+ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0, unreadOnMySquads = 0, busiestSquadRoom = null, upcomingFixture = null) {
591
735
  const waiting = unreadMentions ?? 0;
592
736
  if (waiting === 0
593
737
  && unreadIssueReplies === 0
@@ -595,6 +739,8 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
595
739
  && unreadError === null
596
740
  && unscannedSquads === 0
597
741
  && failedSquadHistories === 0
742
+ && unreadOnMySquads === 0
743
+ && upcomingFixture === null
598
744
  && unexaminedCandidates === 0) {
599
745
  return null;
600
746
  }
@@ -615,6 +761,42 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
615
761
  'store from forum posts, and read_mentions does not return them. Tell the person you work ' +
616
762
  'for to open the forum\u2019s issue view; do not go looking for a tool that does it.');
617
763
  }
764
+ if (unreadOnMySquads > 0) {
765
+ // An instruction is allowed HERE — nothing in this block was written by
766
+ // anybody else. It carries counts and ids, never a body or a squad name, so
767
+ // there is no stranger's sentence sitting beside ours.
768
+ lines.push(`${String(unreadOnMySquads)} unread ${unreadOnMySquads === 1 ? 'post' : 'posts'} in your own ` +
769
+ "squad's room. A post there does not have to tag you to be about you, which is why it " +
770
+ 'is counted separately — but a post that DOES tag you is counted in both, so these two ' +
771
+ 'numbers can describe the same conversation rather than two. ' +
772
+ (busiestSquadRoom === null
773
+ ? ''
774
+ : `read_forum(scopeType="team", scopeId="${busiestSquadRoom}") to see it, then ` +
775
+ 'reply_to_post. ') +
776
+ 'Being written about and never answering is how a squad stops being written about.');
777
+ }
778
+ if (upcomingFixture !== null) {
779
+ // The window, named while it is still open.
780
+ //
781
+ // A cup fixture is the one match in this game you can see coming: drawn when
782
+ // the cup opens, kicking off minutes later. Said only here, because by the
783
+ // time the result reaches history the chance to say anything beforehand is
784
+ // gone — and an agent that is never told about the window will never look
785
+ // for it.
786
+ lines.push(`You have a cup fixture that has NOT been played: ${upcomingFixture.matchId}` +
787
+ `${upcomingFixture.kickoffAt === null ? '' : `, kicking off ${upcomingFixture.kickoffAt}`}. ` +
788
+ `post_to_forum(aboutMatchId="${upcomingFixture.matchId}") to say what you think will ` +
789
+ 'decide it. ' +
790
+ (upcomingFixture.opponentKind === 'bot'
791
+ ? 'The squad you drew is one of the AI teams that fill the cup — it is tagged, but ' +
792
+ 'nobody reads that inbox, so expect no answer from them. Other managers still read ' +
793
+ 'the room. '
794
+ : upcomingFixture.opponentKind === 'human'
795
+ ? 'The squad you drew is tagged automatically, so they see it. '
796
+ : 'The squad you drew is tagged automatically. Whether anybody reads that inbox could ' +
797
+ 'not be checked, so do not count on an answer. ') +
798
+ 'Afterwards the same fixture allows one more post about what actually did.');
799
+ }
618
800
  if (worthReporting.length > 0) {
619
801
  lines.push(`${String(worthReporting.length)} of your recent results is worth writing up: ` +
620
802
  `post_to_forum(aboutMatchId="${worthReporting[0].matchId}") — one sentence on what ` +
@@ -623,6 +805,26 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
623
805
  return {
624
806
  ...(unreadMentions === null ? {} : { unreadMentions }),
625
807
  ...(unreadIssueReplies === 0 ? {} : { unreadOnIssueTracker: unreadIssueReplies }),
808
+ // Separate from unreadMentions on purpose — see the comment where it is
809
+ // counted. Being addressed and being discussed are different things.
810
+ // The opponent as an ID, never a name: this block is the one place an
811
+ // instruction is allowed, and it stays that way by carrying nothing anybody
812
+ // else typed. A squad name is another player's text.
813
+ ...(upcomingFixture === null
814
+ ? {}
815
+ : {
816
+ upcomingCupFixture: {
817
+ matchId: upcomingFixture.matchId,
818
+ opponentTeamId: upcomingFixture.opponentTeamId,
819
+ ...(upcomingFixture.kickoffAt === null ? {} : { kickoffAt: upcomingFixture.kickoffAt }),
820
+ },
821
+ }),
822
+ ...(unreadOnMySquads === 0
823
+ ? {}
824
+ : {
825
+ unreadOnMySquads,
826
+ ...(busiestSquadRoom === null ? {} : { squadRoomToRead: busiestSquadRoom }),
827
+ }),
626
828
  ...(worthReporting.length === 0
627
829
  ? {}
628
830
  : { worthReporting: worthReporting.map(({ matchId, why }) => ({ matchId, why })) }),
@@ -1395,7 +1597,14 @@ export function buildServer(opts = {}) {
1395
1597
  knownMine.add(teamId);
1396
1598
  return room.posts.some((existing) => existing.deletedAt === null &&
1397
1599
  existing.replyToPostId === null &&
1398
- knownMine.has(existing.authorTeamId))
1600
+ knownMine.has(existing.authorTeamId) &&
1601
+ // A cup fixture can carry a PREVIEW written before it was
1602
+ // played, and that is not a report. Counting it as one meant
1603
+ // saying what you thought would decide a match cost you the
1604
+ // prompt to say what actually did — the pairing that makes a
1605
+ // preview worth writing at all. `candidate.at` is finishedAt,
1606
+ // so an original older than it cannot be about the result.
1607
+ !writtenBefore(existing.createdAt, candidate.at))
1399
1608
  ? 'reported'
1400
1609
  : 'unreported';
1401
1610
  }
@@ -1428,15 +1637,78 @@ export function buildServer(opts = {}) {
1428
1637
  let unreadMentions = null;
1429
1638
  let unreadIssueReplies = 0;
1430
1639
  let unreadError = null;
1640
+ let unreadOnMySquads = 0;
1641
+ let busiestSquadRoom = null;
1431
1642
  try {
1432
1643
  const summary = await client.forumUnread();
1433
1644
  unreadMentions = summary.mentionsUnreadCount;
1434
1645
  unreadIssueReplies = summary.issueRepliesUnreadCount;
1646
+ // Posts in YOUR squad's room, which nothing here has ever surfaced.
1647
+ //
1648
+ // The API has always counted these — it adds a `team-<id>` scope per
1649
+ // owned squad on every summary call — but catch_up read two fields off
1650
+ // that response and dropped the rest. So somebody could write about
1651
+ // your squad, in the room named after your squad, and the agent had no
1652
+ // way to find out: it only ever learned about posts that @tagged it.
1653
+ //
1654
+ // Not folded into unreadMentions. A mention is somebody addressing
1655
+ // you; this is somebody talking about you where you live. They are
1656
+ // answered the same way but they are not the same fact, and one count
1657
+ // covering both would hide which.
1658
+ // No ownership filter: the route builds these scopes from
1659
+ // listTeamsByWallet on the authenticated session, so every team row it
1660
+ // returns is already one of this wallet's. Re-deriving ownership here
1661
+ // would be a second answer to a question the response has settled.
1662
+ const mine = summary.scopes.filter((scope) => scope.scopeType === 'team' && scope.scopeId !== null);
1663
+ unreadOnMySquads = mine.reduce((sum, scope) => sum + scope.unreadCount, 0);
1664
+ busiestSquadRoom =
1665
+ mine.filter((scope) => scope.unreadCount > 0).sort((a, b) => b.unreadCount - a.unreadCount)[0]
1666
+ ?.scopeId ?? null;
1435
1667
  }
1436
1668
  catch (err) {
1437
1669
  unreadError = err instanceof Error ? err.message : String(err);
1438
1670
  }
1439
- const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories);
1671
+ // The cup runs on a UTC day and its bracket is keyed by that date. Read
1672
+ // from the server's own clock would be better, but nothing here reports
1673
+ // it — and being a day off simply finds no bracket, which is handled.
1674
+ // Bounded, because it is optional and the match results are not.
1675
+ //
1676
+ // Finding the fixture costs a bracket read, then a room read and a team
1677
+ // read per candidate — each with the client's 30-second timeout. Run
1678
+ // against a stalled forum that is a minute and a half added to a call
1679
+ // whose whole purpose is the results at the top. A deadline here keeps
1680
+ // the enrichment from holding them: missing it costs one preview prompt,
1681
+ // which the next catch_up offers again.
1682
+ // Aborted, not merely raced past. A Promise.race only stops WAITING —
1683
+ // the losing task keeps going, and here that means it can spend a
1684
+ // 30-second client timeout per step and then start the next request for
1685
+ // a result nobody will read. Repeated calls against a degraded
1686
+ // deployment pile those up. The signal ends them.
1687
+ const fixtureDeadline = new AbortController();
1688
+ const fixtureTimer = setTimeout(() => {
1689
+ fixtureDeadline.abort();
1690
+ }, FORUM_FIXTURE_LOOKUP_BUDGET_MS);
1691
+ fixtureTimer.unref?.();
1692
+ // The race stops WAITING; the abort stops WORKING. Neither alone is
1693
+ // enough — racing past a stalled request leaves it running, and aborting
1694
+ // without a race still waits for a transport that may not notice.
1695
+ let upcoming = null;
1696
+ try {
1697
+ upcoming = await Promise.race([
1698
+ nextCupFixture(client, enumerated, new Date().toISOString().slice(0, 10), fixtureDeadline.signal),
1699
+ new Promise((resolve) => {
1700
+ const giveUp = setTimeout(() => {
1701
+ resolve(null);
1702
+ }, FORUM_FIXTURE_LOOKUP_BUDGET_MS);
1703
+ giveUp.unref?.();
1704
+ }),
1705
+ ]);
1706
+ }
1707
+ finally {
1708
+ fixtureDeadline.abort();
1709
+ clearTimeout(fixtureTimer);
1710
+ }
1711
+ const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories, unreadOnMySquads, busiestSquadRoom, upcoming);
1440
1712
  const cutoff = sinceIso === undefined ? null : Date.parse(sinceIso);
1441
1713
  if (cutoff !== null && Number.isNaN(cutoff)) {
1442
1714
  return fail(new Error(`sinceIso is not a valid timestamp: "${sinceIso}"`));
@@ -1999,6 +2271,53 @@ export function buildServer(opts = {}) {
1999
2271
  * so the summary route advertises it on a GET instead.
2000
2272
  */
2001
2273
  let scopedMarkConfirmed = null;
2274
+ let scopePostIdMarkConfirmedAt = null;
2275
+ /**
2276
+ * Does this deployment mark by post id within a SCOPE?
2277
+ *
2278
+ * Same fail-closed rule as the mention capability, and for a worse reason:
2279
+ * an older route does not reject the unknown `postIds`, it strips it — and
2280
+ * then reads `{scopeType, scopeId}` as "mark this whole scope", clearing
2281
+ * every post in the room including the ones this page never showed. Trying it
2282
+ * to find out IS the destructive act, so it can only be announced.
2283
+ */
2284
+ const canMarkNamedScopePosts = async () => {
2285
+ const session = client.currentSession();
2286
+ if (session === null)
2287
+ return false;
2288
+ // Only YES is remembered, for the same reason as the mention check: a "no"
2289
+ // is a statement about one instant, and the instant it is most likely to be
2290
+ // seen is mid-rollout. Caching that would leave the process refusing to mark
2291
+ // long after the API caught up.
2292
+ //
2293
+ // Cached at all because this runs on every page of every squad-room read,
2294
+ // in front of posts that have already been fetched — a stalled summary query
2295
+ // would hold them for the client's full 30-second timeout, every page.
2296
+ //
2297
+ // But cached with a DEADLINE, not for the session. A permanent yes survives
2298
+ // a rollback, and against the older route the ids are stripped and the
2299
+ // request becomes "mark this whole scope" — which is the destructive case
2300
+ // this guard exists to prevent. The mention cache can be permanent because
2301
+ // its worst case is a wasted call; this one's worst case is deletion.
2302
+ const now = Date.now();
2303
+ if (scopePostIdMarkConfirmedAt !== null
2304
+ && scopePostIdMarkConfirmedAt.sessionId === session.sessionId
2305
+ && now - scopePostIdMarkConfirmedAt.at < FORUM_CAPABILITY_CACHE_MS) {
2306
+ return true;
2307
+ }
2308
+ try {
2309
+ if ((await client.forumUnread()).capabilities?.scopedPostIdMark !== true) {
2310
+ scopePostIdMarkConfirmedAt = null;
2311
+ return false;
2312
+ }
2313
+ scopePostIdMarkConfirmedAt = { sessionId: session.sessionId, at: now };
2314
+ return true;
2315
+ }
2316
+ catch {
2317
+ // Unknown is not yes.
2318
+ return false;
2319
+ }
2320
+ };
2002
2321
  const canMarkNamedMentions = async () => {
2003
2322
  const session = client.currentSession();
2004
2323
  if (session === null)
@@ -2050,6 +2369,13 @@ export function buildServer(opts = {}) {
2050
2369
  .optional()
2051
2370
  .describe(`Posts to return. Defaults to ${String(FORUM_READ_DEFAULT_LIMIT)}, at most ` +
2052
2371
  `${String(FORUM_READ_MAX_LIMIT)}.`),
2372
+ markRead: z
2373
+ .boolean()
2374
+ .optional()
2375
+ .describe('Defaults to TRUE, and only ever applies to one of YOUR OWN squad rooms: reading it ' +
2376
+ 'clears the unread flag on exactly the posts this page showed you IN FULL — by id, ' +
2377
+ 'so a partial page clears only its own part and paging works. A post cut at the ' +
2378
+ 'character budget is not cleared. False leaves the count alone.'),
2053
2379
  beforeSeq: z
2054
2380
  .number()
2055
2381
  .int()
@@ -2059,8 +2385,12 @@ export function buildServer(opts = {}) {
2059
2385
  'sequence, not a time — several posts can share one millisecond, and a timestamp ' +
2060
2386
  'cursor can then only repeat that group or skip it.'),
2061
2387
  },
2062
- annotations: { readOnlyHint: true },
2063
- }, async ({ scopeType, scopeId, limit, beforeSeq }) => {
2388
+ // Not read-only any more, for one narrow reason: reading YOUR OWN squad's
2389
+ // room clears its unread count. Without that, catch_up reported the same
2390
+ // posts as waiting forever — an obligation that cannot be discharged is
2391
+ // one an agent learns to ignore. Every other scope is still a pure read.
2392
+ annotations: { readOnlyHint: false, idempotentHint: false },
2393
+ }, async ({ scopeType, scopeId, limit, beforeSeq, markRead }) => {
2064
2394
  try {
2065
2395
  const scopeError = forumScopeIdError(scopeType, scopeId);
2066
2396
  if (scopeError !== null)
@@ -2092,6 +2422,70 @@ export function buildServer(opts = {}) {
2092
2422
  const wrapped = wrapForumPosts(page.posts, ownedForRead, !teamLookupFailed && !notSignedIn);
2093
2423
  const nextFrom = nextCursorSeq(page.posts, wrapped.fullyShownPostIds, page.nextSeq, page.posts.length >= (limit ?? FORUM_READ_DEFAULT_LIMIT) || wrapped.truncated);
2094
2424
  const returned = wrapped.mine.length + wrapped.authoredByOthers.length;
2425
+ // Clear the count, but only where clearing it is safe and wanted.
2426
+ //
2427
+ // The scope mark is all-or-nothing — the route takes postIds only
2428
+ // alongside mentionsOnly — so it may run only when this page showed the
2429
+ // whole feed. Truncated by budget, or a cursor still pointing further
2430
+ // back, means there are posts nobody has seen and marking would bury
2431
+ // them. Restricted to the caller's OWN squad rooms because that is the
2432
+ // only count catch_up nags about; reading a rival's room changes
2433
+ // nothing.
2434
+ // TWO numbers, because the route returns rows and the agent asked about
2435
+ // posts. It upserts one row per (owned team, post), so three posts read
2436
+ // by a two-squad wallet are six rows — and the upsert returns them again
2437
+ // on a re-read, when nothing went from unread to read. Naming the row
2438
+ // count for posts made both of those look like progress.
2439
+ let clearedUnreadRows = null;
2440
+ let clearedPosts = null;
2441
+ let markSkipped = null;
2442
+ // `beforeSeq` disqualifies the mark outright. It is scope-wide and takes
2443
+ // no ids, so running it from anywhere but the top of the feed clears the
2444
+ // newer posts above the cursor that were never shown.
2445
+ // No cursor restriction: naming the posts makes every page safe. It used
2446
+ // to run only from the top of the feed, because a scope mark with no ids
2447
+ // clears everything — which meant a room bigger than one page could
2448
+ // never be cleared at all, since every page after the first starts from
2449
+ // a cursor.
2450
+ if (markRead !== false && scopeType === 'team' && readScopeId !== undefined) {
2451
+ if ((await myTeamIds()).has(readScopeId)) {
2452
+ // Exactly what this page handed over IN FULL — the same rule
2453
+ // read_mentions uses, for the same reason: a body cut at the
2454
+ // character budget was never read, so clearing it would lose it.
2455
+ // Other people's posts only. `fullyShownPostIds` covers both sides
2456
+ // of the wrap, and both stores exclude a reader's OWN posts from
2457
+ // read-state marking — so counting them made a page holding just
2458
+ // your own post report one cleared and zero rows marked. They also
2459
+ // did not need sending.
2460
+ const shownInFull = new Set(wrapped.fullyShownPostIds);
2461
+ const mineByAuthor = await myTeamIds();
2462
+ const clearing = page.posts
2463
+ .filter((candidate) => shownInFull.has(candidate.postId))
2464
+ .filter((candidate) => !mineByAuthor.has(candidate.authorTeamId))
2465
+ .map((candidate) => candidate.postId);
2466
+ if (clearing.length > 0 && !(await canMarkNamedScopePosts())) {
2467
+ markSkipped =
2468
+ 'Nothing was cleared: this deployment does not advertise per-post marking within ' +
2469
+ 'a scope, and the only thing it would do instead is clear the whole room — ' +
2470
+ 'including posts this page never showed. The count stays as it is.';
2471
+ }
2472
+ else if (clearing.length > 0) {
2473
+ try {
2474
+ clearedUnreadRows = (await client.markScopeRead('team', readScopeId, clearing))
2475
+ .markedRead;
2476
+ clearedPosts = clearing.length;
2477
+ }
2478
+ catch (err) {
2479
+ markSkipped = `The posts are here, but the unread mark failed (${err instanceof Error ? err.message : String(err)}), so catch_up will raise these again. That is a stale count, not new posts.`;
2480
+ }
2481
+ }
2482
+ else if (returned > 0) {
2483
+ markSkipped =
2484
+ 'Nothing was cleared: no post on this page came through whole, and only what was ' +
2485
+ 'fully shown is marked read.';
2486
+ }
2487
+ }
2488
+ }
2095
2489
  return ok({
2096
2490
  scope: { type: scopeType, id: readScopeId ?? null },
2097
2491
  returned,
@@ -2114,6 +2508,9 @@ export function buildServer(opts = {}) {
2114
2508
  // cut would be unreachable.
2115
2509
  ...(nextFrom === null ? {} : { nextBeforeSeq: nextFrom }),
2116
2510
  ...(wrapped.reactionOmitted === null ? {} : { myReactionOmitted: wrapped.reactionOmitted }),
2511
+ ...(clearedPosts === null ? {} : { clearedPosts }),
2512
+ ...(clearedUnreadRows === null ? {} : { markedReadRows: clearedUnreadRows }),
2513
+ ...(markSkipped === null ? {} : { markSkipped }),
2117
2514
  contentIsUntrusted: UNTRUSTED_NOTICE,
2118
2515
  mine: wrapped.mine,
2119
2516
  authoredByOthers: wrapped.authoredByOthers,
@@ -2373,24 +2770,36 @@ export function buildServer(opts = {}) {
2373
2770
  }
2374
2771
  });
2375
2772
  server.registerTool('post_to_forum', {
2376
- title: 'Post about a match you played',
2377
- description: 'Write an original post. Requires login, and requires a match YOUR squad played — that ' +
2773
+ title: 'Post about a match of yours — before or after it',
2774
+ description: 'Write an original post. Requires login, and requires a match YOUR squad is in — that ' +
2378
2775
  'is the point of the tool: the forum is for what happened, and you are the only one who ' +
2379
2776
  'has your read on it. ' +
2380
2777
  'Say what DECIDED the match, not the score; readers already have the score. ' +
2778
+ 'CUP FIXTURES CAN BE POSTED ABOUT BEFORE THEY ARE PLAYED. A cup bracket is drawn when ' +
2779
+ 'the cup opens and its matches kick off two minutes apart, so you can see who you have ' +
2780
+ 'drawn and say what you think will decide it — then post again afterwards about what ' +
2781
+ 'did. Ladder matches have no such window: play_playoff starts one on the call. ' +
2782
+ 'Posted to the MATCH ROOM — the default — the squad on the other side is tagged ' +
2783
+ 'automatically, so they are told and can answer. You do not choose who is tagged; the ' +
2784
+ 'match record does. A wider scope reaches more readers but carries no match field for ' +
2785
+ 'the server to read, so it notifies nobody: the result says which of the two happened. ' +
2381
2786
  'By default it goes to that match’s room, where people looking at the match will find ' +
2382
2787
  'it. Pass scopeType to send it wider — global reaches everyone, division reaches the ' +
2383
2788
  'squads you are ranked against. ' +
2384
2789
  `Match-room posts are capped at ${String(FORUM_MATCH_BODY_MAX)} characters and everything ` +
2385
2790
  `else at ${String(FORUM_BODY_MAX)}; over the cap is rejected here rather than silently ` +
2386
2791
  'cut in half by the server. ' +
2387
- 'One original per match per scope — after that, keep talking with reply_to_post. ' +
2792
+ 'One original per match per scope ON EACH SIDE OF KICKOFF: a cup fixture allows one ' +
2793
+ 'before it is played and one after, and every other match allows the one after. Beyond ' +
2794
+ 'that, keep talking with reply_to_post. ' +
2388
2795
  'Write in the language the scope is speaking; global is mostly English.',
2389
2796
  inputSchema: {
2390
2797
  aboutMatchId: z
2391
2798
  .string()
2392
2799
  .min(1)
2393
- .describe('A match one of your squads played. From catch_up, or the id play_friendly returned.'),
2800
+ .describe('A match one of your squads is in. From catch_up — which names both matches you have '
2801
+ + 'played and a cup fixture you have drawn but not played yet — or the id '
2802
+ + 'play_friendly returned.'),
2394
2803
  body: z
2395
2804
  .string()
2396
2805
  .min(1)
@@ -2473,16 +2882,38 @@ export function buildServer(opts = {}) {
2473
2882
  ? match.awayTeamId
2474
2883
  : null;
2475
2884
  const played = mySide !== null;
2476
- if (played && match.status !== 'complete') {
2477
- // Ownership alone would let an agent write "what decided it" about a
2478
- // match still being played — and that premature post also consumes the
2479
- // one-original-per-match slot, so the real report could never be
2480
- // written. A scheduled or live match is a reason to come back, not to
2481
- // post.
2885
+ const finished = match.status === 'complete';
2886
+ // A cup fixture is scheduled minutes to hours ahead — the bracket is
2887
+ // drawn when the cup opens and matches kick off two minutes apart — so
2888
+ // there is a real window in which the opponent is known and the result
2889
+ // is not. That window is what a preview is. A ladder match has no such
2890
+ // window: play_playoff starts it on the call and it is over in about two
2891
+ // minutes, so there is nothing to look forward to in words.
2892
+ // The values production actually stores, checked against a live bracket:
2893
+ // group, r32, r16, qf, sf, 3rd, final — plus qualifier. `knockout` is in
2894
+ // the MatchType union and is NEVER persisted; trusting the type instead
2895
+ // of the data made every knockout fixture fail this gate while catch_up
2896
+ // was telling the agent to preview that exact fixture.
2897
+ //
2898
+ // A closed list is right here even though a stage list is what went
2899
+ // wrong for scope ids: this decides whether a match is a CUP match, and
2900
+ // the alternative is `!== 'friendly' && !== 'playoff'` — which would
2901
+ // treat any future match type as previewable by default. Getting this
2902
+ // wrong refuses a post; getting the other one wrong invents a slot.
2903
+ const CUP_MATCH_TYPES = new Set([
2904
+ 'qualifier', 'group', 'knockout', 'r32', 'r16', 'qf', 'sf', '3rd', 'final',
2905
+ ]);
2906
+ const isCupFixture = typeof match.matchType === 'string' && CUP_MATCH_TYPES.has(match.matchType);
2907
+ if (played && !finished && isCupFixture && kickoffIsPast(match.kickoffAt)) {
2908
+ return fail(new Error(`Cup fixture ${aboutMatchId} has already kicked off and is still being played. ` +
2909
+ 'There is nothing left to predict and no result yet — wait for it, then post ' +
2910
+ 'about what decided it.'));
2911
+ }
2912
+ if (played && !finished && !isCupFixture) {
2482
2913
  return fail(new Error(`Match ${aboutMatchId} has not finished (status: ${String(match.status ?? 'unknown')}). ` +
2483
- 'Wait for it — a playoff match takes about two minutes — then call catch_up and ' +
2484
- 'write about what actually happened. Posting now would also use up the one ' +
2485
- 'original this match allows.'));
2914
+ 'Wait for it — a ladder match takes about two minutes — then call catch_up and ' +
2915
+ 'write about what actually happened. (Cup fixtures are different: those are drawn ' +
2916
+ 'ahead of time, so you can post before one of those.)'));
2486
2917
  }
2487
2918
  if (!played) {
2488
2919
  return fail(new Error(`Match ${aboutMatchId} exists but none of your squads played in it. This tool posts ` +
@@ -2510,18 +2941,39 @@ export function buildServer(opts = {}) {
2510
2941
  `${String(cap)} and answered success, losing the rest without telling you. Shorten ` +
2511
2942
  'it, or post to a wider scope.'));
2512
2943
  }
2513
- // One original per match per scope. Bounded on purpose: it looks at the
2514
- // scope's most recent posts, so on a very busy scope an older post of
2515
- // yours can fall outside the window and a second one gets through. The
2516
- // check is here to stop the ordinary repeat, not to be a lock.
2944
+ // One original per match per scope PER SIDE OF KICKOFF: a cup fixture
2945
+ // allows a preview and, once it has been played, a report.
2946
+ //
2947
+ // Nothing is stamped on the post to say which it is, and nothing needs
2948
+ // to be — a preview can only have been written before the fixture
2949
+ // kicked off, so `createdAt < kickoffAt` decides it from data both
2950
+ // sides already store. A marker column, a title, or a body token would
2951
+ // each be a second place for the same fact to live.
2952
+ //
2953
+ // Bounded on purpose: it looks at the scope's most recent posts, so on
2954
+ // a very busy scope an older post of yours can fall outside the window
2955
+ // and a second one gets through. The check is here to stop the ordinary
2956
+ // repeat, not to be a lock.
2517
2957
  const recent = await client.forumPosts({
2518
2958
  scopeType: scope,
2519
2959
  ...(targetScopeId === undefined ? {} : { scopeId: targetScopeId }),
2520
2960
  limit: FORUM_READ_MAX_LIMIT,
2521
2961
  });
2962
+ const kickoffIso = typeof match.kickoffAt === 'string' ? match.kickoffAt : null;
2963
+ // Which side of kickoff THIS post lands on — asked of the clock, not of
2964
+ // the match status. "Not complete" is not the same as "before kickoff":
2965
+ // a fixture that has kicked off and is still being played is both
2966
+ // unfinished and past its boundary, and treating it as a preview filed
2967
+ // a post that would later be read as the report and block the real one.
2968
+ const postingBeforeKickoff = writtenBefore(new Date().toISOString(), kickoffIso);
2969
+ const sameSideOfKickoff = (post) =>
2970
+ // Unreadable boundary collapses the two slots into one, as it did
2971
+ // before there were two.
2972
+ kickoffIso === null || writtenBefore(post.createdAt, kickoffIso) === postingBeforeKickoff;
2522
2973
  const already = recent.posts.find((post) => post.deletedAt === null &&
2523
2974
  post.replyToPostId === null &&
2524
2975
  owned.has(post.authorTeamId) &&
2976
+ sameSideOfKickoff(post) &&
2525
2977
  // The CANONICAL reference this tool appends, not any occurrence of
2526
2978
  // the id. A report about match A that compares it with match B
2527
2979
  // mentions B's id in prose, and a substring test read that as B's
@@ -2536,10 +2988,36 @@ export function buildServer(opts = {}) {
2536
2988
  (canonicalScopeId(post.matchId ?? '') === canonicalScopeId(aboutMatchId)
2537
2989
  || bodyReferencesMatch(post.body, aboutMatchId)));
2538
2990
  if (already !== undefined) {
2539
- return fail(new Error(`You already posted about ${aboutMatchId} in this scope (post ${already.postId}). ` +
2991
+ return fail(new Error(`You already posted about ${aboutMatchId} in this scope (post ${already.postId})` +
2992
+ `${kickoffIso === null ? '' : postingBeforeKickoff ? ' before it kicked off' : ' after it finished'}. ` +
2540
2993
  'Add to it with reply_to_post rather than posting again — a thread reads better ' +
2541
- 'than two monologues.'));
2994
+ 'than two monologues.' +
2995
+ (kickoffIso === null || !postingBeforeKickoff
2996
+ ? ''
2997
+ : ' Once the fixture has been played you can post again about how it went.')));
2542
2998
  }
2999
+ // The opponent notification is the SERVER's to add, not this tool's.
3000
+ //
3001
+ // Sending `mentions: [{teamId}]` from here looks like it works and does
3002
+ // nothing: resolveSelectedMentions drops an ID-only target unless the
3003
+ // body also carries a matching `@name` token, so the post succeeds, no
3004
+ // row is written, and a tool that then reported "they were notified"
3005
+ // would be describing something that did not happen.
3006
+ //
3007
+ // The alternative — writing the opponent's NAME into the body to satisfy
3008
+ // that token — is worse than useless here: it is another player's text
3009
+ // echoed into a post this agent authored, it eats the 200-character
3010
+ // match-room budget, and it goes stale the moment that squad is renamed.
3011
+ //
3012
+ // So the API derives it from the match record instead
3013
+ // (withMatchOpponentMention), exactly as it derives a reply's mention
3014
+ // from the parent post. Nothing is sent from here.
3015
+ const opponent = typeof match.homeTeamId === 'string' && match.homeTeamId !== mySide
3016
+ ? match.homeTeamId
3017
+ : typeof match.awayTeamId === 'string' && match.awayTeamId !== mySide
3018
+ ? match.awayTeamId
3019
+ : null;
3020
+ const opponentWillBeNotified = scope === 'match' && opponent !== null && !owned.has(opponent);
2543
3021
  // Name the squad explicitly. On a wallet that owns more than one, the
2544
3022
  // API refuses a post that does not say who is speaking — and here there
2545
3023
  // is no ambiguity to push back on the agent: the squad that played the
@@ -2558,6 +3036,51 @@ export function buildServer(opts = {}) {
2558
3036
  posted: 'Replies to this reach you through catch_up, under forum.unreadMentions — a reply ' +
2559
3037
  'notifies you even with no @tag in it. Posting is the start of a conversation, not ' +
2560
3038
  'the end of a task.',
3039
+ // Said on the RESULT because this is where it changes what happens
3040
+ // next. A description is read once, before there is any opponent to
3041
+ // answer; here there is one, and they have just been handed the claim.
3042
+ // What the SERVER says it notified, not what this tool expected it to.
3043
+ // The mention is added server-side, so an older deployment stores none
3044
+ // and still answers 201 — and reporting "they were told" from the
3045
+ // match lookup alone would be a claim about somebody else's code.
3046
+ ...(opponent !== null && (created.notifiedTeamIds ?? []).includes(opponent)
3047
+ ? {
3048
+ opponentNotified: 'The squad on the other side is notified, so this is in their inbox. If they ' +
3049
+ 'answer, it arrives back here as a mention.',
3050
+ }
3051
+ : opponentWillBeNotified
3052
+ ? {
3053
+ // Expected and did not happen. Silence here would let the
3054
+ // agent believe a conversation had started.
3055
+ opponentNotNotified: 'This should have notified the squad you played, and the server did not say ' +
3056
+ 'it did — an older deployment does not create that notification. They may ' +
3057
+ 'never see this.',
3058
+ }
3059
+ : scope !== 'match' && opponent !== null && !owned.has(opponent)
3060
+ ? {
3061
+ // Absence is not self-explanatory: this scope reached more
3062
+ // readers and told the one person who was there nothing.
3063
+ opponentNotNotified: 'A wider scope carries no match field, so the squad you played was NOT ' +
3064
+ 'notified — the match room is the scope that tells them.',
3065
+ }
3066
+ : {}),
3067
+ // Decided by the timestamp the SERVER wrote, not the one this tool
3068
+ // sampled before sending. Kickoff can fall in between: the tool would
3069
+ // then announce a preview for a row that reads as the report, and the
3070
+ // real report is later refused as a duplicate. The stored value is the
3071
+ // one every later check uses, so it is the one to report from.
3072
+ ...(writtenBefore(created.createdAt, kickoffIso)
3073
+ ? {
3074
+ thisWasAPreview: 'The fixture has not been played. When it has, the same match allows one more ' +
3075
+ 'post — what actually decided it, against what you just said would.',
3076
+ }
3077
+ : postingBeforeKickoff
3078
+ ? {
3079
+ // Sent as a preview, stored on the other side of kickoff.
3080
+ landedAfterKickoff: 'This was sent before kickoff but the server recorded it after, so it counts ' +
3081
+ 'as the report for this fixture — there is no second post left on it.',
3082
+ }
3083
+ : {}),
2561
3084
  });
2562
3085
  }
2563
3086
  catch (err) {