@showmeonmap/mcp 0.7.0 → 0.7.1
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/README.md +5 -2
- package/dist/nextActions.js +340 -0
- package/dist/tools.js +125 -24
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,8 @@ Ask Claude Desktop things like:
|
|
|
101
101
|
**Feedback channel (1)**: `report_blocker`.
|
|
102
102
|
**Saved workflows (3)**: `save_workflow`, `list_workflows`, `run_workflow`.
|
|
103
103
|
|
|
104
|
+
**0.7.1**: `build_map`'s `next_actions` come from the map itself (real data filters and the plan's own follow-ups, not generic suggestions); a Cloudflare error page from the backend reaches the model as a typed message with its code and Ray ID instead of raw HTML; shared maps keep the fields they paint and say when they hold a sample of the features.
|
|
105
|
+
|
|
104
106
|
**0.7.0** (everything merged since 0.6.0): the three workflow tools (with `batch` runs over a value list); `run_analysis` adds spatial join, merge/append, near and dissolve; `rationale` is optional on every tool; no tuple schemas (Claude dropped `set_camera` / `add_annotation`); fact-only tool descriptions; `export_layer_data` works on a saved map; share links store the features the map shows after `filter_layer` and its counts follow the filter; the server reports its real version; and an npm install no longer writes rationale logs into your `node_modules`.
|
|
105
107
|
|
|
106
108
|
`focus_area` (0.4.0) refines an existing map to a **named** area — no coordinates required. Pass a `layerId` plus an `areaRef` (`{kind:'corridor', street, from?, to?}`, `{kind:'place', name}`, or `{kind:'aroundIntersection', street, cross}`) and the server resolves the real geometry from OpenStreetMap, then applies one of three `mode`s: `filter` (hide non-matching), `select` (extract to a new layer), or `clip` (geometrically trim — lines are split at the boundary). The reply carries a grounded `contextAnswer` such as `"kept 37 of 150 features"`. A resolver miss returns a `clarification` with real nearby candidates rather than a guessed location.
|
|
@@ -118,14 +120,15 @@ Every tool returns a **three-channel envelope**:
|
|
|
118
120
|
"shareable_url": "http://localhost:3000/m/…",
|
|
119
121
|
"metadata": { "title": "…", "layer_count": 2, "last_updated": "…" },
|
|
120
122
|
"next_actions": [
|
|
121
|
-
{ "label": "
|
|
123
|
+
{ "label": "Show only Earthquakes with magnitude ≥ 5.3 (5 of 10 features)", "tool": "filter_layer",
|
|
124
|
+
"argsTemplate": { "layerId": "smo_usgs_earthquakes", "predicate": { "op": "gte", "field": "magnitude", "value": 5.3 } } }
|
|
122
125
|
],
|
|
123
126
|
"outOfScope": { "reason": "…" },
|
|
124
127
|
"clarification": { "question": "…", "candidates": [ … ] }
|
|
125
128
|
}
|
|
126
129
|
```
|
|
127
130
|
|
|
128
|
-
The calling agent reasons over `structured`, surfaces `shareable_url` to its end user for interactive drill-down, and uses `next_actions` as typed hints for follow-up tool calls.
|
|
131
|
+
The calling agent reasons over `structured`, surfaces `shareable_url` to its end user for interactive drill-down, and uses `next_actions` as typed hints for follow-up tool calls: each hint's `argsTemplate` is exactly the args (minus `map_id`) of the named `tool`. Hints come from the map's own data, followups and current state; an empty list means nothing useful to suggest.
|
|
129
132
|
|
|
130
133
|
For the full Op catalog and predicate grammar that the V2 tools accept, fetch the MCP resource at `showmeonmap://docs/workspace-ops-spec` — agents should not guess these shapes.
|
|
131
134
|
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build_map `next_actions` — state-aware, subject-aware follow-up hints.
|
|
3
|
+
*
|
|
4
|
+
* Until 2026-10-12 every build_map returned the same three constants
|
|
5
|
+
* ("Filter the layer to features where name is not empty", "Change the
|
|
6
|
+
* basemap to satellite", "Zoom the camera closer") whatever the map was. A
|
|
7
|
+
* live MCP test on three maps (M5+ earthquakes near Japan, California
|
|
8
|
+
* earthquakes, wildfires near Vancouver) showed all three were junk: every
|
|
9
|
+
* earthquake and fire detection HAS a name, the wildfire map was ALREADY on
|
|
10
|
+
* satellite, and the plan's own better follow-ups ("Smoke plume", "Air
|
|
11
|
+
* quality") never reached the caller. Agents learned to ignore the field.
|
|
12
|
+
*
|
|
13
|
+
* The rules this module enforces (pinned both directions in
|
|
14
|
+
* __tests__/tools/nextActions.test.ts):
|
|
15
|
+
*
|
|
16
|
+
* - A filter hint names a field that exists on the layer AND varies across
|
|
17
|
+
* its features, and its threshold/value comes from the features
|
|
18
|
+
* themselves (a numeric threshold near the observed median, re-checked so
|
|
19
|
+
* it keeps some features and drops some). Nothing is invented.
|
|
20
|
+
* - Never suggest a state the map is already in (basemap already that
|
|
21
|
+
* style, already zoomed close, already zoomed out).
|
|
22
|
+
* - Surface the plan's own `followups` (handler + companion chips) — with
|
|
23
|
+
* "this area" / "here" rewritten to the place in the caller's query,
|
|
24
|
+
* because a fresh build_map has no "this area". When the place cannot be
|
|
25
|
+
* read back out of the query, a region-neutral follow-up is dropped
|
|
26
|
+
* rather than sent somewhere wrong.
|
|
27
|
+
* - Nothing useful → `[]`. An empty list is a correct answer.
|
|
28
|
+
*
|
|
29
|
+
* Each hint is `{ label, argsTemplate, tool }`: `argsTemplate` is exactly
|
|
30
|
+
* the args (minus `map_id`) of `tool`, so it passes that tool's strict
|
|
31
|
+
* input validation as-is — `filter_layer` gets `{layerId, predicate}`,
|
|
32
|
+
* `mutate_map` gets `{instruction}`, `build_map` gets `{query}`.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_NEXT_ACTIONS = 3;
|
|
35
|
+
const MAX_FILTER_HINTS = 2;
|
|
36
|
+
const MAX_FOLLOWUP_HINTS = 2;
|
|
37
|
+
// Measures worth thresholding when the layer itself does not name one
|
|
38
|
+
// (valueField / filterConfig always qualify). Keys are normalized
|
|
39
|
+
// (lower-case, alphanumerics only) so "Size (ha)" matches "sizeha".
|
|
40
|
+
const KNOWN_MEASURES = [
|
|
41
|
+
'magnitude', 'mag', 'frp', 'brightness', 'sizeha', 'hectares', 'acres', 'areaha',
|
|
42
|
+
'containment', 'depth', 'significance', 'sig', 'population', 'pop',
|
|
43
|
+
];
|
|
44
|
+
// Measures where SMALLER is the notable end (shallow earthquakes do the damage).
|
|
45
|
+
const LOWER_IS_NOTABLE = new Set(['depth']);
|
|
46
|
+
// Categorical fields worth filtering, and the values that are the story when present.
|
|
47
|
+
const KNOWN_CATEGORICAL = new Set([
|
|
48
|
+
'status', 'confidence', 'severity', 'alert', 'alertlevel', 'urgency', 'certainty', 'firestatus', 'stage',
|
|
49
|
+
]);
|
|
50
|
+
const SALIENT_VALUES = [
|
|
51
|
+
'out of control', 'active', 'high', 'h', 'extreme', 'severe', 'major', 'warning', 'emergency', 'red',
|
|
52
|
+
];
|
|
53
|
+
// Never a measure / filter dimension: coordinates (a coordinate column is
|
|
54
|
+
// never a measure — CLAUDE.md), clocks, ids, links, free text.
|
|
55
|
+
const EXCLUDED_FIELD = new RegExp('^(lat|latitude|lng|lon|long|longitude|x|y|easting|northing|' +
|
|
56
|
+
'time|timestamp|date|datetime|updated|hoursago|epoch|' +
|
|
57
|
+
'id|fid|objectid|gid|eventid|code|url|link|details|' +
|
|
58
|
+
'name|title|place|label|description|incident)$');
|
|
59
|
+
function normKey(k) {
|
|
60
|
+
return k.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
61
|
+
}
|
|
62
|
+
function isInternalField(k) {
|
|
63
|
+
return k.startsWith('_');
|
|
64
|
+
}
|
|
65
|
+
function featuresOf(layer) {
|
|
66
|
+
const data = layer.source?.data;
|
|
67
|
+
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features))
|
|
68
|
+
return [];
|
|
69
|
+
return data.features.map((f) => (f && typeof f.properties === 'object' && f.properties ? f.properties : {}));
|
|
70
|
+
}
|
|
71
|
+
function layerName(layer) {
|
|
72
|
+
return (layer.title ?? layer.id ?? 'the layer').trim();
|
|
73
|
+
}
|
|
74
|
+
/** A threshold that reads cleanly AND still splits the observed values. */
|
|
75
|
+
function niceThreshold(values, lowerIsNotable) {
|
|
76
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
77
|
+
const n = sorted.length;
|
|
78
|
+
const median = n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
|
|
79
|
+
const allInt = sorted.every((v) => Number.isInteger(v));
|
|
80
|
+
const range = sorted[n - 1] - sorted[0];
|
|
81
|
+
const candidates = [];
|
|
82
|
+
if (allInt)
|
|
83
|
+
candidates.push(Math.round(median));
|
|
84
|
+
else if (range <= 20)
|
|
85
|
+
candidates.push(Math.round(median * 10) / 10);
|
|
86
|
+
else {
|
|
87
|
+
const mag = Math.pow(10, Math.floor(Math.log10(Math.abs(median) || 1)) - 1);
|
|
88
|
+
candidates.push(Math.round(median / mag) * mag);
|
|
89
|
+
}
|
|
90
|
+
candidates.push(median);
|
|
91
|
+
for (const raw of candidates) {
|
|
92
|
+
const t = Number(raw.toPrecision(6));
|
|
93
|
+
const kept = sorted.filter((v) => (lowerIsNotable ? v <= t : v >= t)).length;
|
|
94
|
+
if (kept > 0 && kept < n)
|
|
95
|
+
return { t, kept };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
function collectFieldStats(props) {
|
|
100
|
+
const stats = new Map();
|
|
101
|
+
for (const p of props) {
|
|
102
|
+
for (const [k, v] of Object.entries(p)) {
|
|
103
|
+
if (v === null || v === undefined || v === '')
|
|
104
|
+
continue;
|
|
105
|
+
let s = stats.get(k);
|
|
106
|
+
if (!s) {
|
|
107
|
+
s = { key: k, numeric: [], strings: new Map() };
|
|
108
|
+
stats.set(k, s);
|
|
109
|
+
}
|
|
110
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
111
|
+
s.numeric.push(v);
|
|
112
|
+
else if (typeof v === 'string')
|
|
113
|
+
s.strings.set(v, (s.strings.get(v) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return stats;
|
|
117
|
+
}
|
|
118
|
+
/** Data-derived filter_layer hints for one layer, best first. */
|
|
119
|
+
function filterHintsForLayer(layer) {
|
|
120
|
+
const layerId = layer.id;
|
|
121
|
+
if (!layerId)
|
|
122
|
+
return [];
|
|
123
|
+
const props = featuresOf(layer);
|
|
124
|
+
const n = props.length;
|
|
125
|
+
if (n < 3)
|
|
126
|
+
return [];
|
|
127
|
+
const stats = collectFieldStats(props);
|
|
128
|
+
const name = layerName(layer);
|
|
129
|
+
const preferred = new Set();
|
|
130
|
+
if (layer.viz?.valueField)
|
|
131
|
+
preferred.add(layer.viz.valueField);
|
|
132
|
+
for (const fc of layer.filterConfig ?? [])
|
|
133
|
+
if (fc.field)
|
|
134
|
+
preferred.add(fc.field);
|
|
135
|
+
const eligible = (k) => !isInternalField(k) && !EXCLUDED_FIELD.test(normKey(k));
|
|
136
|
+
// ── numeric thresholds ────────────────────────────────────────────────
|
|
137
|
+
const numericFields = [...stats.values()]
|
|
138
|
+
.filter((s) => eligible(s.key))
|
|
139
|
+
.filter((s) => s.numeric.length >= Math.ceil(n * 0.8))
|
|
140
|
+
.filter((s) => new Set(s.numeric).size >= 3)
|
|
141
|
+
.filter((s) => preferred.has(s.key) || KNOWN_MEASURES.includes(normKey(s.key)))
|
|
142
|
+
.sort((a, b) => rank(a.key) - rank(b.key));
|
|
143
|
+
function rank(k) {
|
|
144
|
+
if (layer.viz?.valueField === k)
|
|
145
|
+
return -2;
|
|
146
|
+
if (preferred.has(k))
|
|
147
|
+
return -1;
|
|
148
|
+
return KNOWN_MEASURES.indexOf(normKey(k));
|
|
149
|
+
}
|
|
150
|
+
const preferredNumeric = [];
|
|
151
|
+
const otherNumeric = [];
|
|
152
|
+
const seenSignatures = new Set();
|
|
153
|
+
for (const s of numericFields) {
|
|
154
|
+
// A synthetic duplicate (e.g. `value` = magnitude for sizing) says nothing new.
|
|
155
|
+
const signature = s.numeric.join(',');
|
|
156
|
+
if (seenSignatures.has(signature))
|
|
157
|
+
continue;
|
|
158
|
+
seenSignatures.add(signature);
|
|
159
|
+
const lower = LOWER_IS_NOTABLE.has(normKey(s.key));
|
|
160
|
+
const th = niceThreshold(s.numeric, lower);
|
|
161
|
+
if (!th)
|
|
162
|
+
continue;
|
|
163
|
+
const op = lower ? 'lte' : 'gte';
|
|
164
|
+
const sym = lower ? '≤' : '≥';
|
|
165
|
+
(preferred.has(s.key) ? preferredNumeric : otherNumeric).push({
|
|
166
|
+
label: `Show only ${name} with ${s.key} ${sym} ${th.t} (${th.kept} of ${n} features)`,
|
|
167
|
+
argsTemplate: { layerId, predicate: { op, field: s.key, value: th.t } },
|
|
168
|
+
tool: 'filter_layer',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const categorical = [];
|
|
172
|
+
// ── categorical: a salient value that some, not all, features carry ──
|
|
173
|
+
for (const s of stats.values()) {
|
|
174
|
+
if (!eligible(s.key))
|
|
175
|
+
continue;
|
|
176
|
+
const isKnown = KNOWN_CATEGORICAL.has(normKey(s.key)) ||
|
|
177
|
+
(layer.filterConfig ?? []).some((fc) => fc.field === s.key && fc.kind === 'category');
|
|
178
|
+
if (!isKnown)
|
|
179
|
+
continue;
|
|
180
|
+
const totalStrings = [...s.strings.values()].reduce((a, b) => a + b, 0);
|
|
181
|
+
if (totalStrings < Math.ceil(n * 0.8) || s.strings.size < 2 || s.strings.size > 12)
|
|
182
|
+
continue;
|
|
183
|
+
const salient = SALIENT_VALUES
|
|
184
|
+
.map((sv) => [...s.strings.keys()].find((v) => v.trim().toLowerCase() === sv))
|
|
185
|
+
.find((v) => typeof v === 'string');
|
|
186
|
+
if (!salient)
|
|
187
|
+
continue;
|
|
188
|
+
const count = s.strings.get(salient) ?? 0;
|
|
189
|
+
if (count === 0 || count === totalStrings)
|
|
190
|
+
continue;
|
|
191
|
+
categorical.push({
|
|
192
|
+
label: `Show only ${name} where ${s.key} is "${salient}" (${count} of ${n} features)`,
|
|
193
|
+
argsTemplate: { layerId, predicate: { op: 'eq', field: s.key, value: salient } },
|
|
194
|
+
tool: 'filter_layer',
|
|
195
|
+
});
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
// The layer's own encoded measure, then the story value of a status-like
|
|
199
|
+
// field, then other known measures.
|
|
200
|
+
return [...preferredNumeric, ...categorical, ...otherNumeric];
|
|
201
|
+
}
|
|
202
|
+
// ── plan follow-ups ────────────────────────────────────────────────────────
|
|
203
|
+
const TIME_WINDOW_TAIL_RE = /(?:^|\s+)(?:(?:in|over|during|for|within|from|since)\s+)?(?:the\s+)?(?:(?:this|last|past|next|recent|previous|current|coming)\s+|\d+\s+)(?:\d+\s+)?(?:hour|hours|day|days|week|weeks|month|months|year|years|decade|decades)(?:\s+ago)?$/i;
|
|
204
|
+
const SIMPLE_TIME_TAIL_RE = /\s+(?:(?:in|during|since|for)\s+)?(?:today|tonight|now|right now|recently|yesterday|(?:19|20)\d{2})$/i;
|
|
205
|
+
const PRESENTATION_TAIL_RE = /,\s*(?:sized|colou?red|shown|styled|drawn|rendered|displayed|with|using|as|on)\b.*$/i;
|
|
206
|
+
const LOCATION_PREP_RE = /\b(?:in|near|around|across|over|at|within)\s+/gi;
|
|
207
|
+
const BAD_PLACE_START = /^(?:the\s+)?(?:past|last|this|next|recent|previous|current|coming|today|tonight|least|most)\b/i;
|
|
208
|
+
/**
|
|
209
|
+
* The place a build_map query names, or null. Conservative on purpose: a
|
|
210
|
+
* wrong place is worse than no follow-up, so anything doubtful returns null.
|
|
211
|
+
* "earthquakes above magnitude 5 near Japan this month" → "Japan"
|
|
212
|
+
* "earthquakes in California in the past year" → "California"
|
|
213
|
+
* "active wildfires near Vancouver, British Columbia" → "Vancouver, British Columbia"
|
|
214
|
+
*/
|
|
215
|
+
export function placeFromQuery(query) {
|
|
216
|
+
const q = (query ?? '').trim().replace(/[.?!]+$/, '');
|
|
217
|
+
for (const m of q.matchAll(LOCATION_PREP_RE)) {
|
|
218
|
+
let place = q.slice((m.index ?? 0) + m[0].length);
|
|
219
|
+
place = place.replace(PRESENTATION_TAIL_RE, '');
|
|
220
|
+
for (let i = 0; i < 3; i++) {
|
|
221
|
+
const before = place;
|
|
222
|
+
place = place.replace(TIME_WINDOW_TAIL_RE, '').replace(SIMPLE_TIME_TAIL_RE, '').trim();
|
|
223
|
+
if (place === before)
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if (!place || place.length > 60 || /\d/.test(place) || BAD_PLACE_START.test(place))
|
|
227
|
+
continue;
|
|
228
|
+
return place;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
// Phrases that only mean something next to the map the user is looking at.
|
|
233
|
+
const CONTEXT_PHRASE_RE = /\b(?:this area|this region|the area|here|nearby|on screen|current view|neighbou?ring)\b/i;
|
|
234
|
+
function localizeMessage(message, place) {
|
|
235
|
+
if (!CONTEXT_PHRASE_RE.test(message))
|
|
236
|
+
return message;
|
|
237
|
+
if (!place)
|
|
238
|
+
return null;
|
|
239
|
+
const out = message
|
|
240
|
+
.replace(/\b(?:this area|this region|the area)\b/gi, place)
|
|
241
|
+
.replace(/\bhere\b/gi, `near ${place}`);
|
|
242
|
+
// "nearby" / "on screen" / "neighbouring" have no faithful rewrite — drop.
|
|
243
|
+
return CONTEXT_PHRASE_RE.test(out) ? null : out;
|
|
244
|
+
}
|
|
245
|
+
const MUTATION_RE = /^(?:filter|only show|show only|zoom|switch|change|set|remove|hide|colou?r|restyle|clear)\b/i;
|
|
246
|
+
const BASEMAP_STYLES = ['satellite', 'dark', 'light', 'streets', 'engineering'];
|
|
247
|
+
/** True when the hint would put the map into a state it is already in. */
|
|
248
|
+
export function isRedundantWithState(text, plan) {
|
|
249
|
+
const t = text.toLowerCase();
|
|
250
|
+
const current = plan.basemap?.style?.toLowerCase();
|
|
251
|
+
const aboutBasemap = /\b(?:basemap|base map|map style|satellite (?:view|imagery|basemap|map))\b/.test(t) ||
|
|
252
|
+
/\b(?:switch|change|set)\s+(?:the\s+)?(?:map\s+)?to\s+(?:satellite|dark|light|streets|engineering)\b/.test(t);
|
|
253
|
+
if (current && aboutBasemap) {
|
|
254
|
+
const target = BASEMAP_STYLES.find((s) => new RegExp(`\\b${s}\\b`).test(t));
|
|
255
|
+
if (target && target === current)
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
const cam = plan.camera;
|
|
259
|
+
if (cam && /\bzoom\b/.test(t)) {
|
|
260
|
+
const zoom = typeof cam.zoom === 'number' ? cam.zoom : undefined;
|
|
261
|
+
const span = cam.mode === 'fitBounds' && Array.isArray(cam.bounds)
|
|
262
|
+
? Math.max(Math.abs(cam.bounds[2] - cam.bounds[0]), Math.abs(cam.bounds[3] - cam.bounds[1]))
|
|
263
|
+
: undefined;
|
|
264
|
+
const close = (zoom !== undefined && zoom >= 12) || (span !== undefined && span <= 0.1);
|
|
265
|
+
const far = cam.mode === 'globalView' || (zoom !== undefined && zoom <= 2) || (span !== undefined && span >= 300);
|
|
266
|
+
if (/\bzoom\b(?:\s+\w+){0,3}?\s+(?:in|closer)\b/.test(t) && close)
|
|
267
|
+
return true;
|
|
268
|
+
if (/\bzoom\b(?:\s+\w+){0,3}?\s+out\b/.test(t) && far)
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
function followupHints(plan, query) {
|
|
274
|
+
const followups = Array.isArray(plan.followups) ? plan.followups : [];
|
|
275
|
+
if (!followups.length)
|
|
276
|
+
return [];
|
|
277
|
+
const place = placeFromQuery(query);
|
|
278
|
+
const hints = [];
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
for (const f of followups) {
|
|
281
|
+
const message = typeof f?.message === 'string' ? f.message.trim() : '';
|
|
282
|
+
const label = typeof f?.label === 'string' && f.label.trim() ? f.label.trim() : message;
|
|
283
|
+
if (!message)
|
|
284
|
+
continue;
|
|
285
|
+
if (isRedundantWithState(`${label} ${message}`, plan))
|
|
286
|
+
continue;
|
|
287
|
+
if (MUTATION_RE.test(message)) {
|
|
288
|
+
if (seen.has(message.toLowerCase()))
|
|
289
|
+
continue;
|
|
290
|
+
seen.add(message.toLowerCase());
|
|
291
|
+
hints.push({ label, argsTemplate: { instruction: message }, tool: 'mutate_map' });
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const localized = localizeMessage(message, place);
|
|
295
|
+
if (!localized || seen.has(localized.toLowerCase()))
|
|
296
|
+
continue;
|
|
297
|
+
seen.add(localized.toLowerCase());
|
|
298
|
+
hints.push({ label: `${label} — new map: "${localized}"`, argsTemplate: { query: localized }, tool: 'build_map' });
|
|
299
|
+
}
|
|
300
|
+
return hints;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* next_actions for a freshly built map. `plan` must be the full-resolution
|
|
304
|
+
* MapPlan the API returned (features inline), `query` the build_map query.
|
|
305
|
+
*/
|
|
306
|
+
export function buildMapNextActions(plan, query) {
|
|
307
|
+
if (!plan)
|
|
308
|
+
return [];
|
|
309
|
+
const layers = (Array.isArray(plan.layers) ? plan.layers : []);
|
|
310
|
+
const subjectLayers = layers
|
|
311
|
+
.filter((l) => l && l.role !== 'context' && !l.hideFromLegend && !l.hiddenByDefault)
|
|
312
|
+
.map((l) => ({ layer: l, count: featuresOf(l).length }))
|
|
313
|
+
.filter((x) => x.count >= 3)
|
|
314
|
+
.sort((a, b) => b.count - a.count);
|
|
315
|
+
// Best hint from each of the busiest layers first (diversity), then a
|
|
316
|
+
// second hint from the busiest layer if room remains.
|
|
317
|
+
const perLayer = subjectLayers.map((x) => filterHintsForLayer(x.layer));
|
|
318
|
+
const filters = [];
|
|
319
|
+
for (const hs of perLayer)
|
|
320
|
+
if (hs[0] && filters.length < MAX_FILTER_HINTS)
|
|
321
|
+
filters.push(hs[0]);
|
|
322
|
+
for (const hs of perLayer) {
|
|
323
|
+
for (const h of hs.slice(1))
|
|
324
|
+
if (filters.length < MAX_FILTER_HINTS)
|
|
325
|
+
filters.push(h);
|
|
326
|
+
}
|
|
327
|
+
const follow = followupHints(plan, query).slice(0, MAX_FOLLOWUP_HINTS);
|
|
328
|
+
// Best data filter, then the plan's own follow-ups, then the rest — so a
|
|
329
|
+
// map that has both surfaces both.
|
|
330
|
+
const out = [...filters.slice(0, 1), ...follow, ...filters.slice(1)];
|
|
331
|
+
return out.filter((h) => !isRedundantWithState(h.label, plan)).slice(0, MAX_NEXT_ACTIONS);
|
|
332
|
+
}
|
|
333
|
+
/** mutate_map's server-suggested strings → mutate_map hints. */
|
|
334
|
+
export function instructionHints(strings) {
|
|
335
|
+
return (strings ?? []).slice(0, MAX_NEXT_ACTIONS).map((label) => ({
|
|
336
|
+
label,
|
|
337
|
+
argsTemplate: { instruction: label },
|
|
338
|
+
tool: 'mutate_map',
|
|
339
|
+
}));
|
|
340
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import { dirname, resolve } from 'node:path';
|
|
24
24
|
import { MCP_TOOL_META, mcpRequiresConfirmation } from "./toolMeta.js";
|
|
25
25
|
import { buildAnalysisStory } from "./storyDraft.js";
|
|
26
|
+
import { buildMapNextActions, instructionHints, MAX_NEXT_ACTIONS } from "./nextActions.js";
|
|
26
27
|
import { fetchNoRedirect } from "./fetchNoRedirect.js";
|
|
27
28
|
import { BUILD_MAP_DESCRIPTION, SERVER_INSTRUCTIONS } from "./serverInstructions.js";
|
|
28
29
|
import { describeTolerance, generalizeLayers, storedBytes, WORKSPACE_CEILING } from "./generalizeGeometry.js";
|
|
@@ -252,7 +253,7 @@ function generalizedNote(generalized) {
|
|
|
252
253
|
const parts = generalized.map((g) => `${g.title ?? g.layerId} (${describeTolerance(g.toleranceDeg)})`);
|
|
253
254
|
return `Boundaries simplified to fit the conversation view: ${parts.join(', ')}. Data values are unchanged; the shareable link shows full detail until the map is edited.`;
|
|
254
255
|
}
|
|
255
|
-
function envelope(baseUrl, mapId, workspace, mapPlan,
|
|
256
|
+
function envelope(baseUrl, mapId, workspace, mapPlan, nextActions, extras = {}) {
|
|
256
257
|
const layerCount = Array.isArray(mapPlan?.layers) ? mapPlan.layers.length : 0;
|
|
257
258
|
const opsCount = Array.isArray(workspace?.opsLog) ? workspace.opsLog.length : 0;
|
|
258
259
|
return {
|
|
@@ -268,10 +269,9 @@ function envelope(baseUrl, mapId, workspace, mapPlan, nextActionStrings, extras
|
|
|
268
269
|
layer_count: layerCount,
|
|
269
270
|
last_updated: new Date().toISOString(),
|
|
270
271
|
},
|
|
271
|
-
next_actions: (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
})),
|
|
272
|
+
next_actions: (nextActions ?? []).every((a) => typeof a === 'string')
|
|
273
|
+
? instructionHints(nextActions)
|
|
274
|
+
: nextActions.slice(0, MAX_NEXT_ACTIONS),
|
|
275
275
|
...(extras.outOfScope ? { outOfScope: extras.outOfScope } : {}),
|
|
276
276
|
...(extras.clarification ? { clarification: extras.clarification } : {}),
|
|
277
277
|
...(extras.opsApplied ? { opsApplied: extras.opsApplied } : {}),
|
|
@@ -422,6 +422,30 @@ function withConfirmation(toolName, handler) {
|
|
|
422
422
|
return handler(rest, extra);
|
|
423
423
|
});
|
|
424
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* The error text a failed backend call hands the calling model.
|
|
427
|
+
*
|
|
428
|
+
* Live failure (2026-10-12): a build_map came back as Cloudflare's edge error
|
|
429
|
+
* page (the Worker died before our own handler could answer), and the first
|
|
430
|
+
* 200 characters of that HTML — `<!DOCTYPE html><!--[if lt IE 7]>…` — were all
|
|
431
|
+
* the model saw: no error code, no Ray ID to find the request in Workers Logs,
|
|
432
|
+
* no hint that one retry is the right move. An HTML body is never ours, so say
|
|
433
|
+
* what it is and keep the two identifiers worth having.
|
|
434
|
+
*/
|
|
435
|
+
export function describeErrorBody(text) {
|
|
436
|
+
if (!/^\s*(<!DOCTYPE html|<html)/i.test(text))
|
|
437
|
+
return text.slice(0, 200);
|
|
438
|
+
const code = text.match(/cf-error-code[^>]*>\s*(\d+)\s*</i)?.[1];
|
|
439
|
+
const ray = text.match(/Ray ID:?\s*(?:<[^>]+>\s*)*([0-9a-f]{12,})/i)?.[1];
|
|
440
|
+
const title = text.match(/<title>\s*([^<|]+?)\s*(?:\||<\/title>)/i)?.[1];
|
|
441
|
+
const parts = [
|
|
442
|
+
'upstream error page, not a ShowMeOnMap response',
|
|
443
|
+
code ? `Cloudflare error ${code}` : null,
|
|
444
|
+
title ?? null,
|
|
445
|
+
ray ? `Ray ID ${ray}` : null,
|
|
446
|
+
].filter(Boolean);
|
|
447
|
+
return `${parts.join(' · ')}. Transient — retry once.`;
|
|
448
|
+
}
|
|
425
449
|
async function apiFetch(baseUrl, apiKey, path, body) {
|
|
426
450
|
const resp = await fetchNoRedirect(`${baseUrl}${path}`, {
|
|
427
451
|
method: 'POST',
|
|
@@ -438,7 +462,7 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
438
462
|
// success (one of four parallel build_map calls lost its map this way,
|
|
439
463
|
// 2026-08-01).
|
|
440
464
|
if (!resp.ok) {
|
|
441
|
-
throw new Error(`${path} ${resp.status}: ${text
|
|
465
|
+
throw new Error(`${path} ${resp.status}: ${describeErrorBody(text)}`);
|
|
442
466
|
}
|
|
443
467
|
try {
|
|
444
468
|
return JSON.parse(text);
|
|
@@ -470,7 +494,11 @@ async function apiFetch(baseUrl, apiKey, path, body) {
|
|
|
470
494
|
// own viz reads.
|
|
471
495
|
const SHARE_MAX_VERTICES_PER_RING = 60;
|
|
472
496
|
const SHARE_MAX_POLYS_PER_MULTI = 3;
|
|
473
|
-
|
|
497
|
+
// Mirrors saveMapSnapshot: a byte budget for the whole geojsonLayers
|
|
498
|
+
// sidecar replaces the old first-2000-features cut, which shared a
|
|
499
|
+
// row-ordered analysis grid as a strip of the city (audited 2026-10-13).
|
|
500
|
+
const SHARE_MAX_FEATURES_PER_LAYER = 20_000;
|
|
501
|
+
export const SHARE_GEOJSON_BUDGET_BYTES = 4_000_000;
|
|
474
502
|
const SHARE_BASE_KEEP_PROPS = new Set(['value', 'name', 'iso3', 'label', '_dataVintage']);
|
|
475
503
|
function roundCoord(n) {
|
|
476
504
|
return Math.round(n * 10_000) / 10_000;
|
|
@@ -514,23 +542,87 @@ function compactGeometry(geom) {
|
|
|
514
542
|
}
|
|
515
543
|
return geom;
|
|
516
544
|
}
|
|
517
|
-
/**
|
|
518
|
-
|
|
519
|
-
|
|
545
|
+
/** A key that names a feature property: `field`, `valueField`, `fieldX`, … */
|
|
546
|
+
const SHARE_FIELD_KEY = /^field$|Field$|^field[A-Z]/;
|
|
547
|
+
/** Every feature property `node` reads: string values under field-named
|
|
548
|
+
* keys at ANY depth, plus `["get", X]` expression refs. Mirrors
|
|
549
|
+
* `keepSetForLayer` in src/lib/share/saveMapSnapshot.ts — a top-level-only
|
|
550
|
+
* scan stripped the nested `viz.colorScale.field`, so shared opportunity
|
|
551
|
+
* grids painted every cell the lowest class (2026-10-13). */
|
|
552
|
+
export function collectReadFields(node, out = new Set()) {
|
|
553
|
+
if (Array.isArray(node)) {
|
|
554
|
+
if (node[0] === 'get' && typeof node[1] === 'string')
|
|
555
|
+
out.add(node[1]);
|
|
556
|
+
for (const item of node)
|
|
557
|
+
collectReadFields(item, out);
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
if (!node || typeof node !== 'object')
|
|
561
|
+
return out;
|
|
562
|
+
for (const [k, v] of Object.entries(node)) {
|
|
563
|
+
if (typeof v === 'string') {
|
|
564
|
+
if (SHARE_FIELD_KEY.test(k))
|
|
565
|
+
out.add(v);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
collectReadFields(v, out);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
/** `n` of `items` by a seeded shuffle, returned in original order —
|
|
574
|
+
* deterministic, spread over the whole extent, no stride moiré. Mirrors
|
|
575
|
+
* `sampleSpread` in src/lib/share/saveMapSnapshot.ts. */
|
|
576
|
+
export function sampleSpread(items, n) {
|
|
577
|
+
if (items.length <= n)
|
|
578
|
+
return [...items];
|
|
579
|
+
if (n <= 0)
|
|
580
|
+
return [];
|
|
581
|
+
const idx = Array.from(items, (_, i) => i);
|
|
582
|
+
let seed = (items.length * 2654435761) >>> 0;
|
|
583
|
+
const rand = () => {
|
|
584
|
+
// mulberry32
|
|
585
|
+
seed = (seed + 0x6d2b79f5) >>> 0;
|
|
586
|
+
let t = seed;
|
|
587
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
588
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
589
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
590
|
+
};
|
|
591
|
+
for (let i = 0; i < n; i++) {
|
|
592
|
+
const j = i + Math.floor(rand() * (idx.length - i));
|
|
593
|
+
[idx[i], idx[j]] = [idx[j], idx[i]];
|
|
594
|
+
}
|
|
595
|
+
return idx.slice(0, n).sort((x, y) => x - y).map((i) => items[i]);
|
|
596
|
+
}
|
|
597
|
+
/** Base whitelist + every field the layer reads + `_smo_*` internals
|
|
598
|
+
* (tooltip/popup dispatch). `reads` is the layer minus its source (or just
|
|
599
|
+
* its viz) — walked at any depth. Exported for tests. */
|
|
600
|
+
export function compactFeatureCollection(fc, reads,
|
|
520
601
|
/** Fields the layer's filter reads — /api/maps applies it after this strip. */
|
|
521
602
|
filterFields = []) {
|
|
522
|
-
const keep = new Set([...SHARE_BASE_KEEP_PROPS, ...filterFields]);
|
|
523
|
-
|
|
524
|
-
if (typeof v === 'string' && /(Field|^field[A-Z])/.test(k))
|
|
525
|
-
keep.add(v);
|
|
526
|
-
}
|
|
527
|
-
const features = (fc.features ?? []).slice(0, SHARE_MAX_FEATURES_PER_LAYER).map((f) => ({
|
|
603
|
+
const keep = collectReadFields(reads, new Set([...SHARE_BASE_KEEP_PROPS, ...filterFields]));
|
|
604
|
+
const features = sampleSpread(fc.features ?? [], SHARE_MAX_FEATURES_PER_LAYER).map((f) => ({
|
|
528
605
|
...f,
|
|
529
606
|
geometry: compactGeometry(f.geometry),
|
|
530
607
|
properties: Object.fromEntries(Object.entries(f.properties ?? {}).filter(([k]) => keep.has(k) || k.startsWith('_smo_'))),
|
|
531
608
|
}));
|
|
532
609
|
return { type: fc.type ?? 'FeatureCollection', features };
|
|
533
610
|
}
|
|
611
|
+
/** Shrink every compacted layer by one factor when the sidecar is over
|
|
612
|
+
* budget, sampled spread; returns `{kept,total}` for each layer the share
|
|
613
|
+
* carries only partly. Exported for tests. */
|
|
614
|
+
export function budgetShareLayers(compacted, totals, budgetBytes = SHARE_GEOJSON_BUDGET_BYTES) {
|
|
615
|
+
const bytes = JSON.stringify(compacted).length;
|
|
616
|
+
const scale = bytes > budgetBytes ? budgetBytes / bytes : 1;
|
|
617
|
+
const sampled = {};
|
|
618
|
+
for (const [id, fc] of Object.entries(compacted)) {
|
|
619
|
+
if (scale < 1)
|
|
620
|
+
fc.features = sampleSpread(fc.features, Math.floor(fc.features.length * scale));
|
|
621
|
+
if (fc.features.length < (totals[id] ?? 0))
|
|
622
|
+
sampled[id] = { kept: fc.features.length, total: totals[id] };
|
|
623
|
+
}
|
|
624
|
+
return sampled;
|
|
625
|
+
}
|
|
534
626
|
/** Every `field` a predicate tree reads (attribute/temporal leaves). */
|
|
535
627
|
export function predicateFields(predicate, out = []) {
|
|
536
628
|
if (Array.isArray(predicate)) {
|
|
@@ -557,14 +649,23 @@ layerFilters) {
|
|
|
557
649
|
// Callers pass the build query, or an edit's optional rationale label.
|
|
558
650
|
const query = queryOrLabel || mapPlan.title || 'Map';
|
|
559
651
|
const geojsonLayers = {};
|
|
652
|
+
const totals = {};
|
|
653
|
+
const temporalField = mapPlan.temporalHint?.field;
|
|
560
654
|
const strippedLayers = mapPlan.layers.map((raw) => {
|
|
561
655
|
const layer = raw;
|
|
562
656
|
if (layer?.source?.kind === 'geojson' && layer.source.data && layer.id) {
|
|
563
|
-
|
|
657
|
+
const data = layer.source.data;
|
|
658
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- source carries the data, not field references
|
|
659
|
+
const { source: _source, ...reads } = layer;
|
|
660
|
+
totals[layer.id] = data.features?.length ?? 0;
|
|
661
|
+
geojsonLayers[layer.id] = compactFeatureCollection(data, reads, [...predicateFields(layerFilters?.[layer.id]), ...(typeof temporalField === 'string' ? [temporalField] : [])]);
|
|
564
662
|
return { ...layer, source: { ...layer.source, data: undefined } };
|
|
565
663
|
}
|
|
566
664
|
return layer;
|
|
567
665
|
});
|
|
666
|
+
// Budget the sidecar; /m/ reads `sampledLayers` and tells the viewer the
|
|
667
|
+
// copy is a sample — a partial map must never pass as the whole one.
|
|
668
|
+
const sampled = budgetShareLayers(geojsonLayers, totals);
|
|
568
669
|
const title = mapPlan.title ?? mapPlan.layers[0]?.title ?? 'Untitled Map';
|
|
569
670
|
const body = {
|
|
570
671
|
id: mapId,
|
|
@@ -573,6 +674,7 @@ layerFilters) {
|
|
|
573
674
|
blob: {
|
|
574
675
|
mapPlan: { ...mapPlan, layers: strippedLayers },
|
|
575
676
|
geojsonLayers,
|
|
677
|
+
...(Object.keys(sampled).length ? { sampledLayers: sampled } : {}),
|
|
576
678
|
query,
|
|
577
679
|
version: 1,
|
|
578
680
|
...(story ? { story } : {}),
|
|
@@ -638,7 +740,7 @@ async function fetchPublicSnapshot(baseUrl, mapId) {
|
|
|
638
740
|
return { status: 'deleted', plan: null };
|
|
639
741
|
const text = await resp.text();
|
|
640
742
|
if (!resp.ok)
|
|
641
|
-
throw new Error(`${path} ${resp.status}: ${text
|
|
743
|
+
throw new Error(`${path} ${resp.status}: ${describeErrorBody(text)}`);
|
|
642
744
|
const body = JSON.parse(text);
|
|
643
745
|
const plan = body.blob?.mapPlan;
|
|
644
746
|
if (!plan)
|
|
@@ -989,11 +1091,10 @@ export function createServer(opts) {
|
|
|
989
1091
|
lastAccessed: Date.now(),
|
|
990
1092
|
});
|
|
991
1093
|
await store.put(mapId, prepared.entry);
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
];
|
|
1094
|
+
// Derived from THIS map: its fields, its basemap/camera, its own
|
|
1095
|
+
// followups — computed on the full-resolution API plan (features
|
|
1096
|
+
// inline), never the generalized stored copy. May be [] (nextActions.ts).
|
|
1097
|
+
const nextActions = buildMapNextActions(resp.mapPlan, query);
|
|
997
1098
|
const note = generalizedNote(prepared.generalized);
|
|
998
1099
|
return toContent(envelope(baseUrl, mapId, prepared.entry.workspace, prepared.entry.mapPlan, nextActions, { ...(shared ? {} : { shareUnpersisted: true }), ...(note ? { note } : {}) }));
|
|
999
1100
|
}));
|
|
@@ -2217,7 +2318,7 @@ export function createServer(opts) {
|
|
|
2217
2318
|
});
|
|
2218
2319
|
const text = await resp.text();
|
|
2219
2320
|
if (!resp.ok)
|
|
2220
|
-
throw new Error(`/api/workflows ${resp.status}: ${text
|
|
2321
|
+
throw new Error(`/api/workflows ${resp.status}: ${describeErrorBody(text)}`);
|
|
2221
2322
|
const data = JSON.parse(text);
|
|
2222
2323
|
return toContent({
|
|
2223
2324
|
ok: true,
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@showmeonmap/mcp",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"mcpName": "com.showmeonmap/maps",
|
|
5
5
|
"description": "MCP server for ShowMeOnMap \u2014 geospatial visualization tools for any MCP-speaking agent (Claude Desktop, Cursor, Zed, ChatGPT Apps SDK)",
|
|
6
6
|
"type": "module",
|