Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
54bd405
feat(ai): put every place an answer names on the map, whichever model…
claude Sep 25, 2026
1a3086c
feat(web): compact assistant header, readable answers and one camera fit
claude Sep 25, 2026
00322d7
feat(web): quieter search suggestions
claude Sep 25, 2026
23179f6
feat(web): cleaner layer panel and place detail
claude Sep 25, 2026
5d2260e
fix(map): emergency basemap, route highlight, labels on raster maps
claude Sep 25, 2026
be8775d
fix(ios): no zoom on focus, PNG home-screen icons, calmer touch
claude Sep 25, 2026
1fa103e
fix(data): readable Golemio places; live sources in the memory server
claude Sep 25, 2026
b0d6cfa
docs: audit of UI/UX, AI, data sources, renderer and iOS with a roadmap
claude Sep 25, 2026
1b1623e
fix(web): game scene lifecycle, shared-plan links and preset drift
claude Sep 25, 2026
13d1fc9
fix(discover): outlines without waiting, the What is here chip, the c…
claude Sep 25, 2026
a67d26d
fix(web): steady layer alert, half-height empty planner, stable test …
claude Sep 25, 2026
ce404e0
test(e2e): describe the redesigned app, not the one before it
claude Sep 25, 2026
2df86e1
docs(audit): record the bugs the e2e pass found and the test status
claude Sep 25, 2026
fb97c30
test(e2e): give the map memory benchmark room on a shared runner
claude Sep 25, 2026
7ac767c
fix(game): the game panel opens while the board is running
claude Sep 25, 2026
39194c0
ci: run the map memory benchmark in its own profile
claude Sep 25, 2026
e3b46d9
test(e2e): weather hover and tap read one settled grid
claude Sep 25, 2026
39e73cd
ci: deploy to the VPS from GitHub Actions
claude Sep 25, 2026
805f2d3
test(e2e): CAMS pollutant check counts requests on a settled view
claude Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
- run: npm run test:e2e:accessibility
- run: npm run test:e2e:visual
- run: npm run test:e2e:runtime
- run: npm run test:e2e:performance
- uses: actions/upload-artifact@v4
if: always()
with:
Expand Down
110 changes: 110 additions & 0 deletions .github/workflows/deploy-vps.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Deploy VPS

# Runs scripts/deploy-vps.sh against the production VPS from a GitHub runner: a staged build while
# the current release stays live, a verified database backup and restore drill, an atomic switch
# and an automatic rollback when the new release is unhealthy.
#
# Triggers
# - Label a pull request from this repository `deploy:vps` to deploy its head commit; the label is
# removed again afterwards, so labelling once more redeploys.
# - Actions → Deploy VPS → Run workflow (available once this file is on the default branch).
#
# Repository secrets (Settings → Secrets and variables → Actions)
# - VPS_SSH_KEY: private key authorised for the deploy user, preferred; or
# - VPS_PASSWORD: that user's SSH password.
# - VPS_KNOWN_HOSTS (optional): pinned `ssh-keyscan` output. Without it the host key is read once
# per run, which trusts the network path of that run.
# Repository variable VPS_HOST (optional) overrides the default user@host.

on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
ref:
description: Branch, tag or commit to deploy
required: true
default: main

concurrency:
group: deploy-vps
cancel-in-progress: false

permissions:
contents: read
issues: write
pull-requests: write

jobs:
deploy:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.label.name == 'deploy:vps' &&
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 60
env:
DEPLOY_HOST: ${{ vars.VPS_HOST || 'root@72.61.188.39' }}
DEPLOY_REMOTE_DIR: /opt/ps3000/apps/mapos-v3
DEPLOY_PUBLIC_HOST: mapos.promptstudio3000.com
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || inputs.ref }}

- name: SSH access
env:
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_PASSWORD: ${{ secrets.VPS_PASSWORD }}
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: |
set -Eeuo pipefail
install -d -m 700 ~/.ssh
host="${DEPLOY_HOST#*@}"
if [[ -n "${VPS_KNOWN_HOSTS}" ]]; then
printf '%s\n' "${VPS_KNOWN_HOSTS}" > ~/.ssh/known_hosts
else
ssh-keyscan -T 20 "${host}" > ~/.ssh/known_hosts
fi
chmod 600 ~/.ssh/known_hosts
if [[ -n "${VPS_SSH_KEY}" ]]; then
eval "$(ssh-agent -s)"
printf '%s\n' "${VPS_SSH_KEY}" | ssh-add -
echo "SSH_AUTH_SOCK=${SSH_AUTH_SOCK}" >> "${GITHUB_ENV}"
echo "SSH_AGENT_PID=${SSH_AGENT_PID}" >> "${GITHUB_ENV}"
elif [[ -n "${VPS_PASSWORD}" ]]; then
sudo apt-get update -qq && sudo apt-get install -y -qq sshpass >/dev/null
# The deploy script calls plain ssh/scp; wrappers answer the password prompt from the
# SSHPASS environment variable, so the secret never appears on a command line.
bin="${RUNNER_TEMP}/ssh-bin"
install -d "${bin}"
for tool in ssh scp; do
printf '#!/usr/bin/env bash\nexec sshpass -e /usr/bin/%s -o PubkeyAuthentication=no "$@"\n' "${tool}" > "${bin}/${tool}"
chmod +x "${bin}/${tool}"
done
echo "${bin}" >> "${GITHUB_PATH}"
else
echo "::error::Set the VPS_SSH_KEY or VPS_PASSWORD repository secret." >&2
exit 1
fi

