Files
AtreidesAssetsFree/server/mcp/write-tools.mjs
T

540 lines
19 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:46e628fbaa9521b9e3fa070e20e19f0d90e6d2eab65f98990ca26652e15c420e
import { CONFIG, ENUMS } from '../../shared/config.js';
import { unitToSubunit, unitPriceToBase } from '../../shared/units.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { listAssets } from '../data/assets.js';
import { listAccounts } from '../data/accounts.js';
import { getBaseCurrency } from '../data/portfolio.js';
import { createAiProposal, listAiProposals } from '../data/ai-proposals.js';
import { vertalerVoor } from './session.mjs';
import { idVoorHandle } from './handles.mjs';
const NOTE_MAX = 500;
const AFHANDELEN = 'De gebruiker vindt het voorstel in AssetApp onder Gebeurtenissen, "Af te handelen", '
+ 'en keurt het daar goed of wijst het af. Er is nu nog niets gewijzigd.';
function onbekend(soort, gevraagd) {
return {
text: `De verwijzing '${gevraagd}' hoort bij geen ${soort} in deze administratie. `
+ `Vraag de huidige verwijzingen op met admin_overview; mogelijk is de rij verwijderd of komt de `
+ `verwijzing uit een andere installatie.`,
fieldsShared: [],
};
}
function klaargezet(t, id, omschrijving) {
const handle = t.toAi({ id }, { entity: 'proposal' }).id;
return {
text: [`Voorstel ${handle} staat klaar: ${omschrijving}`, AFHANDELEN].join('\n'),
fieldsShared: t.fieldsShared(),
};
}
const BELOFTE =
'Wijzigt niets: legt een voorstel vast dat de eigenaar van de app goedkeurt of afwijst.';
const NOTE_SCHEMA = Object.freeze({
type: 'string',
maxLength: NOTE_MAX,
description: 'Waarom je dit voorstelt; de gebruiker leest het op de kaart.',
});
const verwijzing = (soort, voorbeeld) => Object.freeze({
type: 'string',
maxLength: 64,
description: `De verwijzing naar ${soort} (${voorbeeld}).`,
});
const MOMENT_SCHEMA = (wat) => Object.freeze({
type: 'string',
maxLength: 32,
description: `${wat}: 2026-03-01 of een ISO-tijdstempel.`,
});
const SYMBOOL_SCHEMA = Object.freeze({
type: 'string',
maxLength: 16,
description: 'Het symbool van het asset zoals admin_overview het gaf: BTC, XAU, EUR.',
});
function assetVoorSymbool(gevraagd) {
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
if (!gezocht) return null;
return listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null;
}
function onbekendSymbool(gevraagd) {
const bestaat = listAssets().map(a => a.symbol).join(', ');
return {
text: `'${gevraagd}' is geen asset in deze administratie. Deze bestaan wel: ${bestaat}.`,
fieldsShared: ['symbol'],
};
}
const stelBronkeuzeVoor = Object.freeze({
name: 'propose_source_choice',
title: 'Een bronkeuze voorstellen',
description:
'Stelt voor een koersbron van één asset als eerste te zetten voor spot of historie, of eruit te '
+ 'halen; zie integration_health. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
choice: { type: 'string', enum: ['spot', 'history'] },
provider: { type: 'string', maxLength: 60, description: 'De sleutel van de bron.' },
action: { type: 'string', enum: [...ENUMS.SOURCE_CHOICE_ACTION] },
note: NOTE_SCHEMA,
},
required: ['symbol', 'choice', 'provider', 'action'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const id = await createAiProposal({
kind: 'source_choice',
assetId: asset.id,
scope: String(args?.choice ?? ''),
key: String(args?.provider ?? ''),
value: String(args?.action ?? ''),
note: notitie(args?.note),
});
const wat = args.action === 'first' ? 'als eerste zetten voor' : 'weghalen uit';
return klaargezet(t, id, `${args.provider} ${wat} de ${args.choice === 'spot' ? 'spot' : 'historie'} van ${asset.symbol}.`);
},
});
const stelKoerspuntVoor = Object.freeze({
name: 'propose_price_point',
title: 'Een koerspunt voorstellen',
description:
'Stelt voor om één koers van één asset op één moment vast te leggen in AssetApp, voor een koers die '
+ 'de app zelf niet kan ophalen. Wordt bij goedkeuring een handmatige koers op dagniveau. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
fiatPrice: {
type: 'number',
exclusiveMinimum: 0,
description: 'De koers in de rekenvaluta per hele eenheid (per BTC, per ozt goud).',
},
observedAt: MOMENT_SCHEMA('Wanneer deze koers gold'),
note: NOTE_SCHEMA,
},
required: ['symbol', 'fiatPrice', 'observedAt'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const id = await createAiProposal({
kind: 'price_point',
assetId: asset.id,
fiatPrice: unitPriceToBase(args.fiatPrice, asset),
observedAt: String(args.observedAt),
note: notitie(args?.note),
});
return klaargezet(t, id, `een koers voor ${asset.symbol} op ${args.observedAt}.`);
},
});
const stelRekeningVoor = Object.freeze({
name: 'propose_account',
title: 'Een account voorstellen',
description:
'Stelt voor om een nieuw account aan te maken in AssetApp: de plek waar bezit wordt aangehouden. '
+ BELOFTE + ' Een boeking kan pas naar dit account verwijzen nádat het voorstel is goedgekeurd; '
+ 'vraag daarna opnieuw admin_overview op om de verwijzing te krijgen.',
inputSchema: Object.freeze({
type: 'object',
properties: {
name: {
type: 'string',
maxLength: 100,
description: 'De naam zoals de gebruiker hem in de app wil zien, bijvoorbeeld "Kluis thuis".',
},
accountKind: {
type: 'string',
enum: [...ENUMS.ACCOUNT_KIND],
description: 'vault is een kluis voor edelmetaal.',
},
ownCustody: {
type: 'boolean',
description: 'True als de gebruiker het bezit zelf beheert, false als een derde het voor hem houdt.',
},
note: NOTE_SCHEMA,
},
required: ['name', 'accountKind', 'ownCustody'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const id = await createAiProposal({
kind: 'account',
name: String(args?.name ?? ''),
accountKind: String(args?.accountKind ?? ''),
isOwnCustody: !!args?.ownCustody,
note: notitie(args?.note),
});
return klaargezet(t, id, `een nieuw account van de soort ${args.accountKind}.`);
},
});
const stelBoekingVoor = Object.freeze({
name: 'propose_event',
title: 'Een boeking voorstellen',
description:
'Stelt één boeking voor. buy en interest: alleen toAccount; sell: alleen fromAccount; transfer: '
+ 'allebei. buy en sell vragen fiatPrice. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
eventType: {
type: 'string',
enum: [...ENUMS.LEDGER_EVENT_TYPE],
description: 'transfer is tussen twee eigen accounts.',
},
amount: {
type: 'number',
exclusiveMinimum: 0,
description: 'De hoeveelheid in de HELE eenheid (0.25 = een kwart bitcoin), niet in de kleinste.',
},
occurredAt: MOMENT_SCHEMA('Wanneer de boeking plaatsvond'),
fromAccount: verwijzing('het account waar het vandaan komt', 'account:7f3a9c'),
toAccount: verwijzing('het account waar het heen gaat', 'account:7f3a9c'),
fiatPrice: {
type: 'number',
exclusiveMinimum: 0,
description: 'De koers in de rekenvaluta per hele eenheid op het moment van de boeking.',
},
fiatFee: {
type: 'number',
minimum: 0,
description: 'Kosten als bedrag in de rekenvaluta (niet per eenheid).',
},
unitFee: {
type: 'number',
minimum: 0,
description: 'Kosten in het asset zelf (hele eenheid), bovenop amount. Alleen bij sell en transfer.',
},
note: NOTE_SCHEMA,
},
required: ['symbol', 'eventType', 'amount', 'occurredAt'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const rekeningIds = listAccounts().map(a => a.id);
let fromAccountId = null;
let toAccountId = null;
if (args?.fromAccount != null) {
fromAccountId = idVoorHandle(t, 'account', args.fromAccount, rekeningIds);
if (fromAccountId === null) return onbekend('account', args.fromAccount);
}
if (args?.toAccount != null) {
toAccountId = idVoorHandle(t, 'account', args.toAccount, rekeningIds);
if (toAccountId === null) return onbekend('account', args.toAccount);
}
const amount = unitToSubunit(args.amount, asset);
const id = await createAiProposal({
kind: 'event',
assetId: asset.id,
eventType: String(args?.eventType ?? ''),
amount,
fiatPrice: args?.fiatPrice == null ? null : unitPriceToBase(args.fiatPrice, asset),
fiatFee: args?.fiatFee == null ? null : Number(args.fiatFee),
unitFee: args?.unitFee == null ? null : unitToSubunit(args.unitFee, asset),
observedAt: String(args.occurredAt),
fromAccountId,
toAccountId,
note: notitie(args?.note),
});
return klaargezet(t, id, `een boeking van de soort ${args.eventType} op ${args.occurredAt}.`);
},
});
function notitie(waarde) {
return typeof waarde === 'string' && waarde.trim() ? waarde.slice(0, NOTE_MAX) : null;
}
const stelVerwijderingVoor = Object.freeze({
name: 'propose_account_deletion',
title: 'Een account laten verwijderen',
description:
'Stelt voor om een bestaand account uit AssetApp te verwijderen. Dit is ONOMKEERBAAR zodra de '
+ 'eigenaar het goedkeurt, dus de app vraagt daar een aparte bevestiging voor en neemt het niet mee '
+ 'in "alles goedkeuren". Een account waar boekingen, balansen of scans aan hangen kan niet weg; het '
+ 'voorstel weigert dan bij het goedkeuren en zegt hoeveel er in de weg staat. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
account: verwijzing('het account dat weg mag', 'account:7f3a9c'),
note: NOTE_SCHEMA,
},
required: ['account'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const accountId = idVoorHandle(t, 'account', args?.account, listAccounts().map(a => a.id));
if (accountId === null) return onbekend('account', args?.account);
const id = await createAiProposal({
kind: 'account_delete',
accountId,
note: notitie(args?.note),
});
return klaargezet(t, id, `${args.account} verwijderen. Dit is onomkeerbaar.`);
},
});
const stelPortefeuilleVoor = Object.freeze({
name: 'propose_portfolio',
title: 'Een portefeuille voorstellen',
description:
'Stelt voor om een nieuwe, LEGE portefeuille (administratie) aan te maken naast de huidige. Bedoeld '
+ 'om een demonstratie- of testadministratie op te bouwen zonder de echte aan te raken. Kopieert '
+ 'niets: er komt geen data en geen sleutel in. Wisselt ook niet, want dat is een herstart van de '
+ 'app; die keuze blijft bij de eigenaar, en jouw voorstellen landen tot dan in de huidige '
+ 'administratie. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
name: {
type: 'string',
maxLength: 60,
description: 'De naam van de nieuwe portefeuille, zoals de eigenaar hem in de app ziet.',
},
currency: {
type: 'string',
description: 'ECB-code van de rekenvaluta (USD); ligt daarna vast. Standaard die van de huidige.',
},
note: NOTE_SCHEMA,
},
required: ['name'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const currency = args?.currency ? String(args.currency).toUpperCase() : getBaseCurrency();
const id = await createAiProposal({
kind: 'portfolio',
name: String(args?.name ?? ''),
currency,
note: notitie(args?.note),
});
return klaargezet(t, id, `een nieuwe lege portefeuille met de naam '${args.name}', in ${currency}.`);
},
});
const stelVoorkeurVoor = Object.freeze({
name: 'propose_preference',
title: 'Een instelling voorstellen',
description:
'Stelt voor één instelling van deze administratie te wijzigen: elke instelling die admin_overview '
+ 'toont, dus ook thema, accent, de vormgeving (glas, gloed) en de timers. Niet de AI-schuiven zelf. Een ongeldige waarde wordt '
+ 'bij het goedkeuren geweigerd. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
scope: {
type: 'string',
enum: [...CONFIG.AI.PROPOSABLE_PREF_SCOPES],
description: 'De groep van de instelling.',
},
key: {
type: 'string',
maxLength: 60,
description: 'De sleutel zoals admin_overview hem toont, bijvoorbeeld currency.',
},
value: {
type: ['string', 'number', 'boolean'],
description: 'De nieuwe waarde.',
},
note: NOTE_SCHEMA,
},
required: ['scope', 'key', 'value'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const id = await createAiProposal({
kind: 'preference',
scope: String(args?.scope ?? ''),
key: String(args?.key ?? ''),
value: JSON.stringify(args?.value ?? null),
note: notitie(args?.note),
});
return klaargezet(t, id,
`de instelling ${args.scope}.${args.key} op ${JSON.stringify(args.value)} zetten.`);
},
});
const stelBalansVoor = Object.freeze({
name: 'propose_valuation',
title: 'Een balans voorstellen',
description:
'Stelt de STAND van één account in één asset op een peildatum voor. Dat is een ANKER: na goedkeuring '
+ 'IS de stand deze balans plus de boekingen erna, dus een verkeerde balans corrigeert het grootboek '
+ 'stil. Gebruik alleen een onafhankelijke bron (afschrift, handelsplatform, telling); een stand die '
+ 'je uit je eigen voorstellen optelt, klopt per constructie en controleert niets. Vergt een koers in '
+ 'de historie op die datum, anders blijft het voorstel staan met die reden. ' + BELOFTE,
inputSchema: Object.freeze({
type: 'object',
properties: {
symbol: SYMBOOL_SCHEMA,
account: verwijzing('het account waarvan dit de stand is', 'account:7f3a9c'),
balance: {
type: 'number',
minimum: 0,
description: 'De stand in de HELE eenheid (0.25 = een kwart bitcoin). Nul mag: leeg op die datum.',
},
valuedOn: MOMENT_SCHEMA('De peildatum'),
note: NOTE_SCHEMA,
},
required: ['symbol', 'account', 'balance', 'valuedOn'],
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.symbol);
if (!asset) return onbekendSymbool(args?.symbol);
const accountId = idVoorHandle(t, 'account', args?.account, listAccounts().map(a => a.id));
if (accountId === null) return onbekend('account', args?.account);
const balance = unitToSubunit(args.balance, asset);
const id = await createAiProposal({
kind: 'valuation',
assetId: asset.id,
accountId,
balance,
observedAt: String(args.valuedOn),
note: notitie(args?.note),
});
return klaargezet(t, id, `de stand van ${args.account} in ${asset.symbol} op ${args.valuedOn}.`);
},
});
const voorstelToestand = Object.freeze({
name: 'proposal_status',
title: 'De toestand van een voorstel',
description:
'Zegt of een voorstel is goedgekeurd, afgewezen of nog wacht, en waarom een uitvoering mislukte. '
+ 'Verandert niets. Vraag dit vóór je een boeking voorstelt naar een pas voorgesteld account.',
inputSchema: Object.freeze({
type: 'object',
properties: {
proposal: {
type: 'string',
maxLength: 32,
description: 'De verwijzing die een propose_-tool teruggaf (proposal:7f3a9c). Weglaten voor alles wat nog openstaat.',
},
},
additionalProperties: false,
}),
scope: 'write',
async run(args, context) {
assertScope('write');
const t = await vertalerVoor(context);
t.beginCall();
const alle = listAiProposals();
const gevraagd = String(args?.proposal ?? '').trim();
if (!gevraagd) {
const open = alle.filter(p => p.status === 'pending');
if (!open.length) {
return { text: 'Er staat geen voorstel meer open; alles is afgehandeld.', fieldsShared: t.fieldsShared() };
}
const { lines } = limitedList(open, p => `- ${voorstelHandle(t, p.id)} (${p.kind}): wacht op een oordeel.`);
return {
text: [`${open.length} voorstel(len) wachten nog op een oordeel:`, ...lines].join('\n'),
fieldsShared: [...t.fieldsShared(), 'kind', 'status'],
};
}
const rij = alle.find(p => voorstelHandle(t, p.id) === gevraagd);
if (!rij) {
return {
text: `'${gevraagd}' is geen voorstel dat ik terugvind. Ik kijk ${CONFIG.AI.PROPOSAL_PAGE_SIZE} voorstellen terug.`,
fieldsShared: [],
};
}
const uitleg = {
pending: 'wacht nog op een oordeel van de eigenaar.',
accepted: 'is goedgekeurd en uitgevoerd.',
rejected: 'is afgewezen en niet uitgevoerd.',
}[rij.status] ?? `heeft een onbekende status (${rij.status}).`;
const reden = rij.status === 'pending' && rij.detail
? ` Een eerdere poging om hem uit te voeren mislukte: ${rij.detail}`
: '';
return {
text: `Voorstel ${gevraagd} (${rij.kind}) ${uitleg}${reden}`,
fieldsShared: [...t.fieldsShared(), 'kind', 'status', ...(reden ? ['detail'] : [])],
};
},
});
const voorstelHandle = (t, id) => String(t.toAi({ id }, { entity: 'proposal' }).id);
export const WRITE_TOOLS = Object.freeze([
stelKoerspuntVoor, stelRekeningVoor, stelBoekingVoor, stelBalansVoor, stelVerwijderingVoor,
stelPortefeuilleVoor, stelVoorkeurVoor, stelBronkeuzeVoor, voorstelToestand,
]);