@cyanheads/gdelt-mcp-server 0.2.3 → 0.2.5
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/CLAUDE.md +1 -1
- package/README.md +13 -8
- package/changelog/0.2.x/0.2.4.md +14 -0
- package/changelog/0.2.x/0.2.5.md +21 -0
- package/dist/mcp-server/tools/date-range.d.ts +55 -3
- package/dist/mcp-server/tools/date-range.d.ts.map +1 -1
- package/dist/mcp-server/tools/date-range.js +107 -3
- package/dist/mcp-server/tools/date-range.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.d.ts +14 -0
- package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.js +125 -25
- package/dist/mcp-server/tools/definitions/get-coverage-breakdown.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.d.ts +7 -0
- package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.js +77 -12
- package/dist/mcp-server/tools/definitions/get-coverage-timeline.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tone-distribution.tool.js +1 -1
- package/dist/mcp-server/tools/definitions/get-tone-distribution.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tv-clips.tool.d.ts +4 -0
- package/dist/mcp-server/tools/definitions/get-tv-clips.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tv-clips.tool.js +52 -8
- package/dist/mcp-server/tools/definitions/get-tv-clips.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tv-context.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tv-context.tool.js +2 -5
- package/dist/mcp-server/tools/definitions/get-tv-context.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tv-trending.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tv-trending.tool.js +1 -3
- package/dist/mcp-server/tools/definitions/get-tv-trending.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts +4 -0
- package/dist/mcp-server/tools/definitions/search-articles.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-articles.tool.js +52 -9
- package/dist/mcp-server/tools/definitions/search-articles.tool.js.map +1 -1
- package/package.json +1 -1
- package/server.json +3 -3
package/CLAUDE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Developer Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** gdelt-mcp-server
|
|
4
|
-
**Version:** 0.2.
|
|
4
|
+
**Version:** 0.2.5
|
|
5
5
|
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.10.14`
|
|
6
6
|
**Engines:** Bun ≥1.3.0, Node ≥24.0.0
|
|
7
7
|
**MCP SDK:** `@modelcontextprotocol/sdk` ^1.29.0
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<div align="center">
|
|
9
9
|
|
|
10
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/gdelt-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/@cyanheads/gdelt-mcp-server) [](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
@@ -33,13 +33,13 @@ Nine tools across two GDELT APIs — DOC API for global print/web news (last 3 m
|
|
|
33
33
|
|
|
34
34
|
| Tool | Description |
|
|
35
35
|
|:---|:---|
|
|
36
|
-
| `gdelt_search_articles` | Search the last 3 months of global news coverage (65 languages) with full-text and filter operators. Returns up to 250 articles. |
|
|
37
|
-
| `gdelt_get_coverage_timeline` | Retrieve a time series of coverage volume or average tone for a query. `volume_with_articles` mode includes top articles per spike timestep. |
|
|
36
|
+
| `gdelt_search_articles` | Search the last 3 months of global news coverage (65 languages) with full-text and filter operators. Returns up to 250 articles, and hands back the date windows to re-query when that ceiling is hit. |
|
|
37
|
+
| `gdelt_get_coverage_timeline` | Retrieve a time series of coverage volume or average tone for a query. `volume_with_articles` mode includes top articles per spike timestep, with `points` to render a timestep's full article list. |
|
|
38
38
|
| `gdelt_get_tone_distribution` | Get a tone histogram (bins ~−30 to +30) showing whether coverage is uniformly negative, bimodal, or clustered near neutral. |
|
|
39
|
-
| `gdelt_get_coverage_breakdown` | Break down coverage volume by source language or source country — a multi-series time series showing geographic propagation. |
|
|
39
|
+
| `gdelt_get_coverage_breakdown` | Break down coverage volume by source language or source country — a multi-series time series showing geographic propagation. Values are normalized shares of media output, not article counts. |
|
|
40
40
|
| `gdelt_search_tv` | Search US television news closed captions (2009–Oct 2024) and return per-station airtime time series. |
|
|
41
|
-
| `gdelt_get_tv_clips` | Retrieve up to 3,000 matching TV clips with transcript excerpts and Internet Archive viewing links. |
|
|
42
|
-
| `gdelt_get_tv_context` | Get the
|
|
41
|
+
| `gdelt_get_tv_clips` | Retrieve up to 3,000 matching TV clips with transcript excerpts and Internet Archive viewing links, and the date windows to re-query when that ceiling is hit. |
|
|
42
|
+
| `gdelt_get_tv_context` | Get the most frequent co-occurring words and phrases from TV clips matching a query. |
|
|
43
43
|
| `gdelt_get_tv_trending` | Retrieve trending topics currently dominating US television news (updated every 15 minutes; no query required). |
|
|
44
44
|
| `gdelt_list_tv_stations` | List all TV stations with market, network, and monitoring date ranges to verify station availability before querying. |
|
|
45
45
|
|
|
@@ -52,6 +52,7 @@ Search the last 3 months of global news with GDELT's full query syntax.
|
|
|
52
52
|
- Proximity and repetition: `near20:"flu virus"`, `repeat3:"outbreak"`
|
|
53
53
|
- Configurable sort (relevance, date) and result count (up to 250)
|
|
54
54
|
- Returns URL, title, publication date, domain, language, source country, and social image URL
|
|
55
|
+
- 250 is a hard per-call ceiling, not a page size — GDELT exposes no cursor. Fill it and the response returns `continuationWindows`: the queried window halved, ready to re-query. The halves overlap by a second so nothing falls through the seam; de-duplicate by `url`
|
|
55
56
|
- Query is echoed in response for chaining
|
|
56
57
|
|
|
57
58
|
---
|
|
@@ -63,6 +64,7 @@ Retrieve when coverage of a topic spiked, with three modes:
|
|
|
63
64
|
- `volume` — normalized percentage of all global coverage per timestep
|
|
64
65
|
- `volume_with_articles` — volume plus top articles driving each spike; use for signal detection without a follow-up search call
|
|
65
66
|
- `tone` — average sentiment score per timestep (combine with `gdelt_get_tone_distribution` for the full picture)
|
|
67
|
+
- Every article reference is always in `structuredContent`; the text surface renders the first 3 links per timestep beside that timestep's true count, and `points: ["<date>"]` renders named timesteps in full
|
|
66
68
|
- Configurable smoothing and time range
|
|
67
69
|
|
|
68
70
|
---
|
|
@@ -82,7 +84,9 @@ Snapshot tone histogram across all articles matching a query.
|
|
|
82
84
|
Multi-series time series showing which countries or languages drove coverage.
|
|
83
85
|
|
|
84
86
|
- Break down by `language` or `country`
|
|
85
|
-
- Top 10 series by total volume; remaining series aggregated into an "Other" bucket
|
|
87
|
+
- Top 10 series by total volume; remaining series aggregated into an "Other" bucket and named in `otherSeriesLabels`
|
|
88
|
+
- Nothing is dissolved into "Other" anonymously — pass any label to `series: ["<label>"]` and that series comes back complete under `selectedSeries`, alongside the usual overview
|
|
89
|
+
- Values are normalized — the topic's share of media output, not absolute article counts. Small media markets with concentrated coverage rank above large markets with diverse output, so a high value means the topic dominated that source's coverage rather than that it published the most articles
|
|
86
90
|
- Use to trace how a story propagated geographically
|
|
87
91
|
|
|
88
92
|
---
|
|
@@ -104,6 +108,7 @@ Retrieve actual TV news clips driving a coverage signal.
|
|
|
104
108
|
|
|
105
109
|
- Up to 3,000 clips per call
|
|
106
110
|
- Each clip: show name, station, air timestamp, 15-second transcript excerpt, direct Archive.org link, and optional thumbnail
|
|
111
|
+
- 3,000 is a hard per-call ceiling, not a page size — GDELT exposes no cursor. Fill it and the response returns `continuationWindows`: the queried window halved, ready to re-query. The halves overlap by a second so nothing falls through the seam; de-duplicate by `archiveUrl`
|
|
107
112
|
- Sort by relevance, date descending, or date ascending
|
|
108
113
|
|
|
109
114
|
---
|
|
@@ -112,7 +117,7 @@ Retrieve actual TV news clips driving a coverage signal.
|
|
|
112
117
|
|
|
113
118
|
Vocabulary framing analysis for TV coverage of a topic.
|
|
114
119
|
|
|
115
|
-
- Returns the
|
|
120
|
+
- Returns the most frequent non-stopword terms from matching clips
|
|
116
121
|
- Relative frequency scores (query term = 100)
|
|
117
122
|
- Use to identify narrative framing, related concepts, or follow-up search terms
|
|
118
123
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Fixed: content[] omitted array data five tools returned in full in structuredContent (#19); gdelt_get_coverage_breakdown now documents its normalized values (#10)"
|
|
3
|
+
breaking: false
|
|
4
|
+
security: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 0.2.4 — 2026-07-15
|
|
8
|
+
|
|
9
|
+
## Fixed
|
|
10
|
+
|
|
11
|
+
- **`gdelt_get_tone_distribution`, `gdelt_get_tv_context`, `gdelt_get_tv_trending`** — `format()` sliced `content[]` to 2/50/50 elements while `structuredContent` carried the full array; clients reading only the text surface lost data. All three now render every element ([#19](https://github.com/cyanheads/gdelt-mcp-server/issues/19)).
|
|
12
|
+
- **`gdelt_get_coverage_timeline`** — removed the 10-point cap on rendered series points; every point's date and value now renders. The per-point article link list stays capped at 3 (`ARTICLES_PER_POINT`), tracked separately in [#24](https://github.com/cyanheads/gdelt-mcp-server/issues/24) ([#19](https://github.com/cyanheads/gdelt-mcp-server/issues/19)).
|
|
13
|
+
- **`gdelt_get_coverage_breakdown`** — `format()` rendered only each series' total and peak; every `topSeries` and `otherAggregated` point now renders. `gdelt_search_tv`'s series points remain unrendered in `content[]`, tracked separately in [#23](https://github.com/cyanheads/gdelt-mcp-server/issues/23) ([#19](https://github.com/cyanheads/gdelt-mcp-server/issues/19)).
|
|
14
|
+
- **`gdelt_get_coverage_breakdown`** — description, `format()` output, and the `topSeries`/`otherAggregated` schema descriptions now state that values are normalized shares of a source's media output, not absolute article counts ([#10](https://github.com/cyanheads/gdelt-mcp-server/issues/10)).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Added: selectable series (#20) and named point expansion (#24); search_articles/get_tv_clips return continuationWindows instead of a dead-end cap notice (#21)"
|
|
3
|
+
breaking: false
|
|
4
|
+
security: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 0.2.5 — 2026-07-15
|
|
8
|
+
|
|
9
|
+
## Added
|
|
10
|
+
|
|
11
|
+
- **`gdelt_get_coverage_breakdown`** — optional `series: string[]` input returns each named label complete under a new `selectedSeries` output, alongside the existing top-10 overview. New optional `otherSeriesLabels` output names every series folded into "Other", ranked by volume. An unmatched label throws `unknown_series`, listing the labels actually available ([#20](https://github.com/cyanheads/gdelt-mcp-server/issues/20)).
|
|
12
|
+
- **`gdelt_get_coverage_timeline`** — optional `points: string[]` input renders a named timestep's complete article list in the text surface, echoed in the new optional `expandedPoints` output. An unmatched date throws `unknown_point`, listing the timeline's actual dates ([#24](https://github.com/cyanheads/gdelt-mcp-server/issues/24)).
|
|
13
|
+
- **`gdelt_search_articles`, `gdelt_get_tv_clips`** — new optional `continuationWindows` enrichment field returns the queried `startDatetime`/`endDatetime` window halved once `maxRecords` is at its ceiling (250 / 3,000), ready to re-query. Halves overlap by one second so a record timestamped exactly on the seam is never silently dropped — de-duplicate by `url` / `archiveUrl` on reassembly. Shared window-partition helpers (`resolveEffectiveWindow`, `splitWindow`, `planWindowContinuation`) added to `src/mcp-server/tools/date-range.ts` ([#21](https://github.com/cyanheads/gdelt-mcp-server/issues/21)).
|
|
14
|
+
|
|
15
|
+
## Changed
|
|
16
|
+
|
|
17
|
+
- **`gdelt_get_coverage_timeline`** — capped article rendering now states how many links it showed next to the true count (`"8 articles, 3 shown"`), instead of an unqualified count; a timestep whose articles all fit claims nothing withheld ([#24](https://github.com/cyanheads/gdelt-mcp-server/issues/24)).
|
|
18
|
+
|
|
19
|
+
## Fixed
|
|
20
|
+
|
|
21
|
+
- **`gdelt_search_articles`, `gdelt_get_tv_clips`** — the cap-hit notice no longer tells the caller to raise `maxRecords` when it is already at its ceiling; it now states the ceiling is GDELT's own and points to `continuationWindows` ([#21](https://github.com/cyanheads/gdelt-mcp-server/issues/21)).
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Shared date-range
|
|
3
|
-
*
|
|
4
|
-
* and the
|
|
2
|
+
* @fileoverview Shared date-range handling for the GDELT tools that accept an explicit
|
|
3
|
+
* startDatetime/endDatetime window — the YYYYMMDDHHMMSS field pattern, the both-or-neither
|
|
4
|
+
* pairing predicate, and the window partitioning the record-cap tools hand back as a
|
|
5
|
+
* continuation contract.
|
|
5
6
|
* @module mcp-server/tools/date-range
|
|
6
7
|
*/
|
|
7
8
|
/**
|
|
@@ -11,6 +12,57 @@
|
|
|
11
12
|
* Schema as `pattern`, letting a caller see the constraint before it calls.
|
|
12
13
|
*/
|
|
13
14
|
export declare const GDELT_DATETIME_PATTERN: RegExp;
|
|
15
|
+
/** A GDELT query window, both boundaries in the 14-digit YYYYMMDDHHMMSS wire format. */
|
|
16
|
+
export type GdeltWindow = {
|
|
17
|
+
startDatetime: string;
|
|
18
|
+
endDatetime: string;
|
|
19
|
+
};
|
|
20
|
+
/** Format a Date as GDELT's 14-digit YYYYMMDDHHMMSS wire format (UTC). */
|
|
21
|
+
export declare function toGdeltDatetime(date: Date): string;
|
|
22
|
+
/**
|
|
23
|
+
* The window a call actually ran against, in GDELT's wire format.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `applyTimeRange()`'s precedence: an explicit boundary pair wins, a timespan
|
|
26
|
+
* is resolved against now, and a call that pinned neither gets `undefined` rather than
|
|
27
|
+
* a guessed window — GDELT's own default is not something this server can observe.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveEffectiveWindow(args: {
|
|
30
|
+
timespan?: string | undefined;
|
|
31
|
+
startDatetime?: string | undefined;
|
|
32
|
+
endDatetime?: string | undefined;
|
|
33
|
+
}): GdeltWindow | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Split a window into two halves whose union covers it exactly, or `undefined` when it
|
|
36
|
+
* is already too narrow to divide.
|
|
37
|
+
*
|
|
38
|
+
* The second half deliberately **overlaps** the first by one second rather than resuming
|
|
39
|
+
* at the shared midpoint. GDELT documents both boundaries as exclusive — STARTDATETIME
|
|
40
|
+
* considers "only articles published *after* this date/time stamp" and ENDDATETIME "only
|
|
41
|
+
* articles published *before*" it (DOC 2.0 and TV 2.0 API docs alike) — so halves that
|
|
42
|
+
* merely touch at the midpoint would drop every record timestamped exactly there, silently.
|
|
43
|
+
*
|
|
44
|
+
* Reaching back one second closes that seam: under the documented exclusive reading the
|
|
45
|
+
* two halves tile the original window with no gap and no repeat, and if the boundaries
|
|
46
|
+
* turn out to behave inclusively instead, the overlap costs at most two seconds of
|
|
47
|
+
* duplicates — which a caller can see and de-duplicate. Gap-free either way.
|
|
48
|
+
*/
|
|
49
|
+
export declare function splitWindow(window: GdeltWindow): [GdeltWindow, GdeltWindow] | undefined;
|
|
50
|
+
/** The next-call windows and the prose explaining them, for a record cap at its ceiling. */
|
|
51
|
+
export type WindowContinuation = {
|
|
52
|
+
windows?: [GdeltWindow, GdeltWindow];
|
|
53
|
+
guidance: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* How a caller retrieves records left behind once `maxRecords` is already at its ceiling.
|
|
57
|
+
*
|
|
58
|
+
* GDELT exposes no offset or cursor, so narrowing the time window is the only lever, and
|
|
59
|
+
* each outcome is stated rather than implied: halves to re-query when the window divides,
|
|
60
|
+
* how to pin a window when the call never set one, and — when the window is already too
|
|
61
|
+
* narrow to divide — that the remaining records are simply unreachable.
|
|
62
|
+
*
|
|
63
|
+
* Callers own the record-noun prose; this covers only the window reasoning both tools share.
|
|
64
|
+
*/
|
|
65
|
+
export declare function planWindowContinuation(window: GdeltWindow | undefined): WindowContinuation;
|
|
14
66
|
/**
|
|
15
67
|
* True when exactly one of the two boundaries is present.
|
|
16
68
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"date-range.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"date-range.d.ts","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,QAAa,CAAC;AAEjD,wFAAwF;AACxF,MAAM,MAAM,WAAW,GAAG;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAaF,0EAA0E;AAC1E,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAElD;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAClC,GAAG,WAAW,GAAG,SAAS,CAc1B;AASD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,CAAC,WAAW,EAAE,WAAW,CAAC,GAAG,SAAS,CAcvF;AAED,4FAA4F;AAC5F,MAAM,MAAM,kBAAkB,GAAG;IAC/B,OAAO,CAAC,EAAE,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;IACrC,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,GAAG,kBAAkB,CA4B1F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAEzF"}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Shared date-range
|
|
3
|
-
*
|
|
4
|
-
* and the
|
|
2
|
+
* @fileoverview Shared date-range handling for the GDELT tools that accept an explicit
|
|
3
|
+
* startDatetime/endDatetime window — the YYYYMMDDHHMMSS field pattern, the both-or-neither
|
|
4
|
+
* pairing predicate, and the window partitioning the record-cap tools hand back as a
|
|
5
|
+
* continuation contract.
|
|
5
6
|
* @module mcp-server/tools/date-range
|
|
6
7
|
*/
|
|
8
|
+
import { resolveTimespan } from '../../services/gdelt/gdelt-fetch.js';
|
|
7
9
|
/**
|
|
8
10
|
* GDELT's datetime wire format: exactly 14 digits, YYYYMMDDHHMMSS, no separators.
|
|
9
11
|
*
|
|
@@ -11,6 +13,108 @@
|
|
|
11
13
|
* Schema as `pattern`, letting a caller see the constraint before it calls.
|
|
12
14
|
*/
|
|
13
15
|
export const GDELT_DATETIME_PATTERN = /^\d{14}$/;
|
|
16
|
+
/** GDELT's datetime resolution — the narrowest window a caller can express. */
|
|
17
|
+
const GDELT_RESOLUTION_MS = 1000;
|
|
18
|
+
/** Parse a 14-digit YYYYMMDDHHMMSS string as UTC. Invalid calendar values yield an Invalid Date. */
|
|
19
|
+
function parseGdeltDatetime(value) {
|
|
20
|
+
return new Date(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` +
|
|
21
|
+
`T${value.slice(8, 10)}:${value.slice(10, 12)}:${value.slice(12, 14)}Z`);
|
|
22
|
+
}
|
|
23
|
+
/** Format a Date as GDELT's 14-digit YYYYMMDDHHMMSS wire format (UTC). */
|
|
24
|
+
export function toGdeltDatetime(date) {
|
|
25
|
+
return date.toISOString().slice(0, 19).replace(/[-:T]/g, '');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The window a call actually ran against, in GDELT's wire format.
|
|
29
|
+
*
|
|
30
|
+
* Mirrors `applyTimeRange()`'s precedence: an explicit boundary pair wins, a timespan
|
|
31
|
+
* is resolved against now, and a call that pinned neither gets `undefined` rather than
|
|
32
|
+
* a guessed window — GDELT's own default is not something this server can observe.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveEffectiveWindow(args) {
|
|
35
|
+
if (args.startDatetime && args.endDatetime) {
|
|
36
|
+
return { startDatetime: args.startDatetime, endDatetime: args.endDatetime };
|
|
37
|
+
}
|
|
38
|
+
if (args.timespan) {
|
|
39
|
+
const range = resolveTimespan(args.timespan);
|
|
40
|
+
if (range) {
|
|
41
|
+
return {
|
|
42
|
+
startDatetime: toGdeltDatetime(range.start),
|
|
43
|
+
endDatetime: toGdeltDatetime(range.end),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Smallest span that still divides into two strictly-narrower halves. The second half
|
|
51
|
+
* reaches back a second to cover the seam, so it costs `span - floor(span/2) + 1`
|
|
52
|
+
* seconds — only under four does that stop shrinking.
|
|
53
|
+
*/
|
|
54
|
+
const MIN_SPLITTABLE_MS = 4 * GDELT_RESOLUTION_MS;
|
|
55
|
+
/**
|
|
56
|
+
* Split a window into two halves whose union covers it exactly, or `undefined` when it
|
|
57
|
+
* is already too narrow to divide.
|
|
58
|
+
*
|
|
59
|
+
* The second half deliberately **overlaps** the first by one second rather than resuming
|
|
60
|
+
* at the shared midpoint. GDELT documents both boundaries as exclusive — STARTDATETIME
|
|
61
|
+
* considers "only articles published *after* this date/time stamp" and ENDDATETIME "only
|
|
62
|
+
* articles published *before*" it (DOC 2.0 and TV 2.0 API docs alike) — so halves that
|
|
63
|
+
* merely touch at the midpoint would drop every record timestamped exactly there, silently.
|
|
64
|
+
*
|
|
65
|
+
* Reaching back one second closes that seam: under the documented exclusive reading the
|
|
66
|
+
* two halves tile the original window with no gap and no repeat, and if the boundaries
|
|
67
|
+
* turn out to behave inclusively instead, the overlap costs at most two seconds of
|
|
68
|
+
* duplicates — which a caller can see and de-duplicate. Gap-free either way.
|
|
69
|
+
*/
|
|
70
|
+
export function splitWindow(window) {
|
|
71
|
+
const start = parseGdeltDatetime(window.startDatetime);
|
|
72
|
+
const spanMs = parseGdeltDatetime(window.endDatetime).getTime() - start.getTime();
|
|
73
|
+
if (!Number.isFinite(spanMs) || spanMs < MIN_SPLITTABLE_MS)
|
|
74
|
+
return;
|
|
75
|
+
const midpointMs = start.getTime() + Math.floor(spanMs / 2 / GDELT_RESOLUTION_MS) * GDELT_RESOLUTION_MS;
|
|
76
|
+
return [
|
|
77
|
+
{ startDatetime: window.startDatetime, endDatetime: toGdeltDatetime(new Date(midpointMs)) },
|
|
78
|
+
{
|
|
79
|
+
startDatetime: toGdeltDatetime(new Date(midpointMs - GDELT_RESOLUTION_MS)),
|
|
80
|
+
endDatetime: window.endDatetime,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* How a caller retrieves records left behind once `maxRecords` is already at its ceiling.
|
|
86
|
+
*
|
|
87
|
+
* GDELT exposes no offset or cursor, so narrowing the time window is the only lever, and
|
|
88
|
+
* each outcome is stated rather than implied: halves to re-query when the window divides,
|
|
89
|
+
* how to pin a window when the call never set one, and — when the window is already too
|
|
90
|
+
* narrow to divide — that the remaining records are simply unreachable.
|
|
91
|
+
*
|
|
92
|
+
* Callers own the record-noun prose; this covers only the window reasoning both tools share.
|
|
93
|
+
*/
|
|
94
|
+
export function planWindowContinuation(window) {
|
|
95
|
+
if (!window) {
|
|
96
|
+
return {
|
|
97
|
+
guidance: 'GDELT exposes no offset or cursor, so narrowing the time window is the only way to reach the rest: ' +
|
|
98
|
+
'pin one with startDatetime/endDatetime (or a timespan), then re-run this query against successively narrower halves of it.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const windows = splitWindow(window);
|
|
102
|
+
if (!windows) {
|
|
103
|
+
return {
|
|
104
|
+
guidance: `The window ${window.startDatetime}–${window.endDatetime} is already too narrow to divide at GDELT's ` +
|
|
105
|
+
'one-second resolution, so the records past this cap are not retrievable through this API.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const [first, second] = windows;
|
|
109
|
+
return {
|
|
110
|
+
windows,
|
|
111
|
+
guidance: 'GDELT exposes no offset or cursor. Re-run this query unchanged against each half of the current window — ' +
|
|
112
|
+
`${first.startDatetime}–${first.endDatetime}, then ${second.startDatetime}–${second.endDatetime} ` +
|
|
113
|
+
'(both echoed in continuationWindows) — and split a half again if it also hits the cap. ' +
|
|
114
|
+
'The halves overlap by one second so nothing falls through the seam, so a record on that second can ' +
|
|
115
|
+
'appear in both: de-duplicate on re-assembly.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
14
118
|
/**
|
|
15
119
|
* True when exactly one of the two boundaries is present.
|
|
16
120
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"date-range.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"date-range.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/date-range.ts"],"names":[],"mappings":"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"}
|
|
@@ -14,6 +14,7 @@ export declare const gdeltGetCoverageBreakdown: import("@cyanheads/mcp-ts-core")
|
|
|
14
14
|
timespan: z.ZodOptional<z.ZodString>;
|
|
15
15
|
startDatetime: z.ZodOptional<z.ZodString>;
|
|
16
16
|
endDatetime: z.ZodOptional<z.ZodString>;
|
|
17
|
+
series: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
18
|
}, z.core.$strip>, z.ZodObject<{
|
|
18
19
|
dateResolution: z.ZodEnum<{
|
|
19
20
|
hour: "hour";
|
|
@@ -30,11 +31,24 @@ export declare const gdeltGetCoverageBreakdown: import("@cyanheads/mcp-ts-core")
|
|
|
30
31
|
date: z.ZodString;
|
|
31
32
|
value: z.ZodNumber;
|
|
32
33
|
}, z.core.$strip>>>;
|
|
34
|
+
otherSeriesLabels: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
35
|
+
selectedSeries: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
36
|
+
label: z.ZodString;
|
|
37
|
+
data: z.ZodArray<z.ZodObject<{
|
|
38
|
+
date: z.ZodString;
|
|
39
|
+
value: z.ZodNumber;
|
|
40
|
+
}, z.core.$strip>>;
|
|
41
|
+
}, z.core.$strip>>>;
|
|
33
42
|
}, z.core.$strip>, readonly [{
|
|
34
43
|
readonly reason: "no_breakdown_data";
|
|
35
44
|
readonly code: JsonRpcErrorCode.NotFound;
|
|
36
45
|
readonly when: "No breakdown data returned for the query.";
|
|
37
46
|
readonly recovery: "Broaden the query, extend the timespan, or verify the query operators are correct.";
|
|
47
|
+
}, {
|
|
48
|
+
readonly reason: "unknown_series";
|
|
49
|
+
readonly code: JsonRpcErrorCode.NotFound;
|
|
50
|
+
readonly when: "A label passed in the series input matches no series in this breakdown.";
|
|
51
|
+
readonly recovery: "Read the labels listed in the error and retry series with exact matches from that list.";
|
|
38
52
|
}, {
|
|
39
53
|
readonly reason: "invalid_date_range";
|
|
40
54
|
readonly code: JsonRpcErrorCode.ValidationError;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"get-coverage-breakdown.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-coverage-breakdown.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;
|
|
1
|
+
{"version":3,"file":"get-coverage-breakdown.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/get-coverage-breakdown.tool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAkDjE,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsUpC,CAAC"}
|
|
@@ -10,13 +10,53 @@ import { GDELT_DATETIME_PATTERN, isUnpairedDateRange } from '../date-range.js';
|
|
|
10
10
|
import { inferDateResolution } from '../date-resolution.js';
|
|
11
11
|
/** Maximum number of series to include before aggregating the rest into "Other". */
|
|
12
12
|
const MAX_SERIES = 10;
|
|
13
|
+
/**
|
|
14
|
+
* One language or country coverage series. Shared by the ranked `topSeries` overview and
|
|
15
|
+
* the `selectedSeries` arm so a label retrieved by name carries the identical shape it
|
|
16
|
+
* would have had inside the top 10.
|
|
17
|
+
*/
|
|
18
|
+
const breakdownSeriesSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
label: z.string().describe('Series label (language name or country name).'),
|
|
21
|
+
data: z
|
|
22
|
+
.array(z
|
|
23
|
+
.object({
|
|
24
|
+
date: z.string().describe('Timestep in ISO 8601 format.'),
|
|
25
|
+
value: z
|
|
26
|
+
.number()
|
|
27
|
+
.describe("Normalized coverage volume at this timestep — the topic's share of this " +
|
|
28
|
+
"source's media output, not an absolute article count."),
|
|
29
|
+
})
|
|
30
|
+
.describe('A single data point for this series.'))
|
|
31
|
+
.describe('Time-ordered data points for this series.'),
|
|
32
|
+
})
|
|
33
|
+
.describe('A single language or country coverage series.');
|
|
34
|
+
/** Render one series in full — header, total, peak, and every data point. */
|
|
35
|
+
function renderSeries(series) {
|
|
36
|
+
const total = series.data.reduce((sum, d) => sum + d.value, 0);
|
|
37
|
+
const peak = series.data.reduce((max, d) => (d.value > max.value ? d : max), series.data[0] ?? { date: '', value: 0 });
|
|
38
|
+
const lines = [
|
|
39
|
+
`\n### ${series.label} (total: ${total.toFixed(2)})`,
|
|
40
|
+
`Data points: ${series.data.length}`,
|
|
41
|
+
];
|
|
42
|
+
if (peak.date)
|
|
43
|
+
lines.push(`Peak: ${peak.value.toFixed(3)} at ${peak.date}`);
|
|
44
|
+
for (const d of series.data)
|
|
45
|
+
lines.push(`- ${d.date}: ${d.value.toFixed(3)}`);
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
13
48
|
export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
14
49
|
title: 'Get GDELT Coverage Breakdown',
|
|
15
50
|
description: 'Break down news coverage volume over time by source language or source country, returning a ' +
|
|
16
51
|
'multi-series time series (one series per language or country). ' +
|
|
17
52
|
'Shows which countries or languages drove early vs. late coverage — useful for tracing how a ' +
|
|
18
53
|
'story propagated geographically or across language communities. ' +
|
|
19
|
-
'Returns up to 10 series by total volume
|
|
54
|
+
'Returns up to 10 series by total volume and aggregates the rest into an "Other" bucket, naming ' +
|
|
55
|
+
'every series it folded in there under otherSeriesLabels — pass any of those labels back as the ' +
|
|
56
|
+
'series input to get that series complete, ranked or not. ' +
|
|
57
|
+
"Values are normalized: each point is the topic's share of media output, not an absolute article count. " +
|
|
58
|
+
'Small media markets with concentrated coverage therefore rank above large markets with diverse output — ' +
|
|
59
|
+
"a high value means the topic dominated that source's coverage, not that it published the most articles. " +
|
|
20
60
|
'Use breakdownBy "country" with the signal-detection chain to map geographic attention, ' +
|
|
21
61
|
'or "language" to detect non-English media surges.',
|
|
22
62
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
@@ -27,6 +67,12 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
|
27
67
|
when: 'No breakdown data returned for the query.',
|
|
28
68
|
recovery: 'Broaden the query, extend the timespan, or verify the query operators are correct.',
|
|
29
69
|
},
|
|
70
|
+
{
|
|
71
|
+
reason: 'unknown_series',
|
|
72
|
+
code: JsonRpcErrorCode.NotFound,
|
|
73
|
+
when: 'A label passed in the series input matches no series in this breakdown.',
|
|
74
|
+
recovery: 'Read the labels listed in the error and retry series with exact matches from that list.',
|
|
75
|
+
},
|
|
30
76
|
{
|
|
31
77
|
reason: 'invalid_date_range',
|
|
32
78
|
code: JsonRpcErrorCode.ValidationError,
|
|
@@ -74,33 +120,41 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
|
74
120
|
.optional()
|
|
75
121
|
.describe('End datetime in GDELT format YYYYMMDDHHMMSS — exactly 14 digits, no separators ' +
|
|
76
122
|
'(e.g. 20240131235959). Must pair with startDatetime; supplying only one of the two is rejected.'),
|
|
123
|
+
series: z
|
|
124
|
+
.array(z.string())
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('Exact series labels to additionally return in full, e.g. ["Portuguese", "Vietnamese"]. ' +
|
|
127
|
+
'Take them verbatim from otherSeriesLabels (the series folded into "Other") or topSeries[].label ' +
|
|
128
|
+
'in a response, or from the label list an unknown_series error prints. Each one comes back complete ' +
|
|
129
|
+
'under selectedSeries, on top of the usual top-10 overview; a label that matches nothing is rejected ' +
|
|
130
|
+
'rather than silently skipped. Omit to get the overview alone.'),
|
|
77
131
|
}),
|
|
78
132
|
output: z.object({
|
|
79
133
|
dateResolution: z.enum(['hour', 'day']).describe('Temporal resolution of data points.'),
|
|
80
|
-
topSeries: z
|
|
81
|
-
.array(z
|
|
82
|
-
.object({
|
|
83
|
-
label: z.string().describe('Series label (language name or country name).'),
|
|
84
|
-
data: z
|
|
85
|
-
.array(z
|
|
86
|
-
.object({
|
|
87
|
-
date: z.string().describe('Timestep in ISO 8601 format.'),
|
|
88
|
-
value: z.number().describe('Normalized coverage volume at this timestep.'),
|
|
89
|
-
})
|
|
90
|
-
.describe('A single data point for this series.'))
|
|
91
|
-
.describe('Time-ordered data points for this series.'),
|
|
92
|
-
})
|
|
93
|
-
.describe('A single language or country coverage series.'))
|
|
94
|
-
.describe('Top 10 series by total coverage volume.'),
|
|
134
|
+
topSeries: z.array(breakdownSeriesSchema).describe('Top 10 series by total coverage volume.'),
|
|
95
135
|
otherAggregated: z
|
|
96
136
|
.array(z
|
|
97
137
|
.object({
|
|
98
138
|
date: z.string().describe('Timestep in ISO 8601 format.'),
|
|
99
|
-
value: z
|
|
139
|
+
value: z
|
|
140
|
+
.number()
|
|
141
|
+
.describe('Aggregated normalized coverage volume for all remaining series — a share of ' +
|
|
142
|
+
'media output, not an absolute article count.'),
|
|
100
143
|
})
|
|
101
144
|
.describe('A single aggregated data point for the "Other" bucket.'))
|
|
102
145
|
.optional()
|
|
103
146
|
.describe('Combined time series for all series beyond the top 10. Omitted when all series fit.'),
|
|
147
|
+
otherSeriesLabels: z
|
|
148
|
+
.array(z.string())
|
|
149
|
+
.optional()
|
|
150
|
+
.describe('Label of every series folded into otherAggregated, ranked by total volume — the identities the ' +
|
|
151
|
+
'"Other" bucket would otherwise dissolve. Pass any of them to the series input to retrieve that ' +
|
|
152
|
+
"series' complete data. Omitted when all series fit in the top 10."),
|
|
153
|
+
selectedSeries: z
|
|
154
|
+
.array(breakdownSeriesSchema)
|
|
155
|
+
.optional()
|
|
156
|
+
.describe('Complete, untruncated time series for each label requested via the series input, in the order ' +
|
|
157
|
+
'requested. Omitted when series was not supplied.'),
|
|
104
158
|
}),
|
|
105
159
|
// Agent-facing context — query echo, breakdown dimension, total series count, and notice on empty results.
|
|
106
160
|
// Reaches structuredContent and content[] automatically; never in the domain return.
|
|
@@ -160,6 +214,7 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
|
160
214
|
const topSeries = sorted.slice(0, MAX_SERIES).map(({ total: _, ...s }) => s);
|
|
161
215
|
const remainingSeries = sorted.slice(MAX_SERIES);
|
|
162
216
|
let otherAggregated;
|
|
217
|
+
let otherSeriesLabels;
|
|
163
218
|
if (remainingSeries.length > 0) {
|
|
164
219
|
const dateMap = new Map();
|
|
165
220
|
for (const s of remainingSeries) {
|
|
@@ -170,6 +225,31 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
|
170
225
|
otherAggregated = Array.from(dateMap.entries())
|
|
171
226
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
172
227
|
.map(([date, value]) => ({ date, value }));
|
|
228
|
+
otherSeriesLabels = remainingSeries.map((s) => s.label);
|
|
229
|
+
}
|
|
230
|
+
// Label selection re-filters the same complete upstream set the ranking sliced, so a
|
|
231
|
+
// series named on a follow-up call reconstructs statelessly from query + dimension + label.
|
|
232
|
+
const selectedSeries = [];
|
|
233
|
+
if (input.series?.length) {
|
|
234
|
+
const byLabel = new Map(allSeries.map((s) => [s.label, s]));
|
|
235
|
+
const unknown = [];
|
|
236
|
+
for (const label of input.series) {
|
|
237
|
+
const match = byLabel.get(label);
|
|
238
|
+
if (match)
|
|
239
|
+
selectedSeries.push(match);
|
|
240
|
+
else
|
|
241
|
+
unknown.push(label);
|
|
242
|
+
}
|
|
243
|
+
if (unknown.length > 0) {
|
|
244
|
+
const named = unknown.map((label) => `"${label}"`).join(', ');
|
|
245
|
+
throw ctx.fail('unknown_series', `No ${input.breakdownBy} series named ${named}`, {
|
|
246
|
+
unknownLabels: unknown,
|
|
247
|
+
recovery: {
|
|
248
|
+
hint: `This breakdown has no ${input.breakdownBy} series named ${named}. Labels are exact and ` +
|
|
249
|
+
`case-sensitive. Available for "${input.query}": ${sorted.map((s) => s.label).join(', ')}.`,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
173
253
|
}
|
|
174
254
|
const allDates = topSeries.flatMap((s) => s.data.map((d) => d.date));
|
|
175
255
|
const dateResolution = inferDateResolution(allDates);
|
|
@@ -183,26 +263,31 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
|
183
263
|
ctx.log.info('gdelt_get_coverage_breakdown completed', {
|
|
184
264
|
totalSeries: allSeries.length,
|
|
185
265
|
topSeriesCount: topSeries.length,
|
|
266
|
+
selectedSeriesCount: selectedSeries.length,
|
|
186
267
|
});
|
|
187
268
|
return {
|
|
188
269
|
dateResolution,
|
|
189
270
|
topSeries,
|
|
190
271
|
...(otherAggregated ? { otherAggregated } : {}),
|
|
272
|
+
...(otherSeriesLabels ? { otherSeriesLabels } : {}),
|
|
273
|
+
...(selectedSeries.length > 0 ? { selectedSeries } : {}),
|
|
191
274
|
};
|
|
192
275
|
},
|
|
276
|
+
/**
|
|
277
|
+
* Every arm renders on its own field presence, never as an `if`/`else if` chain — the
|
|
278
|
+
* format-parity linter populates all optional fields at once in its synthetic sample,
|
|
279
|
+
* so a mutually-exclusive branch would leave the untaken arm unverified.
|
|
280
|
+
*/
|
|
193
281
|
format: (result) => {
|
|
194
282
|
const lines = [
|
|
195
283
|
`## GDELT Coverage Breakdown`,
|
|
196
284
|
`**Date Resolution:** ${result.dateResolution}`,
|
|
285
|
+
`**Values:** normalized — each value is the topic's share of that source's media output, ` +
|
|
286
|
+
`not an article count. Small media markets with concentrated coverage rank above large ` +
|
|
287
|
+
`markets with diverse output.`,
|
|
197
288
|
];
|
|
198
|
-
for (const s of result.topSeries)
|
|
199
|
-
|
|
200
|
-
lines.push(`\n### ${s.label} (total: ${total.toFixed(2)})`);
|
|
201
|
-
lines.push(`Data points: ${s.data.length}`);
|
|
202
|
-
const peak = s.data.reduce((max, d) => (d.value > max.value ? d : max), s.data[0] ?? { date: '', value: 0 });
|
|
203
|
-
if (peak.date)
|
|
204
|
-
lines.push(`Peak: ${peak.value.toFixed(3)} at ${peak.date}`);
|
|
205
|
-
}
|
|
289
|
+
for (const s of result.topSeries)
|
|
290
|
+
lines.push(...renderSeries(s));
|
|
206
291
|
if (result.otherAggregated) {
|
|
207
292
|
const otherTotal = result.otherAggregated.reduce((sum, d) => sum + d.value, 0);
|
|
208
293
|
const otherPeak = result.otherAggregated.reduce((max, d) => (d.value > max.value ? d : max), result.otherAggregated[0] ?? { date: '', value: 0 });
|
|
@@ -210,6 +295,21 @@ export const gdeltGetCoverageBreakdown = tool('gdelt_get_coverage_breakdown', {
|
|
|
210
295
|
lines.push(`Total: ${otherTotal.toFixed(2)}`);
|
|
211
296
|
if (otherPeak.date)
|
|
212
297
|
lines.push(`Peak: ${otherPeak.value.toFixed(3)} at ${otherPeak.date}`);
|
|
298
|
+
for (const d of result.otherAggregated) {
|
|
299
|
+
lines.push(`- ${d.date}: ${d.value.toFixed(3)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (result.otherSeriesLabels?.length) {
|
|
303
|
+
lines.push(`\n### Series folded into "Other" (${result.otherSeriesLabels.length})`);
|
|
304
|
+
lines.push(`Ranked by total volume. Re-call with series: ["<label>"] to get any of them in full.`);
|
|
305
|
+
for (const label of result.otherSeriesLabels)
|
|
306
|
+
lines.push(`- ${label}`);
|
|
307
|
+
}
|
|
308
|
+
if (result.selectedSeries?.length) {
|
|
309
|
+
lines.push(`\n## Selected Series (${result.selectedSeries.length})`);
|
|
310
|
+
lines.push(`Complete series for the labels requested via the series input.`);
|
|
311
|
+
for (const s of result.selectedSeries)
|
|
312
|
+
lines.push(...renderSeries(s));
|
|
213
313
|
}
|
|
214
314
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
215
315
|
},
|