- name: Deploy
env:
SSHPASS: ${{ secrets.VPS_PASSWORD }}
run: |
set -Eeuo pipefail
tag="$(date -u +%Y%m%dT%H%M%SZ)-$(git rev-parse --short=7 HEAD)"
DRY_RUN=0 ./scripts/deploy-vps.sh "${tag}"
echo "Deployed ${tag} to https://${DEPLOY_PUBLIC_HOST}" >> "${GITHUB_STEP_SUMMARY}"

- name: Public smoke check
run: ./scripts/smoke-vps-public.sh "https://${DEPLOY_PUBLIC_HOST}"

- name: Remove the deploy label
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: >-
gh api -X DELETE
"repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/deploy:vps"
|| true
157 changes: 157 additions & 0 deletions apps/api/src/data/offlineGazetteer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* A handful of Czech places for the offline profile.
*
* The in-memory server has no geocoder. It used to answer every search with Plzeň, which made
* "nejchudší obce v ČR" suggest Plzeň and gave the assistant nothing to put on the map. A small
* gazetteer that only answers when the name actually matches keeps offline development honest:
* a place it does not know is simply not found.
*/

export interface OfflineGazetteerEntry {
name: string;
type: "city" | "town" | "village" | "peak" | "region";
hierarchy: string[];
longitude: number;
latitude: number;
}

export const OFFLINE_GAZETTEER: readonly OfflineGazetteerEntry[] = [
{
name: "Plzeň",
type: "city",
hierarchy: ["Plzeňský kraj", "Česko"],
longitude: 13.3775,
latitude: 49.7475
},
{
name: "Praha",
type: "city",
hierarchy: ["Hlavní město Praha", "Česko"],
longitude: 14.4213,
latitude: 50.0875
},
{
name: "Brno",
type: "city",
hierarchy: ["Jihomoravský kraj", "Česko"],
longitude: 16.6068,
latitude: 49.1951
},
{
name: "Ostrava",
type: "city",
hierarchy: ["Moravskoslezský kraj", "Česko"],
longitude: 18.2625,
latitude: 49.8209
},
{
name: "Olomouc",
type: "city",
hierarchy: ["Olomoucký kraj", "Česko"],
longitude: 17.2509,
latitude: 49.5938
},
{
name: "Liberec",
type: "city",
hierarchy: ["Liberecký kraj", "Česko"],
longitude: 15.0562,
latitude: 50.7671
},
{
name: "České Budějovice",
type: "city",
hierarchy: ["Jihočeský kraj", "Česko"],
longitude: 14.4747,
latitude: 48.9747
},
{
name: "Hradec Králové",
type: "city",
hierarchy: ["Královéhradecký kraj", "Česko"],
longitude: 15.8328,
latitude: 50.2092
},
{
name: "Karlovy Vary",
type: "city",
hierarchy: ["Karlovarský kraj", "Česko"],
longitude: 12.8712,
latitude: 50.2319
},
{
name: "Český Krumlov",
type: "town",
hierarchy: ["Jihočeský kraj", "Česko"],
longitude: 14.3175,
latitude: 48.8127
},
{
name: "Kutná Hora",
type: "town",
hierarchy: ["Středočeský kraj", "Česko"],
longitude: 15.2681,
latitude: 49.9484
},
{
name: "Vrchlabí",
type: "town",
hierarchy: ["Královéhradecký kraj", "Česko"],
longitude: 15.6094,
latitude: 50.627
},
{
name: "Pec pod Sněžkou",
type: "village",
hierarchy: ["Královéhradecký kraj", "Česko"],
longitude: 15.7337,
latitude: 50.6917
},
{
name: "Špindlerův Mlýn",
type: "town",
hierarchy: ["Královéhradecký kraj", "Česko"],
longitude: 15.6093,
latitude: 50.7259
},
{
name: "Sněžka",
type: "peak",
hierarchy: ["Krkonoše", "Česko"],
longitude: 15.7399,
latitude: 50.736
},
{
name: "Jihomoravský kraj",
type: "region",
hierarchy: ["Česko"],
longitude: 16.6,
latitude: 49.0
}
];

function fold(value: string): string {
return value.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/gu, " ").trim();
}

