feat(assistant): expenses, shares, wind, journey tracks and Dawarich checks as MCP tools

- list_budget_items lists a trip's expenses with their ids, payers and
  splits, page by page so no page is cut down on its way to a model;
  get_budget_per_person answers who paid and owes what. Both in a new
  budget-items.mcp.ts, checked like their REST twins.
- get_wind_forecast reads the hourly wind, gusts and direction of a day.
- get_journey_tracks lists a journey's GPX tracks; the line for drawing
  rides under a key the provider never receives.
- The Dawarich wishlist scan and the countries the recordings suggest
  become tools, with their confirming writes, in DawarichAtlasMcp in the
  Atlas group; the shared time window check refuses alike on REST and
  MCP, and the model never sees when the user was last seen in a city.
- The journal's map, tracks and recorded days load with the journey
  group instead of riding in the core of every step.
- The prompt points at list_budget_items for expense ids (the trip
  summary never carried them) and names the shares and the wind tools;
  each new read has its own live step.
This commit is contained in:
Maurice
2026-10-22 00:03:19 +00:00
parent 3c0f324266
commit 6d6efddc4e
63 changed files with 2418 additions and 51 deletions
@@ -16,6 +16,7 @@ import {
DawarichSettingsDto,
DawarichSuggestionStateDto,
} from './dawarich.dto';
import { parseTimeWindow } from './dawarich.helpers';
import { DawarichService } from './dawarich.service';
import {
Body,
@@ -176,7 +177,7 @@ export class DawarichController {
@Get('atlas/suggestions')
async atlasSuggestions(@CurrentUser() user: User, @Query('from') from?: string, @Query('to') to?: string) {
const window = parseWindow(from, to);
const window = parseTimeWindow(from, to);
return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to));
}
@@ -215,7 +216,7 @@ export class DawarichController {
@Query('to') to?: string,
@Query('offset') offset?: string,
) {
const window = parseWindow(from, to);
const window = parseTimeWindow(from, to);
return this.guardAsync(() =>
this.tracks.forWindow(user.id, window.from.toISOString(), window.to.toISOString(), parseOffset(offset)),
);
@@ -310,25 +311,6 @@ function parseDate(raw: string | undefined): string | undefined {
return raw;
}
/**
* An explicit instant window. Both ends are required together: a half-open
* range would silently become "everything since the epoch" on one side, which
* is the request that makes an instance time out.
*/
function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
const start = from ? new Date(from) : null;
const end = to ? new Date(to) : null;
if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400);
}
if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400);
const MAX_DAYS = 400;
if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) {
throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400);
}
return { from: start, to: end };
}
/** The originating client's socket id, so a broadcast does not echo back to it. */
function socketId(req: Request): string | undefined {
const header = req.headers['x-socket-id'];