feat(assistant): every MCP tool of TREK, the core first and the addons on request

The assistant offered 25 hand-picked tools. It now offers every tool the MCP
registry holds, but a short denylist with a reason per tool (deleting the
trip, share, invite and feed links, membership changes, fetching an address
the model picks, documents to the provider, instance-wide categories and
holidays). The scopes widen to every addon but trips:delete, the share
scopes, file contents and plugins.

- A tool annotated read-only runs at once, every other one waits as a change
  card; reads that fetch a planted address wait too.
- A turn starts with the trip's core tools; use_addon_tools loads tours, road
  trip, journey, vacay, atlas, collections, trip chat, files, account or
  plugin tools for the rest of the turn.
- A writing tool without a card of its own gets one from its arguments:
  add, change or remove, the part of TREK it works in, the trip's days,
  places and people named.
- The boot check fails on a tool that shares, invites or publishes and is
  neither denied nor known to only take back.
This commit is contained in:
Maurice
2026-10-21 22:16:23 +02:00
parent b721234249
commit e5f1f573c5
41 changed files with 950 additions and 167 deletions
@@ -12,8 +12,10 @@ import {
MapPinPlus,
NotebookPen,
PencilLine,
Plus,
StickyNote,
Ticket,
Trash2,
TriangleAlert,
Undo2,
Wallet,
@@ -83,6 +85,9 @@ const VERB_LOOK: Record<AssistantChangeVerbKey, { Icon: typeof Check; tone: Tone
addReservation: { Icon: Ticket, tone: 'success' },
importBooking: { Icon: Ticket, tone: 'success' },
rememberNote: { Icon: NotebookPen, tone: 'info' },
genericCreate: { Icon: Plus, tone: 'success' },
genericUpdate: { Icon: PencilLine, tone: 'info' },
genericRemove: { Icon: Trash2, tone: 'danger' },
};
/** How a decided change reads: one quiet mark, its words in the tooltip. */
@@ -125,7 +130,15 @@ function ChangeHead({ preview, struck = false }: { preview: AssistantChangePrevi
if (!preview) return <span className="trek-skeleton inline-block h-4 w-40" aria-hidden="true" />;
return (
<span className="flex min-w-0 flex-col">
<span className="text-caption text-content-muted">{t(`assistant.change.verb.${preview.verbKey}`)}</span>
<span className="flex min-w-0 items-center gap-1.5 text-caption text-content-muted">
<span className="truncate">{t(`assistant.change.verb.${preview.verbKey}`)}</span>
{preview.domain && (
// Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
<span className="flex-shrink-0 rounded-full bg-surface-secondary px-1.5 font-medium text-content-secondary">
{t(`assistant.domain.${preview.domain}`)}
</span>
)}
</span>
<bdi className={`truncate text-body font-semibold text-content ${struck ? 'line-through' : ''}`}>
{preview.subject}
</bdi>
@@ -41,6 +41,12 @@ const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
/** Fields that mean nothing written out. */
const HIDDEN: ReadonlySet<AssistantChangeLabelKey> = new Set(['coordinates', 'position', 'icon', 'color', 'currency']);
/** What a row is called: its label, or for a tool's own argument the argument's name in words. */
function labelOf(row: AssistantChangeRow, t: (key: string) => string): string {
if (row.labelKey === 'field' && row.field) return row.field.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
return t(CHANGE_LABELS[row.labelKey]?.labelKey ?? 'assistant.change.label.field');
}
/** "#3" is how a day comes from the server; it reads as "Day 3". */
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
@@ -50,7 +56,7 @@ function shown(row: AssistantChangeRow, value: string | null | undefined, dayWor
function Chip({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
const label = t(CHANGE_LABELS[row.labelKey].labelKey);
const label = labelOf(row, t);
const dayWord = t('assistant.week.day');
const changed = row.op !== 'add' && !!row.before;
return (
@@ -76,7 +82,7 @@ function DetailLine({ row }: { row: AssistantChangeRow }) {
const changed = row.op !== 'add' && !!row.before;
return (
<div className="flex min-w-0 gap-2 text-caption">
<span className="w-20 flex-shrink-0 truncate text-content-faint">{t(CHANGE_LABELS[row.labelKey].labelKey)}</span>
<span className="w-24 flex-shrink-0 truncate text-content-faint">{labelOf(row, t)}</span>
<span className="flex min-w-0 flex-1 items-center gap-1 text-content-secondary">
{changed && (
<>
@@ -249,6 +249,8 @@ export function undoLeftMs(applied: AssistantApplied | null, now: number): numbe
/** The label of each field a change row can show. A table, so every key is named where it is read. */
export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }> = {
// A tool's own argument: the row names it in `field`, this is only its fallback.
field: { labelKey: 'assistant.change.label.field' },
name: { labelKey: 'assistant.change.label.name' },
description: { labelKey: 'assistant.change.label.description' },
address: { labelKey: 'assistant.change.label.address' },
@@ -0,0 +1,205 @@
import type { McpRegistryListing } from '../../nest-mcp';
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
/**
* Which of TREK's MCP tools the assistant is offered, and how. Every tool the
* registry holds is offered, except the few below that no approval makes
* safe; the scopes in ASSISTANT_SCOPES hide the rest of what must never run
* (deleting a trip, share and invite links, file contents, plugins). A tool
* annotated read-only runs at once, every other one waits as a change card.
*
* Offering all of them in every step would cost a lot of tokens and runs past
* what some providers take in one request, so a turn starts with the core of
* a trip (days, places, bookings, packing, to-dos, budget, weather, search)
* and the model loads an addon's tools when it needs them (USE_TOOLS_TOOL).
*/
/** Never offered, whatever the user approves, each for the reason beside it. */
export const ASSISTANT_DENIED_TOOLS: Readonly<Record<string, string>> = {
delete_trip: 'irreversible, and it takes the conversation with it',
copy_trip: 'makes a trip outside the conversation',
create_trip: 'makes a trip outside the conversation',
leave_trip: 'changes who can see the trip',
add_trip_member: 'changes who can see the trip',
remove_trip_member: 'changes who can see the trip',
leave_collection: 'changes who can see the collection',
set_collection_member_role: 'changes who can see the collection',
remove_collection_member: 'changes who can see the collection',
add_journey_contributor: 'changes who can see the journey',
update_journey_contributor_role: 'changes who can see the journey',
remove_journey_contributor: 'changes who can see the journey',
invite_to_collection: 'invites a person; the model could pick whom',
send_vacay_invite: 'invites a person; the model could pick whom',
share_vacay_calendar: 'shares a calendar with a person',
create_share_link: 'mints a public link, whose token would reach the AI provider',
get_share_link: 'reads a public link token',
delete_share_link: 'belongs with the share links above',
get_trip_invite_link: 'reads an invite token',
create_trip_invite_link: 'mints an invite token',
delete_trip_invite_link: 'belongs with the invite links above',
get_journey_share_link: 'reads a public link token',
create_journey_share_link: 'mints a public link token',
delete_journey_share_link: 'belongs with the share links above',
get_trip_calendar_feed: 'reads a calendar feed token',
enable_trip_calendar_feed: 'mints a calendar feed token',
rotate_trip_calendar_feed: 'mints a calendar feed token',
disable_trip_calendar_feed: 'belongs with the feeds above',
get_all_trips_calendar_feed: 'reads a calendar feed token',
enable_all_trips_calendar_feed: 'mints a calendar feed token',
rotate_all_trips_calendar_feed: 'mints a calendar feed token',
disable_all_trips_calendar_feed: 'belongs with the feeds above',
import_places_from_url: 'fetches an address the model chooses',
read_trip_file: 'would send documents such as passports to the AI provider',
upload_trip_file: 'writes a file the model made up',
create_category: 'changes the categories of every user',
update_category: 'changes the categories of every user',
delete_category: 'changes the categories of every user',
create_manual_school_holiday_country: 'changes the holidays of every user',
create_manual_school_holiday_region: 'changes the holidays of every user',
update_manual_school_holiday_region: 'changes the holidays of every user',
delete_manual_school_holiday_region: 'changes the holidays of every user',
delete_manual_school_holiday_country: 'changes the holidays of every user',
};
/**
* Names that read like sharing but only list or take back what the user
* shared already. Any other name that shares, invites or publishes a feed
* must be denied above; the boot check holds the two apart.
*/
const SHARE_LIKE_SAFE: ReadonlySet<string> = new Set([
'set_packing_item_sharing',
'list_vacay_shares',
'get_shareable_vacay_users',
'get_shared_vacay_calendars',
'unshare_vacay_calendar',
'cancel_vacay_invite',
'accept_vacay_invite',
'decline_vacay_invite',
'cancel_collection_invite',
'accept_collection_invite',
'decline_collection_invite',
]);
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. */
export const HELD_READS: ReadonlySet<string> = new Set([
'resolve_maps_url',
'preview_google_maps_route',
'preview_google_maps_walk',
]);
export function isOfferedTool(name: string): boolean {
return !(name in ASSISTANT_DENIED_TOOLS);
}
/** What is wrong with the denylist against the registry: a sharing tool that slipped through. */
export function checkDenylist(names: Iterable<string>): string[] {
const problems: string[] = [];
for (const name of names) {
if (SHARE_LIKE.test(name) && isOfferedTool(name) && !SHARE_LIKE_SAFE.has(name)) {
problems.push(`"${name}" shares, invites or publishes; deny it or list it as safe`);
}
}
return problems;
}
/**
* 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.
*/
export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; controllers: readonly string[] }>> = {
tours: {
about:
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them, ' +
'their packing, the tour library and imports from Google Maps.',
controllers: [
'ToursMcp',
'CityWalkMcp',
'TourChangeMcp',
'TourChangeUndoMcp',
'TourLibraryMcp',
'TourPackingMcp',
'TourActivitiesMcp',
'TourActivityStatsMcp',
'TourWalkImportMcp',
'CollectionToursMcp',
'PaddleRoutingMcp',
'SkiRoutingMcp',
'GoogleRouteMcp',
],
},
roadtrip: {
about: 'Road trips and transit: the driving route and its stops, charging, hazards, public transport journeys.',
controllers: [
'RoadtripMcp',
'RoadtripPlanningMcp',
'RoadtripPreferencesMcp',
'RoadtripHazardsMcp',
'ChargingMcp',
'TransitMcp',
],
},
journey: {
about: 'Journeys (the travel journal): entries, photos, the journal linked to this trip, location history.',
controllers: ['JourneyMcp', 'TripJournalLinkMcp', 'MemoriesMcp', 'DawarichMcp'],
},
vacay: {
about: 'Vacation planner: vacation days per year, public and school holidays, shared calendars.',
controllers: ['VacayMcp', 'SchoolHolidaysMcp'],
},
atlas: { about: 'Atlas: visited countries and regions, the bucket list.', controllers: ['AtlasMcp'] },
collections: {
about: 'Collections: saved places across trips, their labels and ratings.',
controllers: ['CollectionsMcp'],
},
collab: { about: 'Trip chat: notes, polls and messages to the people on the trip.', controllers: ['CollabMcp'] },
files: {
about: 'Files and imports: the trip files, document sync, flights from AirTrail.',
controllers: ['FilesMcp', 'DocSyncMcp', 'ReservationImportMcp', 'AirtrailMcp'],
},
account: {
about: 'The user: notifications and display settings.',
controllers: ['NotificationsMcp', 'SettingsMcp'],
},
plugins: {
about: 'Places and points of interest from installed plugins.',
controllers: ['PluginPoisMcp', 'PluginSearchMcp'],
},
};
const GROUP_OF_CONTROLLER = new Map(
Object.entries(ASSISTANT_TOOL_GROUPS).flatMap(([group, { controllers }]) =>
controllers.map((controller) => [controller, group] as const),
),
);
/** The group a registered tool loads with; null for the core. */
export function groupOf(entry: Pick<McpRegistryListing, 'className'>): string | null {
return GROUP_OF_CONTROLLER.get(entry.className) ?? null;
}
/** The server's own step that loads a group's tools for the rest of the turn. */
export const USE_TOOLS_TOOL = 'use_addon_tools';
export const USE_TOOLS_SPEC: AiToolSpec = {
name: USE_TOOLS_TOOL,
description:
'Load the tools of more parts of TREK for the rest of this turn. Call it before working with any of: ' +
Object.entries(ASSISTANT_TOOL_GROUPS)
.map(([group, { about }]) => `"${group}" (${about})`)
.join('; ') +
'. The trip itself (days, places, bookings, packing, to-dos, budget, weather, search) needs nothing loaded.',
parameters: {
type: 'object',
properties: {
groups: {
type: 'array',
items: { type: 'string', enum: Object.keys(ASSISTANT_TOOL_GROUPS) },
minItems: 1,
},
},
required: ['groups'],
additionalProperties: false,
},
};
@@ -26,15 +26,21 @@ export const APPROVAL_TTL_MS = 24 * 60 * 60_000;
export const TURNS_PER_MINUTE = 30;
/**
* What an assistant session may do, as the scopes an MCP client would hold.
* Day notes sit in the `trips` group and day assignments in `places`; the
* allowlist below, not these scopes, is what decides which tools exist.
* What an assistant session may do, as the scopes an MCP client would hold:
* everything a user can reach through MCP but deleting trips, share and
* invite links (trips:share, journey:share), file contents and plugin tools,
* whose tools the registry then never attaches. assistant-tool-offer.ts
* denies the few tools inside these scopes that no approval makes safe.
*/
export const ASSISTANT_SCOPES: readonly string[] = [
'trips:read',
'trips:write',
'places:read',
'places:write',
'collections:read',
'collections:write',
'atlas:read',
'atlas:write',
'packing:read',
'packing:write',
'todos:read',
@@ -43,23 +49,20 @@ export const ASSISTANT_SCOPES: readonly string[] = [
'budget:write',
'reservations:read',
'reservations:write',
'collab:read',
'collab:write',
'notifications:read',
'notifications:write',
'vacay:read',
'vacay:write',
'geo:read',
'weather:read',
];
/** Tools that only read: they run as soon as the model calls them. */
export const ASSISTANT_READ_TOOLS: readonly string[] = [
'get_trip_summary',
'list_places',
'search_place',
'get_place_details',
'search_nearby_places',
'search_pois',
'list_categories',
'get_weather',
'list_todos',
'get_settlement_summary',
'search_help',
'journey:read',
'journey:write',
'files:read',
'files:write',
'settings:read',
'settings:write',
];
/**
@@ -73,7 +76,11 @@ interface AssistantWriteTool {
quiet?: boolean;
}
/** Tools that change the trip: each call is held as a change card until the user applies it. */
/**
* 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).
*/
export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>> = {
create_place: { verbKey: 'addPlace', entity: 'place' },
create_and_assign_place: { verbKey: 'addPlaceToDay', entity: 'place' },
@@ -120,8 +127,8 @@ const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place',
/**
* Changes the server applies itself: a booking from an attached file, a search
* result the user picked, a note the user wants kept. They are held, decided
* and recorded like the writes above, and they are not on the allowlist; only
* the note is offered to the model, by the session itself (REMEMBER_SPEC). A booking is applied by the server
* and recorded like the writes above, and no MCP tool has their names; only the
* note is offered to the model, by the session itself (REMEMBER_SPEC). A booking is applied by the server
* itself; a found place deliberately goes through the MCP tool named in its
* `via` argument, so it is applied with the user's rights, broadcast and
* undone exactly like the same change proposed by the model.
@@ -162,57 +169,6 @@ export const MAX_BOOKINGS_PER_FILE = MAX_WRITES_PER_STEP;
/** The largest file the assistant reads. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
/**
* The one removal the model may propose: taking a place off a day. The tool
* is annotated destructive because the assignment row goes, but the place
* stays in the trip, so it is offered like any other change card. Without it
* the model "replaced" a day's plan by renaming the place that was on it.
*/
export const ASSISTANT_REMOVAL_TOOLS: ReadonlySet<string> = new Set(['unassign_place']);
/** Every tool the model is offered. Nothing outside this list is attached to an assistant session. */
export const ASSISTANT_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
...ASSISTANT_READ_TOOLS,
...Object.keys(ASSISTANT_WRITE_TOOLS),
]);
/**
* Tools the assistant must never be offered, kept as a list so a test can
* hold the allowlist apart from it: whatever reaches beyond the trip
* (members, sharing, invitations, feeds, files, other trips), fetches an
* address of the model's choosing, or deletes (taking a place off a day is
* the one removal offered, see ASSISTANT_REMOVAL_TOOLS). `isDeniedTool` adds every name
* that shares, invites or publishes a feed.
*/
const ASSISTANT_TOOL_DENYLIST: ReadonlySet<string> = new Set([
'import_places_from_url',
'resolve_maps_url',
'add_trip_member',
'remove_trip_member',
'leave_trip',
'delete_trip',
'copy_trip',
'read_trip_file',
'upload_trip_file',
'invite_to_collection',
'send_vacay_invite',
'share_vacay_calendar',
'update_display_settings',
'send_collab_message',
'list_upcoming_reservations',
'delete_place',
'delete_day_note',
'delete_packing_item',
'delete_todo',
'delete_budget_item',
]);
const DENIED_NAME = /share|invite|feed/;
export function isDeniedTool(name: string): boolean {
return ASSISTANT_TOOL_DENYLIST.has(name) || DENIED_NAME.test(name);
}
/** The argument every trip tool takes; the server sets it to the trip the conversation is about. */
export const PINNED_ARG = 'tripId';
@@ -230,6 +186,8 @@ export const ASSISTANT_ARG_POLICY: Readonly<
create_place: { stripped: ['image_url'] },
create_and_assign_place: { stripped: ['image_url'] },
update_place: { stripped: ['image_url'] },
bulk_update_places: { stripped: ['image_url'] },
update_trip: { stripped: ['cover_image'] },
};
type ToolArgs = Record<string, unknown>;
@@ -4,15 +4,21 @@ import { AuditService } from '../audit/audit.service';
import { traceEntry } from '../audit/entry-trace.logger';
import { withRequestContext } from '../database/request-context';
import { REMEMBER_SPEC } from './assistant-memory';
import {
ASSISTANT_TOOL_GROUPS,
checkDenylist,
groupOf,
HELD_READS,
isOfferedTool,
USE_TOOLS_SPEC,
USE_TOOLS_TOOL,
} from './assistant-tool-offer';
import {
ASSISTANT_ARG_POLICY,
ASSISTANT_READ_TOOLS,
ASSISTANT_SCOPES,
ASSISTANT_SERVER_TOOLS,
ASSISTANT_TOOL_ALLOWLIST,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
isDeniedTool,
PINNED_ARG,
REMEMBER_TOOL,
} from './assistant-tools.constants';
@@ -32,46 +38,55 @@ export interface ToolRun {
/** Whether a tool runs at once or waits for the user. */
export type AssistantToolKind = 'read' | 'write';
/** What a card for a tool without one of its own goes by: what the tool says it does, and where it works. */
export interface ToolHint {
destructive: boolean;
creates: boolean;
group: string | null;
}
interface ListedTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
kind: AssistantToolKind;
pinned: boolean;
hint: ToolHint;
}
/** A turn's session offers every tool (the core first, the rest by group); an undo's holds its one target. */
type SessionMode = 'turn' | 'undo';
/**
* What is wrong with the tool catalog the assistant relies on, given what the
* registry holds: an allowlisted tool or an undo target that no longer exists,
* an allowlisted tool that is also denied, or a tool named like one of the
* server's own steps. Empty when all is well.
* 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.
*/
export function checkToolCatalog(listing: readonly McpRegistryListing[]): string[] {
const tools = new Set(listing.filter((entry) => entry.kind === 'tool').map((entry) => entry.name));
const problems: string[] = [];
for (const name of ASSISTANT_TOOL_ALLOWLIST) {
if (!tools.has(name)) problems.push(`allowlisted tool "${name}" is not registered`);
if (isDeniedTool(name)) problems.push(`tool "${name}" is both allowlisted and denied`);
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 undo of Object.values(ASSISTANT_UNDO_MAP)) {
if (!tools.has(undo.tool)) problems.push(`undo tool "${undo.tool}" is not registered`);
}
// The server's own steps are told apart from tool calls by name alone.
for (const name of ASSISTANT_SERVER_TOOLS) {
if (tools.has(name) || ASSISTANT_TOOL_ALLOWLIST.has(name)) problems.push(`"${name}" is the server's own step`);
for (const name of [...ASSISTANT_SERVER_TOOLS, USE_TOOLS_TOOL]) {
if (tools.has(name)) problems.push(`"${name}" is the server's own step`);
}
return problems;
return [...problems, ...checkDenylist(tools)];
}
/**
* How a listed tool is treated: a read runs at once, a write waits for the
* user. A tool counts as a read only when both the list and its own
* annotation say so, and as a write only when the list names it; anything
* else is not offered, so a tool whose annotation changed fails closed.
* How a listed tool is treated: a read runs at once, everything else waits
* for the user. A tool counts as a read only when its own annotation says so
* and it fetches nothing a message could have planted, so a tool whose
* annotation is missing or wrong costs an extra card, never a silent write.
*/
function kindOf(name: string, readOnly: boolean): AssistantToolKind | null {
if (readOnly && ASSISTANT_READ_TOOLS.includes(name)) return 'read';
return ASSISTANT_WRITE_TOOLS[name] ? 'write' : null;
function kindOf(name: string, readOnly: boolean): AssistantToolKind {
return readOnly && !HELD_READS.has(name) ? 'read' : 'write';
}
function hiddenArgs(name: string): Set<string> {
@@ -127,25 +142,40 @@ export class AssistantToolSession {
private readonly tools: ReadonlyMap<string, ListedTool>,
private readonly tripId: number,
private readonly audit: (toolName: string) => void,
/** Whether the model may keep trip notes here: a turn's session, never an undo's. */
private readonly offersNotes: boolean,
private readonly mode: SessionMode,
) {}
/** The tools as the model is offered them, with the trip notes the server keeps itself. */
/** The groups this turn loaded; the core is always there. */
private readonly loaded = new Set<string>();
/**
* The tools as the model is offered them: the core and the groups it
* loaded, the trip notes, and the step that loads the other groups.
*/
specs(): AiToolSpec[] {
const listed = [...this.tools.values()].map((tool) => ({
name: tool.name,
description: tool.description,
parameters: modelSchema(tool.name, tool.inputSchema),
}));
return this.offersNotes ? [...listed, REMEMBER_SPEC] : listed;
const listed = [...this.tools.values()]
.filter((tool) => this.mode === 'undo' || tool.hint.group === null || this.loaded.has(tool.hint.group))
.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: modelSchema(tool.name, tool.inputSchema),
}));
if (this.mode === 'undo') return listed;
const more = Object.keys(ASSISTANT_TOOL_GROUPS).some((group) => !this.loaded.has(group));
return [...listed, REMEMBER_SPEC, ...(more ? [USE_TOOLS_SPEC] : [])];
}
kindOf(name: string): AssistantToolKind | null {
if (name === REMEMBER_TOOL) return this.offersNotes ? 'write' : null;
if (name === REMEMBER_TOOL) return this.mode === 'turn' ? 'write' : null;
if (name === USE_TOOLS_TOOL) return this.mode === 'turn' ? 'read' : null;
return this.tools.get(name)?.kind ?? null;
}
/** What a card for this tool goes by; null for a tool this session does not hold. */
hintOf(name: string): ToolHint | null {
return this.tools.get(name)?.hint ?? null;
}
/**
* The model's arguments as they are shown and stored: the trip, the forced
* and the stripped arguments taken out. A trip id other than this
@@ -162,6 +192,7 @@ export class AssistantToolSession {
/** Runs a tool on the user's behalf (a read the model asked for, an applied change, an undo), audited. */
async call(name: string, args: ToolArgs): Promise<ToolRun> {
if (name === USE_TOOLS_TOOL) return this.load(args);
const run = await this.invoke(name, args);
this.audit(name);
return run;
@@ -172,6 +203,17 @@ export class AssistantToolSession {
return this.invoke(name, args);
}
/** Loads tool groups for the rest of the turn, and says which tools came with them. */
private load(args: ToolArgs): ToolRun {
const asked = Array.isArray(args.groups) ? args.groups.filter((g): g is string => typeof g === 'string') : [];
const groups = asked.filter((group) => group in ASSISTANT_TOOL_GROUPS);
if (groups.length === 0)
return { ok: false, value: `Name one of: ${Object.keys(ASSISTANT_TOOL_GROUPS).join(', ')}` };
for (const group of groups) this.loaded.add(group);
const tools = [...this.tools.values()].filter((tool) => tool.hint.group && groups.includes(tool.hint.group));
return { ok: true, value: { loaded: groups, tools: tools.map((tool) => tool.name) } };
}
async close(): Promise<void> {
await this.client.close().catch(() => undefined);
await this.server.close().catch(() => undefined);
@@ -208,11 +250,13 @@ export class AssistantToolsService implements OnApplicationBootstrap {
}
/**
* A session for one user and trip holding the named tools (the allowlist by
* default, a single undo target for an undo). No plugin tools are ever
* A session for one user and trip: for a turn every tool the assistant is
* offered, for an undo only the named targets. No plugin tools are ever
* attached: the assistant passes no dynamic tool source.
*/
async open(userId: number, tripId: number, include: ReadonlySet<string> = ASSISTANT_TOOL_ALLOWLIST) {
async open(userId: number, tripId: number, undoTargets?: ReadonlySet<string>) {
const include = (name: string) => (undoTargets ? undoTargets.has(name) : isOfferedTool(name));
const classOf = new Map(this.registry.list().map((entry) => [entry.name, entry] as const));
const server = createLocalMcpServer('TREK assistant');
const around = (info: { kind: string; name: string }, call: () => unknown): unknown =>
traceEntry('mcp', `${info.kind} ${info.name} user=${userId} client=assistant`, () =>
@@ -222,7 +266,7 @@ export class AssistantToolsService implements OnApplicationBootstrap {
this.registry.attach(
server,
{ userId, scopes: [...ASSISTANT_SCOPES], isStaticToken: false },
{ include: (kind, name) => kind === 'tool' && include.has(name), around },
{ include: (kind, name) => kind === 'tool' && include(name), around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -232,21 +276,26 @@ export class AssistantToolsService implements OnApplicationBootstrap {
const tools = new Map<string, ListedTool>();
for (const tool of (await client.listTools()).tools) {
const isUndoTarget = !ASSISTANT_TOOL_ALLOWLIST.has(tool.name);
const kind = isUndoTarget ? 'write' : kindOf(tool.name, tool.annotations?.readOnlyHint === true);
if (!kind) continue;
const annotations = tool.annotations ?? {};
const kind = undoTargets ? 'write' : kindOf(tool.name, annotations.readOnlyHint === true);
const inputSchema = tool.inputSchema as Record<string, unknown>;
const properties = (inputSchema.properties as Record<string, unknown> | undefined) ?? {};
const entry = classOf.get(tool.name);
tools.set(tool.name, {
name: tool.name,
description: tool.description ?? '',
inputSchema,
kind,
pinned: PINNED_ARG in properties,
hint: {
destructive: annotations.destructiveHint === true,
creates: annotations.idempotentHint === false,
group: entry ? groupOf(entry) : null,
},
});
}
const offersNotes = include === ASSISTANT_TOOL_ALLOWLIST;
return new AssistantToolSession(client, server, tools, tripId, (name) => this.audit(userId, name), offersNotes);
const mode: SessionMode = undoTargets ? 'undo' : 'turn';
return new AssistantToolSession(client, server, tools, tripId, (name) => this.audit(userId, name), mode);
}
/** The audit row an MCP client's call writes, marked as the assistant's. A failed write never fails the call. */
@@ -25,6 +25,9 @@ How you work:
- The trip card may start with what the user is asking about. A question about "this place", "it" or "here" is about that; never ask which place they mean then.
- The user can name days, places and bookings with "@" before the name. The trip card then lists what they named, with the ids; use those.
- The app shows the places your searches find as cards with a picture, and the weather you look up as a strip. Do not list them again in your text; say in a sentence or two which ones you suggest and why.
- Besides the open trip you can work in the rest of TREK: tours (hikes, bike tours, city walks), the road trip, the travel journal, the vacation planner, the atlas, collections, the trip chat, files and the user's settings. Their tools are not loaded at first: call use_addon_tools with the groups you need, then use them in the same reply. Tools without a trip work on the user's own data across trips.
- Propose a deletion only when the user asks for it; it cannot be undone, and the card says so.
- You cannot share, invite people, publish links or calendar feeds, change who can see a trip, leave or delete the trip. If asked, say so in one sentence and where in TREK the user can do it.
- When the user asks you to remember something for this trip (who travels, what they like or avoid), keep it with remember_for_trip; it comes back as <trip_notes> in every later conversation. Never say you cannot remember things.
- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one.
- If the request is unclear, ask one short question instead of guessing.`;
@@ -840,7 +840,8 @@ export class AssistantService {
store: this.stepStore(spec),
describe: async (toolName, toolCallId, args) => {
categories ??= await this.categoriesOf(session);
return describeToolCall(toolName, toolCallId, args, { card, categories, locale: spec.locale });
const hintOf = (name: string) => session.hintOf(name);
return describeToolCall(toolName, toolCallId, args, { card, categories, locale: spec.locale, hintOf });
},
newId: randomUUID,
quota: spec.quota,
@@ -0,0 +1,93 @@
import type { TripCard } from './assistant-context';
import type { ToolHint } from './assistant-tools.service';
import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared';
/**
* The change card of a writing tool without a card of its own: a verb read
* from what the tool says it does (removes, creates, changes), a subject from
* the name or title it is given, and one row per argument, with the trip's
* days, places and people named where an argument holds their id. It is never
* undoable: only the tools with a matching removal are.
*/
type Args = Record<string, unknown>;
const MAX_ROWS = 12;
const MAX_VALUE_CHARS = 160;
const SUBJECT_KEYS = ['name', 'title', 'text', 'label', 'content', 'region', 'country_code', 'date'] as const;
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;
}
/** "update_vacay_plan" reads as "update vacay plan". */
export function humanToolName(name: string): string {
return name.replace(/_/g, ' ');
}
function verbOf(hint: ToolHint | null, name: string): AssistantChangeVerbKey {
if (hint?.destructive || /^(delete|remove|unassign|unmark|clear|dissolve|unlink|unshare)_/.test(name)) {
return 'genericRemove';
}
if (hint?.creates || /^(create|add|import|save|mark|copy|take_over|link)_/.test(name)) return 'genericCreate';
return 'genericUpdate';
}
/** An argument holding the id of a day, place or member, named as the trip names it. */
function named(card: TripCard, key: string, value: unknown): string | null {
if (typeof value !== 'number') return null;
if (/^day_?id$/i.test(key)) {
const day = card.days.find((d) => d.id === value);
return day ? (day.title ?? day.date ?? `#${day.number}`) : null;
}
if (/^place_?id$/i.test(key)) return card.places.get(value)?.name ?? null;
if (/^(user_?id|member_?id|assigned_user_id)$/i.test(key)) return card.members.get(value) ?? null;
return null;
}
function valueText(card: TripCard, key: string, value: unknown): string {
const name = named(card, key, value);
if (name) return clip(name);
if (typeof value === 'boolean') return value ? '✓' : '✗';
if (Array.isArray(value)) {
const items = value.map((item) => (typeof item === 'object' ? JSON.stringify(item) : String(item)));
return clip(items.join(', '));
}
if (value && typeof value === 'object') return clip(JSON.stringify(value));
return clip(String(value));
}
export function describeGeneric(
toolName: string,
toolCallId: string,
args: Args,
card: TripCard,
hint: ToolHint | null,
): AssistantChangePreview {
const subjectKey = SUBJECT_KEYS.find((key) => typeof args[key] === 'string' && (args[key] as string).trim());
const rows: AssistantChangeRow[] = Object.entries(args)
.filter(([key, value]) => key !== subjectKey && value !== undefined && value !== null && value !== '')
.slice(0, MAX_ROWS)
.map(([key, value]) => ({
op: 'add',
labelKey: 'field',
field: key.slice(0, 60),
before: null,
after: valueText(card, key, value),
}));
const dayId = typeof args.dayId === 'number' ? args.dayId : typeof args.day_id === 'number' ? args.day_id : null;
const placeId = typeof args.placeId === 'number' ? args.placeId : null;
return {
toolCallId,
verbKey: verbOf(hint, toolName),
subject: clip(subjectKey ? String(args[subjectKey]) : humanToolName(toolName)),
rows,
dayId: dayId && card.days.some((d) => d.id === dayId) ? dayId : null,
placeIds: placeId && card.places.has(placeId) ? [placeId] : [],
undoable: false,
effect: null,
tool: toolName,
...(hint?.group ? { domain: hint.group } : {}),
};
}
@@ -8,6 +8,8 @@ import {
REMEMBER_TOOL,
writeToolOf,
} from './assistant-tools.constants';
import type { ToolHint } from './assistant-tools.service';
import { describeGeneric } from './tool-call-generic.helpers';
import type {
AssistantApplied,
AssistantChangeEffect,
@@ -29,6 +31,8 @@ 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;
}
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
@@ -494,7 +498,7 @@ export function describeToolCall(
const runs = writeToolOf(toolName, args);
const tool = writeTool(runs);
const describe = DESCRIBERS[runs];
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);
return {
toolCallId,
@@ -542,8 +546,8 @@ export function appliedChange(
dayId: number | null,
undoUntil: string | null,
): AssistantApplied | null {
const tool = writeTool(writeToolOf(toolName, args));
if (!tool) return 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 touched = createdId(result) ?? ARG_KEYS.map((key) => id(args, key)).find((value) => value !== null) ?? null;
return {
toolCallId,
+2 -2
View File
@@ -374,7 +374,7 @@ describe('Trip assistant e2e', () => {
expect(shown.body.messages[1].parts).toEqual([{ type: 'text', text: 'Kyoto in June is lovely.' }]);
});
it('ASTE2E-004: a read runs at once on the trip, and the model never sees the trip id or the denied tools', async () => {
it('ASTE2E-004: a read runs at once on the trip, and the model never sees the trip id or a denied tool', async () => {
const t = await travelerWithTrip();
provider.script = [callTools({ id: 'call_r', name: 'list_places', args: {} }), say('You have Kinkaku-ji.')];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'What is planned?' });
@@ -383,7 +383,7 @@ describe('Trip assistant e2e', () => {
const tools = provider.requests[0].tools ?? [];
const names = tools.map((tool) => tool.function.name);
expect(names).toContain('list_places');
for (const denied of ['delete_place', 'delete_trip', 'import_places_from_url', 'create_share_link']) {
for (const denied of ['delete_trip', 'import_places_from_url', 'create_share_link']) {
expect(names).not.toContain(denied);
}
for (const tool of tools) expect(Object.keys(tool.function.parameters.properties ?? {})).not.toContain('tripId');
@@ -1,23 +1,25 @@
/**
* The assistant's tool sessions over the container's real MCP registry: the
* allowlist exists, stays apart from what is denied and matches the tools'
* own annotations; a session offers nothing else (no plugin tools), hides
* the trip and the arguments the server sets; and its calls run on the
* conversation's trip with the user's rights, broadcasting and auditing as
* an MCP client's would.
* The assistant's tool sessions over the container's real MCP registry: every
* registered tool is offered but the denied ones, a sharing tool cannot slip
* past the denylist, what runs at once is what the tools call read-only, a
* turn starts with the core and loads an addon's tools on request; a session
* hides the trip and the arguments the server sets, and its calls run on the
* conversation's trip with the user's rights, broadcasting and auditing as an
* MCP client's would.
*/
import { AuditLog } from '../../../../src/db/entities/AuditLog.entity';
import { McpRegistryService } from '../../../../src/nest-mcp';
import {
ASSISTANT_DENIED_TOOLS,
ASSISTANT_TOOL_GROUPS,
checkDenylist,
isOfferedTool,
} from '../../../../src/nest/assistant/assistant-tool-offer';
import {
ASSISTANT_ARG_POLICY,
ASSISTANT_READ_TOOLS,
ASSISTANT_REMOVAL_TOOLS,
ASSISTANT_SCOPES,
ASSISTANT_SERVER_TOOLS,
ASSISTANT_TOOL_ALLOWLIST,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
isDeniedTool,
} from '../../../../src/nest/assistant/assistant-tools.constants';
import { AssistantToolsService, checkToolCatalog } from '../../../../src/nest/assistant/assistant-tools.service';
import { withRequestContext } from '../../../../src/nest/database/request-context';
@@ -47,9 +49,9 @@ interface ListedTool {
}
let listed: Map<string, ListedTool>;
/** Every allowlisted tool and undo target as the registry attaches them, with full scopes and every addon on. */
/** Every registered tool as the registry attaches it, with full scopes and every addon on. */
async function listRaw(userId: number): Promise<Map<string, ListedTool>> {
const names = new Set([...ASSISTANT_TOOL_ALLOWLIST, ...Object.values(ASSISTANT_UNDO_MAP).map((undo) => undo.tool)]);
const names = new Set(registry.list().map((entry) => entry.name));
const server = new McpServer({ name: 'assistant-test', version: '1.0.0' });
await withRequestContext(orm, () =>
registry.attach(
@@ -79,46 +81,44 @@ beforeAll(async () => {
});
describe('the assistant tool catalog', () => {
it('ASTTOOL-001: every allowlisted tool and undo target is registered', () => {
it('ASTTOOL-001: the tools with a card of their own and the undo targets are registered', () => {
expect(checkToolCatalog(registry.list())).toEqual([]);
expect([...ASSISTANT_TOOL_ALLOWLIST].filter((name) => !listed.has(name))).toEqual([]);
expect(Object.keys(ASSISTANT_WRITE_TOOLS).filter((name) => !listed.has(name))).toEqual([]);
});
it('ASTTOOL-002: names what is missing or denied', () => {
expect(checkToolCatalog([])).toContain('allowlisted tool "get_trip_summary" is not registered');
it('ASTTOOL-002: names what is missing, what clashes and a sharing tool the denylist missed', () => {
expect(checkToolCatalog([])).toContain('tool "create_place" with a card of its own is not registered');
expect(checkToolCatalog([])).toContain('undo tool "delete_place" is not registered');
const clash = [{ kind: 'tool' as const, name: 'import_booking', className: 'X', methodName: 'y' }];
expect(checkToolCatalog(clash)).toContain('"import_booking" is the server\'s own step');
const entry = (name: string) => ({ kind: 'tool' as const, name, className: 'X', methodName: 'y' });
expect(checkToolCatalog([entry('import_booking')])).toContain('"import_booking" is the server\'s own step');
expect(checkToolCatalog([entry('use_addon_tools')])).toContain('"use_addon_tools" is the server\'s own step');
expect(checkDenylist(['create_party_invite'])).toEqual([
'"create_party_invite" shares, invites or publishes; deny it or list it as safe',
]);
});
it("ASTTOOL-006: the server's own steps are never offered to the model", () => {
for (const name of ASSISTANT_SERVER_TOOLS) {
expect(ASSISTANT_TOOL_ALLOWLIST.has(name), name).toBe(false);
expect(listed.has(name), name).toBe(false);
it("ASTTOOL-006: the server's own steps are no MCP tools", () => {
for (const name of ASSISTANT_SERVER_TOOLS) expect(listed.has(name), name).toBe(false);
});
it('ASTTOOL-003: every registered tool that shares, invites or publishes is denied or only takes back', () => {
expect(checkDenylist(listed.keys())).toEqual([]);
for (const name of ['delete_trip', 'create_share_link', 'enable_trip_calendar_feed', 'import_places_from_url']) {
expect(isOfferedTool(name), name).toBe(false);
}
});
it('ASTTOOL-003: the allowlist and the denylist never meet; undo targets are never offered but the removal', () => {
for (const name of ASSISTANT_TOOL_ALLOWLIST) expect(isDeniedTool(name), name).toBe(false);
for (const undo of Object.values(ASSISTANT_UNDO_MAP)) {
if (ASSISTANT_REMOVAL_TOOLS.has(undo.tool)) continue;
expect(ASSISTANT_TOOL_ALLOWLIST.has(undo.tool), undo.tool).toBe(false);
expect(isDeniedTool(undo.tool), undo.tool).toBe(true);
for (const name of ['delete_place', 'list_upcoming_reservations', 'send_collab_message', 'toggle_vacay_entry']) {
expect(isOfferedTool(name), name).toBe(true);
}
for (const name of ASSISTANT_REMOVAL_TOOLS) expect(ASSISTANT_WRITE_TOOLS[name], name).toBeDefined();
expect(ASSISTANT_READ_TOOLS.filter((name) => name in ASSISTANT_WRITE_TOOLS)).toEqual([]);
expect(isDeniedTool('create_share_link')).toBe(true);
expect(isDeniedTool('enable_trip_calendar_feed')).toBe(true);
expect(isDeniedTool('list_upcoming_reservations')).toBe(true);
for (const reason of Object.values(ASSISTANT_DENIED_TOOLS)) expect(reason.length).toBeGreaterThan(10);
});
it('ASTTOOL-004: what runs at once is what the tools call read-only; nothing allowlisted deletes but the removal', () => {
for (const name of ASSISTANT_READ_TOOLS) expect(listed.get(name)?.annotations?.readOnlyHint, name).toBe(true);
it('ASTTOOL-004: the tools with a card of their own write; every group names controllers that exist', () => {
for (const name of Object.keys(ASSISTANT_WRITE_TOOLS)) {
expect(listed.get(name)?.annotations?.readOnlyHint === true, name).toBe(false);
}
for (const name of ASSISTANT_TOOL_ALLOWLIST) {
expect(listed.get(name)?.annotations?.destructiveHint === true, name).toBe(ASSISTANT_REMOVAL_TOOLS.has(name));
const controllers = new Set(registry.list().map((entry) => entry.className));
for (const [group, { controllers: names }] of Object.entries(ASSISTANT_TOOL_GROUPS)) {
for (const name of names) expect(controllers.has(name), `${group}: ${name}`).toBe(true);
}
});
@@ -142,15 +142,18 @@ describe('an assistant tool session', () => {
return { userId: user.id, tripId: trip.id, otherId: other.id };
}
it('ASTTOOL-010: offers only allowlisted tools, without the trip or the arguments the server sets', async () => {
it('ASTTOOL-010: offers the core and its own steps, never a denied tool, without the trip or what the server sets', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
const specs = session.specs();
expect(specs.length).toBeGreaterThan(0);
const names = specs.map((spec) => spec.name);
expect(names).toEqual(expect.arrayContaining(['get_trip_summary', 'create_place', 'remember_for_trip']));
expect(names).toContain('use_addon_tools');
// An addon's tools wait until the model loads their group.
expect(names).not.toContain('list_tours');
for (const spec of specs) {
// The trip notes are the server's own tool, offered beside the allowlist.
expect(ASSISTANT_TOOL_ALLOWLIST.has(spec.name) || spec.name === 'remember_for_trip', spec.name).toBe(true);
expect(isOfferedTool(spec.name), spec.name).toBe(true);
const properties = Object.keys((spec.parameters.properties as Record<string, unknown> | undefined) ?? {});
expect(properties, spec.name).not.toContain('tripId');
const policy = ASSISTANT_ARG_POLICY[spec.name];
@@ -185,7 +188,11 @@ describe('an assistant tool session', () => {
});
expect(session.kindOf('list_places')).toBe('read');
expect(session.kindOf('create_place')).toBe('write');
expect(session.kindOf('delete_place')).toBeNull();
// A deletion waits as a card; a read that fetches a planted address does too.
expect(session.kindOf('delete_place')).toBe('write');
expect(session.kindOf('resolve_maps_url')).toBe('write');
expect(session.kindOf('delete_trip')).toBeNull();
expect(session.hintOf('delete_place')).toMatchObject({ destructive: true, group: null });
} finally {
await session.close();
}
@@ -223,6 +230,22 @@ describe('an assistant tool session', () => {
}
});
it('ASTTOOL-015: the model loads an addon group for the rest of the turn; an unknown group is refused', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
expect(session.kindOf('use_addon_tools')).toBe('read');
expect((await session.call('use_addon_tools', { groups: ['nope'] })).ok).toBe(false);
const run = await session.call('use_addon_tools', { groups: ['tours'] });
expect(run).toMatchObject({ ok: true, value: { loaded: ['tours'] } });
expect((run.value as { tools: string[] }).tools).toContain('list_tours');
expect(session.specs().map((spec) => spec.name)).toContain('list_tours');
expect(session.hintOf('list_tours')).toMatchObject({ group: 'tours' });
} finally {
await session.close();
}
});
it('ASTTOOL-014: an undo session holds its target and nothing else', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId, new Set(['delete_place']));
@@ -190,13 +190,39 @@ describe('describeToolCall', () => {
expect(preview.dayId).toBe(31);
});
it('ASTPRV-015: every writing tool has a card, and anything else has none', () => {
it('ASTPRV-015: every writing tool has a card, a tool without one of its own a card from its arguments', () => {
for (const name of Object.keys(ASSISTANT_WRITE_TOOLS)) {
const preview = describe_(name, {});
expect(assistantChangePreviewSchema.safeParse(preview).success, name).toBe(true);
expect(preview.verbKey).toBe(ASSISTANT_WRITE_TOOLS[name].verbKey);
}
expect(() => describe_('list_places', {})).toThrow(/No change card/);
const hinted = (destructive: boolean, creates: boolean, group: string | null) => ({
...ctx,
hintOf: () => ({ destructive, creates, group }),
});
const vacay = describeToolCall(
'toggle_vacay_entry',
'tc-9',
{ date: '2026-07-01', half: true },
hinted(false, false, 'vacay'),
);
expect(assistantChangePreviewSchema.safeParse(vacay).success).toBe(true);
expect(vacay).toMatchObject({ verbKey: 'genericUpdate', subject: '2026-07-01', domain: 'vacay', undoable: false });
expect(vacay.rows).toEqual([{ op: 'add', labelKey: 'field', field: 'half', before: null, after: '✓' }]);
const gone = describeToolCall('delete_day_note', 'tc-8', { dayId: 31, noteId: 41 }, hinted(true, false, null));
expect(gone).toMatchObject({
verbKey: 'genericRemove',
subject: 'delete day note',
dayId: 31,
tool: 'delete_day_note',
});
expect(gone.rows[0]).toMatchObject({ field: 'dayId', after: 'Arrival' });
// Without hints the name still tells a removal from an addition.
expect(describe_('create_collab_poll', { question: 'Where?' }).verbKey).toBe('genericCreate');
expect(describe_('unmark_country_visited', {}).verbKey).toBe('genericRemove');
expect(appliedChange('toggle_vacay_entry', 'tc-9', {}, { ok: true }, null, null)).toMatchObject({
entity: 'other',
});
});
it('ASTPRV-018: a change to the day plan says what it does to which day; anything else has no effect', () => {
+23 -1
View File
@@ -67,6 +67,10 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
'addReservation',
'importBooking',
'rememberNote',
// Any other tool: by what its annotations say it does.
'genericCreate',
'genericUpdate',
'genericRemove',
] as const;
export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS);
export type AssistantChangeVerbKey = z.infer<typeof assistantChangeVerbKeySchema>;
@@ -109,6 +113,8 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
'stay',
'members',
'route',
// An argument of a tool without a card of its own; the row names it in `field`.
'field',
] as const;
export const assistantChangeLabelKeySchema = z.enum(ASSISTANT_CHANGE_LABEL_KEYS);
export type AssistantChangeLabelKey = z.infer<typeof assistantChangeLabelKeySchema>;
@@ -250,6 +256,8 @@ export const assistantChangeRowSchema = z.object({
labelKey: assistantChangeLabelKeySchema,
before: z.string().nullable(),
after: z.string(),
// The argument a `field` row shows, as the tool names it.
field: z.string().max(60).optional(),
});
export type AssistantChangeRow = z.infer<typeof assistantChangeRowSchema>;
@@ -267,13 +275,27 @@ export const assistantChangePreviewSchema = z.object({
undoable: z.boolean(),
// Left out by servers and stored previews from before the map showed changes.
effect: assistantChangeEffectSchema.nullable().optional(),
// The tool behind a card without one of its own, and the part of TREK it works in.
tool: z.string().max(80).optional(),
domain: z.string().max(40).optional(),
});
export type AssistantChangePreview = z.infer<typeof assistantChangePreviewSchema>;
/** An applied change: what it touched, and until when it can be undone. */
export const assistantAppliedSchema = z.object({
toolCallId: z.string(),
entity: z.enum(['place', 'assignment', 'day_note', 'packing', 'todo', 'budget', 'reservation', 'memory']),
entity: z.enum([
'place',
'assignment',
'day_note',
'packing',
'todo',
'budget',
'reservation',
'memory',
'tour',
'other',
]),
ids: z.array(idSchema),
dayId: idSchema.nullable(),
undoUntil: z.string().nullable(),
+14
View File
@@ -492,5 +492,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'تذكّر لهذه الرحلة',
'assistant.change.details': 'التفاصيل',
'assistant.change.detailsHide': 'إخفاء التفاصيل',
'assistant.change.verb.genericCreate': 'إضافة',
'assistant.change.verb.genericUpdate': 'تغيير',
'assistant.change.verb.genericRemove': 'إزالة',
'assistant.change.label.field': 'القيمة',
'assistant.domain.tours': 'الجولات',
'assistant.domain.roadtrip': 'رحلة برية',
'assistant.domain.journey': 'يوميات السفر',
'assistant.domain.vacay': 'مخطط الإجازات',
'assistant.domain.atlas': 'الأطلس',
'assistant.domain.collections': 'المجموعات',
'assistant.domain.collab': 'محادثة الرحلة',
'assistant.domain.files': 'الملفات',
'assistant.domain.account': 'الحساب',
'assistant.domain.plugins': 'الإضافات',
};
export default assistant;
+14
View File
@@ -400,5 +400,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Bu səyahət üçün yadda saxla',
'assistant.change.details': 'Təfərrüatlar',
'assistant.change.detailsHide': 'Təfərrüatları gizlət',
'assistant.change.verb.genericCreate': 'Əlavə et',
'assistant.change.verb.genericUpdate': 'Dəyiş',
'assistant.change.verb.genericRemove': 'Sil',
'assistant.change.label.field': 'Dəyər',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Avtosəyahət',
'assistant.domain.journey': 'Səyahət gündəliyi',
'assistant.domain.vacay': 'Məzuniyyət planlayıcısı',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Kolleksiyalar',
'assistant.domain.collab': 'Səyahət söhbəti',
'assistant.domain.files': 'Fayllar',
'assistant.domain.account': 'Hesab',
'assistant.domain.plugins': 'Əlavələr',
};
export default assistant;
+14
View File
@@ -400,5 +400,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Derc’hel soñj evit ar veaj-mañ',
'assistant.change.details': 'Munudoù',
'assistant.change.detailsHide': 'Kuzhat ar munudoù',
'assistant.change.verb.genericCreate': 'Ouzhpennañ',
'assistant.change.verb.genericUpdate': 'Kemmañ',
'assistant.change.verb.genericRemove': 'Lemel',
'assistant.change.label.field': 'Talvoud',
'assistant.domain.tours': 'Troiadoù',
'assistant.domain.roadtrip': 'Beaj war an hent',
'assistant.domain.journey': 'Deizlevr beaj',
'assistant.domain.vacay': 'Steuñver vakañsoù',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Dastumadegoù',
'assistant.domain.collab': 'Kaozeadenn ar veaj',
'assistant.domain.files': 'Restroù',
'assistant.domain.account': 'Kont',
'assistant.domain.plugins': 'Astennoù',
};
export default assistant;
+14
View File
@@ -403,5 +403,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Recorda per a aquest viatge',
'assistant.change.details': 'Detalls',
'assistant.change.detailsHide': 'Amaga els detalls',
'assistant.change.verb.genericCreate': 'Afegeix',
'assistant.change.verb.genericUpdate': 'Canvia',
'assistant.change.verb.genericRemove': 'Elimina',
'assistant.change.label.field': 'Valor',
'assistant.domain.tours': 'Rutes',
'assistant.domain.roadtrip': 'Viatge per carretera',
'assistant.domain.journey': 'Diari de viatge',
'assistant.domain.vacay': 'Planificador de vacances',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Col·leccions',
'assistant.domain.collab': 'Xat del viatge',
'assistant.domain.files': 'Fitxers',
'assistant.domain.account': 'Compte',
'assistant.domain.plugins': 'Complements',
};
export default assistant;
+14
View File
@@ -422,5 +422,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Zapamatovat pro tuto cestu',
'assistant.change.details': 'Podrobnosti',
'assistant.change.detailsHide': 'Skrýt podrobnosti',
'assistant.change.verb.genericCreate': 'Přidat',
'assistant.change.verb.genericUpdate': 'Změnit',
'assistant.change.verb.genericRemove': 'Odebrat',
'assistant.change.label.field': 'Hodnota',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autem',
'assistant.domain.journey': 'Cestovní deník',
'assistant.domain.vacay': 'Plánovač dovolené',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Sbírky',
'assistant.domain.collab': 'Chat cesty',
'assistant.domain.files': 'Soubory',
'assistant.domain.account': 'Účet',
'assistant.domain.plugins': 'Rozšíření',
};
export default assistant;
+14
View File
@@ -400,5 +400,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Für diese Reise merken',
'assistant.change.details': 'Mehr Angaben',
'assistant.change.detailsHide': 'Angaben ausblenden',
'assistant.change.verb.genericCreate': 'Hinzufügen',
'assistant.change.verb.genericUpdate': 'Ändern',
'assistant.change.verb.genericRemove': 'Entfernen',
'assistant.change.label.field': 'Wert',
'assistant.domain.tours': 'Touren',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisetagebuch',
'assistant.domain.vacay': 'Urlaubsplaner',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Sammlungen',
'assistant.domain.collab': 'Trip-Chat',
'assistant.domain.files': 'Dateien',
'assistant.domain.account': 'Konto',
'assistant.domain.plugins': 'Erweiterungen',
};
export default assistant;
+14
View File
@@ -395,5 +395,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Remember for this trip',
'assistant.change.details': 'Details',
'assistant.change.detailsHide': 'Hide details',
'assistant.change.verb.genericCreate': 'Add',
'assistant.change.verb.genericUpdate': 'Change',
'assistant.change.verb.genericRemove': 'Remove',
'assistant.change.label.field': 'Value',
'assistant.domain.tours': 'Tours',
'assistant.domain.roadtrip': 'Road trip',
'assistant.domain.journey': 'Journey',
'assistant.domain.vacay': 'Vacation planner',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Collections',
'assistant.domain.collab': 'Trip chat',
'assistant.domain.files': 'Files',
'assistant.domain.account': 'Account',
'assistant.domain.plugins': 'Plugins',
};
export default assistant;
+14
View File
@@ -398,5 +398,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Recordar para este viaje',
'assistant.change.details': 'Detalles',
'assistant.change.detailsHide': 'Ocultar detalles',
'assistant.change.verb.genericCreate': 'Añadir',
'assistant.change.verb.genericUpdate': 'Cambiar',
'assistant.change.verb.genericRemove': 'Quitar',
'assistant.change.label.field': 'Valor',
'assistant.domain.tours': 'Rutas',
'assistant.domain.roadtrip': 'Viaje por carretera',
'assistant.domain.journey': 'Diario de viaje',
'assistant.domain.vacay': 'Planificador de vacaciones',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Colecciones',
'assistant.domain.collab': 'Chat del viaje',
'assistant.domain.files': 'Archivos',
'assistant.domain.account': 'Cuenta',
'assistant.domain.plugins': 'Complementos',
};
export default assistant;
+14
View File
@@ -399,5 +399,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Jäta selle reisi jaoks meelde',
'assistant.change.details': 'Üksikasjad',
'assistant.change.detailsHide': 'Peida üksikasjad',
'assistant.change.verb.genericCreate': 'Lisa',
'assistant.change.verb.genericUpdate': 'Muuda',
'assistant.change.verb.genericRemove': 'Eemalda',
'assistant.change.label.field': 'Väärtus',
'assistant.domain.tours': 'Matkad',
'assistant.domain.roadtrip': 'Autoreis',
'assistant.domain.journey': 'Reisipäevik',
'assistant.domain.vacay': 'Puhkuse planeerija',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Kogud',
'assistant.domain.collab': 'Reisivestlus',
'assistant.domain.files': 'Failid',
'assistant.domain.account': 'Konto',
'assistant.domain.plugins': 'Laiendused',
};
export default assistant;
+14
View File
@@ -405,5 +405,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Retenir pour ce voyage',
'assistant.change.details': 'Détails',
'assistant.change.detailsHide': 'Masquer les détails',
'assistant.change.verb.genericCreate': 'Ajouter',
'assistant.change.verb.genericUpdate': 'Modifier',
'assistant.change.verb.genericRemove': 'Supprimer',
'assistant.change.label.field': 'Valeur',
'assistant.domain.tours': 'Randonnées',
'assistant.domain.roadtrip': 'Road trip',
'assistant.domain.journey': 'Carnet de voyage',
'assistant.domain.vacay': 'Planificateur de congés',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Collections', // same-as-en
'assistant.domain.collab': 'Discussion du voyage',
'assistant.domain.files': 'Fichiers',
'assistant.domain.account': 'Compte',
'assistant.domain.plugins': 'Extensions',
};
export default assistant;
+14
View File
@@ -404,5 +404,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Απομνημόνευση για αυτό το ταξίδι',
'assistant.change.details': 'Λεπτομέρειες',
'assistant.change.detailsHide': 'Απόκρυψη λεπτομερειών',
'assistant.change.verb.genericCreate': 'Προσθήκη',
'assistant.change.verb.genericUpdate': 'Αλλαγή',
'assistant.change.verb.genericRemove': 'Αφαίρεση',
'assistant.change.label.field': 'Τιμή',
'assistant.domain.tours': 'Διαδρομές',
'assistant.domain.roadtrip': 'Οδικό ταξίδι',
'assistant.domain.journey': 'Ημερολόγιο ταξιδιού',
'assistant.domain.vacay': 'Σχεδιαστής διακοπών',
'assistant.domain.atlas': 'Άτλαντας',
'assistant.domain.collections': 'Συλλογές',
'assistant.domain.collab': 'Συνομιλία ταξιδιού',
'assistant.domain.files': 'Αρχεία',
'assistant.domain.account': 'Λογαριασμός',
'assistant.domain.plugins': 'Πρόσθετα',
};
export default assistant;
+14
View File
@@ -405,5 +405,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Megjegyezni ehhez az utazáshoz',
'assistant.change.details': 'Részletek',
'assistant.change.detailsHide': 'Részletek elrejtése',
'assistant.change.verb.genericCreate': 'Hozzáadás',
'assistant.change.verb.genericUpdate': 'Módosítás',
'assistant.change.verb.genericRemove': 'Eltávolítás',
'assistant.change.label.field': 'Érték',
'assistant.domain.tours': 'Túrák',
'assistant.domain.roadtrip': 'Autós körút',
'assistant.domain.journey': 'Útinapló',
'assistant.domain.vacay': 'Szabadságtervező',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Gyűjtemények',
'assistant.domain.collab': 'Utazási csevegés',
'assistant.domain.files': 'Fájlok',
'assistant.domain.account': 'Fiók',
'assistant.domain.plugins': 'Bővítmények',
};
export default assistant;
+14
View File
@@ -377,5 +377,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Ingat untuk perjalanan ini',
'assistant.change.details': 'Detail',
'assistant.change.detailsHide': 'Sembunyikan detail',
'assistant.change.verb.genericCreate': 'Tambah',
'assistant.change.verb.genericUpdate': 'Ubah',
'assistant.change.verb.genericRemove': 'Hapus',
'assistant.change.label.field': 'Nilai',
'assistant.domain.tours': 'Tur',
'assistant.domain.roadtrip': 'Perjalanan darat',
'assistant.domain.journey': 'Jurnal perjalanan',
'assistant.domain.vacay': 'Perencana liburan',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Koleksi',
'assistant.domain.collab': 'Obrolan perjalanan',
'assistant.domain.files': 'Berkas',
'assistant.domain.account': 'Akun',
'assistant.domain.plugins': 'Ekstensi',
};
export default assistant;
+14
View File
@@ -406,5 +406,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Ricorda per questo viaggio',
'assistant.change.details': 'Dettagli',
'assistant.change.detailsHide': 'Nascondi dettagli',
'assistant.change.verb.genericCreate': 'Aggiungi',
'assistant.change.verb.genericUpdate': 'Modifica',
'assistant.change.verb.genericRemove': 'Rimuovi',
'assistant.change.label.field': 'Valore',
'assistant.domain.tours': 'Percorsi',
'assistant.domain.roadtrip': 'Viaggio su strada',
'assistant.domain.journey': 'Diario di viaggio',
'assistant.domain.vacay': 'Pianificatore ferie',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Raccolte',
'assistant.domain.collab': 'Chat del viaggio',
'assistant.domain.files': 'File',
'assistant.domain.account': 'Account', // same-as-en
'assistant.domain.plugins': 'Estensioni',
};
export default assistant;
+14
View File
@@ -372,5 +372,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'この旅行のために覚えておく',
'assistant.change.details': '詳細',
'assistant.change.detailsHide': '詳細を隠す',
'assistant.change.verb.genericCreate': '追加',
'assistant.change.verb.genericUpdate': '変更',
'assistant.change.verb.genericRemove': '削除',
'assistant.change.label.field': '値',
'assistant.domain.tours': 'ツアー',
'assistant.domain.roadtrip': 'ロードトリップ',
'assistant.domain.journey': '旅行記',
'assistant.domain.vacay': '休暇プランナー',
'assistant.domain.atlas': 'アトラス',
'assistant.domain.collections': 'コレクション',
'assistant.domain.collab': '旅行チャット',
'assistant.domain.files': 'ファイル',
'assistant.domain.account': 'アカウント',
'assistant.domain.plugins': 'プラグイン',
};
export default assistant;
+14
View File
@@ -369,5 +369,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': '이 여행에 기억하기',
'assistant.change.details': '세부 정보',
'assistant.change.detailsHide': '세부 정보 숨기기',
'assistant.change.verb.genericCreate': '추가',
'assistant.change.verb.genericUpdate': '변경',
'assistant.change.verb.genericRemove': '삭제',
'assistant.change.label.field': '값',
'assistant.domain.tours': '투어',
'assistant.domain.roadtrip': '로드 트립',
'assistant.domain.journey': '여행 일지',
'assistant.domain.vacay': '휴가 플래너',
'assistant.domain.atlas': '아틀라스',
'assistant.domain.collections': '컬렉션',
'assistant.domain.collab': '여행 채팅',
'assistant.domain.files': '파일',
'assistant.domain.account': '계정',
'assistant.domain.plugins': '플러그인',
};
export default assistant;
+14
View File
@@ -401,5 +401,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Onthouden voor deze reis',
'assistant.change.details': 'Meer gegevens',
'assistant.change.detailsHide': 'Gegevens verbergen',
'assistant.change.verb.genericCreate': 'Toevoegen',
'assistant.change.verb.genericUpdate': 'Wijzigen',
'assistant.change.verb.genericRemove': 'Verwijderen',
'assistant.change.label.field': 'Waarde',
'assistant.domain.tours': 'Routes',
'assistant.domain.roadtrip': 'Roadtrip',
'assistant.domain.journey': 'Reisdagboek',
'assistant.domain.vacay': 'Vakantieplanner',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Collecties',
'assistant.domain.collab': 'Reischat',
'assistant.domain.files': 'Bestanden',
'assistant.domain.account': 'Account', // same-as-en
'assistant.domain.plugins': 'Uitbreidingen',
};
export default assistant;
+14
View File
@@ -449,5 +449,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Zapamiętaj dla tej podróży',
'assistant.change.details': 'Szczegóły',
'assistant.change.detailsHide': 'Ukryj szczegóły',
'assistant.change.verb.genericCreate': 'Dodaj',
'assistant.change.verb.genericUpdate': 'Zmień',
'assistant.change.verb.genericRemove': 'Usuń',
'assistant.change.label.field': 'Wartość',
'assistant.domain.tours': 'Trasy',
'assistant.domain.roadtrip': 'Podróż samochodem',
'assistant.domain.journey': 'Dziennik podróży',
'assistant.domain.vacay': 'Planer urlopów',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Kolekcje',
'assistant.domain.collab': 'Czat podróży',
'assistant.domain.files': 'Pliki',
'assistant.domain.account': 'Konto',
'assistant.domain.plugins': 'Rozszerzenia',
};
export default assistant;
+14
View File
@@ -449,5 +449,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Запомнить для этой поездки',
'assistant.change.details': 'Подробности',
'assistant.change.detailsHide': 'Скрыть подробности',
'assistant.change.verb.genericCreate': 'Добавить',
'assistant.change.verb.genericUpdate': 'Изменить',
'assistant.change.verb.genericRemove': 'Удалить',
'assistant.change.label.field': 'Значение',
'assistant.domain.tours': 'Маршруты',
'assistant.domain.roadtrip': 'Автопутешествие',
'assistant.domain.journey': 'Дневник путешествий',
'assistant.domain.vacay': 'Планировщик отпуска',
'assistant.domain.atlas': 'Атлас',
'assistant.domain.collections': 'Коллекции',
'assistant.domain.collab': 'Чат поездки',
'assistant.domain.files': 'Файлы',
'assistant.domain.account': 'Аккаунт',
'assistant.domain.plugins': 'Плагины',
};
export default assistant;
+14
View File
@@ -422,5 +422,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Zapamätať pre túto cestu',
'assistant.change.details': 'Podrobnosti',
'assistant.change.detailsHide': 'Skryť podrobnosti',
'assistant.change.verb.genericCreate': 'Pridať',
'assistant.change.verb.genericUpdate': 'Zmeniť',
'assistant.change.verb.genericRemove': 'Odstrániť',
'assistant.change.label.field': 'Hodnota',
'assistant.domain.tours': 'Túry',
'assistant.domain.roadtrip': 'Cesta autom',
'assistant.domain.journey': 'Cestovný denník',
'assistant.domain.vacay': 'Plánovač dovolenky',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Zbierky',
'assistant.domain.collab': 'Chat cesty',
'assistant.domain.files': 'Súbory',
'assistant.domain.account': 'Účet',
'assistant.domain.plugins': 'Rozšírenia',
};
export default assistant;
+14
View File
@@ -399,5 +399,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Kom ihåg för den här resan',
'assistant.change.details': 'Detaljer',
'assistant.change.detailsHide': 'Dölj detaljer',
'assistant.change.verb.genericCreate': 'Lägg till',
'assistant.change.verb.genericUpdate': 'Ändra',
'assistant.change.verb.genericRemove': 'Ta bort',
'assistant.change.label.field': 'Värde',
'assistant.domain.tours': 'Turer',
'assistant.domain.roadtrip': 'Bilresa',
'assistant.domain.journey': 'Resedagbok',
'assistant.domain.vacay': 'Semesterplanerare',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Samlingar',
'assistant.domain.collab': 'Reschatt',
'assistant.domain.files': 'Filer',
'assistant.domain.account': 'Konto',
'assistant.domain.plugins': 'Tillägg',
};
export default assistant;
+14
View File
@@ -369,5 +369,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'จำไว้สำหรับทริปนี้',
'assistant.change.details': 'รายละเอียด',
'assistant.change.detailsHide': 'ซ่อนรายละเอียด',
'assistant.change.verb.genericCreate': 'เพิ่ม',
'assistant.change.verb.genericUpdate': 'เปลี่ยน',
'assistant.change.verb.genericRemove': 'ลบ',
'assistant.change.label.field': 'ค่า',
'assistant.domain.tours': 'ทัวร์',
'assistant.domain.roadtrip': 'โรดทริป',
'assistant.domain.journey': 'บันทึกการเดินทาง',
'assistant.domain.vacay': 'ตัววางแผนวันหยุด',
'assistant.domain.atlas': 'แผนที่โลก',
'assistant.domain.collections': 'คอลเลกชัน',
'assistant.domain.collab': 'แชตทริป',
'assistant.domain.files': 'ไฟล์',
'assistant.domain.account': 'บัญชี',
'assistant.domain.plugins': 'ปลั๊กอิน',
};
export default assistant;
+14
View File
@@ -399,5 +399,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Bu gezi için hatırla',
'assistant.change.details': 'Ayrıntılar',
'assistant.change.detailsHide': 'Ayrıntıları gizle',
'assistant.change.verb.genericCreate': 'Ekle',
'assistant.change.verb.genericUpdate': 'Değiştir',
'assistant.change.verb.genericRemove': 'Kaldır',
'assistant.change.label.field': 'Değer',
'assistant.domain.tours': 'Turlar',
'assistant.domain.roadtrip': 'Yolculuk',
'assistant.domain.journey': 'Seyahat günlüğü',
'assistant.domain.vacay': 'Tatil planlayıcı',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Koleksiyonlar',
'assistant.domain.collab': 'Gezi sohbeti',
'assistant.domain.files': 'Dosyalar',
'assistant.domain.account': 'Hesap',
'assistant.domain.plugins': 'Eklentiler',
};
export default assistant;
+14
View File
@@ -447,5 +447,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Запам’ятати для цієї подорожі',
'assistant.change.details': 'Подробиці',
'assistant.change.detailsHide': 'Сховати подробиці',
'assistant.change.verb.genericCreate': 'Додати',
'assistant.change.verb.genericUpdate': 'Змінити',
'assistant.change.verb.genericRemove': 'Видалити',
'assistant.change.label.field': 'Значення',
'assistant.domain.tours': 'Маршрути',
'assistant.domain.roadtrip': 'Автоподорож',
'assistant.domain.journey': 'Щоденник подорожей',
'assistant.domain.vacay': 'Планувальник відпустки',
'assistant.domain.atlas': 'Атлас',
'assistant.domain.collections': 'Колекції',
'assistant.domain.collab': 'Чат подорожі',
'assistant.domain.files': 'Файли',
'assistant.domain.account': 'Обліковий запис',
'assistant.domain.plugins': 'Плагіни',
};
export default assistant;
+14
View File
@@ -372,5 +372,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': 'Ghi nhớ cho chuyến đi này',
'assistant.change.details': 'Chi tiết',
'assistant.change.detailsHide': 'Ẩn chi tiết',
'assistant.change.verb.genericCreate': 'Thêm',
'assistant.change.verb.genericUpdate': 'Thay đổi',
'assistant.change.verb.genericRemove': 'Xóa',
'assistant.change.label.field': 'Giá trị',
'assistant.domain.tours': 'Tuyến đi',
'assistant.domain.roadtrip': 'Đi đường bộ',
'assistant.domain.journey': 'Nhật ký du lịch',
'assistant.domain.vacay': 'Lập kế hoạch nghỉ phép',
'assistant.domain.atlas': 'Atlas',
'assistant.domain.collections': 'Bộ sưu tập',
'assistant.domain.collab': 'Trò chuyện chuyến đi',
'assistant.domain.files': 'Tệp',
'assistant.domain.account': 'Tài khoản',
'assistant.domain.plugins': 'Tiện ích',
};
export default assistant;
+14
View File
@@ -366,5 +366,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': '為此行程記住',
'assistant.change.details': '詳細資料',
'assistant.change.detailsHide': '隱藏詳細資料',
'assistant.change.verb.genericCreate': '新增',
'assistant.change.verb.genericUpdate': '變更',
'assistant.change.verb.genericRemove': '刪除',
'assistant.change.label.field': '值',
'assistant.domain.tours': '路線',
'assistant.domain.roadtrip': '公路旅行',
'assistant.domain.journey': '旅行日誌',
'assistant.domain.vacay': '假期規劃',
'assistant.domain.atlas': '地圖集',
'assistant.domain.collections': '收藏',
'assistant.domain.collab': '行程聊天',
'assistant.domain.files': '檔案',
'assistant.domain.account': '帳戶',
'assistant.domain.plugins': '外掛',
};
export default assistant;
+14
View File
@@ -366,5 +366,19 @@ const assistant: TranslationStrings = {
'assistant.change.verb.rememberNote': '为此行程记住',
'assistant.change.details': '详情',
'assistant.change.detailsHide': '隐藏详情',
'assistant.change.verb.genericCreate': '添加',
'assistant.change.verb.genericUpdate': '更改',
'assistant.change.verb.genericRemove': '删除',
'assistant.change.label.field': '值',
'assistant.domain.tours': '路线',
'assistant.domain.roadtrip': '自驾游',
'assistant.domain.journey': '旅行日记',
'assistant.domain.vacay': '假期规划',
'assistant.domain.atlas': '地图集',
'assistant.domain.collections': '收藏',
'assistant.domain.collab': '行程聊天',
'assistant.domain.files': '文件',
'assistant.domain.account': '账户',
'assistant.domain.plugins': '插件',
};
export default assistant;