/** Entries whose name the query starts, contains, or is contained in — never a blind default. */
export function searchOfflineGazetteer(query: string, limit = 5): OfflineGazetteerEntry[] {
const needle = fold(query);
if (needle.length < 2) return [];
const scored = OFFLINE_GAZETTEER.map((entry) => {
const name = fold(entry.name);
const score =
name === needle
? 3
: name.startsWith(needle)
? 2
: needle.includes(name) || name.includes(needle)
? 1
: 0;
return { entry, score };
}).filter(({ score }) => score > 0);
return scored
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ entry }) => entry);
}
46 changes: 30 additions & 16 deletions apps/api/src/memory-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ import { memoryPlanDocumentRepository } from "./services/planDocumentMemoryRepos
import { memoryPlanShareRepository } from "./services/planShareMemoryRepository.js";
import { memoryPlanDiscussionRepository } from "./services/planDiscussionMemoryRepository.js";
import { createMemoryAdjacentRouteProvider } from "./services/adjacentRouteProvider.js";
import { searchOfflineGazetteer } from "./data/offlineGazetteer.js";
import { dataSourceProviders } from "./services/dataSources/index.js";
import { withOfflinePlaces } from "./services/ai/offlinePlaces.js";
import { EventService } from "./services/events/eventService.js";
import { MemoryEventRepository } from "./services/events/eventMemoryRepository.js";
import { buildMemoryEventFixtures } from "./services/events/eventFixtures.js";
Expand Down Expand Up @@ -436,10 +439,12 @@ export async function buildMemoryApp(options: MemoryAppOptions = {}) {
// e2e profile exercises: same catalog, same tool, no network.
chatTurn: createAiChatTurnFactory({
conversations: aiRuntime.conversations,
providers: createFixtureChatToolProviders({
fixtures: memoryPoiFixtures,
discover: discoverContext
}),
providers: withOfflinePlaces(
createFixtureChatToolProviders({
fixtures: memoryPoiFixtures,
discover: discoverContext
})
),
onToolTrace: recordAiToolTrace,
planEditor: planProposals.editor
}),
Expand Down Expand Up @@ -721,6 +726,17 @@ export async function buildMemoryApp(options: MemoryAppOptions = {}) {
// nothing for the sweep cache to protect and no database to hold it.
return memoryQuestAnchorFeatures(bbox, request.query.sources);
}
// Stateless live sources (Golemio, BTC Map, Europeana, SpaceAPI, GBFS…) need no database,
// so a developer running without Postgres still sees them. The offline profile keeps them
// off: its fetch guard would refuse the upstream anyway, and the e2e run must stay local.
const live = offlineFixture
? undefined
: dataSourceProviders.find((provider) => provider.id === layerId);
if (live?.features)
return await live.features({
bbox,
query: request.query as Record<string, string | undefined>
});
return reply.code(404).send({ message: "Layer not found" });
} catch (err) {
return reply
Expand Down Expand Up @@ -1612,18 +1628,16 @@ export async function buildMemoryApp(options: MemoryAppOptions = {}) {

app.get("/photos/resolve", async () => ({ url: null }));

app.get("/geocode", async () => ({
results: [
{
display_name: "Plzeň, Česko",
lat: "49.7475",
lon: "13.3775",
type: "city",
hierarchy: ["Plzeňský kraj", "Česko"],
source: { id: "fixture", label: "MapOS offline geokodér" },
confidence: { level: "high", label: "vysoká", basis: "provider-order" }
}
]
app.get<{ Querystring: { q?: string } }>("/geocode", async (request) => ({
results: searchOfflineGazetteer(request.query.q ?? "").map((entry) => ({
display_name: `${entry.name}, Česko`,
lat: String(entry.latitude),
lon: String(entry.longitude),
type: entry.type,
hierarchy: entry.hierarchy,
source: { id: "fixture", label: "MapOS offline geokodér" },
confidence: { level: "high", label: "vysoká", basis: "provider-order" }
}))
}));

app.get("/geocode/reverse", async () => ({ country: "CZ", name: "Offline test area" }));
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/services/ai/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { operationalTelemetry } from "../../observability/operationalTelemetry.js";
import { __resetUpstreamCache, __setUpstreamTestDependencies } from "../../utils/upstream.js";
import { OpenAiCompatibleAdapter } from "./adapters.js";
import { OpenAiCompatibleAdapter, parseToolArguments } from "./adapters.js";

afterEach(() => {
operationalTelemetry.clear();
Expand Down Expand Up @@ -268,3 +268,10 @@ test("reasoning effort is explicit and model responses still use the common tool
assert.equal(body.model, "glm-5.3-flash");
assert.equal(result.text, "Odpověď");
});

test("tool arguments survive a Markdown fence or trailing comma, and nothing more", () => {
assert.deepEqual(parseToolArguments('{"query":"Brno"}'), { query: "Brno" });
assert.deepEqual(parseToolArguments('```json\n{"query":"Brno",}\n```'), { query: "Brno" });
assert.deepEqual(parseToolArguments('{"stops":[{"name":"A"},],}'), { stops: [{ name: "A" }] });
assert.equal(parseToolArguments("query: Brno"), undefined);
});
Loading
Loading