feat(assistant): a card framework every addon package plugs into

This commit is contained in:
Maurice
2026-10-22 12:14:24 +02:00
parent d2a2728cde
commit 3967ca2790
514 changed files with 9868 additions and 1274 deletions
+26 -5
View File
@@ -3,7 +3,9 @@ import {
FERRATAS_RESULT_CAP,
dawarichTrackSegmentSchema,
assistantChangePreviewSchema,
assistantMessagePartSchema,
assistantChangePreviewWriteSchema,
assistantMessagePartWriteSchema,
assistantStoredPartsSchema,
ferrataSchema,
hikingPreferencesSchema,
obstacleSnapshotSchema,
@@ -335,12 +337,24 @@ export const JOURNEY_RECORDED_DAY_SEGMENTS: JsonColumn<DawarichTrackSegment[]> =
/**
* The parts of an assistant message as the client renders them: text, tool
* calls with their results and approvals, change previews and receipts. A
* broken row reads as an empty message and is logged.
* calls with their results and approvals, change previews, receipts and
* cards. A data part this build does not know (one a later build wrote) is
* dropped on read, not the message, and a later verb, entity or card kind
* reads as its generic stand-in; any other broken row reads as an empty
* message and is logged. A row this build rewrites (a decision, a cancel)
* is written back as it was read, so what only a later build knew is gone
* from it for good: a rollback costs those cards, never the conversation.
*/
export const ASSISTANT_MESSAGE_PARTS: JsonColumn<AssistantMessagePart[]> = {
column: 'assistant_messages.parts',
schema: z.array(assistantMessagePartSchema),
schema: assistantStoredPartsSchema,
fallback: () => [],
};
/** The same column as it is written: strictly, so a part this build cannot describe is a bug in the writer. */
export const ASSISTANT_MESSAGE_PARTS_WRITE: JsonColumn<AssistantMessagePart[]> = {
column: ASSISTANT_MESSAGE_PARTS.column,
schema: z.array(assistantMessagePartWriteSchema),
fallback: () => [],
};
@@ -370,13 +384,20 @@ export const ASSISTANT_TOOL_ARGS: JsonColumn<Record<string, unknown>> = {
fallback: () => ({}),
};
/** The change card of a proposed change. A broken row reads as none. */
/** The change card of a proposed change. A broken row reads as none, a later verb or card kind as its stand-in. */
export const ASSISTANT_TOOL_PREVIEW: JsonColumn<AssistantChangePreview | null> = {
column: 'assistant_tool_calls.preview',
schema: assistantChangePreviewSchema.nullable(),
fallback: () => null,
};
/** The same column as it is written: strictly, like the message parts. */
export const ASSISTANT_TOOL_PREVIEW_WRITE: JsonColumn<AssistantChangePreview | null> = {
column: ASSISTANT_TOOL_PREVIEW.column,
schema: assistantChangePreviewWriteSchema.nullable(),
fallback: () => null,
};
/** What an executed change answered, the tool's own result. A broken row reads as none, which offers no undo. */
export const ASSISTANT_TOOL_RESULT: JsonColumn<unknown> = {
column: 'assistant_tool_calls.result',
@@ -19,5 +19,7 @@ import { Module } from '@nestjs/common';
imports: [MikroOrmModule.forFeature([Reservations, ReservationEndpoints]), SchedulingModule],
controllers: [AirportsController],
providers: [AirportsService, AirportsMcp],
// The assistant's cards name an airport by its code (AssistantModule).
exports: [AirportsService],
})
export class AirportsModule {}
+22 -5
View File
@@ -9,15 +9,18 @@ import {
import { activityOf, foundPlacesOf, googleIdsOf, weatherDayOf, weatherOf } from './assistant-cards.helpers';
import type { TripCard } from './assistant-context';
import { outputText } from './assistant-history';
import { toolOutputForProvider } from './assistant-redact';
import { MAX_READS_PER_STEP, MAX_STEPS, MAX_WRITES_PER_STEP } from './assistant-tools.constants';
import { toolOutputForProvider } from './assistant-output';
import { MAX_CARDS_PER_STEP, MAX_READS_PER_STEP, MAX_STEPS, MAX_WRITES_PER_STEP } from './assistant-tools.constants';
import type { AssistantToolKind, ToolRun } from './assistant-tools.service';
import type { AnswerInput } from './cards/card-types';
import { answerCardOf } from './cards/registry';
import type { UiStreamWriter } from './ui-stream.writer';
import type {
AssistantChangePreview,
AssistantErrorCode,
AssistantMessagePart,
AssistantWeatherDay,
TrekCard,
} from '@trek/shared';
type ToolArgs = Record<string, unknown>;
@@ -66,6 +69,8 @@ export interface AgentDeps {
card: TripCard;
/** Today in the user's time zone (`YYYY-MM-DD`). */
today: string;
/** The card a read's answer shows; the registry's (`answerCardOf`) unless a test hands in its own. */
answerCard?: (tool: string, input: AnswerInput) => TrekCard | null;
}
export interface AgentInput {
@@ -96,8 +101,13 @@ interface StepParts {
weather: AssistantWeatherDay[];
/** The google place ids the turn has seen so far; a search adds what it found. */
knownPlaceIds: Set<string>;
/** The answer cards the step has shown; past MAX_CARDS_PER_STEP a read shows none. */
cards: number;
}
/** The longest call id a card is kept under; a read under a longer one shows no card. */
const MAX_CARD_CALL_ID = 64;
const UNKNOWN_PLACE_ID =
'Unknown place id. Use a google place id from the trip card or from a search result of this turn; ' +
'if you have none, call search_place first.';
@@ -132,8 +142,9 @@ const GO_ON =
/**
* Runs a read at once and records it: where it looks (streamed only), its
* call and answer, and the cards its answer makes. The cards are built from
* the tool's own answer, the parts the provider sees from the redacted one.
* call and answer, and the cards its answer makes (its package's card first,
* then the places strip). The cards are built from the tool's own answer, the
* parts the provider sees from the redacted one.
*/
async function runRead(
deps: AgentDeps,
@@ -158,6 +169,12 @@ async function runRead(
...(run.ok ? {} : { isError: true }),
});
if (!run.ok) return;
const input = { toolCallId: id, args, value: run.value, card: deps.card, today: deps.today };
const card = id.length <= MAX_CARD_CALL_ID ? (deps.answerCard ?? answerCardOf)(name, input) : null;
if (card && step.cards++ < MAX_CARDS_PER_STEP) {
w.card({ toolCallId: id, card });
step.parts.push({ type: 'data-trek-card', data: { toolCallId: id, card } });
}
for (const placeId of googleIdsOf(name, run.value)) step.knownPlaceIds.add(placeId);
const places = foundPlacesOf(name, id, run.value, deps.card, deps.today);
if (places) {
@@ -370,7 +387,7 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
}
const calls = received.map((call) => ({ ...call, id: uniqueCallId(call, input.usedCallIds, deps.newId) }));
const collected: StepParts = { parts, weather: [], knownPlaceIds: turn.knownPlaceIds };
const collected: StepParts = { parts, weather: [], knownPlaceIds: turn.knownPlaceIds, cards: 0 };
const held = await runCalls(turn, calls, collected);
// One weather strip per step, whatever the step held: it is told before the changes.
const weather = weatherOf(collected.weather);
@@ -1,6 +1,6 @@
import type { CardPlace, TripCard } from './assistant-context';
import { redactForProvider } from './assistant-redact';
import { dayLabel, type PreviewContext } from './tool-call-preview.helpers';
import { clipped, finite, isLat, isLng, recordOf, type Row } from './cards/card-values';
import { dayLabel } from './cards/preview-format';
import {
haversineMetres,
kindOfResult,
@@ -26,8 +26,6 @@ import {
* a photo is asked for by a key, through TREK's own photo route.
*/
type Row = Record<string, unknown>;
/** Places a search shows as cards; `more` counts the rest. */
const PLACES_SHOWN = 20;
const MAX_NAME_CHARS = 120;
@@ -57,30 +55,6 @@ const PLACE_LISTS: Readonly<Record<string, 'places' | 'pois'>> = {
search_pois: 'pois',
};
function recordOf(value: unknown): Row {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
}
function finite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function isLat(value: number | null): value is number {
return value !== null && Math.abs(value) <= 90;
}
function isLng(value: number | null): value is number {
return value !== null && Math.abs(value) <= 180;
}
/** A text as a card may show it: on one line, masked like a provider answer, and cut to `max`. */
function clipped(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = String(redactForProvider(value.replace(/\s+/g, ' ').trim()));
if (line === '') return null;
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/** Whether the trip runs today, the only time a source's "open now" means anything for it. */
function tripRunsOn(card: TripCard, today: string): boolean {
return !!card.startDate && !!card.endDate && card.startDate <= today && today <= card.endDate;
@@ -285,7 +259,11 @@ export function activityOf(toolName: string, toolCallId: string, args: Row, card
}
/** What a mention is called in the trip; null when the trip holds no such thing. */
function mentionLabel(ctx: PreviewContext, kind: AssistantMention['kind'], id: number): string | null {
function mentionLabel(
ctx: { card: TripCard; locale: string },
kind: AssistantMention['kind'],
id: number,
): string | null {
if (kind === 'day') return ctx.card.days.some((day) => day.id === id) ? dayLabel(ctx, id) : null;
if (kind === 'place') return ctx.card.places.get(id)?.name ?? null;
return ctx.card.reservations.get(id)?.title ?? null;
@@ -303,7 +281,7 @@ export function mentionItems(
): AssistantMentions['items'] {
const items: AssistantMentions['items'] = [];
const seen = new Set<string>();
const ctx = { card, categories: new Map<number, string>(), locale };
const ctx = { card, locale };
for (const { kind, id } of mentions) {
const label = mentionLabel(ctx, kind, id);
const shown = clipped(label, MAX_NAME_CHARS);
+21 -2
View File
@@ -1,3 +1,4 @@
import { fileKeyOf } from './cards/card-values';
import type { AssistantFocus, AssistantMention } from '@trek/shared';
/**
@@ -58,8 +59,16 @@ interface CardPackingItem {
packedQuantity: number | null;
}
/** A trip member: the name the trip shows, and the stored file of their picture (none for one on another host). */
interface CardMember {
name: string;
avatarKey: string | null;
}
export interface TripCard {
tripId: number;
/** The user the turn runs for; a card compares people with it instead of carrying "me". */
viewerId: number;
title: string;
startDate: string | null;
endDate: string | null;
@@ -69,11 +78,17 @@ export interface TripCard {
assignments: Map<number, { dayId: number; placeId: number }>;
notes: Map<number, CardNote>;
packing: Map<number, CardPackingItem>;
members: Map<number, string>;
members: Map<number, CardMember>;
reservations: Map<number, CardBooking>;
focus: AssistantFocus | null;
/** What the user named with `@` in the message, as sent; checked against the card when it is rendered. */
mentions: readonly AssistantMention[];
/**
* The raw answers the card was built from. A package that needs more of the
* trip than the card holds reads it from here, in its own file (memoised per
* card), rather than growing the card for every part of TREK.
*/
sources: { summary: unknown; places: unknown };
}
/** Places a day lists by name on the card; the rest are counted. */
@@ -132,6 +147,7 @@ function placeOf(row: Row): CardPlace | null {
*/
export function buildTripCard(
tripId: number,
viewerId: number,
summary: unknown,
placeList: unknown,
focus: AssistantFocus | null,
@@ -141,6 +157,7 @@ export function buildTripCard(
const trip = recordOf(root.trip);
const card: TripCard = {
tripId,
viewerId,
title: str(trip.title) ?? '',
startDate: str(trip.start_date),
endDate: str(trip.end_date),
@@ -154,6 +171,7 @@ export function buildTripCard(
reservations: new Map(),
focus,
mentions,
sources: { summary, places: placeList },
};
for (const row of rowsOf(recordOf(placeList).places)) {
const place = placeOf(row);
@@ -208,7 +226,8 @@ export function buildTripCard(
const members = recordOf(root.members);
for (const member of [recordOf(members.owner), ...rowsOf(members.collaborators)]) {
const id = num(member.id);
if (id !== null) card.members.set(id, str(member.username) ?? `#${id}`);
if (id === null) continue;
card.members.set(id, { name: str(member.username) ?? `#${id}`, avatarKey: fileKeyOf(member.avatar_url) });
}
return card;
}
@@ -1,7 +1,11 @@
import { AssistantMessages } from '../../db/entities/AssistantMessages.entity';
import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
import { ASSISTANT_MESSAGE_PARTS, ASSISTANT_TOOL_ARGS, ASSISTANT_TOOL_PREVIEW } from '../../db/json-columns';
import {
ASSISTANT_MESSAGE_PARTS_WRITE,
ASSISTANT_TOOL_ARGS,
ASSISTANT_TOOL_PREVIEW_WRITE,
} from '../../db/json-columns';
import type {
AssistantMessageRow,
AssistantMessagesRepository,
@@ -16,12 +20,19 @@ import { encodeJson } from '../../utils/json-column';
import { aiRefusal } from '../ai-connections/ai-connections.constants';
import { RateLimitService } from '../common/rate-limit.service';
import { UnitOfWork } from '../database/unit-of-work';
import { buildTripCard, categoriesFrom, type CardCategory, type CardPlace, type TripCard } from './assistant-context';
import {
buildTripCard,
categoriesFrom,
todayIn,
type CardCategory,
type CardPlace,
type TripCard,
} from './assistant-context';
import { partsOf, toThreadMessages } from './assistant-history';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { ADD_FOUND_PLACE_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { AssistantToolsService } from './assistant-tools.service';
import { describeToolCall } from './tool-call-preview.helpers';
import { AssistantCardContext } from './cards/card-context';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
@@ -111,6 +122,7 @@ export class AssistantFoundPlacesService {
private readonly rateLimit: RateLimitService,
private readonly streams: AssistantStreamRegistry,
private readonly uow: UnitOfWork,
private readonly cardContext: AssistantCardContext,
) {}
/**
@@ -161,7 +173,10 @@ export class AssistantFoundPlacesService {
const id = randomUUID();
const args = suggestionArgs(place, req.dayId, card, categories);
const names = new Map(categories.map((category): [number, string] => [category.id, category.name]));
const preview = describeToolCall(ADD_FOUND_PLACE_TOOL, id, args, { card, categories: names, locale: req.locale });
const ctx = this.cardContext.contextOf(card, names, req.locale, todayIn(req.timeZone, new Date()));
const [{ preview }] = await this.cardContext.describeProposed(userId, tripId, ctx, [
{ id, toolName: ADD_FOUND_PLACE_TOOL, args },
]);
const parts: AssistantMessagePart[] = [
{ type: 'tool-call', toolCallId: id, toolName: ADD_FOUND_PLACE_TOOL, args, approval: { id } },
{ type: 'data-trek-change', data: preview },
@@ -171,7 +186,7 @@ export class AssistantFoundPlacesService {
const messageId = await this.messages.append({
threadId,
role: 'assistant',
parts: encodeJson(ASSISTANT_MESSAGE_PARTS, parts),
parts: encodeJson(ASSISTANT_MESSAGE_PARTS_WRITE, parts),
providerState: null,
status: 'awaiting_approval',
model: null,
@@ -185,7 +200,7 @@ export class AssistantFoundPlacesService {
providerCallId: `found_${id}`,
toolName: ADD_FOUND_PLACE_TOOL,
args: encodeJson(ASSISTANT_TOOL_ARGS, args),
preview: encodeJson(ASSISTANT_TOOL_PREVIEW, preview),
preview: encodeJson(ASSISTANT_TOOL_PREVIEW_WRITE, preview),
createdAt: now,
},
]);
@@ -201,7 +216,7 @@ export class AssistantFoundPlacesService {
const places = await session.read('list_places');
const listed = await session.read('list_categories');
return {
card: buildTripCard(tripId, summary.ok ? summary.value : null, places.ok ? places.value : null, null),
card: buildTripCard(tripId, userId, summary.ok ? summary.value : null, places.ok ? places.value : null, null),
categories: categoriesFrom(listed.ok ? listed.value : null),
};
} finally {
@@ -4,7 +4,7 @@ import type { AssistantToolCallRow } from '../../db/repositories/AssistantToolCa
import { parseDbTimestamp, utcSuffix } from '../../db/types';
import { decodeJson } from '../../utils/json-column';
import type { AiChatMessage, AiToolCall } from '../ai-connections/ai-chat-client.interface';
import { fitToSize } from './assistant-redact';
import { fitToSize } from './assistant-output';
import {
ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS,
@@ -0,0 +1,148 @@
import { redactForProvider } from './assistant-redact';
import { MAX_TOOL_OUTPUT_CHARS } from './assistant-tools.constants';
import { PROJECTIONS, refuseCoreOverlap } from './cards/registry';
/**
* A tool's answer as the provider gets it: projected to what the model plans
* with (the core's projections here, a package's from its cards), redacted
* (assistant-redact.ts), and cut down until it fits. The client is shown the
* same, so a stored or streamed answer never carries more than the model saw.
*/
type Json = unknown;
/** Longest string kept whole; a longer one keeps its start. */
const MAX_STRING_CHARS = 4_000;
/** Array lengths tried, longest first, until the answer fits. */
const ARRAY_CAPS = [100, 50, 20, 10, 5, 2, 1];
function isRecord(value: Json): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pick(row: Json, keys: readonly string[]): Record<string, unknown> | null {
if (!isRecord(row)) return null;
const out: Record<string, unknown> = {};
for (const key of keys) if (row[key] !== undefined) out[key] = row[key];
return out;
}
function pickAll(rows: Json, keys: readonly string[]): Record<string, unknown>[] {
return Array.isArray(rows) ? rows.map((row) => pick(row, keys)).filter((row) => row !== null) : [];
}
const ASSIGNMENT_KEYS = ['id', 'place_id', 'order_index', 'assignment_time', 'assignment_end_time', 'notes'] as const;
const PLACE_KEYS = ['name', 'address', 'lat', 'lng', 'category_id', 'place_time', 'end_time'] as const;
function projectDay(day: Json): Record<string, unknown> | null {
if (!isRecord(day)) return null;
return {
...pick(day, ['id', 'day_number', 'date', 'title']),
assignments: (Array.isArray(day.assignments) ? day.assignments : [])
.filter(isRecord)
.map((assignment) => ({ ...pick(assignment, ASSIGNMENT_KEYS), place: pick(assignment.place, PLACE_KEYS) })),
notes: pickAll(day.notes, ['id', 'text', 'time', 'icon']),
};
}
/**
* `get_trip_summary` as the model gets it: the plan (days with their places
* and notes, stays, bookings, the packing and to-do lists), without the
* members' contact details, the shared notes or the budget rows, which have
* tools of their own.
*/
function projectTripSummary(summary: Json): Json {
if (!isRecord(summary)) return summary;
const members = isRecord(summary.members) ? summary.members : {};
const people = [members.owner, ...(Array.isArray(members.collaborators) ? members.collaborators : [])];
const packing = isRecord(summary.packing) ? summary.packing : {};
return {
trip: pick(summary.trip, ['id', 'title', 'description', 'start_date', 'end_date', 'currency']),
members: pickAll(people, ['id', 'username']),
days: (Array.isArray(summary.days) ? summary.days : []).map(projectDay).filter((day) => day !== null),
accommodations: pickAll(summary.accommodations, [
'id',
'place_id',
'start_day_id',
'end_day_id',
'check_in',
'check_out',
]),
reservations: pickAll(summary.reservations, [
'id',
'title',
'type',
'status',
'reservation_time',
'reservation_end_time',
'location',
'day_id',
'end_day_id',
'place_id',
'assignment_id',
]),
packing: pickAll(packing.items, ['id', 'name', 'category', 'checked', 'quantity', 'packed_quantity']),
todos: pickAll(summary.todos, ['id', 'name', 'category', 'checked', 'due_date', 'priority', 'assigned_user_id']),
};
}
/**
* `suggest_atlas_countries` as the model gets it: each country with its
* cities and the minutes spent in them, without when the user was last seen
* in each city. The model offers countries to mark; a time per city would
* hand the provider a dated trail of the user's whereabouts for nothing it
* plans with.
*/
function projectAtlasSuggestions(value: Json): Json {
if (!isRecord(value) || !Array.isArray(value.countries)) return value;
return {
...value,
countries: value.countries.map((country) =>
isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country,
),
};
}
/** The core's projections; the packages' come from their cards (`project`), never for the same tool. */
const CORE_PROJECTIONS: Readonly<Record<string, (value: Json) => Json>> = {
get_trip_summary: projectTripSummary,
suggest_atlas_countries: projectAtlasSuggestions,
};
refuseCoreOverlap('project', Object.keys(CORE_PROJECTIONS));
function shrink(value: Json, cap: number, cut: { done: boolean }): Json {
if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
cut.done = true;
return `${value.slice(0, MAX_STRING_CHARS)}…`;
}
if (Array.isArray(value)) {
if (value.length > cap) cut.done = true;
return value.slice(0, cap).map((item) => shrink(item, cap, cut));
}
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shrink(inner, cap, cut)]));
}
/**
* The value cut down until its JSON fits `maxChars`: long strings keep their
* start, arrays their first entries, and the answer says it was cut
* (`truncated: true`), so the model knows to narrow its question rather than
* read a list as complete. Never a cut through the middle of the JSON.
*/
export function fitToSize(value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
if (JSON.stringify(value).length <= maxChars) return value;
for (const cap of ARRAY_CAPS) {
const cut = { done: false };
const shrunk = shrink(value, cap, cut);
const marked = isRecord(shrunk) ? { ...shrunk, truncated: true } : { items: shrunk, truncated: true };
if (JSON.stringify(marked).length <= maxChars) return marked;
}
return { truncated: true, note: 'The answer was too large. Ask for less at once.' };
}
/** A tool's answer as the provider and the client see it: projected, redacted and cut to size. */
export function toolOutputForProvider(toolName: string, value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
const project = CORE_PROJECTIONS[toolName] ?? PROJECTIONS[toolName];
return fitToSize(redactForProvider(project ? project(value) : value), maxChars);
}
+3 -134
View File
@@ -1,10 +1,9 @@
import { MAX_TOOL_OUTPUT_CHARS } from './assistant-tools.constants';
/**
* What a tool answer may carry to the provider. The model plans the trip; it
* has no use for anyone's e-mail address, a booking's confirmation code or a
* phone number, so those are hidden, and the bulky fields (pictures inlined as
* data URLs, route geometry) are dropped before the answer is cut to size.
* data URLs, route geometry) are dropped before the answer is cut to size
* (assistant-output.ts). The cards mask their texts with it too.
*/
const HIDDEN = '[hidden]';
@@ -13,16 +12,10 @@ const HIDDEN = '[hidden]';
const SECRET_KEYS = new Set(['email', 'confirmation_number', 'confirmation', 'phone', 'feed_token', 'password']);
/** Keys that cost a lot and tell the model nothing it plans with. */
const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata)$|geometry/;
const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata|place_image|trip_cover)$|geometry/;
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
/** Longest string kept whole; a longer one keeps its start. */
const MAX_STRING_CHARS = 4_000;
/** Array lengths tried, longest first, until the answer fits. */
const ARRAY_CAPS = [100, 50, 20, 10, 5, 2, 1];
type Json = unknown;
function isRecord(value: Json): value is Record<string, unknown> {
@@ -41,127 +34,3 @@ export function redactForProvider(value: Json): Json {
}
return out;
}
function pick(row: Json, keys: readonly string[]): Record<string, unknown> | null {
if (!isRecord(row)) return null;
const out: Record<string, unknown> = {};
for (const key of keys) if (row[key] !== undefined) out[key] = row[key];
return out;
}
function pickAll(rows: Json, keys: readonly string[]): Record<string, unknown>[] {
return Array.isArray(rows) ? rows.map((row) => pick(row, keys)).filter((row) => row !== null) : [];
}
const ASSIGNMENT_KEYS = ['id', 'place_id', 'order_index', 'assignment_time', 'assignment_end_time', 'notes'] as const;
const PLACE_KEYS = ['name', 'address', 'lat', 'lng', 'category_id', 'place_time', 'end_time'] as const;
function projectDay(day: Json): Record<string, unknown> | null {
if (!isRecord(day)) return null;
return {
...pick(day, ['id', 'day_number', 'date', 'title']),
assignments: (Array.isArray(day.assignments) ? day.assignments : [])
.filter(isRecord)
.map((assignment) => ({ ...pick(assignment, ASSIGNMENT_KEYS), place: pick(assignment.place, PLACE_KEYS) })),
notes: pickAll(day.notes, ['id', 'text', 'time', 'icon']),
};
}
/**
* `get_trip_summary` as the model gets it: the plan (days with their places
* and notes, stays, bookings, the packing and to-do lists), without the
* members' contact details, the shared notes or the budget rows, which have
* tools of their own.
*/
function projectTripSummary(summary: Json): Json {
if (!isRecord(summary)) return summary;
const members = isRecord(summary.members) ? summary.members : {};
const people = [members.owner, ...(Array.isArray(members.collaborators) ? members.collaborators : [])];
const packing = isRecord(summary.packing) ? summary.packing : {};
return {
trip: pick(summary.trip, ['id', 'title', 'description', 'start_date', 'end_date', 'currency']),
members: pickAll(people, ['id', 'username']),
days: (Array.isArray(summary.days) ? summary.days : []).map(projectDay).filter((day) => day !== null),
accommodations: pickAll(summary.accommodations, [
'id',
'place_id',
'start_day_id',
'end_day_id',
'check_in',
'check_out',
]),
reservations: pickAll(summary.reservations, [
'id',
'title',
'type',
'status',
'reservation_time',
'reservation_end_time',
'location',
'day_id',
'end_day_id',
'place_id',
'assignment_id',
]),
packing: pickAll(packing.items, ['id', 'name', 'category', 'checked', 'quantity', 'packed_quantity']),
todos: pickAll(summary.todos, ['id', 'name', 'category', 'checked', 'due_date', 'priority', 'assigned_user_id']),
};
}
/**
* `suggest_atlas_countries` as the model gets it: each country with its
* cities and the minutes spent in them, without when the user was last seen
* in each city. The model offers countries to mark; a time per city would
* hand the provider a dated trail of the user's whereabouts for nothing it
* plans with.
*/
function projectAtlasSuggestions(value: Json): Json {
if (!isRecord(value) || !Array.isArray(value.countries)) return value;
return {
...value,
countries: value.countries.map((country) =>
isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country,
),
};
}
const PROJECTIONS: Readonly<Record<string, (value: Json) => Json>> = {
get_trip_summary: projectTripSummary,
suggest_atlas_countries: projectAtlasSuggestions,
};
function shrink(value: Json, cap: number, cut: { done: boolean }): Json {
if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
cut.done = true;
return `${value.slice(0, MAX_STRING_CHARS)}…`;
}
if (Array.isArray(value)) {
if (value.length > cap) cut.done = true;
return value.slice(0, cap).map((item) => shrink(item, cap, cut));
}
if (!isRecord(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shrink(inner, cap, cut)]));
}
/**
* The value cut down until its JSON fits `maxChars`: long strings keep their
* start, arrays their first entries, and the answer says it was cut
* (`truncated: true`), so the model knows to narrow its question rather than
* read a list as complete. Never a cut through the middle of the JSON.
*/
export function fitToSize(value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
if (JSON.stringify(value).length <= maxChars) return value;
for (const cap of ARRAY_CAPS) {
const cut = { done: false };
const shrunk = shrink(value, cap, cut);
const marked = isRecord(shrunk) ? { ...shrunk, truncated: true } : { items: shrunk, truncated: true };
if (JSON.stringify(marked).length <= maxChars) return marked;
}
return { truncated: true, note: 'The answer was too large. Ask for less at once.' };
}
/** A tool's answer as the provider and the client see it: projected, redacted and cut to size. */
export function toolOutputForProvider(toolName: string, value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
const project = PROJECTIONS[toolName];
return fitToSize(redactForProvider(project ? project(value) : value), maxChars);
}
@@ -1,5 +1,6 @@
import type { McpRegistryListing } from '../../nest-mcp';
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
import { PACKAGE_HELD_READS, PACKAGE_TOOL_CONTROLLERS } from './cards/registry';
/**
* Which of TREK's MCP tools the assistant is offered, and how. Every tool the
@@ -81,11 +82,15 @@ const SHARE_LIKE_SAFE: ReadonlySet<string> = new Set([
]);
const SHARE_LIKE = /share|invite|feed/;
/** Read-only tools that still wait for the user: they fetch an address that came from the web or a message. */
/**
* Read-only tools that still wait for the user: they fetch an address that
* came from the web or a message. The packages add theirs in their cards.
*/
export const HELD_READS: ReadonlySet<string> = new Set([
'resolve_maps_url',
'preview_google_maps_route',
'preview_google_maps_walk',
...PACKAGE_HELD_READS,
]);
export function isOfferedTool(name: string): boolean {
@@ -103,12 +108,15 @@ export function checkDenylist(names: Iterable<string>): string[] {
return problems;
}
type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly string[] }>>;
/**
* The tool groups a turn can load, by the MCP controller that registers the
* tools, each with the words the model reads to pick it. A controller not
* listed here belongs to the core, which every step offers.
* listed here, nor added by a package (`toolControllers` in its cards),
* belongs to the core, which every step offers.
*/
export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; controllers: readonly string[] }>> = {
const CORE_TOOL_GROUPS: ToolGroups = {
tours: {
about:
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
@@ -184,6 +192,29 @@ export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; con
},
};
/**
* The groups with the controllers the packages added to them. A package that
* names a group this file does not know, or a controller a group lists
* already, stops the server at load.
*/
export function withPackageControllers(
core: ToolGroups,
added: Readonly<Record<string, readonly string[]>>,
): ToolGroups {
const groups = Object.fromEntries(Object.entries(core).map(([group, value]) => [group, { ...value }]));
const listed = new Set(Object.values(core).flatMap((group) => group.controllers));
for (const [group, controllers] of Object.entries(added)) {
const known = groups[group];
if (!known) throw new Error(`Assistant tool groups: no group "${group}" for ${controllers.join(', ')}`);
const twice = controllers.find((controller) => listed.has(controller));
if (twice) throw new Error(`Assistant tool groups: controller "${twice}" is listed already`);
known.controllers = [...known.controllers, ...controllers];
}
return groups;
}
export const ASSISTANT_TOOL_GROUPS = withPackageControllers(CORE_TOOL_GROUPS, PACKAGE_TOOL_CONTROLLERS);
const GROUP_OF_CONTROLLER = new Map(
Object.entries(ASSISTANT_TOOL_GROUPS).flatMap(([group, { controllers }]) =>
controllers.map((controller) => [controller, group] as const),
@@ -1,3 +1,5 @@
import type { ToolUndo } from './cards/card-types';
import { argId, idIn } from './cards/card-values';
import type { AssistantApplied, AssistantChangeVerbKey } from '@trek/shared';
/**
@@ -13,6 +15,9 @@ export const MAX_WRITES_PER_STEP = 50;
/** Lookups one step may run; the rest come back to the model as an error (searches bill the instance's maps key). */
export const MAX_READS_PER_STEP = 30;
/** Answer cards one step shows; the reads past it answer the model as usual, without a card. */
export const MAX_CARDS_PER_STEP = 12;
/** What a tool answer may cost the conversation, as JSON text, after it was cut down to its structure. */
export const MAX_TOOL_OUTPUT_CHARS = 40_000;
@@ -70,18 +75,19 @@ export const ASSISTANT_SCOPES: readonly string[] = [
* applied. A `quiet` one is decided without a model turn after it: the user
* proposed it, so there is nothing for the model to carry on with.
*/
interface AssistantWriteTool {
export interface AssistantWriteTool {
verbKey: AssistantChangeVerbKey;
entity: AssistantApplied['entity'];
quiet?: boolean;
}
/**
* The writing tools with a change card of their own: its verb and what it
* touches once applied. Every other writing tool is held as a card too, one
* built from its arguments (tool-call-generic.helpers.ts).
* The core writing tools with a change card of their own: its verb and what
* it touches once applied. The packages add theirs (cards/registry.ts merges
* them into ASSISTANT_WRITE_TOOLS). Every other writing tool is held as a card
* too, one built from its arguments (tool-call-generic.helpers.ts).
*/
export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>> = {
export const CORE_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>> = {
create_place: { verbKey: 'addPlace', entity: 'place' },
create_and_assign_place: { verbKey: 'addPlaceToDay', entity: 'place' },
update_place: { verbKey: 'updatePlace', entity: 'place' },
@@ -96,11 +102,6 @@ export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>>
create_budget_item: { verbKey: 'addBudgetItem', entity: 'budget' },
update_budget_item: { verbKey: 'updateBudgetItem', entity: 'budget' },
create_reservation: { verbKey: 'addReservation', entity: 'reservation' },
// A tour is a place with a route: its id is its place's.
create_hike: { verbKey: 'addTour', entity: 'tour' },
create_city_walk: { verbKey: 'addTour', entity: 'tour' },
update_tour: { verbKey: 'updateTour', entity: 'tour' },
import_tour_gpx: { verbKey: 'importTour', entity: 'tour' },
};
/**
@@ -179,9 +180,9 @@ export const PINNED_ARG = 'tripId';
* (place details stay on the cheap lookup, which the instance pays for);
* `stripped` ones are taken away (a picture URL would be loaded by every
* member's browser, a tracking pixel the model could plant). Neither appears
* in the schema the model sees.
* in the schema the model sees. The packages add theirs (ASSISTANT_ARG_POLICY).
*/
export const ASSISTANT_ARG_POLICY: Readonly<
export const CORE_ARG_POLICY: Readonly<
Record<string, { forced?: Readonly<Record<string, unknown>>; stripped?: readonly string[] }>
> = {
get_place_details: { forced: { expand: false, refresh: false } },
@@ -192,49 +193,16 @@ export const ASSISTANT_ARG_POLICY: Readonly<
update_trip: { stripped: ['cover_image'] },
};
type ToolArgs = Record<string, unknown>;
function idIn(value: unknown, key: string): number | null {
const record = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
const id = record && typeof record === 'object' ? (record as Record<string, unknown>).id : undefined;
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
function tourIdIn(value: unknown): number | null {
const tour = value && typeof value === 'object' ? (value as Record<string, unknown>).tour : undefined;
const id = tour && typeof tour === 'object' ? (tour as Record<string, unknown>).place_id : undefined;
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
function argId(args: ToolArgs, key: string): number | null {
const id = args[key];
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
/**
* The way back from an applied change, for the server only: the tool that
* removes what the change created, and its arguments, from the arguments the
* change ran with and the result it gave. Null when the result does not name
* what was created, which offers no undo. The model is never offered these
* tools, except the removal it may also propose itself; they run in a session
* of their own that holds nothing else.
* of their own that holds nothing else. The packages add theirs
* (ASSISTANT_UNDO_MAP).
*/
interface AssistantUndo {
tool: string;
args: (args: ToolArgs, result: unknown) => ToolArgs | null;
}
/** A created tour taken back with its place, which takes its route and points along. */
const DELETE_CREATED_TOUR: AssistantUndo = {
tool: 'delete_place',
args: (_args, result) => {
const placeId = tourIdIn(result);
return placeId ? { placeId } : null;
},
};
export const ASSISTANT_UNDO_MAP: Readonly<Record<string, AssistantUndo>> = {
export const CORE_UNDO_MAP: Readonly<Record<string, ToolUndo>> = {
create_place: {
tool: 'delete_place',
args: (_args, result) => {
@@ -286,6 +254,4 @@ export const ASSISTANT_UNDO_MAP: Readonly<Record<string, AssistantUndo>> = {
return itemId ? { itemId } : null;
},
},
create_hike: DELETE_CREATED_TOUR,
create_city_walk: DELETE_CREATED_TOUR,
};
@@ -13,15 +13,15 @@ import {
USE_TOOLS_SPEC,
USE_TOOLS_TOOL,
} from './assistant-tool-offer';
import { ASSISTANT_SCOPES, ASSISTANT_SERVER_TOOLS, PINNED_ARG, REMEMBER_TOOL } from './assistant-tools.constants';
import type { ToolCards } from './cards/card-types';
import {
ASSISTANT_ARG_POLICY,
ASSISTANT_SCOPES,
ASSISTANT_SERVER_TOOLS,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
PINNED_ARG,
REMEMBER_TOOL,
} from './assistant-tools.constants';
PACKAGE_HELD_READS,
TOOL_CARDS,
} from './cards/registry';
import { MikroORM } from '@mikro-orm/core';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
@@ -58,20 +58,50 @@ interface ListedTool {
type SessionMode = 'turn' | 'undo';
/**
* What is wrong with the tool catalog the assistant relies on, given what the
* registry holds: a tool with a card of its own or an undo target that no
* longer exists, a sharing tool the denylist missed, or a tool named like one
* of the server's own steps. Empty when all is well.
* The tools the packages' undo checks read. A package's `reads` names its
* tools whatever the arguments, so asking with none shows which they are.
*/
export function checkToolCatalog(listing: readonly McpRegistryListing[]): string[] {
function undoReadTools(cards: Readonly<Record<string, ToolCards>>): string[] {
return Object.values(cards).flatMap((entry) => {
try {
return (entry.undo?.reads?.({}, {}) ?? []).map((read) => read.tool);
} catch {
return [];
}
});
}
/**
* What is wrong with the tool catalog the assistant relies on, given what the
* registry holds: a tool with a card of its own, a package's cards for a tool,
* an undo target, an undo read or a package's held read that no longer
* exists, a sharing tool the denylist missed, or a tool named like one of the
* server's own steps. Empty when all is well.
*/
export function checkToolCatalog(
listing: readonly McpRegistryListing[],
cards: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
heldReads: Iterable<string> = PACKAGE_HELD_READS,
): string[] {
const tools = new Set(listing.filter((entry) => entry.kind === 'tool').map((entry) => entry.name));
const problems: string[] = [];
for (const name of Object.keys(ASSISTANT_WRITE_TOOLS)) {
if (!tools.has(name)) problems.push(`tool "${name}" with a card of its own is not registered`);
}
for (const name of Object.keys(cards)) {
if (!tools.has(name) && !ASSISTANT_SERVER_TOOLS.has(name)) {
problems.push(`tool "${name}" with cards of a package is neither registered nor the server's own`);
}
}
for (const undo of Object.values(ASSISTANT_UNDO_MAP)) {
if (!tools.has(undo.tool)) problems.push(`undo tool "${undo.tool}" is not registered`);
}
for (const name of undoReadTools(cards)) {
if (!tools.has(name)) problems.push(`undo read "${name}" is not registered`);
}
for (const name of heldReads) {
if (!tools.has(name)) problems.push(`held read "${name}" of a package is not registered`);
}
// The server's own steps are told apart from tool calls by name alone.
for (const name of [...ASSISTANT_SERVER_TOOLS, USE_TOOLS_TOOL]) {
if (tools.has(name)) problems.push(`"${name}" is the server's own step`);
@@ -1,100 +1,42 @@
import type { AssistantToolSession } from './assistant-tools.service';
import type { ToolArgs, UndoSnapshot } from './cards/card-types';
import { readKey, recordOf, type Row } from './cards/card-values';
import { TOOL_CARDS, undoReadsOf } from './cards/registry';
import {
byId,
placeUnchanged,
rowsOf,
same,
snapshotAssignments,
snapshotDays,
STABLE_FIELDS,
} from './cards/undo-snapshot';
/**
* Whether an applied change may still be taken back: what it created must be
* as it was then. An undo removes the created row with everything that hangs
* off it, so a place someone planned on another day since, or booked, or
* linked an expense to, is not removed by an undo the user meant for the bare
* place. The trip is read as the tools answer it (`get_trip_summary` raw, and
* `list_places` for places).
* `list_places` for places), plus the reads a package's undo asks for.
*/
type Row = Record<string, unknown>;
/** The trip as an undo reads it; the package reads are left out where none were made. */
export type TripSnapshot = Omit<UndoSnapshot, 'reads'> & { reads?: UndoSnapshot['reads'] };
export interface TripSnapshot {
summary: unknown;
places: unknown;
}
/** The trip reads every undo makes before it checks. */
const TRIP_READS = ['get_trip_summary', 'list_places'] as const;
function recordOf(value: unknown): Row {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
}
function rowsOf(value: unknown): Row[] {
return Array.isArray(value) ? value.map(recordOf) : [];
}
/** The fields that say a row is still the one that was created; a field either side lacks is not compared. */
const STABLE_FIELDS = {
place: ['name', 'updated_at', 'lat', 'lng', 'address', 'category_id', 'description', 'notes'],
assignment: ['day_id', 'place_id'],
note: ['text', 'time', 'icon', 'color'],
packing: ['name', 'updated_at', 'checked', 'quantity', 'category'],
todo: ['name', 'checked', 'due_date', 'description', 'priority', 'assigned_user_id', 'category'],
budget: ['name', 'total_price', 'currency', 'category', 'note', 'expense_date'],
} as const;
function same(created: Row, current: Row | undefined, fields: readonly string[]): boolean {
if (!current) return false;
return fields.every((field) => !(field in created) || !(field in current) || created[field] === current[field]);
}
function byId(rows: Row[], id: unknown): Row | undefined {
return rows.find((row) => row.id === id);
}
function days(snapshot: TripSnapshot): Row[] {
return rowsOf(recordOf(snapshot.summary).days);
}
function assignments(snapshot: TripSnapshot): Row[] {
return days(snapshot).flatMap((day) => rowsOf(day.assignments));
}
/** A created place, unchanged and still only where the change put it: on no other day, in no booking or expense. */
function placeUnchanged(result: Row, snapshot: TripSnapshot): boolean {
const created = recordOf(result.place);
const current = byId(rowsOf(recordOf(snapshot.places).places), created.id);
if (!same(created, current, STABLE_FIELDS.place)) return false;
const ownAssignment = recordOf(result.assignment).id;
const plannedElsewhere = assignments(snapshot).some((a) => a.place_id === created.id && a.id !== ownAssignment);
const summary = recordOf(snapshot.summary);
const referenced = [
...rowsOf(summary.reservations),
...rowsOf(summary.accommodations),
...rowsOf(recordOf(summary.budget).items),
].some((row) => row.place_id === created.id);
return !plannedElsewhere && !referenced;
}
/**
* A created tour, unchanged and on no day: its place still has the name and
* description it was created with, has not been written since it was made,
* and nothing plans, books or pays for it. Every write of a tour's route or
* name (a change over MCP, the planner's save, a rename) stamps its place's
* `updated_at`, which the create left equal to `created_at`; the revision is
* not in the snapshot. A tour planned on a day since is kept too.
*/
function tourUnchanged(result: Row, snapshot: TripSnapshot): boolean {
const tour = recordOf(result.tour);
const current = byId(rowsOf(recordOf(snapshot.places).places), tour.place_id);
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
return (
untouched &&
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
);
}
const CHECKS: Readonly<Record<string, (result: Row, snapshot: TripSnapshot) => boolean>> = {
const CORE_CHECKS: Readonly<Record<string, (result: Row, snapshot: UndoSnapshot) => boolean>> = {
create_place: placeUnchanged,
create_and_assign_place: placeUnchanged,
create_hike: tourUnchanged,
create_city_walk: tourUnchanged,
assign_place_to_day: (result, snapshot) => {
const created = recordOf(result.assignment);
return same(created, byId(assignments(snapshot), created.id), STABLE_FIELDS.assignment);
return same(created, byId(snapshotAssignments(snapshot), created.id), STABLE_FIELDS.assignment);
},
create_day_note: (result, snapshot) => {
const created = recordOf(result.note);
const notes = days(snapshot).flatMap((day) => rowsOf(day.notes));
const notes = snapshotDays(snapshot).flatMap((day) => rowsOf(day.notes));
return same(created, byId(notes, created.id), STABLE_FIELDS.note);
},
create_packing_item: (result, snapshot) => {
@@ -113,8 +55,47 @@ const CHECKS: Readonly<Record<string, (result: Row, snapshot: TripSnapshot) => b
},
};
/**
* The core checks and each package's `undo.unchanged`, which the registry
* guards (one that throws reads as changed) and keeps off the core's tools.
*/
const CHECKS: Readonly<Record<string, (result: Row, snapshot: UndoSnapshot) => boolean>> = {
...CORE_CHECKS,
...Object.fromEntries(
Object.entries(TOOL_CARDS).flatMap(([tool, cards]) => {
const undo = cards.undo;
return undo ? [[tool, (result: Row, snapshot: UndoSnapshot) => undo.unchanged(result, snapshot)]] : [];
}),
),
};
/** True when the change `toolName` made (its stored `result`) is still as it left it. */
export function unchangedSinceApplied(toolName: string, result: unknown, snapshot: TripSnapshot): boolean {
const check = CHECKS[toolName];
return !!check && check(recordOf(result), snapshot);
return !!check && check(recordOf(result), { ...snapshot, reads: snapshot.reads ?? new Map() });
}
/** The tools an undo's session holds: the removing tool, the trip reads and what the change's package reads. */
export function undoTargetsOf(undoTool: string, toolName: string, args: ToolArgs, result: unknown): Set<string> {
return new Set([undoTool, ...TRIP_READS, ...undoReadsOf(toolName, args, result).map((read) => read.tool)]);
}
/** The trip as the undo checks it, read through the undo's session; null when a trip read failed. */
export async function undoSnapshotOf(
session: Pick<AssistantToolSession, 'read'>,
toolName: string,
args: ToolArgs,
result: unknown,
reads = undoReadsOf(toolName, args, result),
): Promise<UndoSnapshot | null> {
const summary = await session.read('get_trip_summary');
const places = await session.read('list_places');
if (!summary.ok || !places.ok) return null;
const extra = await Promise.all(
reads.map(async (read): Promise<[string, unknown]> => {
const run = await session.read(read.tool, read.args);
return [readKey(read.tool, read.args), run.ok ? run.value : null];
}),
);
return { summary: summary.value, places: places.value, reads: new Map(extra) };
}
@@ -3,6 +3,7 @@ import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
import { AddonsModule } from '../addons/addons.module';
import { AiConnectionsModule } from '../ai-connections/ai-connections.module';
import { AirportsModule } from '../airports/airports.module';
import { AuditModule } from '../audit/audit.module';
import { BookingImportModule } from '../booking-import/booking-import.module';
import { CollabModule } from '../collab/collab.module';
@@ -21,6 +22,7 @@ import { AssistantStreamRegistry, InMemoryAssistantStreamRegistry } from './assi
import { AssistantToolsService } from './assistant-tools.service';
import { AssistantController } from './assistant.controller';
import { AssistantService } from './assistant.service';
import { AssistantCardContext } from './cards/card-context';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
@@ -29,13 +31,15 @@ import { Module } from '@nestjs/common';
* model the user's connection reaches (AiConnectionsModule), working the trip
* through the MCP tools of the global registry. A file attached in the chat
* is read through the booking import, and an answer shared in the trip's
* chat is posted through the collab chat. Its conversations are kept per user
* and trip and swept by the retention job.
* chat is posted through the collab chat. A card names an airport through the
* airports' data. Its conversations are kept per user and trip and swept by
* the retention job.
*/
@Module({
imports: [
AddonsModule,
AiConnectionsModule,
AirportsModule,
AuditModule,
BookingImportModule,
CollabModule,
@@ -57,6 +61,7 @@ import { Module } from '@nestjs/common';
AssistantFoundPlacesService,
AssistantShareService,
AssistantRetentionJob,
AssistantCardContext,
{ provide: AssistantStreamRegistry, useClass: InMemoryAssistantStreamRegistry },
],
})
@@ -1,3 +1,5 @@
import { CARD_PROMPT_LINES } from './cards/registry';
/**
* The assistant's standing instructions. They stay the same for every turn
* of a conversation (the provider caches them); what changes from turn to
@@ -33,7 +35,10 @@ How you work:
- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour.
- If the request is unclear, ask one short question instead of guessing.`;
/** The instructions with the packages' lines about their cards, built once so the provider's cache holds. */
const STANDING = INSTRUCTIONS + CARD_PROMPT_LINES.map((line) => `\n- ${line}`).join('');
/** The standing instructions, telling the model which language to answer in. */
export function systemPrompt(language: string): string {
return INSTRUCTIONS.replace('{language}', language);
return STANDING.replace('{language}', language);
}
+36 -50
View File
@@ -2,10 +2,11 @@ import { AssistantMessages } from '../../db/entities/AssistantMessages.entity';
import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
import {
ASSISTANT_MESSAGE_PARTS,
ASSISTANT_MESSAGE_PARTS_WRITE,
ASSISTANT_PROVIDER_STATE,
ASSISTANT_TOOL_ARGS,
ASSISTANT_TOOL_PREVIEW,
ASSISTANT_TOOL_PREVIEW_WRITE,
ASSISTANT_TOOL_RESULT,
} from '../../db/json-columns';
import type {
@@ -34,18 +35,17 @@ import { SettingsService } from '../settings/settings.service';
import { runAgentLoop, type AgentStore, type HeldCall, type StepRecord } from './assistant-agent';
import { AssistantBookingImportService } from './assistant-booking-import.service';
import { mentionItems } from './assistant-cards.helpers';
import { buildTripCard, categoriesFrom, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
import { buildTripCard, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
import { DISCARDED, NOT_RUN, type Outcome, withCancelled, withOutcomes } from './assistant-decisions.helpers';
import { messageIdOf, outputText, partsOf, toChatHistory, toThreadMessages } from './assistant-history';
import { rememberForTrip } from './assistant-memory';
import { toolOutputForProvider } from './assistant-redact';
import { toolOutputForProvider } from './assistant-output';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { titleFrom } from './assistant-title';
import {
ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS,
ASSISTANT_SERVER_WRITE_TOOLS,
ASSISTANT_UNDO_MAP,
foundPlaceArgs,
IMPORT_BOOKING_TOOL,
READ_BOOKING_FILE_TOOL,
@@ -55,9 +55,11 @@ import {
writeToolOf,
} from './assistant-tools.constants';
import { AssistantToolsService, type AssistantToolSession, type ToolRun } from './assistant-tools.service';
import { unchangedSinceApplied } from './assistant-undo.helpers';
import { unchangedSinceApplied, undoSnapshotOf, undoTargetsOf } from './assistant-undo.helpers';
import { systemPrompt } from './assistant.prompt';
import { appliedChange, describeToolCall } from './tool-call-preview.helpers';
import { AssistantCardContext } from './cards/card-context';
import { ASSISTANT_UNDO_MAP } from './cards/registry';
import { appliedChange } from './tool-call-preview.helpers';
import { UiStreamWriter } from './ui-stream.writer';
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -65,6 +67,7 @@ import { Injectable } from '@nestjs/common';
import type {
AssistantAttachmentRequest,
AssistantChatRequest,
AssistantClientCtx,
AssistantDecision,
AssistantDecisionsRequest,
AssistantFocus,
@@ -133,7 +136,7 @@ function summaryOf(thread: AssistantThreadRow): AssistantThreadSummary {
}
function encodeParts(parts: AssistantMessagePart[]): string {
return encodeJson(ASSISTANT_MESSAGE_PARTS, parts);
return encodeJson(ASSISTANT_MESSAGE_PARTS_WRITE, parts);
}
const threadNotFound = () => aiRefusal('not_found', 'Conversation not found');
@@ -166,6 +169,7 @@ export class AssistantService {
private readonly streams: AssistantStreamRegistry,
private readonly uow: UnitOfWork,
private readonly orm: MikroORM,
private readonly cardContext: AssistantCardContext,
) {}
async listThreads(userId: number, tripId: number): Promise<AssistantThreadListResponse> {
@@ -265,7 +269,8 @@ export class AssistantService {
if (begun.created) w.thread(begun.threadId, begun.title);
const stopKeepalive = armKeepalive(stream);
try {
const turn = { userId, tripId, threadId: begun.threadId, rowId: begun.rowId, locale: req.locale, name };
const { locale, timeZone } = req;
const turn = { userId, tripId, threadId: begun.threadId, rowId: begun.rowId, locale, timeZone, name };
await this.proposeBookings(turn, file, w, signal);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
@@ -416,19 +421,13 @@ export class AssistantService {
const tool = writeToolOf(call.tool_name, args);
const undo = ASSISTANT_UNDO_MAP[tool];
const result = decodeJson(ASSISTANT_TOOL_RESULT, call.result, `tool call ${call.id}`);
const undoArgs = undo && call.state === 'executed' ? undo.args(args, result) : null;
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
const undoArgs = undo && call.state === 'executed' ? undo.args(args, result, preview) : null;
if (!undo || !undoArgs) throw stale();
const session = await this.tools.open(userId, tripId, new Set([undo.tool, 'get_trip_summary', 'list_places']));
const session = await this.tools.open(userId, tripId, undoTargetsOf(undo.tool, tool, args, result));
try {
const summary = await session.read('get_trip_summary');
const places = await session.read('list_places');
if (
!summary.ok ||
!places.ok ||
!unchangedSinceApplied(tool, result, { summary: summary.value, places: places.value })
) {
throw stale();
}
const snapshot = await undoSnapshotOf(session, tool, args, result);
if (!snapshot || !unchangedSinceApplied(tool, result, snapshot)) throw stale();
if (!(await this.toolCalls.claimUndo(call.id, userId, dbNow()))) throw stale();
const run = await session.call(undo.tool, undoArgs);
if (!run.ok) {
@@ -530,7 +529,7 @@ export class AssistantService {
* transaction of its own.
*/
private async proposeBookings(
turn: { userId: number; tripId: number; threadId: number; rowId: number; locale: string; name: string },
turn: { userId: number; tripId: number; threadId: number; rowId: number; name: string } & AssistantClientCtx,
file: Express.Multer.File,
w: UiStreamWriter,
signal: AbortSignal,
@@ -574,18 +573,14 @@ export class AssistantService {
return;
}
const card = await this.tripCardOf(turn.userId, turn.tripId);
const ctx = { card, categories: new Map<number, string>(), locale: turn.locale };
const held: HeldCall[] = items.map((item) => {
const id = randomUUID();
const args = item as Record<string, unknown>;
return {
id,
providerCallId: `import_${id}`,
toolName: IMPORT_BOOKING_TOOL,
args,
preview: describeToolCall(IMPORT_BOOKING_TOOL, id, args, ctx),
};
});
const ctx = this.cardContext.contextOf(card, new Map(), turn.locale, todayIn(turn.timeZone, new Date()));
const proposed = items.map((item) => ({
id: randomUUID(),
toolName: IMPORT_BOOKING_TOOL,
args: item as Record<string, unknown>,
}));
const described = await this.cardContext.describeProposed(turn.userId, turn.tripId, ctx, proposed);
const held: HeldCall[] = described.map((change) => ({ ...change, providerCallId: `import_${change.id}` }));
const parts: AssistantMessagePart[] = [
readPart(found),
...held.map((change): AssistantMessagePart => ({
@@ -613,7 +608,7 @@ export class AssistantService {
try {
const summary = await session.read('get_trip_summary');
const places = await session.read('list_places');
return buildTripCard(tripId, summary.ok ? summary.value : null, places.ok ? places.value : null, null);
return buildTripCard(tripId, userId, summary.ok ? summary.value : null, places.ok ? places.value : null, null);
} finally {
await session.close();
}
@@ -714,7 +709,8 @@ export class AssistantService {
const args = decodeJson(ASSISTANT_TOOL_ARGS, call.args, `tool call ${call.id}`);
const ran = await run(call.tool_name, args);
const output = toolOutputForProvider(call.tool_name, ran.value);
const undoable = ran.ok && !!ASSISTANT_UNDO_MAP[writeToolOf(call.tool_name, args)]?.args(args, ran.value);
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
const undoable = ran.ok && !!ASSISTANT_UNDO_MAP[writeToolOf(call.tool_name, args)]?.args(args, ran.value, preview);
const undoUntil = undoable ? dbNow(new Date(Date.now() + UNDO_WINDOW_MS)) : null;
await this.inContext(() =>
this.toolCalls.recordOutcome(
@@ -731,7 +727,6 @@ export class AssistantService {
return;
}
w.toolOutput(call.id, output);
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
const applied = appliedChange(
call.tool_name,
call.id,
@@ -744,6 +739,7 @@ export class AssistantService {
w.applied(applied);
receipts.push({ type: 'data-trek-applied', data: applied });
}
this.cardContext.receipt(w, receipts, call, args, ran.value, preview);
}
/**
@@ -832,21 +828,17 @@ export class AssistantService {
const providerIds = new Map([...calls.values()].map((call) => [call.id, call.provider_call_id]));
const history = toChatHistory(rows, providerIds);
const prefs = await this.inContext(() => this.settings.getUserSettings(spec.userId));
let categories: Map<number, string> | null = null;
const today = todayIn(spec.timeZone, new Date());
const outcome = await runAgentLoop(
{
client: this.chatClients.create(spec.connection),
tools: session,
store: this.stepStore(spec),
describe: async (toolName, toolCallId, args) => {
categories ??= await this.categoriesOf(session);
const hintOf = (name: string) => session.hintOf(name);
return describeToolCall(toolName, toolCallId, args, { card, categories, locale: spec.locale, hintOf });
},
describe: this.cardContext.forTurn(session, card, spec.locale, today).describe,
newId: randomUUID,
quota: spec.quota,
card,
today: todayIn(spec.timeZone, new Date()),
today,
},
{
system: systemPrompt(languageName(spec.locale)),
@@ -890,13 +882,7 @@ export class AssistantService {
const summary = await session.read('get_trip_summary');
const places = await session.read('list_places');
const [found, listed] = [summary.ok ? summary.value : null, places.ok ? places.value : null];
return buildTripCard(spec.tripId, found, listed, spec.focus, spec.mentions);
}
private async categoriesOf(session: AssistantToolSession): Promise<Map<number, string>> {
const run = await session.read('list_categories');
const categories = categoriesFrom(run.ok ? run.value : null);
return new Map(categories.map((category): [number, string] => [category.id, category.name]));
return buildTripCard(spec.tripId, spec.userId, found, listed, spec.focus, spec.mentions);
}
/**
@@ -973,7 +959,7 @@ export class AssistantService {
providerCallId: change.providerCallId,
toolName: change.toolName,
args: encodeJson(ASSISTANT_TOOL_ARGS, change.args),
preview: encodeJson(ASSISTANT_TOOL_PREVIEW, change.preview),
preview: encodeJson(ASSISTANT_TOOL_PREVIEW_WRITE, change.preview),
createdAt,
};
}
@@ -0,0 +1,33 @@
import { writeToolOf } from '../assistant-tools.constants';
import type { UiStreamWriter } from '../ui-stream.writer';
import type { ToolArgs } from './card-types';
import { appliedCardOf } from './registry';
import type { AssistantChangePreview, AssistantMessagePart, TrekCard } from '@trek/shared';
/** Builds the card of an applied change of `tool`; null when the tool has none. */
type AppliedCardBuilder = (
tool: string,
input: { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null },
) => TrekCard | null;
/**
* The card of an applied change, streamed and kept beside its receipt under
* the write's own id, so the client puts it in place of the preview card. Its
* builder gets no trip card (the decisions run before the turn reads one): it
* builds on the stored preview card and the tool's raw answer.
*/
export function receiptCard(
w: Pick<UiStreamWriter, 'card'>,
receipts: AssistantMessagePart[],
call: { id: string; tool_name: string },
args: ToolArgs,
result: unknown,
preview: AssistantChangePreview | null,
build: AppliedCardBuilder = appliedCardOf,
): void {
const card = build(writeToolOf(call.tool_name, args), { args, result, preview });
if (!card) return;
const data = { toolCallId: call.id, card };
w.card(data);
receipts.push({ type: 'data-trek-card', data });
}
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the atlas: countries, regions and the bucket list. None yet: this package fills them in. */
export const ATLAS_CARDS: DomainCards = { id: 'atlas', tools: {} };
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the bookings: flights, trains, stays and other reservations. None yet: this package fills them in. */
export const BOOKING_CARDS: DomainCards = { id: 'booking', tools: {} };
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the budget: expenses, payments and who settles with whom. None yet: this package fills them in. */
export const BUDGET_CARDS: DomainCards = { id: 'budget', tools: {} };
@@ -0,0 +1,182 @@
import { AirportsService } from '../../airports/airports.service';
import { logWarn } from '../../audit/audit-log.logger';
import { categoriesFrom, type TripCard } from '../assistant-context';
import { writeToolOf } from '../assistant-tools.constants';
import { AssistantToolsService, type AssistantToolSession } from '../assistant-tools.service';
import { describeToolCall } from '../tool-call-preview.helpers';
import type { UiStreamWriter } from '../ui-stream.writer';
import { receiptCard } from './applied-cards';
import type { CardAirport, CardReader, PreviewContext, ToolArgs, ToolCards } from './card-types';
import { readKey } from './card-values';
import { appliedCardOf, TOOL_CARDS } from './registry';
import { Injectable } from '@nestjs/common';
import type { AssistantChangePreview, AssistantMessagePart, TrekCard } from '@trek/shared';
/** Reads one held change's lookup may make; the ones past it read as null. */
export const MAX_CARD_READS = 4;
/** What the turn's reads go through: the session, unaudited. */
type ReadSession = Pick<AssistantToolSession, 'read'>;
/** A change the server proposes itself, before its card is written. */
interface ProposedCall {
id: string;
toolName: string;
args: ToolArgs;
}
/** A turn's describer of held calls, as the agent loop asks for it. */
interface TurnCards {
describe(tool: string, toolCallId: string, args: ToolArgs): Promise<AssistantChangePreview>;
}
/**
* Reads through the session, each tool and set of arguments once per turn:
* the second card that asks for the same read gets the first one's answer.
* A read that failed reads as null.
*/
export function memoisedReader(session: ReadSession): (tool: string, args?: ToolArgs) => Promise<unknown> {
const memo = new Map<string, Promise<unknown>>();
return (tool, args = {}) => {
const key = readKey(tool, args);
let read = memo.get(key);
if (!read) {
read = session.read(tool, args).then(
(run) => (run.ok ? run.value : null),
() => null,
);
memo.set(key, read);
}
return read;
};
}
/**
* The reader one held change's lookup gets: the turn's, cut off after
* MAX_CARD_READS reads. A read the memo answers counts too, so a lookup asks
* at most that often whatever the turn read before it.
*/
function cappedReader(read: CardReader, tool: string): CardReader {
let left = MAX_CARD_READS;
return (name, args) => {
if (left <= 0) {
logWarn(`[assistant] the lookup of ${tool} asked for more than ${MAX_CARD_READS} reads`);
return Promise.resolve(null);
}
left -= 1;
return read(name, args);
};
}
/** What a package's lookup found for a held call, or null when it has none or it failed. */
export async function loadFor(
tool: string,
args: ToolArgs,
read: CardReader,
registry: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
): Promise<unknown> {
const cards = registry[tool];
if (!cards?.load) return null;
try {
return (await cards.load(args, cappedReader(read, tool))) ?? null;
} catch (err) {
logWarn(`[assistant] the lookup of ${tool} failed: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
/**
* What the assistant makes its cards with beyond the tool's own answer: the
* context a change card is written in (the trip card, the categories, the
* user's locale and day, the airports), the lookups a package's card makes
* before it is shown, and the card of an applied change.
*/
@Injectable()
export class AssistantCardContext {
constructor(
private readonly airports: AirportsService,
private readonly tools: AssistantToolsService,
) {}
/**
* The describer of one turn's held calls. The categories are read once,
* when the first change needs them; a package's lookup reads through the
* turn's memo, at most MAX_CARD_READS reads per change.
*/
forTurn(session: ReadSession & Pick<AssistantToolSession, 'hintOf'>, card: TripCard, locale: string, today: string) {
const read = memoisedReader(session);
const hintOf = (name: string) => session.hintOf(name);
let categories: Promise<ReadonlyMap<number, string>> | null = null;
const turn: TurnCards = {
describe: async (tool, toolCallId, args) => {
categories ??= read('list_categories').then(
(value) => new Map(categoriesFrom(value).map((category): [number, string] => [category.id, category.name])),
);
const ctx = this.contextOf(card, await categories, locale, today, hintOf);
const loaded = await loadFor(writeToolOf(tool, args), args, read);
return describeToolCall(tool, toolCallId, args, ctx, loaded);
},
};
return turn;
}
/** The context a change card is written in, for a change the server proposes without a turn. */
contextOf(
card: TripCard,
categories: ReadonlyMap<number, string>,
locale: string,
today: string,
hintOf?: PreviewContext['hintOf'],
): PreviewContext {
return { card, categories, locale, today, ...(hintOf ? { hintOf } : {}), airport: (code) => this.airport(code) };
}
/**
* The change cards of changes the server proposes without a turn (a booking
* from a file, a found place), each with what its package looked up. A
* session for the lookups is opened only when one of the tools has a lookup.
*/
async describeProposed<C extends ProposedCall>(
userId: number,
tripId: number,
ctx: PreviewContext,
calls: readonly C[],
registry: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
): Promise<Array<C & { preview: AssistantChangePreview }>> {
const looksUp = calls.some((call) => !!registry[writeToolOf(call.toolName, call.args)]?.load);
const session = looksUp ? await this.tools.open(userId, tripId) : null;
try {
const read: CardReader = session ? memoisedReader(session) : () => Promise.resolve(null);
return await Promise.all(
calls.map(async (call) => {
const loaded = await loadFor(writeToolOf(call.toolName, call.args), call.args, read, registry);
return { ...call, preview: describeToolCall(call.toolName, call.id, call.args, ctx, loaded) };
}),
);
} finally {
await session?.close();
}
}
/** The card of an applied change beside its receipt, streamed and kept on the proposing message. */
receipt(
w: Pick<UiStreamWriter, 'card'>,
receipts: AssistantMessagePart[],
call: { id: string; tool_name: string },
args: ToolArgs,
result: unknown,
preview: AssistantChangePreview | null,
): void {
receiptCard(w, receipts, call, args, result, preview, (tool, input) => this.appliedCard(tool, input));
}
/** The guarded applied card of `tool`; the e2e suite stubs it here. */
appliedCard(tool: string, input: Parameters<typeof appliedCardOf>[1]): TrekCard | null {
return appliedCardOf(tool, input);
}
private airport(code: string): CardAirport | null {
const found = /^[A-Za-z]{3}$/.test(code) ? this.airports.findByIata(code.toUpperCase()) : null;
return found ? { city: found.city || found.name, lat: found.lat, lng: found.lng, tz: found.tz } : null;
}
}
@@ -0,0 +1,144 @@
import type { TripCard } from '../assistant-context';
import type { AssistantWriteTool } from '../assistant-tools.constants';
import type { ToolHint } from '../assistant-tools.service';
import type { Rows } from './preview-format';
import type { AssistantChangeEffect, AssistantChangePreview, AssistantChangeVerbKey, TrekCard } from '@trek/shared';
/**
* What a part of TREK hands the assistant for its tools: one `ToolCards`
* entry per tool, gathered per package in `cards/<pkg>/index.ts` and merged
* by `registry.ts`. Types only, so any file may import them.
*/
export type ToolArgs = Record<string, unknown>;
/** The packages that bring cards of their own, one folder each. */
type CardPackageId =
'tour' | 'roadtrip' | 'budget' | 'booking' | 'collab' | 'collection' | 'atlas' | 'vacay' | 'journey' | 'packing';
/**
* A read a card may make while a change is described: unaudited, made once
* per turn for the same tool and arguments, null on any failure.
*/
export type CardReader = (tool: string, args?: ToolArgs) => Promise<unknown>;
/** An airport as a card names it, by its IATA code. */
export interface CardAirport {
city: string;
lat: number;
lng: number;
tz: string;
}
/** What a change card is written with. */
export interface PreviewContext {
card: TripCard;
categories: ReadonlyMap<number, string>;
locale: string;
/** Today in the user's time zone (`YYYY-MM-DD`). */
today: string;
/** What a tool without a card of its own says about itself, for the card built from its arguments. */
hintOf?: (tool: string) => ToolHint | null;
airport: (code: string) => CardAirport | null;
}
/**
* A change described in plain words. `verbKey`, `tool` and `domain` override
* what the tool's `write` entry and the generic card would say: a verb that
* depends on the arguments, the tool's own label, the part of TREK it works in.
*/
export interface Described {
subject: string;
rows: Rows;
dayId: number | null;
placeIds: number[];
verbKey?: AssistantChangeVerbKey;
tool?: string;
domain?: string;
}
/** What an answer card is built from: a read that ran, with its raw value (never the redacted one). */
export interface AnswerInput {
toolCallId: string;
args: ToolArgs;
value: unknown;
card: TripCard;
today: string;
}
/**
* What an undo is checked against: the trip as `get_trip_summary` and
* `list_places` answer it, and the reads the change's `undo.reads` asked for,
* by `readKey(tool, args)` (null for one that failed).
*/
export interface UndoSnapshot {
summary: unknown;
places: unknown;
reads: ReadonlyMap<string, unknown>;
}
/** The way back from an applied change: the removing tool and its arguments, null when there is none. */
export interface ToolUndo {
tool: string;
args(args: ToolArgs, result: unknown, preview: AssistantChangePreview | null): ToolArgs | null;
}
/**
* Everything a package says about one tool. Every member is optional and
* written as a method, so a `ToolCards<X>` fits where a `ToolCards` is asked.
* A builder that throws, returns a card the union refuses or one past
* MAX_CARD_CHARS gives no card (logged), never a broken turn; any other
* member that throws is logged and read as saying nothing (registry.ts).
*/
export interface ToolCards<L = unknown> {
/** A curated change card: its verb and what the applied change touches. */
write?: AssistantWriteTool;
/** Arguments the model never chooses: `forced` ones are set, `stripped` ones taken away. */
argPolicy?: { forced?: ToolArgs; stripped?: readonly string[] };
/** The answer as the provider gets it, before redaction and fitting. */
project?(value: unknown): unknown;
/** The card a read shows beside the answer; only for a read that ran well. */
answer?(input: AnswerInput): TrekCard | null;
/** What describing a held call needs beyond the trip card: at most four reads, null on failure. */
load?(args: ToolArgs, read: CardReader): Promise<L | null>;
/** The change card's words; must give a valid card for `{}` arguments too. */
describe?(ctx: PreviewContext, args: ToolArgs, loaded: L | null): Described;
/** What the change does to the day plan, for the map and the day's timeline. */
effect?(ctx: PreviewContext, args: ToolArgs, loaded: L | null): AssistantChangeEffect | null;
/** The card a held call shows inside its change card. */
change?(ctx: PreviewContext, args: ToolArgs, loaded: L | null): TrekCard | null;
/** The card of the applied change, from the stored preview card and the tool's raw answer. */
applied?(input: { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null }): TrekCard | null;
/** The ids the receipt names, when the answer does not name them under the usual keys. */
appliedIds?(args: ToolArgs, result: unknown): number[];
/** The way back, with what it reads first and how it tells that nothing changed since. */
undo?: ToolUndo & {
/**
* Reads the undo makes before it checks; every tool named here must be
* registered. The boot check asks with empty arguments and result, so the
* tools it names may not depend on them (their arguments may).
*/
reads?(args: ToolArgs, result: unknown): ReadonlyArray<{ tool: string; args: ToolArgs }>;
unchanged(result: unknown, snapshot: UndoSnapshot): boolean;
};
/** The name of an id argument the trip card cannot name, for the generic card. */
nameArg?(key: string, value: number, card: TripCard): string | null;
}
/** One package's cards. */
export interface DomainCards {
readonly id: CardPackageId;
readonly tools: Readonly<Record<string, ToolCards>>;
/** At most two lines for the standing instructions, added once at load so the prompt cache stays stable. */
readonly prompt?: readonly string[];
/** Tool groups whose `placeId` arguments are no trip places (collections, atlas). */
readonly foreignIdGroups?: readonly string[];
/**
* MCP controllers the package added, by the tool group they load with (a
* key of ASSISTANT_TOOL_GROUPS); a controller listed nowhere is offered on
* every step as part of the core.
*/
readonly toolControllers?: Readonly<Record<string, readonly string[]>>;
/** Read-only tools that still wait for the user: they fetch an address that came from the web or a message. */
readonly heldReads?: readonly string[];
}
@@ -0,0 +1,134 @@
import type { TripCard } from '../assistant-context';
import { redactForProvider } from '../assistant-redact';
import { cardFileKeySchema, type CardPerson } from '@trek/shared';
/**
* The readers every card is built with. They take a tool's own answer or a
* call's arguments as they come, trust no shape, and give null for what is
* not there. Text from a RESULT is masked like a provider answer (`clipped`,
* `blockText` with `mask`), so an e-mail address in a name or a note never
* reaches a card; text from ARGS is only cut, since it is what will be stored.
*/
export type Row = Record<string, unknown>;
export function recordOf(value: unknown): Row {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
}
export function finite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function isLat(value: number | null): value is number {
return value !== null && Math.abs(value) <= 90;
}
export function isLng(value: number | null): value is number {
return value !== null && Math.abs(value) <= 180;
}
function cut(text: string, max: number): string {
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}
/** A text as a card may show it: on one line, masked like a provider answer, and cut to `max`. */
export function clipped(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = String(redactForProvider(value.replace(/\s+/g, ' ').trim()));
return line === '' ? null : cut(line, max);
}
/**
* A longer text (a note, a message) with its line breaks kept: spaces run
* together, at most one empty line between paragraphs, cut to `max`. Masked
* when it comes from a result.
*/
export function blockText(value: unknown, max: number, { mask }: { mask: boolean }): string | null {
if (typeof value !== 'string') return null;
const lines = value
.replace(/\r\n?/g, '\n')
.split('\n')
.map((line) => line.replace(/[^\S\n]+/g, ' ').trim());
const text = lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
const shown = mask ? String(redactForProvider(text)) : text;
return shown === '' ? null : cut(shown, max);
}
function positiveId(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
/** The id of the row an answer names under `key` (`{ place: { id } }`). */
export function idIn(value: unknown, key: string): number | null {
return positiveId(recordOf(recordOf(value)[key]).id);
}
/** An argument read as an id. */
export function argId(args: Row, key: string): number | null {
return positiveId(args[key]);
}
const CLOCK = /^(?:\d{4}-\d{2}-\d{2}[T ])?([01]?\d|2[0-3]):([0-5]\d)(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/;
/** A time of day as `HH:MM`, from a clock (`9:30`, `09:30:00`) or the clock of a date and time as written. */
export function clockOf(value: unknown): string | null {
if (typeof value !== 'string') return null;
const match = CLOCK.exec(value.trim());
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null;
}
const ZONED = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/;
/**
* A moment as `YYYY-MM-DDTHH:MM:SSZ`, from a date and time that names its
* zone. One without a zone is a local time and no moment, so it gives null.
*/
export function instantOf(value: unknown): string | null {
if (typeof value !== 'string' || !ZONED.test(value.trim())) return null;
const ms = Date.parse(value.trim());
return Number.isNaN(ms) ? null : `${new Date(ms).toISOString().slice(0, 19)}Z`;
}
/** A colour the data carries, `#rrggbb`; anything else gives null. */
export function hexOrNull(value: unknown): string | null {
return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value : null;
}
/**
* The stored file name behind one of TREK's own upload paths
* (`/uploads/<dir>/<file>`), for the client to build the path again. Anything
* else, a picture on another host among them, gives null.
*/
export function fileKeyOf(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const match = /^\/uploads\/[\w-]+\/([^/?#]+)$/.exec(raw);
return match && cardFileKeySchema.safeParse(match[1]).success ? match[1] : null;
}
/** A trip member as a card shows them, from the trip card; null for someone not in the trip. */
export function personOf(card: TripCard, id: number): CardPerson | null {
const member = card.members.get(id);
const name = member ? clipped(member.name, 80) : null;
return member && name ? { id, name, avatarKey: member.avatarKey } : null;
}
/** JSON with the keys of every object in order, so the same arguments always give the same text. */
export function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value && typeof value === 'object') {
const entries = Object.entries(value as Row)
.filter(([, inner]) => inner !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([key, inner]) => `${JSON.stringify(key)}:${canonicalJson(inner)}`).join(',')}}`;
}
return JSON.stringify(value) ?? 'null';
}
/** How one read is named: its tool and its arguments, the same for the same read whatever the key order. */
export function readKey(tool: string, args: Row = {}): string {
return `${tool} ${canonicalJson(args)}`;
}
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the trip chat: messages, polls and shared notes. None yet: this package fills them in. */
export const COLLAB_CARDS: DomainCards = { id: 'collab', tools: {} };
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the collections and their places. None yet: this package fills them in. */
export const COLLECTION_CARDS: DomainCards = { id: 'collection', tools: {} };
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the travel journal. None yet: this package fills them in. */
export const JOURNEY_CARDS: DomainCards = { id: 'journey', tools: {} };
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of packing lists and to-dos. None yet: this package fills them in. */
export const PACKING_CARDS: DomainCards = { id: 'packing', tools: {} };
@@ -0,0 +1,115 @@
import type { PreviewContext } from './card-types';
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
/**
* How a change card writes what it shows: a value cut to what a row holds,
* an argument read as an id, dates and amounts in the user's locale, days,
* places, people and categories by their names. Shared by the core cards
* (tool-call-preview.helpers.ts) and every package's own.
*/
type Args = Record<string, unknown>;
/** What naming a day or a place needs of the context. */
type Naming = Pick<PreviewContext, 'card' | 'locale'>;
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
const MAX_VALUE_CHARS = 160;
const CHECKED = '✓';
export const UNCHECKED = '✗';
export function clip(value: string): string {
const line = value.replace(/\s+/g, ' ').trim();
return line.length > MAX_VALUE_CHARS ? `${line.slice(0, MAX_VALUE_CHARS - 1)}…` : line;
}
export function id(args: Args, key: string): number | null {
const value = args[key];
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
export function text(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? CHECKED : UNCHECKED;
return clip(String(value));
}
export class Rows {
readonly rows: AssistantChangeRow[] = [];
add(labelKey: AssistantChangeLabelKey, after: string): this {
if (after !== '') this.rows.push({ op: 'add', labelKey, before: null, after });
return this;
}
change(labelKey: AssistantChangeLabelKey, before: string | null, after: string): this {
this.rows.push({ op: 'change', labelKey, before: before || null, after });
return this;
}
move(labelKey: AssistantChangeLabelKey, before: string, after: string): this {
this.rows.push({ op: 'move', labelKey, before: before || null, after });
return this;
}
}
/** A locale Intl accepts, the user's when it can. */
export function localeOf(ctx: Pick<PreviewContext, 'locale'>): string {
try {
return Intl.getCanonicalLocales(ctx.locale)[0] ?? 'en';
} catch {
return 'en';
}
}
export function formatDate(ctx: Pick<PreviewContext, 'locale'>, isoDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) return isoDate;
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return new Intl.DateTimeFormat(localeOf(ctx), {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
}).format(date);
}
export function formatDateTime(ctx: Pick<PreviewContext, 'locale'>, value: string): string {
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
return match ? `${formatDate(ctx, match[1])}, ${match[2]}` : value;
}
/** A day as the planner names it: its date (and title), or its title, or its number. */
export function dayLabel(ctx: Naming, dayId: number | null): string {
if (dayId === null) return '';
const day = ctx.card.days.find((d) => d.id === dayId);
if (!day) return `#${dayId}`;
if (day.date) return day.title ? `${formatDate(ctx, day.date)}, ${day.title}` : formatDate(ctx, day.date);
return day.title ?? `#${day.number}`;
}
export function placeName(ctx: Pick<PreviewContext, 'card'>, placeId: number | null): string {
if (placeId === null) return '';
return ctx.card.places.get(placeId)?.name ?? `#${placeId}`;
}
export function money(ctx: Pick<PreviewContext, 'locale'>, amount: number, currency: string | null): string {
if (currency) {
try {
return new Intl.NumberFormat(localeOf(ctx), { style: 'currency', currency }).format(amount);
} catch {
return `${amount} ${currency}`;
}
}
return new Intl.NumberFormat(localeOf(ctx)).format(amount);
}
export function memberNames(ctx: Pick<PreviewContext, 'card'>, ids: unknown): string {
if (!Array.isArray(ids)) return '';
return clip(ids.map((memberId) => ctx.card.members.get(Number(memberId))?.name ?? `#${String(memberId)}`).join(', '));
}
export function categoryName(ctx: Pick<PreviewContext, 'categories'>, categoryId: number | null): string {
return categoryId === null ? '' : (ctx.categories.get(categoryId) ?? `#${categoryId}`);
}
+350
View File
@@ -0,0 +1,350 @@
import { logWarn } from '../../audit/audit-log.logger';
import type { TripCard } from '../assistant-context';
import {
ASSISTANT_SERVER_WRITE_TOOLS,
CORE_ARG_POLICY,
CORE_UNDO_MAP,
CORE_WRITE_TOOLS,
type AssistantWriteTool,
} from '../assistant-tools.constants';
import { ATLAS_CARDS } from './atlas';
import { BOOKING_CARDS } from './booking';
import { BUDGET_CARDS } from './budget';
import type {
AnswerInput,
Described,
DomainCards,
PreviewContext,
ToolArgs,
ToolCards,
ToolUndo,
UndoSnapshot,
} from './card-types';
import { COLLAB_CARDS } from './collab';
import { COLLECTION_CARDS } from './collection';
import { JOURNEY_CARDS } from './journey';
import { PACKING_CARDS } from './packing';
import { ROADTRIP_CARDS } from './roadtrip';
import { TOUR_CARDS } from './tour';
import { VACAY_CARDS } from './vacay';
import { idSchema, MAX_CARD_CHARS, trekCardSchema, type AssistantChangePreview, type TrekCard } from '@trek/shared';
import type { ZodType } from 'zod';
/**
* Every package's cards, merged by tool name, and the calls the assistant
* makes on them. A package claims the fields of the tools it knows; two
* packages claiming one field of one tool, a package taking over a core
* table's entry, or a describer without a verb stop the server at load,
* before a turn could pick one of them at random. Every hook a package hands
* in is guarded: one that throws is logged and says nothing, never a broken
* turn, decision or undo.
*/
/** The packages, in the order their prompt lines are told. */
export const DOMAINS: readonly DomainCards[] = [
TOUR_CARDS,
ROADTRIP_CARDS,
BUDGET_CARDS,
BOOKING_CARDS,
COLLAB_CARDS,
COLLECTION_CARDS,
ATLAS_CARDS,
VACAY_CARDS,
JOURNEY_CARDS,
PACKING_CARDS,
];
/** Prompt lines one package may add. */
const MAX_PROMPT_LINES = 2;
type Undo = NonNullable<ToolCards['undo']>;
type UndoReads = ReadonlyArray<{ tool: string; args: ToolArgs }>;
/** The registry the assistant reads, merged from the packages. */
interface MergedCards {
tools: Readonly<Record<string, ToolCards>>;
writes: Readonly<Record<string, AssistantWriteTool>>;
undo: Readonly<Record<string, ToolUndo>>;
argPolicy: Readonly<Record<string, NonNullable<ToolCards['argPolicy']>>>;
projections: Readonly<Record<string, (value: unknown) => unknown>>;
promptLines: readonly string[];
foreignIdGroups: ReadonlySet<string>;
toolControllers: Readonly<Record<string, readonly string[]>>;
heldReads: ReadonlySet<string>;
}
/** The core's own tables a package may not take an entry of. */
interface CoreTables {
writes: Readonly<Record<string, unknown>>;
undo: Readonly<Record<string, unknown>>;
argPolicy: Readonly<Record<string, unknown>>;
}
const CORE: CoreTables = {
writes: { ...CORE_WRITE_TOOLS, ...ASSISTANT_SERVER_WRITE_TOOLS },
undo: CORE_UNDO_MAP,
argPolicy: CORE_ARG_POLICY,
};
/** One field of every tool that has it, by tool name. */
function fieldOf<T>(tools: Readonly<Record<string, ToolCards>>, read: (cards: ToolCards) => T | undefined) {
const found: Record<string, T> = {};
for (const [tool, cards] of Object.entries(tools)) {
const value = read(cards);
if (value !== undefined) found[tool] = value;
}
return found;
}
function reason(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** `run`, or `fallback` and a log line when it throws. */
function safely<T>(tool: string, what: string, fallback: T, run: () => T): T {
try {
return run();
} catch (err) {
logWarn(`[assistant] the ${what} of ${tool} failed: ${reason(err)}`);
return fallback;
}
}
function guardUndo(tool: string, undo: Undo): Undo {
return {
tool: undo.tool,
args: (args: ToolArgs, result: unknown, preview: AssistantChangePreview | null) =>
safely(tool, 'undo arguments', null, () => undo.args(args, result, preview)),
...(undo.reads
? {
reads: (args: ToolArgs, result: unknown) =>
safely<UndoReads>(tool, 'undo reads', [], () => undo.reads?.(args, result) ?? []),
}
: {}),
unchanged: (result: unknown, snapshot: UndoSnapshot) =>
safely(tool, 'undo check', false, () => undo.unchanged(result, snapshot)),
};
}
/**
* A package's entry with the hooks a turn, a decision or an undo calls
* outside the card guard wrapped: one that throws is logged and reads as
* saying nothing (the answer as it came, no effect, no ids, no way back,
* "changed since"), never a broken turn or a write left unrecorded. Ids that
* are no row ids are left out. The describer has its own guard, `describedOf`,
* since the generic card stands in for it.
*/
function guardTool(tool: string, cards: ToolCards): ToolCards {
return {
...cards,
...(cards.project
? { project: (value: unknown) => safely(tool, 'projection', value, () => cards.project?.(value)) }
: {}),
...(cards.effect
? {
effect: (ctx: PreviewContext, args: ToolArgs, loaded: unknown) =>
safely(tool, 'effect', null, () => cards.effect?.(ctx, args, loaded) ?? null),
}
: {}),
...(cards.appliedIds
? {
appliedIds: (args: ToolArgs, result: unknown) =>
safely<number[]>(tool, 'applied ids', [], () =>
(cards.appliedIds?.(args, result) ?? []).filter((value) => idSchema.safeParse(value).success),
),
}
: {}),
...(cards.nameArg
? {
nameArg: (key: string, value: number, card: TripCard) =>
safely(tool, `name of ${key}`, null, () => cards.nameArg?.(key, value, card) ?? null),
}
: {}),
...(cards.undo ? { undo: guardUndo(tool, cards.undo) } : {}),
};
}
/** Merges the packages' cards, refusing every overlap at load. */
export function mergeDomains(domains: readonly DomainCards[], core: CoreTables = CORE): MergedCards {
const tools: Record<string, ToolCards> = {};
const owners = new Map<string, string>();
const ids = new Set<string>();
for (const domain of domains) {
if (ids.has(domain.id)) throw new Error(`Assistant cards: package "${domain.id}" is registered twice`);
ids.add(domain.id);
if ((domain.prompt ?? []).length > MAX_PROMPT_LINES) {
throw new Error(`Assistant cards: package "${domain.id}" adds more than ${MAX_PROMPT_LINES} prompt lines`);
}
for (const [tool, cards] of Object.entries(domain.tools)) {
for (const field of Object.keys(cards)) {
const owner = owners.get(`${tool}.${field}`);
if (owner) throw new Error(`Assistant cards: "${field}" of "${tool}" is claimed by ${owner} and ${domain.id}`);
owners.set(`${tool}.${field}`, domain.id);
}
tools[tool] = { ...tools[tool], ...guardTool(tool, cards) };
}
}
for (const [field, table] of [
['write', core.writes],
['undo', core.undo],
['argPolicy', core.argPolicy],
] as const) {
refuseCoreOverlap(field, Object.keys(table), tools);
}
// A curated card is a verb with its words: one without the other is half a card.
for (const [tool, cards] of Object.entries(tools)) {
if (!cards.describe !== !cards.write) {
throw new Error(`Assistant cards: "${tool}" needs both a write entry and a describer, or neither`);
}
}
return {
tools,
writes: { ...CORE_WRITE_TOOLS, ...fieldOf(tools, (cards) => cards.write) },
undo: { ...CORE_UNDO_MAP, ...fieldOf(tools, (cards): ToolUndo | undefined => cards.undo) },
argPolicy: { ...CORE_ARG_POLICY, ...fieldOf(tools, (cards) => cards.argPolicy) },
projections: fieldOf(tools, (cards) =>
cards.project ? (value: unknown): unknown => cards.project?.(value) : undefined,
),
promptLines: domains.flatMap((domain) => domain.prompt ?? []),
foreignIdGroups: new Set(domains.flatMap((domain) => domain.foreignIdGroups ?? [])),
toolControllers: controllersOf(domains),
heldReads: new Set(domains.flatMap((domain) => domain.heldReads ?? [])),
};
}
/** The controllers the packages add, by tool group; one controller in two places is refused. */
function controllersOf(domains: readonly DomainCards[]): Record<string, string[]> {
const groups: Record<string, string[]> = {};
const seen = new Set<string>();
for (const domain of domains) {
for (const [group, controllers] of Object.entries(domain.toolControllers ?? {})) {
for (const controller of controllers) {
if (seen.has(controller)) throw new Error(`Assistant cards: controller "${controller}" is listed twice`);
seen.add(controller);
(groups[group] ??= []).push(controller);
}
}
}
return groups;
}
/**
* Refuses a package entry for `field` on a tool the core already covers with
* its own table (`names`): the core describers and effects, projections and
* the like, which live beside the files that read them.
*/
export function refuseCoreOverlap(
field: keyof ToolCards,
names: Iterable<string>,
tools: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
): void {
for (const name of names) {
if (tools[name]?.[field] !== undefined) {
throw new Error(`Assistant cards: "${field}" of "${name}" is the core's own`);
}
}
}
const MERGED = mergeDomains(DOMAINS);
export const TOOL_CARDS = MERGED.tools;
/** Every writing tool with a card of its own: the core's and the packages'. */
export const ASSISTANT_WRITE_TOOLS = MERGED.writes;
export const ASSISTANT_UNDO_MAP = MERGED.undo;
export const ASSISTANT_ARG_POLICY = MERGED.argPolicy;
/** The packages' provider projections; the core's live in assistant-output.ts. */
export const PROJECTIONS = MERGED.projections;
/** The packages' lines for the standing instructions, in package order. */
export const CARD_PROMPT_LINES = MERGED.promptLines;
/** Tool groups whose place ids are no trip places. */
export const FOREIGN_ID_GROUPS = MERGED.foreignIdGroups;
/** The MCP controllers the packages add to the tool groups (assistant-tool-offer.ts merges them). */
export const PACKAGE_TOOL_CONTROLLERS = MERGED.toolControllers;
/** The packages' read-only tools that wait for the user like a change. */
export const PACKAGE_HELD_READS = MERGED.heldReads;
/** How a card is checked before it goes out: the union, and the most its JSON may weigh. */
export interface CardCheck {
schema: ZodType<TrekCard>;
maxChars: number;
}
const UNION_CHECK: CardCheck = { schema: trekCardSchema, maxChars: MAX_CARD_CHARS };
/** The card checked against the union and the size cap; null, and a log line, for anything wrong. */
function checked(tool: string, what: string, check: CardCheck, build: () => TrekCard | null): TrekCard | null {
let card: TrekCard | null;
try {
card = build();
} catch (err) {
logWarn(`[assistant] the ${what} card of ${tool} failed: ${reason(err)}`);
return null;
}
if (!card) return null;
const parsed = check.schema.safeParse(card);
if (!parsed.success) {
const issue = parsed.error.issues[0];
logWarn(
`[assistant] the ${what} card of ${tool} does not fit its kind: ${issue?.path.join('.')} ${issue?.message}`,
);
return null;
}
const size = JSON.stringify(parsed.data).length;
if (size > check.maxChars) {
logWarn(`[assistant] the ${what} card of ${tool} is too large (${size} characters)`);
return null;
}
return parsed.data;
}
/** The guarded calls over one registry; a builder that throws or misbuilds gives no card, never a broken turn. */
export function guardedCards(tools: Readonly<Record<string, ToolCards>>, check: CardCheck = UNION_CHECK) {
return {
answerCardOf(tool: string, input: AnswerInput): TrekCard | null {
const cards = tools[tool];
return cards?.answer ? checked(tool, 'answer', check, () => cards.answer?.(input) ?? null) : null;
},
changeCardOf(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: unknown): TrekCard | null {
const cards = tools[tool];
return cards?.change ? checked(tool, 'change', check, () => cards.change?.(ctx, args, loaded) ?? null) : null;
},
appliedCardOf(
tool: string,
input: { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null },
): TrekCard | null {
const cards = tools[tool];
return cards?.applied ? checked(tool, 'applied', check, () => cards.applied?.(input) ?? null) : null;
},
};
}
const GUARDED = guardedCards(TOOL_CARDS);
export const answerCardOf = GUARDED.answerCardOf;
export const changeCardOf = GUARDED.changeCardOf;
export const appliedCardOf = GUARDED.appliedCardOf;
/** What a package calls an id argument the trip card cannot name; null when it has no name for it. */
export function nameArgOf(tool: string, key: string, value: number, card: TripCard): string | null {
return TOOL_CARDS[tool]?.nameArg?.(key, value, card) ?? null;
}
/** The reads an undo of `tool` makes before its check; none when its package asks for none or fails to say. */
export function undoReadsOf(
tool: string,
args: ToolArgs,
result: unknown,
): UndoReads {
return TOOL_CARDS[tool]?.undo?.reads?.(args, result) ?? [];
}
/**
* The words of a package's change card for `tool`; null when it has no
* describer, or a log line and null when its describer throws, so the card
* built from the arguments stands in.
*/
export function describedOf(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: unknown): Described | null {
const cards = TOOL_CARDS[tool];
if (!cards?.describe) return null;
return safely(tool, 'describer', null, () => cards.describe?.(ctx, args, loaded) ?? null);
}
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the road trip, its drives, charging and transit. None yet: this package fills them in. */
export const ROADTRIP_CARDS: DomainCards = { id: 'roadtrip', tools: {} };
@@ -0,0 +1,25 @@
import type { DomainCards } from '../card-types';
import { TOUR_DESCRIBERS } from './tour-describe';
import { DELETE_CREATED_TOUR } from './tour-undo';
/**
* The tours in the assistant chat. A tour is a place with a route: its id is
* its place's, so a created one is taken back by deleting that place.
*/
export const TOUR_CARDS: DomainCards = {
id: 'tour',
tools: {
create_hike: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_hike,
undo: DELETE_CREATED_TOUR,
},
create_city_walk: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_city_walk,
undo: DELETE_CREATED_TOUR,
},
update_tour: { write: { verbKey: 'updateTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.update_tour },
import_tour_gpx: { write: { verbKey: 'importTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.import_tour_gpx },
},
};
@@ -1,5 +1,6 @@
import { clip, id, Rows, text } from './preview-rows.helpers';
import type { Describer, PreviewContext } from './tool-call-preview.helpers';
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { recordOf } from '../card-values';
import { clip, id, Rows, text } from '../preview-format';
import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
/**
@@ -9,12 +10,6 @@ import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
* grade (T1 to T6), a planned time in hours and minutes.
*/
type Args = Record<string, unknown>;
function recordOf(value: unknown): Args {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Args) : {};
}
/** A point's name: its own, else the name of the trip's place it stands for. */
function pointName(ctx: PreviewContext, point: unknown): string {
const record = recordOf(point);
@@ -56,7 +51,7 @@ function gpxName(gpx: unknown): string {
return close < 0 ? '' : text(gpx.slice(open + '<name>'.length, close));
}
export const TOUR_DESCRIBERS: Readonly<Record<string, Describer>> = {
export const TOUR_DESCRIBERS = {
create_hike: (ctx, args) => {
const rows = new Rows()
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
@@ -91,4 +86,4 @@ export const TOUR_DESCRIBERS: Readonly<Record<string, Describer>> = {
const rows = new Rows().add('type', text(args.tour_type));
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
},
};
} satisfies Readonly<Record<string, (ctx: PreviewContext, args: ToolArgs) => Described>>;
@@ -0,0 +1,37 @@
import type { ToolCards, UndoSnapshot } from '../card-types';
import { recordOf } from '../card-values';
import { byId, placeUnchanged, snapshotPlaces } from '../undo-snapshot';
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
function tourIdIn(value: unknown): number | null {
const id = recordOf(recordOf(value).tour).place_id;
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
/**
* A created tour, unchanged and on no day: its place still has the name and
* description it was created with, has not been written since it was made,
* and nothing plans, books or pays for it. Every write of a tour's route or
* name (a change over MCP, the planner's save, a rename) stamps its place's
* `updated_at`, which the create left equal to `created_at`; the revision is
* not in the snapshot. A tour planned on a day since is kept too.
*/
function tourUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const tour = recordOf(recordOf(result).tour);
const current = byId(snapshotPlaces(snapshot), tour.place_id);
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
return (
untouched &&
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
);
}
/** A created tour taken back with its place, which takes its route and points along. */
export const DELETE_CREATED_TOUR: NonNullable<ToolCards['undo']> = {
tool: 'delete_place',
args: (_args, result) => {
const placeId = tourIdIn(result);
return placeId ? { placeId } : null;
},
unchanged: tourUnchanged,
};
@@ -0,0 +1,66 @@
import type { UndoSnapshot } from './card-types';
import { recordOf, type Row } from './card-values';
/**
* The pieces an undo check reads the trip with, for the core checks
* (assistant-undo.helpers.ts) and every package's own `undo.unchanged`. An
* undo removes the created row with everything that hangs off it, so a check
* holds that row to what the change created and asks whether anything else
* has come to depend on it since.
*/
type TripReads = Pick<UndoSnapshot, 'summary' | 'places'>;
export function rowsOf(value: unknown): Row[] {
return Array.isArray(value) ? value.map(recordOf) : [];
}
/** The fields that say a row is still the one that was created; a field either side lacks is not compared. */
export const STABLE_FIELDS = {
place: ['name', 'updated_at', 'lat', 'lng', 'address', 'category_id', 'description', 'notes'],
assignment: ['day_id', 'place_id'],
note: ['text', 'time', 'icon', 'color'],
packing: ['name', 'updated_at', 'checked', 'quantity', 'category'],
todo: ['name', 'checked', 'due_date', 'description', 'priority', 'assigned_user_id', 'category'],
budget: ['name', 'total_price', 'currency', 'category', 'note', 'expense_date'],
} as const;
export function same(created: Row, current: Row | undefined, fields: readonly string[]): boolean {
if (!current) return false;
return fields.every((field) => !(field in created) || !(field in current) || created[field] === current[field]);
}
export function byId(rows: Row[], id: unknown): Row | undefined {
return rows.find((row) => row.id === id);
}
export function snapshotDays(snapshot: TripReads): Row[] {
return rowsOf(recordOf(snapshot.summary).days);
}
export function snapshotAssignments(snapshot: TripReads): Row[] {
return snapshotDays(snapshot).flatMap((day) => rowsOf(day.assignments));
}
/** The trip's places as `list_places` answered. */
export function snapshotPlaces(snapshot: TripReads): Row[] {
return rowsOf(recordOf(snapshot.places).places);
}
/** A created place, unchanged and still only where the change put it: on no other day, in no booking or expense. */
export function placeUnchanged(result: Row, snapshot: TripReads): boolean {
const created = recordOf(result.place);
const current = byId(snapshotPlaces(snapshot), created.id);
if (!same(created, current, STABLE_FIELDS.place)) return false;
const ownAssignment = recordOf(result.assignment).id;
const plannedElsewhere = snapshotAssignments(snapshot).some(
(a) => a.place_id === created.id && a.id !== ownAssignment,
);
const summary = recordOf(snapshot.summary);
const referenced = [
...rowsOf(summary.reservations),
...rowsOf(summary.accommodations),
...rowsOf(recordOf(summary.budget).items),
].some((row) => row.place_id === created.id);
return !plannedElsewhere && !referenced;
}
@@ -0,0 +1,4 @@
import type { DomainCards } from '../card-types';
/** The cards of the vacation planner. None yet: this package fills them in. */
export const VACAY_CARDS: DomainCards = { id: 'vacay', tools: {} };
@@ -1,51 +0,0 @@
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
/**
* The pieces every change card is written with: a value cut to what a row
* shows, an argument read as an id, and the rows themselves. Shared by the
* cards of the trip's own tools (tool-call-preview.helpers.ts) and of the
* tours (tour-call-preview.helpers.ts).
*/
type Args = Record<string, unknown>;
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
const MAX_VALUE_CHARS = 160;
const CHECKED = '✓';
export const UNCHECKED = '✗';
export function clip(value: string): string {
const line = value.replace(/\s+/g, ' ').trim();
return line.length > MAX_VALUE_CHARS ? `${line.slice(0, MAX_VALUE_CHARS - 1)}…` : line;
}
export function id(args: Args, key: string): number | null {
const value = args[key];
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
export function text(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? CHECKED : UNCHECKED;
return clip(String(value));
}
export class Rows {
readonly rows: AssistantChangeRow[] = [];
add(labelKey: AssistantChangeLabelKey, after: string): this {
if (after !== '') this.rows.push({ op: 'add', labelKey, before: null, after });
return this;
}
change(labelKey: AssistantChangeLabelKey, before: string | null, after: string): this {
this.rows.push({ op: 'change', labelKey, before: before || null, after });
return this;
}
move(labelKey: AssistantChangeLabelKey, before: string, after: string): this {
this.rows.push({ op: 'move', labelKey, before: before || null, after });
return this;
}
}
@@ -1,5 +1,6 @@
import type { TripCard } from './assistant-context';
import type { ToolHint } from './assistant-tools.service';
import { FOREIGN_ID_GROUPS, nameArgOf } from './cards/registry';
import type {
AssistantChangeLabelKey,
AssistantChangePreview,
@@ -15,11 +16,21 @@ import type {
* the labels the other cards use (time, end, notes, day …), with ids named and
* lists of ids read as the names in their order. The verb comes from what the
* tool says it does; the client names the tool's action where it knows it. It
* is never undoable: only the tools with a matching removal are.
* is never undoable: only the tools with a matching removal are. A package
* names the ids the trip card cannot (`nameArg`), and the place ids of a tool
* that works outside the trip (a collection's, the atlas') are never read as
* the trip's places.
*/
type Args = Record<string, unknown>;
/** What naming one tool's arguments needs: the trip, the tool, and whether its place ids are the trip's. */
interface Naming {
card: TripCard;
toolName: string;
foreign: boolean;
}
const MAX_ROWS = 12;
const MAX_VALUE_CHARS = 160;
const NAME_KEYS = ['name', 'title', 'text', 'label', 'question', 'content', 'region', 'country_code'] as const;
@@ -98,35 +109,37 @@ function dayName(card: TripCard, dayId: number): string | null {
}
/** What one id argument names in the trip: a place, a day, a note, a booking, an item, a person. */
function nameOf(card: TripCard, key: string, value: number, toolName: string): string | null {
function nameOf({ card, toolName, foreign }: Naming, key: string, value: number): string | null {
const named = nameArgOf(toolName, key, value, card);
if (named !== null) return named;
const k = key.toLowerCase().replace(/_/g, '');
if (k === 'assignmentid') {
const assignment = card.assignments.get(value);
return assignment ? (card.places.get(assignment.placeId)?.name ?? null) : null;
}
if (k === 'placeid' || k === 'sourceplaceid') return card.places.get(value)?.name ?? null;
if (k === 'placeid' || k === 'sourceplaceid') return foreign ? null : (card.places.get(value)?.name ?? null);
if (k === 'dayid' || k === 'enddayid' || k === 'startdayid') return dayName(card, value);
if (k === 'noteid') return card.notes.get(value)?.text ?? null;
if (k === 'reservationid' || k === 'accommodationid') return card.reservations.get(value)?.title ?? null;
if (k === 'itemid' && toolName.includes('packing')) return card.packing.get(value)?.name ?? null;
if (k === 'userid' || k === 'memberid' || k === 'assigneduserid') return card.members.get(value) ?? null;
if (k === 'userid' || k === 'memberid' || k === 'assigneduserid') return card.members.get(value)?.name ?? null;
return null;
}
/** An id list in its order, named where the card knows the ids (a new order of a day's places). */
function namesOf(card: TripCard, key: string, values: unknown[], toolName: string): string | null {
function namesOf(naming: Naming, key: string, values: unknown[]): string | null {
const singular = key.replace(/(Ids|_ids)$/, (m) => (m === 'Ids' ? 'Id' : '_id'));
const names = values.map((v) => (typeof v === 'number' ? nameOf(card, singular, v, toolName) : null));
const names = values.map((v) => (typeof v === 'number' ? nameOf(naming, singular, v) : null));
return names.some((n) => n === null) ? null : names.join(', ');
}
function valueText(card: TripCard, key: string, value: unknown, toolName: string): string {
function valueText(naming: Naming, key: string, value: unknown): string {
if (typeof value === 'number') {
const name = nameOf(card, key, value, toolName);
const name = nameOf(naming, key, value);
if (name) return clip(name);
}
if (Array.isArray(value)) {
const names = namesOf(card, key, value, toolName);
const names = namesOf(naming, key, value);
if (names) return clip(names);
return clip(value.map((item) => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join(', '));
}
@@ -136,31 +149,31 @@ function valueText(card: TripCard, key: string, value: unknown, toolName: string
}
/** The subject: what the tool is given a name for, else the first thing of the trip it works on. */
function subjectOf(card: TripCard, args: Args, toolName: string): { text: string; key: string | null } {
function subjectOf(naming: Naming, args: Args): { text: string; key: string | null } {
const named = NAME_KEYS.find((key) => typeof args[key] === 'string' && (args[key] as string).trim());
if (named) return { text: String(args[named]), key: named };
const order = ['assignmentId', 'placeId', 'noteId', 'reservationId', 'itemId', 'dayId', 'day_id'];
for (const key of order) {
const value = args[key];
const name = typeof value === 'number' ? nameOf(card, key, value, toolName) : null;
const name = typeof value === 'number' ? nameOf(naming, key, value) : null;
if (name) return { text: name, key };
}
const list = Object.entries(args).find(([key, value]) => /(Ids|_ids)$/.test(key) && Array.isArray(value));
if (list) {
const names = namesOf(card, list[0], list[1] as unknown[], toolName);
const names = namesOf(naming, list[0], list[1] as unknown[]);
if (names) return { text: names, key: list[0] };
}
return { text: humanToolName(toolName), key: null };
return { text: humanToolName(naming.toolName), key: null };
}
function rowOf(card: TripCard, key: string, value: unknown, toolName: string): AssistantChangeRow {
function rowOf(naming: Naming, key: string, value: unknown): AssistantChangeRow {
const labelKey = LABEL_OF[key] ?? 'field';
return {
op: 'add',
labelKey,
...(labelKey === 'field' ? { field: key.slice(0, 60) } : {}),
before: null,
after: valueText(card, key, value, toolName),
after: valueText(naming, key, value),
};
}
@@ -171,12 +184,13 @@ export function describeGeneric(
card: TripCard,
hint: ToolHint | null,
): AssistantChangePreview {
const subject = subjectOf(card, args, toolName);
const naming: Naming = { card, toolName, foreign: !!hint?.group && FOREIGN_ID_GROUPS.has(hint.group) };
const subject = subjectOf(naming, args);
const rows = Object.entries(args)
.filter(([key]) => key !== subject.key && !HIDDEN_KEYS.has(key))
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.slice(0, MAX_ROWS)
.map(([key, value]) => rowOf(card, key, value, toolName));
.map(([key, value]) => rowOf(naming, key, value));
const dayArg = args.dayId ?? args.day_id;
const assignment = typeof args.assignmentId === 'number' ? card.assignments.get(args.assignmentId) : undefined;
const dayId = typeof dayArg === 'number' ? dayArg : (assignment?.dayId ?? null);
@@ -187,7 +201,7 @@ export function describeGeneric(
subject: clip(subject.text),
rows,
dayId: dayId !== null && card.days.some((d) => d.id === dayId) ? dayId : null,
placeIds: placeId !== null && card.places.has(placeId) ? [placeId] : [],
placeIds: placeId !== null && !naming.foreign && card.places.has(placeId) ? [placeId] : [],
undoable: false,
effect: null,
tool: toolName,
@@ -1,17 +1,36 @@
import type { CardPlace, TripCard } from './assistant-context';
import type { CardPlace } from './assistant-context';
import { noteOf } from './assistant-memory';
import {
ASSISTANT_SERVER_WRITE_TOOLS,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
IMPORT_BOOKING_TOOL,
REMEMBER_TOOL,
writeToolOf,
} from './assistant-tools.constants';
import type { ToolHint } from './assistant-tools.service';
import { clip, id, Rows, text, UNCHECKED } from './preview-rows.helpers';
import type { Described, PreviewContext } from './cards/card-types';
import {
categoryName,
clip,
dayLabel,
formatDate,
formatDateTime,
id,
localeOf,
memberNames,
money,
placeName,
Rows,
text,
UNCHECKED,
} from './cards/preview-format';
import {
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
changeCardOf,
describedOf,
refuseCoreOverlap,
TOOL_CARDS,
} from './cards/registry';
import { describeGeneric } from './tool-call-generic.helpers';
import { TOUR_DESCRIBERS } from './tour-call-preview.helpers';
import type {
AssistantApplied,
AssistantChangeEffect,
@@ -23,76 +42,18 @@ import type {
* The change card of a held tool call: what it does and to what, in plain
* words. Days, places, categories and people are named instead of numbered,
* dates and amounts are written in the user's locale, and an update shows
* what the field held before.
* what the field held before. A package's cards (cards/registry.ts) add the
* describers, effects and drawn cards of their tools.
*/
type Args = Record<string, unknown>;
export interface PreviewContext {
card: TripCard;
categories: ReadonlyMap<number, string>;
locale: string;
/** What a tool without a card of its own says about itself, for the card built from its arguments. */
hintOf?: (toolName: string) => ToolHint | null;
}
const MAX_ROWS = 12;
function has(args: Args, key: string): boolean {
return args[key] !== undefined;
}
/** A locale Intl accepts, the user's when it can. */
function localeOf(ctx: PreviewContext): string {
try {
return Intl.getCanonicalLocales(ctx.locale)[0] ?? 'en';
} catch {
return 'en';
}
}
function formatDate(ctx: PreviewContext, isoDate: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) return isoDate;
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
return new Intl.DateTimeFormat(localeOf(ctx), {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
}).format(date);
}
function formatDateTime(ctx: PreviewContext, value: string): string {
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
return match ? `${formatDate(ctx, match[1])}, ${match[2]}` : value;
}
/** A day as the planner names it: its date (and title), or its title, or its number. */
export function dayLabel(ctx: PreviewContext, dayId: number | null): string {
if (dayId === null) return '';
const day = ctx.card.days.find((d) => d.id === dayId);
if (!day) return `#${dayId}`;
if (day.date) return day.title ? `${formatDate(ctx, day.date)}, ${day.title}` : formatDate(ctx, day.date);
return day.title ?? `#${day.number}`;
}
function placeName(ctx: PreviewContext, placeId: number | null): string {
if (placeId === null) return '';
return ctx.card.places.get(placeId)?.name ?? `#${placeId}`;
}
function money(ctx: PreviewContext, amount: number, currency: string | null): string {
if (currency) {
try {
return new Intl.NumberFormat(localeOf(ctx), { style: 'currency', currency }).format(amount);
} catch {
return `${amount} ${currency}`;
}
}
return new Intl.NumberFormat(localeOf(ctx)).format(amount);
}
function openingHours(ctx: PreviewContext, value: unknown): string {
if (!Array.isArray(value)) return '';
const weekday = new Intl.DateTimeFormat(localeOf(ctx), { weekday: 'short', timeZone: 'UTC' });
@@ -107,20 +68,11 @@ function openingHours(ctx: PreviewContext, value: unknown): string {
);
}
function memberNames(ctx: PreviewContext, ids: unknown): string {
if (!Array.isArray(ids)) return '';
return clip(ids.map((memberId) => ctx.card.members.get(Number(memberId)) ?? `#${String(memberId)}`).join(', '));
}
function coordinates(args: Args): string {
const { lat, lng } = args;
return typeof lat === 'number' && typeof lng === 'number' ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : '';
}
function categoryName(ctx: PreviewContext, categoryId: number | null): string {
return categoryId === null ? '' : (ctx.categories.get(categoryId) ?? `#${categoryId}`);
}
/** The fields a new place carries, shared by both ways of creating one. */
function newPlaceRows(ctx: PreviewContext, args: Args, rows: Rows, notesKey: string): void {
rows
@@ -227,14 +179,7 @@ function describeBooking(ctx: PreviewContext, args: Args): Described {
return { subject: text(args.title), rows, dayId, placeIds: [] };
}
interface Described {
subject: string;
rows: Rows;
dayId: number | null;
placeIds: number[];
}
export type Describer = (ctx: PreviewContext, args: Args) => Described;
type Describer = (ctx: PreviewContext, args: Args) => Described;
/** Who shares an expense: the uneven split's members, else the equal one's. */
function splitOf(args: Args): unknown {
@@ -391,7 +336,6 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
};
},
[IMPORT_BOOKING_TOOL]: describeBooking,
...TOUR_DESCRIBERS,
};
/** A held write of the model's tools or of the server's own (a booking from a file). */
@@ -445,34 +389,52 @@ const EFFECTS: Readonly<Record<string, (ctx: PreviewContext, args: Args) => Assi
},
};
// A tool's words and its effect come from the core or from its package, never from both.
refuseCoreOverlap('describe', Object.keys(DESCRIBERS));
refuseCoreOverlap('effect', Object.keys(EFFECTS));
/**
* The change card for a held call of one of the writing tools, or of a
* change the server proposed itself (a booking from a file, a found place).
* `args` are the arguments as stored (the trip id is not among them). A found
* place reads as the tool it runs through.
* place reads as the tool it runs through. `loaded` is what the tool's
* package looked up for it (card-context.ts), null when it looked up nothing.
*/
export function describeToolCall(
toolName: string,
toolCallId: string,
args: Args,
ctx: PreviewContext,
loaded: unknown = null,
): AssistantChangePreview {
const runs = writeToolOf(toolName, args);
const tool = writeTool(runs);
const describe = DESCRIBERS[runs];
const cards = TOOL_CARDS[runs];
const describe: ((c: PreviewContext, a: Args) => Described | null) | undefined = cards?.describe
? (c, a) => describedOf(runs, c, a, loaded)
: DESCRIBERS[runs];
// The server's own steps all have a card; one without is a call that names no real tool.
if (tool && !describe) throw new Error(`No change card for tool "${toolName}"`);
if (!tool || !describe) return describeGeneric(runs, toolCallId, args, ctx.card, ctx.hintOf?.(runs) ?? null);
const described = describe(ctx, args);
const card = changeCardOf(runs, ctx, args, loaded);
const drawn = card ? { card } : {};
// A package describer that failed gives way to the card built from the arguments.
const described = tool && describe ? describe(ctx, args) : null;
if (!tool || !described) {
return { ...describeGeneric(runs, toolCallId, args, ctx.card, ctx.hintOf?.(runs) ?? null), ...drawn };
}
const planned = cards?.effect ? cards.effect(ctx, args, loaded) : EFFECTS[runs]?.(ctx, args);
return {
toolCallId,
verbKey: tool.verbKey,
verbKey: described.verbKey ?? tool.verbKey,
subject: clip(described.subject),
rows: described.rows.rows.slice(0, MAX_ROWS),
dayId: described.dayId,
placeIds: described.placeIds,
undoable: runs in ASSISTANT_UNDO_MAP,
effect: EFFECTS[runs]?.(ctx, args) ?? null,
effect: planned ?? null,
...(described.tool ? { tool: described.tool } : {}),
...(described.domain ? { domain: described.domain } : {}),
...drawn,
};
}
@@ -525,8 +487,10 @@ export function appliedChange(
undoUntil: string | null,
): AssistantApplied | null {
// A tool without a card of its own touched something the client has no view for.
const tool = writeTool(writeToolOf(toolName, args)) ?? { entity: 'other' as const };
const created = createdIds(result);
const runs = writeToolOf(toolName, args);
const tool = writeTool(runs) ?? { entity: 'other' as const };
const own = TOOL_CARDS[runs]?.appliedIds?.(args, result) ?? [];
const created = own.length > 0 ? own : createdIds(result);
const named = ARG_KEYS.map((key) => id(args, key)).filter((value): value is number => value !== null);
return {
toolCallId,
@@ -3,6 +3,7 @@ import {
UI_STREAM_DONE,
type AssistantActivity,
type AssistantApplied,
type AssistantCard,
type AssistantChangePreview,
type AssistantErrorCode,
type AssistantPlaces,
@@ -96,6 +97,11 @@ export class UiStreamWriter {
this.send({ type: 'data-trek-weather', data: weather });
}
/** The card of one tool call: a read's beside the answer, an applied write's beside its receipt. */
card(data: AssistantCard): void {
this.send({ type: 'data-trek-card', data });
}
/** Where a step that is about to run looks; only streamed, the message never keeps it. */
activity(activity: AssistantActivity): void {
this.endText();