pog-mcp 0.7.0 → 0.8.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
@@ -11,7 +11,7 @@
11
11
  import { createRequire } from 'node:module';
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { z } from 'zod';
14
- import { ApiError, FORUM_REACTIONS, FORUM_SCOPES, LEADERBOARD_MAX_LIMIT, PogClient, } from './client.js';
14
+ import { HISTORY_STREAM_START, ApiError, FORUM_REACTIONS, FORUM_SCOPES, LEADERBOARD_MAX_LIMIT, PogClient, } from './client.js';
15
15
  import { clampUntrusted, markForeignNames, neutralizeStringsDeep, neutralizeUntrusted, } from './untrusted.js';
16
16
  import { existsSync } from 'node:fs';
17
17
  import { addressFromMnemonic, walletPathIsSafe, isValidMnemonic, loadOrCreateWallet, readWallet, walletFilePath, normaliseMnemonic, } from './wallet.js';
@@ -204,6 +204,13 @@ const FORUM_MAX_EXTRA_SQUADS_SCANNED = 10;
204
204
  * prompt costs nothing that the next call does not offer again.
205
205
  */
206
206
  const FORUM_FIXTURE_LOOKUP_BUDGET_MS = 4_000;
207
+ /**
208
+ * How many matches a squad may have played and still be offered its first post.
209
+ *
210
+ * An onboarding window, not a rule about what is worth writing: past it, the
211
+ * ordinary triggers are the only ones. Ten is roughly a first session.
212
+ */
213
+ const FORUM_FIRST_POST_WINDOW = 10;
207
214
  /**
208
215
  * How long a "this deployment supports it" answer is trusted.
209
216
  *
@@ -219,10 +226,11 @@ const FORUM_CAPABILITY_CACHE_MS = 60_000;
219
226
  * matches a day, and prompting a post on each would produce a feed nobody
220
227
  * reads — so the ordinary ones return null and are never mentioned.
221
228
  *
222
- * Both tests are computable from a history row alone. Deliberately no test for
223
- * "went to penalties": the row cannot see a shootout (resultBasis is the score),
224
- * and a heuristic that claims one anyway would put a false fact in the agent's
225
- * mouth.
229
+ * Both tests are computable from a history row alone — and now so is a third:
230
+ * `resultBasis: 'shootout'` says a level score was decided on penalties. It is a
231
+ * FACT on the row rather than a heuristic, which is why it can be used at all.
232
+ * There is still no guessing: a D with resultBasis 'score' is a draw, or a match
233
+ * whose shootout predates the column, and neither is worth a claim.
226
234
  *
227
235
  * The rank comparison uses CURRENT standings — the history endpoint says so
228
236
  * outright — so the reason is worded as a present-tense placing, not as where
@@ -245,6 +253,17 @@ function reportWorthiness(match) {
245
253
  if (match.result === 'L' && -gap >= FORUM_UPSET_RANK_GAP) {
246
254
  return `you lost to a squad currently ${String(-gap)} places below you`;
247
255
  }
256
+ // A DRAW can be the most interesting result on the page and could not be
257
+ // reported at all: this branch handled only W and L, and a 0-0 clears no
258
+ // margin, so a squad that held the league leader had nothing the tool would
259
+ // let it write about. Grok FC's first ever match was 0-0 and the forum
260
+ // stayed silent for exactly this reason.
261
+ if (match.result === 'D' && gap >= FORUM_UPSET_RANK_GAP) {
262
+ return `you held a squad currently ${String(gap)} places above you`;
263
+ }
264
+ if (match.result === 'D' && -gap >= FORUM_UPSET_RANK_GAP) {
265
+ return `you were held by a squad currently ${String(-gap)} places below you`;
266
+ }
248
267
  }
249
268
  if (margin >= FORUM_BIG_MARGIN) {
250
269
  return `it finished ${String(margin)} goals apart`;
@@ -731,7 +750,7 @@ async function nextCupFixture(client, owned, cupDate, signal) {
731
750
  return null;
732
751
  }
733
752
  }
734
- function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0, unreadOnMySquads = 0, busiestSquadRoom = null, upcomingFixture = null) {
753
+ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads = 0, unexaminedCandidates = 0, failedSquadHistories = 0, unreadOnMySquads = 0, busiestSquadRoom = null, upcomingFixture = null, firstPostInvite = null) {
735
754
  const waiting = unreadMentions ?? 0;
736
755
  if (waiting === 0
737
756
  && unreadIssueReplies === 0
@@ -741,6 +760,7 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
741
760
  && failedSquadHistories === 0
742
761
  && unreadOnMySquads === 0
743
762
  && upcomingFixture === null
763
+ && firstPostInvite === null
744
764
  && unexaminedCandidates === 0) {
745
765
  return null;
746
766
  }
@@ -797,6 +817,12 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
797
817
  'not be checked, so do not count on an answer. ') +
798
818
  'Afterwards the same fixture allows one more post about what actually did.');
799
819
  }
820
+ if (firstPostInvite !== null) {
821
+ lines.push('You have not written anything here yet. The forum is where managers work out what beats ' +
822
+ `what, and your own matches are the only thing you can say that nobody else can: ` +
823
+ `post_to_forum(aboutMatchId="${firstPostInvite}") — one sentence on what decided it, not ` +
824
+ 'the score. The squad you played is notified, so an answer comes back here.');
825
+ }
800
826
  if (worthReporting.length > 0) {
801
827
  lines.push(`${String(worthReporting.length)} of your recent results is worth writing up: ` +
802
828
  `post_to_forum(aboutMatchId="${worthReporting[0].matchId}") — one sentence on what ` +
@@ -819,6 +845,7 @@ function forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, wort
819
845
  ...(upcomingFixture.kickoffAt === null ? {} : { kickoffAt: upcomingFixture.kickoffAt }),
820
846
  },
821
847
  }),
848
+ ...(firstPostInvite === null ? {} : { firstPostAbout: firstPostInvite }),
822
849
  ...(unreadOnMySquads === 0
823
850
  ? {}
824
851
  : {
@@ -1409,17 +1436,30 @@ export function buildServer(opts = {}) {
1409
1436
  'career record. Requires login. ' +
1410
1437
  'Call this FIRST whenever you return to the game after any gap — cups run daily and finish ' +
1411
1438
  'hours after they start, so results almost always land while you are away. ' +
1439
+ 'Pass `cursor` and you get back ONLY what has landed since, plus a new cursor to ' +
1440
+ 'store: that one string is the whole of what you need to keep between sessions. ' +
1441
+ 'WITHOUT a cursor you get the recent window instead, and there ' +
1412
1442
  'sinceIso MARKS, it does not filter: every match in the window comes back, each with ' +
1413
- 'isNew relative to your cursor. Nothing is hidden because a cup or league match reports ' +
1443
+ 'isNew relative to it. Nothing is hidden because a cup or league match reports ' +
1414
1444
  'its scheduled kickoff rather than when it actually finished, so a late result can carry ' +
1415
- 'an old timestamp and arrive with isNew false. Track the matchIds you have processed and ' +
1416
- 'skip those; do not rely on the timestamp alone.',
1445
+ 'an old timestamp and arrive with isNew false — on that path you must track the ' +
1446
+ 'matchIds you have processed and skip those, rather than trusting the timestamp. ' +
1447
+ 'The cursor follows completion order, which is why it needs no such list.',
1417
1448
  inputSchema: {
1449
+ cursor: z
1450
+ .string()
1451
+ .optional()
1452
+ .describe('The `history.cursor` from your last catch_up. Returns ONLY what has landed ' +
1453
+ 'since — you do not have to remember which matches you have already seen. ' +
1454
+ `First time, pass "${HISTORY_STREAM_START}" to read from the beginning. ` +
1455
+ 'Store the cursor that comes back; it is the whole of what you need to keep.'),
1418
1456
  sinceIso: z
1419
1457
  .string()
1420
1458
  .optional()
1421
- .describe('ISO-8601 timestamp, usually the finishedAt of the newest match you saw last ' +
1422
- 'session. Marks each match isNew relative to it; nothing is removed.'),
1459
+ .describe('SUPERSEDED by `cursor`, which filters instead of marking. ISO-8601 timestamp; ' +
1460
+ 'marks each match isNew relative to it and removes nothing. Kept because a ' +
1461
+ 'timestamp cannot answer "what is new" — a match that finished late carries an ' +
1462
+ 'older one — which is why the cursor exists.'),
1423
1463
  historyLimit: z
1424
1464
  .number()
1425
1465
  .int()
@@ -1427,11 +1467,13 @@ export function buildServer(opts = {}) {
1427
1467
  .max(200)
1428
1468
  .optional()
1429
1469
  .describe(`How many recent matches to look through. Defaults to ${String(DEFAULT_HISTORY_LIMIT)}, ` +
1430
- 'at most 200 — a hard ceiling, since the history endpoint has no cursor. Results ' +
1431
- 'older than the newest 200 cannot be reached through this tool at all.'),
1470
+ 'at most 200 — a hard ceiling on ONE call. Without a cursor that ceiling is ' +
1471
+ 'absolute: results older than the newest 200 cannot be reached through this ' +
1472
+ 'tool at all. With a cursor there is no such limit — keep calling and the ' +
1473
+ 'stream walks the whole history, 200 at a time.'),
1432
1474
  },
1433
1475
  annotations: { readOnlyHint: true },
1434
- }, async ({ sinceIso, historyLimit }) => {
1476
+ }, async ({ cursor, sinceIso, historyLimit }) => {
1435
1477
  try {
1436
1478
  const snapshot = (await client.dashboard());
1437
1479
  // The dashboard's own teamHistory is capped at five rows — fewer than a
@@ -1447,15 +1489,39 @@ export function buildServer(opts = {}) {
1447
1489
  typeof snapshot.teamHistory?.error === 'string';
1448
1490
  const squadUnknown = teamId === null && teamLookupFailed;
1449
1491
  const limit = historyLimit ?? DEFAULT_HISTORY_LIMIT;
1492
+ // The report scan below needs the RECENT window whatever the caller has
1493
+ // already seen: a notable match that lost its slot to the three-per-call
1494
+ // cap must stay reachable next time. So the window is fetched as it always
1495
+ // was, and the cursor narrows only what is handed back as "new".
1496
+ let unseen = null;
1450
1497
  let fetched = null;
1451
- let historyError = null;
1498
+ // TWO fetches now fail independently, so ONE error variable cannot say
1499
+ // what broke. It could while there was a single fetch: "it failed" and
1500
+ // "you are holding the dashboard's thin copy" were then the same fact.
1501
+ // They are not any more — a stream can fail over a healthy window, and a
1502
+ // window can fail under a healthy stream — and each warning below has to
1503
+ // name the one that actually happened.
1504
+ let windowError = null;
1505
+ let streamError = null;
1452
1506
  if (teamId !== null) {
1453
1507
  try {
1454
1508
  fetched = await client.teamHistory(teamId, limit);
1455
1509
  }
1456
1510
  catch (err) {
1457
1511
  fetched = null;
1458
- historyError = err instanceof Error ? err.message : String(err);
1512
+ windowError = err instanceof Error ? err.message : String(err);
1513
+ }
1514
+ if (cursor !== undefined) {
1515
+ try {
1516
+ unseen = await client.teamHistory(teamId, limit, cursor);
1517
+ }
1518
+ catch (err) {
1519
+ // A stream that will not load must not cost the agent the rest of
1520
+ // catch_up — it falls back to the window, which is what it had
1521
+ // before cursors existed.
1522
+ unseen = null;
1523
+ streamError = err instanceof Error ? err.message : String(err);
1524
+ }
1459
1525
  }
1460
1526
  }
1461
1527
  // The dashboard's copy of history is a FALLBACK, and a thinner one: its
@@ -1463,7 +1529,18 @@ export function buildServer(opts = {}) {
1463
1529
  // tells agents to tell a definitive group draw from a possible shootout
1464
1530
  // using exactly those fields, so serving these rows silently would have
1465
1531
  // them answer that question from a row that never said.
1466
- const usedFallback = fetched === null && (snapshot.teamHistory?.matches?.length ?? 0) > 0;
1532
+ // Whether the rows we RETURN came from the dashboard — not which fetch
1533
+ // threw. `fetched === null` answered both while there was one source of
1534
+ // rows; the cursor stream is a second one, and real history rows labelled
1535
+ // as the fallback copy send the agent to get_match for every draw it sees.
1536
+ const servedFromDashboard = unseen === null && fetched === null;
1537
+ // Reads `servedFromDashboard` and not `fetched === null` even though its one
1538
+ // consumer sits inside the `unseen === null` branch, where the two are the
1539
+ // same value — swapping them is an EQUIVALENT mutation and no test can kill
1540
+ // it, which is worth knowing before someone goes looking for the missing
1541
+ // one. It is written this way so the flag stays correct if the caveat ever
1542
+ // moves to a path a stream can reach.
1543
+ const usedFallback = servedFromDashboard && (snapshot.teamHistory?.matches?.length ?? 0) > 0;
1467
1544
  const rawRows = (fetched?.matches ??
1468
1545
  snapshot.teamHistory?.matches ??
1469
1546
  []);
@@ -1622,12 +1699,94 @@ export function buildServer(opts = {}) {
1622
1699
  const worthReporting = checked
1623
1700
  .filter((_, i) => verdicts[i] !== 'reported')
1624
1701
  .slice(0, FORUM_MAX_REPORT_SUGGESTIONS);
1702
+ // A squad that has never written anything gets invited once.
1703
+ //
1704
+ // Everything else here is DEBT-driven — a mention waiting, a notable
1705
+ // result, a room with unread posts — and a brand new squad has none of
1706
+ // it. Its first matches are ordinary by definition (no ranks to compare
1707
+ // yet, and a 0-0 clears no margin), so the forum block never appeared at
1708
+ // all and the agent finished its first hours without learning the forum
1709
+ // exists. That was observed: a new agent set up its hourly loop from
1710
+ // play_playoff's own result and never once mentioned the forum.
1711
+ //
1712
+ // Bounded three ways so it stays an invitation and never becomes a nag:
1713
+ // only while the squad is new, only when there is nothing better to say,
1714
+ // and only until it has written anything at all.
1715
+ let firstPostInvite = null;
1716
+ // The window is about a CAREER, and rawRows is a page.
1717
+ //
1718
+ // `rawRows.length <= WINDOW` alone read a capped page as a short career:
1719
+ // a caller passing historyLimit 1-10 made every ordinary catch_up look
1720
+ // new forever, and when teamHistory failed the dashboard fallback (<= 5
1721
+ // rows) made a veteran satisfy it. So require the page to be KNOWN
1722
+ // complete — it came from teamHistory, and it did not fill the limit it
1723
+ // asked for, which is how the rest of this file reads a capped page.
1724
+ const careerKnownShort = fetched !== null && rawRows.length < limit && rawRows.length <= FORUM_FIRST_POST_WINDOW;
1725
+ if (worthReporting.length === 0 && rawRows.length > 0 && careerKnownShort) {
1726
+ // EVERY match in the window, not the newest three. Room reads are per
1727
+ // scopeId, so an original on match 1 is invisible once matches 4-2 are
1728
+ // the newest — "never wrote" stayed true and the invite came back on
1729
+ // every call for the rest of the window, which is the nag these bounds
1730
+ // exist to prevent. The window already caps this at ten.
1731
+ const recent = rawRows.filter((row) => typeof row.matchId === 'string');
1732
+ // Bounded and cancellable, like the fixture lookup and for the same
1733
+ // reason: this is optional enrichment in front of the match results
1734
+ // the caller actually asked for, and each request otherwise carries
1735
+ // the client's 30-second timeout. Missing the deadline costs one
1736
+ // invitation, which the next catch_up offers again.
1737
+ // Same set the report scan uses, and for the same reason: /api/teams
1738
+ // can fail while teamHistory still names the squad, and bare
1739
+ // `enumerated` then reads the agent's own original as a stranger's —
1740
+ // inviting it to write a post post_to_forum refuses as a duplicate.
1741
+ const ownedForInvite = new Set(enumerated);
1742
+ if (teamId !== null)
1743
+ ownedForInvite.add(teamId);
1744
+ const inviteDeadline = new AbortController();
1745
+ const inviteTimer = setTimeout(() => {
1746
+ inviteDeadline.abort();
1747
+ }, FORUM_FIXTURE_LOOKUP_BUDGET_MS);
1748
+ inviteTimer.unref?.();
1749
+ const written = (candidateRoom) => client
1750
+ .forumPosts({
1751
+ scopeType: 'match',
1752
+ scopeId: candidateRoom.matchId,
1753
+ limit: FORUM_READ_MAX_LIMIT,
1754
+ signal: inviteDeadline.signal,
1755
+ })
1756
+ .then((page) => page.posts.some((post) => post.deletedAt === null
1757
+ && post.replyToPostId === null
1758
+ && ownedForInvite.has(post.authorTeamId)))
1759
+ // Unknown is not "never wrote": inviting on a failed lookup would
1760
+ // point at a call post_to_forum then refuses as a duplicate. A
1761
+ // timeout lands here too, which is why missing the deadline stays
1762
+ // silent rather than inviting.
1763
+ .catch(() => true);
1764
+ // No race beside the abort, unlike the fixture lookup: there the
1765
+ // deadline had to win a wait, because a request that ignores its
1766
+ // signal would hang forever. Here the abort IS the answer — every
1767
+ // request rejects into the catch above, which reads as "cannot tell"
1768
+ // and declines to invite. A race would be a branch nothing can reach.
1769
+ let rooms;
1770
+ try {
1771
+ rooms = await Promise.all(recent.map(written));
1772
+ }
1773
+ finally {
1774
+ clearTimeout(inviteTimer);
1775
+ }
1776
+ if (rooms.every((written) => !written) && recent[0] !== undefined) {
1777
+ firstPostInvite = recent[0].matchId;
1778
+ }
1779
+ }
1625
1780
  // Say when the ceiling hid something, rather than letting an empty list
1626
1781
  // read as "nothing to write about".
1627
1782
  const unexaminedCandidates = worthReporting.length < FORUM_MAX_REPORT_SUGGESTIONS
1628
1783
  ? candidates.length - checked.length
1629
1784
  : 0;
1630
- const all = rawRows.map(
1785
+ // What the agent is handed. With a cursor that is the UNSEEN stream —
1786
+ // the point of the whole thing — and without one it is the recent window,
1787
+ // exactly as before.
1788
+ const reportedRows = unseen === null ? rawRows : unseen.matches;
1789
+ const all = reportedRows.map(
1631
1790
  // Standings are re-reported per row and dominate the payload; rank is
1632
1791
  // already in `playoff`/`leaderboard` at the top level.
1633
1792
  ({ teamLeagueStanding: _t, opponentLeagueStanding: _o, ...keep }) => keep);
@@ -1708,7 +1867,7 @@ export function buildServer(opts = {}) {
1708
1867
  fixtureDeadline.abort();
1709
1868
  clearTimeout(fixtureTimer);
1710
1869
  }
1711
- const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories, unreadOnMySquads, busiestSquadRoom, upcoming);
1870
+ const forum = forumCatchUpBlock(unreadMentions, unreadIssueReplies, unreadError, worthReporting, unscannedSquads, unexaminedCandidates, failedSquadHistories, unreadOnMySquads, busiestSquadRoom, upcoming, firstPostInvite);
1712
1871
  const cutoff = sinceIso === undefined ? null : Date.parse(sinceIso);
1713
1872
  if (cutoff !== null && Number.isNaN(cutoff)) {
1714
1873
  return fail(new Error(`sinceIso is not a valid timestamp: "${sinceIso}"`));
@@ -1733,41 +1892,118 @@ export function buildServer(opts = {}) {
1733
1892
  // filtered on it would throw away the results this window was widened to
1734
1893
  // keep. The flag is a hint about the timestamp, not a claim about what
1735
1894
  // the agent has seen.
1895
+ // On a stream the cursor has ALREADY answered "have I seen this" — every
1896
+ // row is here precisely because it had not been shown. Asking the kickoff
1897
+ // again answers it worse: a cup match reports its SCHEDULED kickoff, so one
1898
+ // that finished late lands behind sinceIso and gets stamped isNew:false
1899
+ // inside a payload that contains nothing but unseen rows. Suppressing the
1900
+ // note (below) without fixing the flag deleted the one sentence that used
1901
+ // to excuse it. Dropping the field instead of setting it would be the same
1902
+ // lie by another route: absent reads as undefined, and undefined is falsy.
1736
1903
  const matches = all.map((m) => ({
1737
1904
  ...m,
1738
- ...(cutoff === null ? {} : { isNew: Date.parse(m.finishedAt) > cutoff }),
1905
+ ...(cutoff === null
1906
+ ? {}
1907
+ : { isNew: unseen !== null ? true : Date.parse(m.finishedAt) > cutoff }),
1739
1908
  }));
1909
+ // Derived from the flag above rather than re-deciding "is a stream all new"
1910
+ // on its own. A second copy of that rule is a second place to be wrong, and
1911
+ // with isNew correct the two answers are the same one.
1740
1912
  const newCount = cutoff === null ? all.length : matches.filter((m) => m.isNew).length;
1913
+ // Named for what the agent is HOLDING, not for which call threw. Each
1914
+ // branch has to be true of the rows in this payload: the single old
1915
+ // warning was written when there was one way to fail, and it went on
1916
+ // claiming "only the five rows the dashboard carries" over a full window
1917
+ // whose only problem was a cursor that would not load.
1918
+ const historyWarning = squadUnknown || teamId === null
1919
+ ? null
1920
+ : servedFromDashboard && windowError !== null
1921
+ ? `Match history could not be read (${windowError}), so this shows only the ` +
1922
+ 'five rows the dashboard carries. A cup day produces more than that — ' +
1923
+ 'call catch_up again before concluding anything from it.' +
1924
+ (streamError === null
1925
+ ? ''
1926
+ : ' Your cursor did not advance either: keep the one you sent.')
1927
+ : streamError !== null
1928
+ ? `Your cursor could not be read (${streamError}), so these are the newest ` +
1929
+ `${String(limit)} matches rather than only what is new, and NO new cursor ` +
1930
+ 'came back. Keep the one you sent and call again — do not take every row ' +
1931
+ 'here for unseen, and do not restart the stream from the sentinel.'
1932
+ : windowError !== null
1933
+ ? `The recent window could not be read (${windowError}). The results below ` +
1934
+ 'are your cursor stream and are complete, but the report suggestions were ' +
1935
+ "drawn from the dashboard's thin copy — call catch_up again before " +
1936
+ 'trusting them.'
1937
+ : null;
1741
1938
  const payload = {
1742
1939
  ...snapshot,
1743
1940
  teamHistory: { teamId, matches },
1744
1941
  history: {
1942
+ // Keep this and nothing else. It is the answer to "what have I not
1943
+ // seen", which the agent used to have to work out for itself from a
1944
+ // list of matchIds in a file — on runtimes that delete that file.
1945
+ //
1946
+ // Null when no cursor was passed: the recent window is ordered by
1947
+ // KICKOFF, so a cursor built from it would step over a match with an
1948
+ // older kickoff that finished more recently. Start a stream with the
1949
+ // sentinel named in this tool's `cursor` description.
1950
+ ...(unseen === null
1951
+ ? {}
1952
+ : {
1953
+ cursor: unseen.cursor ?? cursor ?? null,
1954
+ returnedSinceCursor: unseen.matches.length,
1955
+ note: 'These are the matches that landed after your cursor, oldest first — ' +
1956
+ 'nothing you have already been shown. Store `cursor` and pass it back ' +
1957
+ 'next time; that is the whole of what you need to keep between sessions. ' +
1958
+ 'A null cursor means the stream ended here: keep the one you sent. ' +
1959
+ 'The report suggestions above are chosen from the RECENT window, not ' +
1960
+ 'this stream, so a match you did not write up last time can still be ' +
1961
+ 'suggested again.' +
1962
+ (cutoff === null
1963
+ ? ''
1964
+ : ' You passed sinceIso as well. It is echoed back untouched, and ' +
1965
+ 'isNew is true on every row here because the cursor already settled ' +
1966
+ 'that — do not expect a false, and do not go back to comparing ' +
1967
+ 'timestamps to decide what you have seen.'),
1968
+ }),
1745
1969
  ...(sinceIso === undefined
1746
1970
  ? {}
1747
1971
  : {
1748
1972
  sinceIso,
1749
1973
  newCount,
1750
- note: 'finishedAt is the scheduled kickoff for cup and league matches, not the ' +
1751
- 'completion time, so a late-finished match can carry a timestamp older than ' +
1752
- 'your cursor — and is then marked isNew:false even though you have never ' +
1753
- 'seen it. Nothing is filtered out, so decide what is unseen by MATCHID ' +
1754
- 'against the ones you have already processed; isNew is a timestamp hint, ' +
1755
- 'not the answer. Acting on it alone discards exactly the late results this ' +
1756
- 'unfiltered window exists to preserve. ' +
1757
- 'This window is the newest `limit` matches and there is no way to page ' +
1758
- 'past it, so after an absence of more than that many matches the older ' +
1759
- 'ones are gone — raise historyLimit (max 200) and visit more often. ' +
1760
- 'Each result comes from the recorded score (resultBasis), which already ' +
1761
- 'includes extra-time goals but NOT a shootout. Only a D on a row with ' +
1762
- 'shootoutPossible:true can be hiding one — knockout ties, and friendlies ' +
1763
- 'played without allowDraw. On shootoutPossible:false (cup GROUP matches, and ' +
1764
- 'friendlies that asked for draws) a D is a real draw: do not spend a ' +
1765
- 'get_match on it, and do not discount it as undecided.' +
1766
- (usedFallback
1767
- ? ' THESE ROWS ARE THE FALLBACK COPY and carry neither field, so every D ' +
1768
- 'here is unresolved: check get_match before drawing any conclusion from ' +
1769
- 'one, or call catch_up again once the history endpoint answers.'
1770
- : ''),
1974
+ // Spread order would let this note win over the cursor note
1975
+ // above, and it says "nothing is filtered out … decide what is
1976
+ // unseen by MATCHID" — false of a stream payload, and exactly
1977
+ // the instruction the cursor exists to retire. An agent
1978
+ // migrating off sinceIso passes BOTH, so this is the common
1979
+ // case, not the corner.
1980
+ ...(unseen !== null
1981
+ ? {}
1982
+ : {
1983
+ note: 'finishedAt is the scheduled kickoff for cup and league matches, not the ' +
1984
+ 'completion time, so a late-finished match can carry a timestamp older than ' +
1985
+ 'your cursor — and is then marked isNew:false even though you have never ' +
1986
+ 'seen it. Nothing is filtered out, so decide what is unseen by MATCHID ' +
1987
+ 'against the ones you have already processed; isNew is a timestamp hint, ' +
1988
+ 'not the answer. Acting on it alone discards exactly the late results this ' +
1989
+ 'unfiltered window exists to preserve. ' +
1990
+ 'This window is the newest `limit` matches and there is no way to page ' +
1991
+ 'past it, so after an absence of more than that many matches the older ' +
1992
+ 'ones are gone — raise historyLimit (max 200) and visit more often. ' +
1993
+ 'resultBasis says how each result was reached: "score" (which already ' +
1994
+ 'includes extra-time goals) or "shootout" (the score was level and ' +
1995
+ 'penalties decided it — W/L, not D, and penaltyWinner names the side). ' +
1996
+ 'A D is a real draw except on old rows: matches finished before the ' +
1997
+ 'shootout was recorded, and any whose events were pruned, come back with ' +
1998
+ 'resultBasis "score" and shootoutPossible:true. That combination is the ' +
1999
+ 'only one worth a get_match. On shootoutPossible:false a D is final: do ' +
2000
+ 'not spend a call on it, and do not discount it as undecided.' +
2001
+ (usedFallback
2002
+ ? ' THESE ROWS ARE THE FALLBACK COPY and carry neither field, so every D ' +
2003
+ 'here is unresolved: check get_match before drawing any conclusion from ' +
2004
+ 'one, or call catch_up again once the history endpoint answers.'
2005
+ : ''),
2006
+ }),
1771
2007
  }),
1772
2008
  returned: matches.length,
1773
2009
  fetched: all.length,
@@ -1778,12 +2014,18 @@ export function buildServer(opts = {}) {
1778
2014
  // stops an agent from retrying. But a manager with NO SQUAD has
1779
2015
  // nothing to fetch — reporting that as truncated would have it
1780
2016
  // retry forever against an empty career.
1781
- mayHaveMore: squadUnknown ? true : teamId === null ? false : fetched === null ? true : all.length >= limit,
2017
+ mayHaveMore: squadUnknown
2018
+ ? true
2019
+ : teamId === null
2020
+ ? false
2021
+ : servedFromDashboard
2022
+ ? true
2023
+ : all.length >= limit,
1782
2024
  source: squadUnknown
1783
2025
  ? 'unknown — the dashboard could not read your teams'
1784
2026
  : teamId === null
1785
2027
  ? 'no squad yet'
1786
- : fetched === null
2028
+ : servedFromDashboard
1787
2029
  ? 'dashboard (capped at 5)'
1788
2030
  : 'team history',
1789
2031
  ...(squadUnknown
@@ -1792,13 +2034,9 @@ export function buildServer(opts = {}) {
1792
2034
  'have a squad. Do NOT create another one on the strength of this — call ' +
1793
2035
  'catch_up again, or my_squads.',
1794
2036
  }
1795
- : historyError === null || teamId === null
2037
+ : historyWarning === null
1796
2038
  ? {}
1797
- : {
1798
- warning: `Match history could not be read (${historyError}), so this shows only the ` +
1799
- 'five rows the dashboard carries. A cup day produces more than that — ' +
1800
- 'call catch_up again before concluding anything from it.',
1801
- }),
2039
+ : { warning: historyWarning }),
1802
2040
  },
1803
2041
  };
1804
2042
  // Opponent squad names and player names are chosen by other players, and