Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
// Generated, do not edit. sha256:35230db92740b73c0985590b9f5ef4f49deba0d1a3c63e124e62b21c8b219a63
import { fileURLToPath } from 'node:url';
import { CONFIG } from '../../shared/config.js';
const STDIO_PAD = fileURLToPath(new URL('./stdio.mjs', import.meta.url));
export function mcpClientConfig() {
const document = {
mcpServers: {
[CONFIG.AI.SERVER_NAME]: { command: 'node', args: [STDIO_PAD] },
},
};
return {
serverKey: CONFIG.AI.SERVER_NAME,
path: STDIO_PAD,
snippet: JSON.stringify(document, null, 2),
};
}
+106
View File
@@ -0,0 +1,106 @@
// Generated, do not edit. sha256:588c505552ce949b88f55a05209c95d02e3be8d37929538a9fb8ef9f58643cd2
import { CONFIG, TARGETS, targetHash } from '../../shared/config.js';
import { placeLabel } from '../../shared/places.js';
import { installLocale } from '../installation.mjs';
import { assertScope } from '../ai/gate.js';
import { createCommand, getCommand } from '../data/commands.js';
import { flushChanges } from '../data/bus.js';
const DOELEN = Object.keys(TARGETS);
const doelenTekst = () => DOELEN
.map(naam => `${naam} - ${placeLabel(naam, installLocale())} (${targetHash(TARGETS[naam])})`).join(', ');
const KIJK_INTERVAL_MS = 400;
const wacht = (ms) => new Promise(r => setTimeout(r, ms));
async function wachtOpUitkomst(id, seconden) {
const einde = Date.now() + seconden * 1000;
let c = getCommand(id);
while (c && c.resolvedAt == null && Date.now() < einde) {
await wacht(KIJK_INTERVAL_MS);
c = getCommand(id);
}
return c;
}
async function stuurOpdracht(kind, payload = null) {
const verloopt = new Date(Date.now() + CONFIG.COMMANDS.CLAIM_SECONDS * 1000).toISOString();
const id = await createCommand({ kind, payload, expiresAt: verloopt });
flushChanges();
return wachtOpUitkomst(id, CONFIG.COMMANDS.CLAIM_SECONDS);
}
const NIET_OPEN =
'De app lijkt niet open te staan. Vraag de gebruiker of hij AssetApp opent en probeer het daarna '
+ 'opnieuw.';
const navigeerNaar = Object.freeze({
name: 'navigate_to',
title: 'Naar een plek in de app gaan, of zien waar de gebruiker is',
description:
'Brengt de gebruiker naar een plek in AssetApp; zonder target zegt hij waar de gebruiker nu kijkt, '
+ 'met de naam van die plek. Een sprong ziet de gebruiker aankomen en kan hij tegenhouden. Leest en '
+ 'wijzigt de administratie niet. Staat de app niet open, dan zegt het antwoord dat.',
inputSchema: Object.freeze({
type: 'object',
properties: {
target: {
type: 'string',
enum: DOELEN,
description: 'De plek. Weglaten om te horen waar de gebruiker is.',
},
},
additionalProperties: false,
}),
scope: 'control',
async run(args) {
assertScope('control');
if (!args || args.target === undefined) return waarKijktDeGebruiker();
const naam = typeof args.target === 'string' ? args.target : '';
if (!Object.hasOwn(TARGETS, naam)) {
return {
text: `'${naam}' is geen bekende plek. Kies er een uit: ${doelenTekst()}.`,
fieldsShared: [],
};
}
const doel = TARGETS[naam];
const uit = await stuurOpdracht('navigate',
{ route: doel.route, tab: doel.tab, sub: doel.sub, hash: targetHash(doel) });
const stand = uit ? uit.state : 'expired';
if (stand === 'done') {
return { text: `Gedaan. De gebruiker kijkt nu naar ${targetHash(doel)}.`, fieldsShared: [] };
}
if (stand === 'rejected') {
return {
text: `Niet uitgevoerd: ${uit.detail || 'de opdracht is afgewezen.'} Er is niet genavigeerd.`,
fieldsShared: [],
};
}
return { text: `Er is niet genavigeerd. ${NIET_OPEN}`, fieldsShared: [] };
},
});
const NAAM_PER_HASH = new Map(DOELEN.map(naam => [targetHash(TARGETS[naam]), naam]));
async function waarKijktDeGebruiker() {
const uit = await stuurOpdracht('locate');
if (!uit || uit.state !== 'done') return { text: NIET_OPEN, fieldsShared: [] };
const hash = typeof uit.detail === 'string' ? uit.detail : '';
const naam = NAAM_PER_HASH.get(hash);
if (naam) return { text: `De gebruiker kijkt naar ${naam}: ${placeLabel(naam, installLocale())} (${hash}).`, fieldsShared: [] };
return {
text: hash
? `De gebruiker kijkt naar ${hash}. Dat is geen van de plekken die navigate_to kent.`
: 'De app staat open, maar liet niet weten waar de gebruiker kijkt.',
fieldsShared: [],
};
}
export const CONTROL_TOOLS = Object.freeze([navigeerNaar]);
+179
View File
@@ -0,0 +1,179 @@
// Generated, do not edit. sha256:c8d6a3abd48730edcf0689606e46d90e911be6fb20a947fc1829093ad98e9800
import { listAccounts } from '../data/accounts.js';
import { listAssets } from '../data/assets.js';
import { listLabels } from '../data/labels.js';
import { accountStructure, assetStructure, labelStructure, eventRefs } from '../data/structure.js';
import { rowOrigin } from '../data/ai-proposals.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { HANDLE_VORM, idVoorHandle } from './handles.mjs';
function assetVoorSymbool(gevraagd) {
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
if (!gezocht || gezocht.includes(':')) return null;
return listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null;
}
const SOORTEN = Object.freeze({
account: Object.freeze({ ids: () => listAccounts().map(r => r.id), structuur: accountStructure }),
asset: Object.freeze({ ids: () => listAssets().map(r => r.id), structuur: assetStructure }),
label: Object.freeze({ ids: () => listLabels().map(r => r.id), structuur: labelStructure }),
});
const SOORT_NAMEN = Object.keys(SOORTEN);
const telRegel = (aantal, enkelvoud, meervoud) =>
`${aantal} ${aantal === 1 ? enkelvoud : meervoud}`;
const TELLING_TEKST = Object.freeze({
events: n => telRegel(n, 'gebeurtenis', 'gebeurtenissen'),
assets: n => telRegel(n, 'asset waarin geboekt is', 'assets waarin geboekt is'),
accounts: n => telRegel(n, 'account waarop hij voorkomt', 'accounts waarop hij voorkomt'),
walletSources: n => telRegel(n, 'wallet-bron', 'wallet-bronnen'),
addresses: n => telRegel(n, 'adres', 'adressen'),
walletTransactions: n => telRegel(n, 'on-chain beweging', 'on-chain bewegingen'),
valuations: n => telRegel(n, 'balans', 'balansen'),
pricePoints: n => telRegel(n, 'koerspunt', 'koerspunten'),
products: n => telRegel(n, 'metaalproduct', 'metaalproducten'),
coverage: n => telRegel(n, 'koersbron die dit symbool zegt te dekken', 'koersbronnen die dit symbool zeggen te dekken'),
});
function rijRegels(veilig) {
return Object.entries(veilig)
.filter(([naam]) => naam !== 'id')
.map(([naam, waarde]) => ` ${naam} = ${waarde === null ? '(leeg)' : waarde}`);
}
const recordDetail = Object.freeze({
name: 'record_detail',
title: 'Eén rij uitdiepen',
description:
'Geeft van één rij uit de administratie van AssetApp terug waar hij aan vasthangt: zijn eigen '
+ 'kenmerken en hoeveel gebeurtenissen, adressen, balansen en koerspunten eraan hangen. Bedoeld als '
+ 'vervolgvraag op een verwijzing die admin_overview of data_health heeft gegeven. Werkt op een '
+ 'account (account:...) of een label (label:...), ook als die verwijzing uit een eerder gesprek '
+ 'komt, en op een asset bij zijn SYMBOOL (BTC). Bij die twee ook de gebeurtenissen met hun '
+ 'verwijzing, soort en maand. Alleen kenmerken, aantallen en verwijzingen; geen bedragen, geen '
+ 'exacte datums en geen adressen, en wijzigt niets.',
inputSchema: Object.freeze({
type: 'object',
properties: {
handle: {
type: 'string',
maxLength: 64,
description:
'De verwijzing zoals een andere tool hem gaf (account:7f3a9c, label:9e8f7a), of het symbool '
+ 'van een asset (BTC).',
},
},
required: ['handle'],
additionalProperties: false,
}),
scope: 'read',
async run(args, context) {
assertScope(recordDetail.scope);
const t = await vertalerVoor(context);
t.beginCall();
const gevraagd = typeof args?.handle === 'string' ? args.handle.trim() : '';
const symbool = assetVoorSymbool(gevraagd);
if (symbool) return detailVan('asset', symbool.id, gevraagd, t);
const vorm = HANDLE_VORM.exec(gevraagd);
if (!vorm) {
return {
text: `'${gevraagd}' is geen verwijzing zoals deze app ze uitdeelt en geen asset-symbool. `
+ `Verwijzingen zien eruit als account:7f3a9c: een soort, een dubbele punt en zes tekens. `
+ `Staat onbewerkt delen aan, dan is het de soort met het rij-id erachter, zoals account:3. `
+ `Een asset wijs je aan met zijn symbool, zoals BTC.`,
fieldsShared: [],
};
}
const soort = vorm[1];
if (!Object.hasOwn(SOORTEN, soort)) {
return {
text: `Een verwijzing van de soort '${soort}' kan deze tool niet uitdiepen. Dat kan voor: `
+ `${SOORT_NAMEN.join(', ')}.`,
fieldsShared: [],
};
}
const kandidaten = SOORTEN[soort].ids();
const id = idVoorHandle(t, soort, gevraagd, kandidaten) ?? idVoorHandle(t, soort, vorm[2], kandidaten);
if (id === null) {
return {
text: `De verwijzing ${gevraagd} hoort bij geen enkele ${soort} in deze administratie. `
+ `Mogelijk is de rij verwijderd.`,
fieldsShared: [],
};
}
return detailVan(soort, id, gevraagd, t);
},
});
function detailVan(soort, id, gevraagd, t) {
{
const structuur = SOORTEN[soort].structuur(id);
if (!structuur) {
return { text: `De ${soort} achter ${gevraagd} bestaat niet meer.`, fieldsShared: [] };
}
const veilig = t.toAi(structuur.row, { entity: soort });
const tellingen = limitedList(
Object.entries(structuur.counts),
([naam, n]) => ` ${TELLING_TEKST[naam] ? TELLING_TEKST[naam](n) : `${naam} = ${n}`}`,
).lines;
const verwijzingen = Object.entries(structuur.refs)
.filter(([, ids]) => Array.isArray(ids) && ids.length)
.map(([naam, ids]) => {
const soortVanRef = naam === 'assetIds' ? 'asset' : 'account';
const { lines } = limitedList(ids, refId => ` ${t.toAi({ id: refId }, { entity: soortVanRef }).id}`);
return [`${naam === 'assetIds' ? 'Assets' : 'Accounts'}:`, ...lines].join('\n');
});
const gebeurtenissen = (soort === 'account' || soort === 'asset')
? limitedList(
eventRefs(soort === 'account' ? { accountId: id } : { assetId: id }),
(e) => {
const uit = rowOrigin('event', e.id);
const bron = uit ? ` · uit ${t.toAi({ id: uit.proposalId }, { entity: 'proposal' }).id}` : '';
return ` ${t.toAi({ id: e.id }, { entity: 'event' }).id} · ${e.type} · ${e.month}${bron}`;
},
).lines
: [];
const herkomst = soort === 'account' ? rowOrigin('account', id) : null;
const herkomstRegels = herkomst
? ['', `Ontstaan uit ${t.toAi({ id: herkomst.proposalId }, { entity: 'proposal' }).id}, `
+ `goedgekeurd op ${String(herkomst.resolvedAt || '').slice(0, 10)}.`]
: [];
return {
text: [
`${soort} ${gevraagd}:`,
...rijRegels(veilig),
'',
'Hangt eraan:',
...tellingen,
...herkomstRegels,
...(gebeurtenissen.length ? ['', 'Gebeurtenissen (nieuwste eerst, maand):', ...gebeurtenissen] : []),
...(verwijzingen.length ? ['', ...verwijzingen] : []),
].join('\n'),
fieldsShared: [...new Set([
...t.fieldsShared(),
'assetId', 'fromAccountId', 'toAccountId', 'accountId', 'labelId', 'providerId',
...(gebeurtenissen.length ? ['type', 'occurredAt'] : []),
...(gebeurtenissen.some(r => r.includes(' · uit ')) ? ['createdEventId'] : []),
...(herkomst ? ['createdAccountId'] : []),
])].sort(),
};
}
}
export const DETAIL_TOOLS = Object.freeze([recordDetail]);
+33
View File
@@ -0,0 +1,33 @@
// Generated, do not edit. sha256:a80e6dd97d8d48819bc999d4057ab86d1dddc182a1d9038097a88796139bb3df
import { logAiToolCall } from '../data/ai-audit.js';
import { createMcpServer } from './protocol.mjs';
import { allTools } from './tools.mjs';
let server = null;
function ensureServer() {
if (!server) {
server = createMcpServer({
tools: allTools(),
logCall: logAiToolCall,
onLogError: (e) => console.error('[assetapp] een MCP-aanroep is NIET vastgelegd:', e.message),
});
}
return server;
}
export function dryRunTools() {
return allTools().map(t => ({
name: t.name,
title: t.title,
description: t.description,
scope: t.scope,
inputSchema: t.inputSchema,
}));
}
export function dryRunTool(naam, args) {
return ensureServer().dryRun(naam, args);
}
export function _resetDryRunServer() { server = null; }
+12
View File
@@ -0,0 +1,12 @@
// Generated, do not edit. sha256:934f10111106ef1ed676c3715e4aeefd364cfa02688bedd903fe7320f775ae01
export const HANDLE_VORM = /^([a-zA-Z]+):([0-9a-f]{6}|[1-9][0-9]*)$/;
export function idVoorHandle(t, soort, gevraagd, ids) {
const gezocht = String(gevraagd ?? '').trim();
if (!gezocht) return null;
for (const id of ids) {
const uit = String(t.toAi({ id }, { entity: soort }).id);
if (uit === gezocht || `${soort}:${uit}` === gezocht) return id;
}
return null;
}
+35
View File
@@ -0,0 +1,35 @@
// Generated, do not edit. sha256:8dc9633b14245eff1f7c9bee179cd3a162123ee1e24f229791775df14dfa3934
import { TARGETS, targetHash } from '../../shared/config.js';
import { placeActions } from '../../shared/places.js';
import { installLocale } from '../installation.mjs';
import { assertScope } from '../ai/gate.js';
const regels = () => {
const acties = placeActions(installLocale());
return Object.keys(TARGETS)
.map(naam => `- ${naam} (${targetHash(TARGETS[naam])}): ${acties[naam] || '(geen beschrijving)'}`)
.join('\n');
};
const appPlaces = Object.freeze({
name: 'app_places',
title: 'Wat u waar in de app kunt doen',
description:
'Geeft de lijst van plekken in AssetApp met per plek wat de gebruiker daar kan doen: instellen, '
+ 'boeken, beoordelen of alleen bekijken. Bedoeld om te kunnen antwoorden op vragen als "waar vind ik '
+ 'dit" of "waar stel ik dat in", en om daarna de juiste plek te kiezen: de namen in het antwoord zijn '
+ 'dezelfde die navigate_to als plek aanvaardt. Leest de administratie niet en wijzigt niets; het '
+ 'antwoord is voor elke installatie gelijk.',
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'none',
async run() {
assertScope(appPlaces.scope);
return {
text: `De plekken in AssetApp en wat u er kunt doen:\n${regels()}`,
fieldsShared: [],
};
},
});
export const PLACES_TOOLS = Object.freeze([appPlaces]);
+217
View File
@@ -0,0 +1,217 @@
// Generated, do not edit. sha256:d62e710a5bda41c8a7b1a7ff304738dfd9331056f2936a826970a6092995f68b
import { CONFIG, SOURCE_CAPABILITIES } from '../../shared/config.js';
import { listAssets } from '../data/assets.js';
import { listPriceHistory, listPriceGaps, listIgnoredPriceGaps } from '../data/price-history.js';
import { listProviders, getProvider } from '../data/providers.js';
import { listPriceSource } from '../data/price-source.js';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { dailyProvenance } from '../../shared/price-provenance.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { serviceCoveredAssets } from './service-cover.mjs';
import { priceFromBase, priceUnitLabel } from '../../shared/units.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { getBaseCurrency } from '../data/portfolio.js';
const DAG = /^\d{4}-\d{2}-\d{2}$/;
const DAG_MS = 86400000;
const plusDagen = (dag, n) => new Date(Date.parse(`${dag}T00:00:00Z`) + n * DAG_MS).toISOString().slice(0, 10);
const dagenTussen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAG_MS);
const STANDAARD_DAGEN = 14;
const MAX_DAGEN = 92;
const SOORT_TEKST = Object.freeze({
close: 'slotkoers', manual: 'met de hand', live: 'vandaag, beweegt nog', spot: 'afgeleid uit een spot',
carried: 'doorgetrokken', none: 'geen koers',
});
function getal(n) {
if (n == null || !Number.isFinite(n)) return '?';
const d = Math.abs(n) >= 100 ? 2 : Math.abs(n) >= 1 ? 4 : 6;
return n.toLocaleString('nl-NL', { maximumFractionDigits: d, useGrouping: false });
}
function assetVoorSymbool(gevraagd) {
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
return gezocht ? listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null : null;
}
const priceData = Object.freeze({
name: 'price_data',
title: 'Koersen van een asset',
description:
'Alle koersinformatie van één asset: per dag de gebruikte koers met herkomst en de koers per bron '
+ '(days), de gaten (gaps), of de bronnen met keuze, munt, limiet en verbruik (sources). '
+ 'Wijzigt niets.',
inputSchema: Object.freeze({
type: 'object',
properties: {
asset: { type: 'string', maxLength: 16, description: 'Symbool, zoals XAG.' },
view: { type: 'string', enum: ['days', 'gaps', 'sources'] },
from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
until: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
},
required: ['asset'],
additionalProperties: false,
}),
scope: 'read',
async run(args, context) {
assertScope(priceData.scope);
const t = await vertalerVoor(context);
t.beginCall();
const asset = assetVoorSymbool(args?.asset);
if (!asset) {
const symbolen = listAssets().map(a => a.symbol).join(', ');
return { text: `Geen asset met symbool '${String(args?.asset ?? '')}'. Er zijn: ${symbolen}.`, fieldsShared: ['symbol'] };
}
const view = args?.view || 'days';
const sleutelVan = providerSleutels(t);
if (view === 'gaps') return gatenAntwoord(asset, t);
if (view === 'sources') return bronnenAntwoord(asset, t, sleutelVan, (await serviceCoveredAssets()).has(asset.id));
return dagenAntwoord(asset, args, t, sleutelVan);
},
});
function providerSleutels(t) {
const m = new Map(listProviders().map(p => [p.id, t.toAi({ key: p.key }, { entity: 'provider' }).key]));
return id => m.get(id) ?? `bron ${id}`;
}
function dagenAntwoord(asset, args, t, sleutelVan) {
const vandaag = new Date().toISOString().slice(0, 10);
let until = DAG.test(String(args?.until ?? '')) ? args.until : vandaag;
let from = DAG.test(String(args?.from ?? '')) ? args.from : plusDagen(until, -(STANDAARD_DAGEN - 1));
if (from > until) [from, until] = [until, from];
let ingekort = false;
if (dagenTussen(from, until) + 1 > MAX_DAGEN) { from = plusDagen(until, -(MAX_DAGEN - 1)); ingekort = true; }
const rijen = listPriceHistory(asset.id, plusDagen(from, -31)).filter(r => r.observedAt.slice(0, 10) <= until);
const manual = listProviders().find(p => p.key === 'manual');
const dagen = dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manual ? manual.id : null,
today: vandaag, from, until });
const eenheid = priceUnitLabel(asset);
const prijs = (fiatPrice) => getal(priceFromBase(t.toAi({ fiatPrice },{ entity: 'priceHistory' }).fiatPrice, asset));
const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE;
const perDag = new Map();
for (const r of rijen) {
const dag = r.observedAt.slice(0, 10);
if (dag < from) continue;
if (!perDag.has(dag)) perDag.set(dag, []);
perDag.get(dag).push(r);
}
const regels = limitedList(dagen.length ? dagen : [], d => {
const gebruikt = d.kind === 'none'
? 'geen koers'
: `${prijs(d.fiatPrice)} (${SOORT_TEKST[d.kind] || d.kind}${d.row ? `, ${sleutelVan(d.row.providerId)}` : ''}`
+ `${d.kind === 'carried' ? ` van ${d.row.observedAt.slice(0, 10)}, ${d.ageDays} dagen` : ''})`;
const bronnen = (perDag.get(d.day) || []).map(r => {
const prijsTekst = r.fiatPrice != null && r.fiatPrice > 0 ? prijs(r.fiatPrice) : 'leeg';
const moment = r.resolution === 'timestamp' ? ` spot ${r.observedAt.slice(11, 16)} UTC` : '';
return `${sleutelVan(r.providerId)}${moment} ${prijsTekst}`;
});
const dagrijen = (perDag.get(d.day) || []).filter(r => r.resolution === 'day' && r.fiatPrice > 0);
let oneens = '';
if (new Set(dagrijen.map(r => r.providerId)).size > 1) {
const laag = Math.min(...dagrijen.map(r => r.fiatPrice)), hoog = Math.max(...dagrijen.map(r => r.fiatPrice));
const spreiding = hoog / laag - 1;
if (spreiding > grens) oneens = ` · BRONNEN ONEENS: ${getal(Math.round(spreiding * 1000) / 10)}%`;
}
return `${d.day}: ${gebruikt}${bronnen.length ? ` · ${bronnen.join('; ')}` : ''}${oneens}`;
}).lines;
return {
text: [
`Koersen van ${t.toAi(asset, { entity: 'asset' }).symbol} in ${getBaseCurrency()} per ${eenheid}, ${from} t/m ${until}`
+ `${ingekort ? ` (ingekort tot ${MAX_DAGEN} dagen)` : ''}. Per dag: de koers die de app gebruikt met`
+ ' herkomst en bron, daarna elke opgeslagen koers per bron. Een spot is een moment, geen slot.',
...(regels.length ? regels : ['Geen koersen in dit venster, en geen die ervoor doorgetrokken kan worden.']),
].join('\n'),
fieldsShared: [...new Set([...t.fieldsShared(), 'baseCurrency', 'symbol', 'kind', 'unit', 'observedAt', 'fiatPrice',
'resolution', 'key', 'updatedAt'])].sort(),
};
}
function gatenAntwoord(asset, t) {
const open = listPriceGaps().rows.filter(g => g.assetId === asset.id);
const genegeerd = listIgnoredPriceGaps().filter(g => g.assetId === asset.id);
const symbool = t.toAi(asset, { entity: 'asset' }).symbol;
return {
text: [
`Gaten in de koershistorie van ${symbool} (dagen zonder koers, vanaf de eerste boeking t/m gisteren,`
+ ` langer dan ${CONFIG.HYGIENE.PRICE_GAP_DAYS - 1} dagen):`,
...(open.length ? limitedList(open, g => `- ${g.from} t/m ${g.until}, ${g.days} dagen`).lines : ['- geen']),
'Door de gebruiker genegeerd (telt als gedekt):',
...(genegeerd.length ? limitedList(genegeerd, g => `- ${g.fromDay} t/m ${g.untilDay}`).lines : ['- geen']),
].join('\n'),
fieldsShared: ['symbol', 'observedAt', 'fromDay', 'untilDay'],
};
}
function bronnenAntwoord(asset, t, sleutelVan, uitDienst = false) {
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
const etmaal = new Date(Date.now() - DAG_MS).toISOString().slice(0, 16);
const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean);
const usage = {};
for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls;
const cellen = listPriceSource().filter(c => c.assetId === asset.id);
const rijen = listPriceHistory(asset.id);
const keten = SOURCE_CAPABILITIES.map(cap => {
const k = cellen.filter(c => c.capability === cap && c.rank != null).sort((a, b) => a.rank - b.rank)
.map(c => { const v = t.toAi({ rank: c.rank, status: c.status }, { entity: 'priceSource' });
return `${v.rank}. ${sleutelVan(c.providerId)} (${v.status})`; });
return `${cap === 'spot' ? 'Spot' : 'Historie'}: ${k.length ? k.join(', ') : 'geen bron gekozen'}`;
});
const dekkend = providers.filter(p => (p.coverage || []).includes(asset.symbol) || p.key === 'manual');
const regels = limitedList(dekkend, p => {
const rauw = (p.symbols || []).find(s => !s.isCatchAll && s.symbol === asset.symbol)
|| (p.symbols || []).find(s => s.isCatchAll) || null;
const kaart = rauw ? t.toAi({ externalId: rauw.externalId, quoteUnit: rauw.quoteUnit }, { entity: 'provider' }) : null;
const eindpunten = (p.endpoints || []).map(e => {
const v = t.toAi({ capability: e.capability, quoteCurrency: e.quoteCurrency }, { entity: 'provider' });
return `${v.capability}${v.quoteCurrency ? ` in ${v.quoteCurrency}` : ''}`;
});
const eigen = rijen.filter(r => r.providerId === p.id);
const laatste = eigen[0] ? eigen[0].observedAt.slice(0, 10) : null;
const eerste = eigen.length ? eigen[eigen.length - 1].observedAt.slice(0, 10) : null;
const m = apiMetricsTotals(p.id, etmaal);
const delen = [
p.isActive ? 'actief' : 'uit',
eindpunten.length ? eindpunten.join(', ') : 'geen eindpunten',
...(kaart ? [`invoer ${kaart.externalId}${kaart.quoteUnit ? `, per ${kaart.quoteUnit}` : ''}`] : []),
...(p.callsPerMonth ? [`${usage[p.id] ?? 0} van ${p.callsPerMonth} verzoeken deze maand`] : []),
`${m.calls} aanroepen in 24 uur, ${m.fail} mislukt`,
eigen.length ? `${eigen.length} koersen, ${eerste} t/m ${laatste}` : 'geen koersen opgeslagen',
];
return `- ${sleutelVan(p.id)}: ${delen.join('; ')}`;
}).lines;
const historyChain = cellen.filter(c => c.capability === 'history' && c.rank != null)
.sort((a, b) => a.rank - b.rank).map(c => providers.find(p => p.id === c.providerId)?.key).filter(Boolean);
const bevindingen = diagnoseSources(asset, { providers, historyChain, usage, serviceCovers: uitDienst })
.map(b => `- ${b.code}${(b.names || []).length ? ` (${b.names.join(', ')})` : ''}`
+ `${b.proposal ? `, voorstel: ${b.proposal.providerKey}` : ''}`);
return {
text: [
`Bronnen van ${t.toAi(asset, { entity: 'asset' }).symbol} (koersen worden per gram opgeslagen bij metaal):`,
...keten,
...(uitDienst ? ['Dienst: historie uit Atreides Services (stand niet uit, symbool gedekt).'] : []),
'Bronnen die dit symbool dekken:',
...(regels.length ? regels : ['- geen']),
'Diagnose van de bronkeuze:',
...(bevindingen.length ? bevindingen : ['- in orde']),
].join('\n'),
fieldsShared: [...new Set([...t.fieldsShared(), 'symbol', 'key', 'isActive', 'capability', 'rank', 'status',
'quoteCurrency', 'quoteUnit', 'externalId', 'callsPerMonth', 'okCount', 'failCount', 'observedAt',
...(uitDienst ? ['value'] : [])])].sort(),
};
}
export const PRICE_TOOLS = Object.freeze([priceData]);
+159
View File
@@ -0,0 +1,159 @@
// Generated, do not edit. sha256:6e59cd9bc5c34f22b316c129925de56d6e800bca08debd1308bf90cb9f449baa
import { randomUUID } from 'node:crypto';
import { CONFIG } from '../../shared/config.js';
import { AI_REFUSALS } from '../ai/gate.js';
import { limitText } from '../ai/limits.js';
import { ENUMS } from '../../shared/config.js';
const PARSE_ERROR = -32700;
const INVALID_REQUEST = -32600;
const METHOD_NOT_FOUND = -32601;
const INVALID_PARAMS = -32602;
const fout = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
const goed = (id, result) => ({ jsonrpc: '2.0', id, result });
export function createMcpServer({ tools, logCall, onLogError } = {}) {
if (typeof logCall !== 'function') {
throw new Error('An MCP server without an audit log is not allowed in this project.');
}
if (!Array.isArray(tools) || tools.length === 0) {
throw new Error('An MCP server without tools is meaningless.');
}
const zonderScope = tools.filter(t => !ENUMS.AI_SCOPE.includes(t?.scope));
if (zonderScope.length) {
const namen = zonderScope.map(t => t?.name ?? '(naamloos)').join(', ');
throw new Error(`Tool without a valid scope: ${namen}. Allowed is ${ENUMS.AI_SCOPE.join(', ')}.`);
}
const perNaam = new Map(tools.map(t => [t.name, t]));
const sessie = { id: randomUUID(), client: null, initialized: false };
async function log(regel, messages) {
try {
await logCall({
sessionId: sessie.id, client: sessie.client,
serverVersion: CONFIG.AI.SERVER_VERSION, ...regel,
...(messages ? { messages } : {}),
});
} catch (e) {
if (onLogError) onLogError(e);
else throw e;
}
}
const gesprek = (binnenAt, args, uit) => [
{ kind: 'request', text: JSON.stringify(args ?? {}), occurredAt: binnenAt },
{ kind: 'response', text: String(uit ?? ''), occurredAt: new Date().toISOString() },
];
async function voerUit(naam, args, droog) {
const binnenAt = new Date().toISOString();
const tool = perNaam.get(naam);
if (!tool) {
const regel = { tool: naam, arguments: args, outcome: 'refused', detail: 'Deze tool bestaat niet.' };
if (!droog) await log(regel, gesprek(binnenAt, args, regel.detail));
return { regel, text: regel.detail, onbekend: true };
}
try {
const { text, fieldsShared } = await tool.run(args, { session: sessie });
const { text: uitvoer, capped } = limitText(String(text ?? ''));
const regel = {
tool: naam, arguments: args, fieldsShared: fieldsShared ?? [],
charactersOut: uitvoer.length, outcome: 'ok',
detail: capped ? 'Antwoord liep tegen de tekengrens en is ingekort.' : undefined,
};
if (!droog) await log(regel, gesprek(binnenAt, args, uitvoer));
return { regel, text: uitvoer };
} catch (e) {
const melding = e && e.message ? e.message : String(e);
const soort = e && AI_REFUSALS.includes(e.code) ? 'refused' : 'error';
const regel = { tool: naam, arguments: args, outcome: soort, detail: melding };
if (!droog) await log(regel, gesprek(binnenAt, args, melding));
return { regel, text: melding, mislukt: true };
}
}
async function toolsCall(id, params) {
const naam = params && typeof params.name === 'string' ? params.name : null;
const args = params && params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
if (!naam) {
await log({ tool: '(geen)', outcome: 'refused', detail: 'Verzoek zonder toolnaam.' });
return fout(id, INVALID_PARAMS, 'Een tools/call-verzoek noemt geen tool.');
}
const r = await voerUit(naam, args, false);
if (r.onbekend) return fout(id, INVALID_PARAMS, `Onbekende tool '${naam}'.`);
if (r.mislukt) return goed(id, { content: [{ type: 'text', text: r.text }], isError: true });
return goed(id, { content: [{ type: 'text', text: r.text }] });
}
async function dryRun(naam, args = {}) {
const veiligeArgs = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
const r = await voerUit(String(naam ?? ''), veiligeArgs, true);
return {
...r.regel,
fieldsShared: r.regel.fieldsShared ?? [],
charactersOut: r.regel.charactersOut ?? null,
detail: r.regel.detail ?? null,
text: r.text,
};
}
async function handle(bericht) {
if (!bericht || typeof bericht !== 'object' || Array.isArray(bericht)) {
return fout(null, INVALID_REQUEST, 'Geen JSON-RPC-object.');
}
const { id, method, params } = bericht;
const isNotificatie = id === undefined || id === null;
if (typeof method !== 'string') {
return isNotificatie ? null : fout(id, INVALID_REQUEST, 'Verzoek zonder methode.');
}
switch (method) {
case 'initialize': {
sessie.client = params?.clientInfo?.name ? String(params.clientInfo.name) : null;
sessie.initialized = true;
return goed(id, {
protocolVersion: typeof params?.protocolVersion === 'string'
? params.protocolVersion : CONFIG.AI.PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: CONFIG.AI.SERVER_NAME, version: CONFIG.AI.SERVER_VERSION },
});
}
case 'notifications/initialized':
case 'initialized':
return null;
case 'ping':
return isNotificatie ? null : goed(id, {});
case 'tools/list': {
if (isNotificatie) return null;
return goed(id, {
tools: tools.map(t => ({
name: t.name,
title: t.title,
description: t.description,
inputSchema: t.inputSchema,
})),
});
}
case 'tools/call':
if (isNotificatie) return null;
return toolsCall(id, params);
default:
return isNotificatie ? null : fout(id, METHOD_NOT_FOUND, `Onbekende methode '${method}'.`);
}
}
async function handleRegel(regel) {
let bericht;
try { bericht = JSON.parse(regel); } catch { return fout(null, PARSE_ERROR, 'Ongeldige JSON.'); }
return handle(bericht);
}
return { handle, handleRegel, dryRun, get session() { return { ...sessie }; } };
}
+414
View File
@@ -0,0 +1,414 @@
// Generated, do not edit. sha256:df37b3da57b8535420153ba9f7331337454235c42435f0fd9deff4f9829eb96b
import { listProviders, getProvider } from '../data/providers.js';
import { listPriceSource } from '../data/price-source.js';
import { diagnoseSources } from '../../shared/source-diagnosis.js';
import { serviceCoveredAssets } from './service-cover.mjs';
import { apiMetricsTotals } from '../data/api-metrics.js';
import { listConnections } from '../data/connections.js';
import { listAssets } from '../data/assets.js';
import { getBaseCurrency } from '../data/portfolio.js';
import { listAccounts } from '../data/accounts.js';
import { listLabels, labelUsage } from '../data/labels.js';
import { counts } from '../data/counts.js';
import { listMetalProducts } from '../data/metals.js';
import { listProfiles, listWidgets } from '../data/dashboard.js';
import { getPref } from '../data/preferences.js';
import { hygieneReport } from '../data/hygiene.js';
import { listScanLogs } from '../data/scan-logs.js';
import { listIntegrationCalls } from '../data/integration-log.js';
import { assertScope, mayScope } from '../ai/gate.js';
import { limitedList, limitRows, omittedNote } from '../ai/limits.js';
import { vertalerVoor } from './session.mjs';
import { CONFIG, PREFERENCES, SOURCE_CAPABILITIES, TAX_CATEGORY_BY_ASSET_KIND, balanceCaptureOn } from '../../shared/config.js';
import { fromBaseByFactor } from '../../shared/units.js';
const GEEN_BEDRAGEN = 'Zonder bedragen, saldi of adressen, en wijzigt niets.';
function sindsUren(uren) {
return new Date(Date.now() - uren * 3600_000).toISOString().slice(0, 16);
}
function pct(deel, geheel) {
return geheel ? `${Math.round((deel / geheel) * 1000) / 10}%` : '0%';
}
const VENSTER_UREN = CONFIG.AI.LIMITS.METRICS_WINDOW_HOURS;
function scanBlok(t) {
const regels = [];
for (const rekening of listAccounts()) {
const logs = listScanLogs(rekening.id);
if (!logs.length && !rekening.scanEnabled) continue;
const veiligeRekening = t.toAi(rekening, { entity: 'account' });
if (!logs.length) {
regels.push(`- ${veiligeRekening.id}: staat op automatisch scannen maar is nog nooit gescand.`);
continue;
}
const laatste = t.toAi({
status: logs[0].status,
scannedAt: logs[0].scannedAt,
addressesFound: logs[0].addressesFound,
proposalsCreated: logs[0].proposalsCreated,
}, { entity: 'scanLog' });
const mislukt = logs.filter(l => l.status === 'failed').length;
regels.push(
`- ${veiligeRekening.id}: laatste scan ${laatste.status} op ${laatste.scannedAt}`
+ `, ${laatste.addressesFound ?? 0} adressen, ${laatste.proposalsCreated ?? 0} voorstellen`
+ ` (${logs.length} scans in totaal, ${mislukt} mislukt).`);
}
return limitedList(regels, r => r).lines;
}
const DIAGNOSE_TEKST = Object.freeze({
noCoverage: () => 'geen enkele bron levert hier historie voor',
allInactive: n => `de dekkende bronnen staan uit (${n})`,
noChoice: () => 'geen bron gekozen voor de historie',
choiceUnusable: n => `de gekozen bron werkt niet (${n})`,
limitReached: n => `maandlimiet bereikt (${n})`,
choiceInactive: n => `gekozen maar uitgezet (${n})`,
});
function bronkeuzeBlok(t, uitDienst = new Set()) {
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean);
const perId = new Map(providers.map(p => [p.id, p]));
const usage = {};
for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls;
const cellen = listPriceSource().filter(c => c.rank != null).sort((a, b) => a.rank - b.rank);
const regels = [];
const basis = getBaseCurrency();
for (const asset of listAssets()) {
if (asset.symbol === basis) continue;
const veilig = t.toAi(asset, { entity: 'asset' });
const keten = cap => cellen.filter(c => c.assetId === asset.id && c.capability === cap)
.map(c => perId.get(c.providerId)).filter(Boolean);
const naam = p => `${t.toAi({ key: p.key }, { entity: 'provider' }).key}${p.isActive ? '' : ' (uit)'}`;
const delen = SOURCE_CAPABILITIES.map(cap => {
const k = keten(cap);
return `${cap === 'spot' ? 'spot' : 'historie'}: ${k.length ? k.map(naam).join(', ') : 'geen'}`;
});
const gekozen = new Set(cellen.filter(c => c.assetId === asset.id).map(c => c.providerId));
const vrij = providers.filter(p => !p.isSystem && (p.coverage || []).includes(asset.symbol) && !gekozen.has(p.id));
if (vrij.length) delen.push(`dekt ook: ${vrij.map(naam).join(', ')}`);
const dienst = uitDienst.has(asset.id);
if (dienst) delen.push('historie uit Atreides Services');
const bevindingen = diagnoseSources(asset, { providers, historyChain: keten('history').map(p => p.key), usage,
serviceCovers: dienst });
for (const b of bevindingen) {
const namen = (b.names || []).join(', ');
const tekst = (DIAGNOSE_TEKST[b.code] || (() => b.code))(namen);
delen.push(b.proposal ? `${tekst}, voorstel: ${b.proposal.providerKey}` : tekst);
}
regels.push(`- ${veilig.symbol} (${veilig.kind}): ${delen.join('; ')}.`);
}
return limitedList(regels, r => r).lines;
}
const ILOG = CONFIG.INTEGRATION_LOG;
function foutenBlok(t, magAdministratie) {
const rijen = listIntegrationCalls({ days: ILOG.WINDOW_DAYS, limit: ILOG.MAX_ROWS });
const regels = rijen.slice(0, ILOG.AI_MAX_ROWS).map(r => {
const v = t.toAi({
occurredAt: r.occurredAt, providerKey: r.providerKey, operation: r.operation, outcome: r.outcome,
httpStatus: r.httpStatus, errorKey: r.errorKey, requestId: r.requestId,
...(magAdministratie ? { subject: r.subject } : {}),
}, { entity: 'integrationCall' });
const bron = r.target === 'service' ? 'atreides_services' : v.providerKey;
return `- ${v.occurredAt} ${bron} ${v.operation}${v.subject ? ` ${v.subject}` : ''}: ${v.outcome}`
+ `${v.httpStatus ? ` ${v.httpStatus}` : ''}${v.errorKey ? ` ${v.errorKey}` : ''}`
+ `${v.requestId ? ` (request-id ${v.requestId})` : ''}`;
});
if (rijen.length > regels.length) regels.push(`- en nog ${rijen.length - regels.length} oudere.`);
return regels;
}
const integrationHealth = Object.freeze({
name: 'integration_health',
title: 'Gezondheid van de koppelingen',
description:
'Geeft de technische toestand van de externe koppelingen van AssetApp: per koersbron de geslaagde '
+ 'en mislukte aanroepen van de afgelopen 24 uur met de gemiddelde en langste reactietijd, per '
+ 'verbinding de laatst gemeten status, per automatisch gescand account de uitslag van de '
+ 'laatste wallet-scan, per asset de bronkeuze voor spot en historie met wat eraan schort, en de recente '
+ 'fouten met request-id. Zegt dus of een koersbron onbetrouwbaar is, een verbinding stuk staat of een '
+ 'scan niet meer doorkomt. Verbindingen en accounts worden aangeduid met een verwijzing zoals '
+ 'connection:7f3a9c, zodat twee koppelingen van dezelfde soort te onderscheiden zijn. '
+ GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'none',
async run(_args, context) {
assertScope('none');
const t = await vertalerVoor(context);
const sinds = sindsUren(VENSTER_UREN);
const regels = limitedList(listProviders(), p => {
const veilig = t.toAi(p, { entity: 'provider' });
const m = apiMetricsTotals(p.id, sinds);
const status = veilig.isActive ? 'actief' : 'uitgeschakeld';
return m.calls === 0
? `- ${veilig.key} (${status}): geen aanroepen in de afgelopen ${VENSTER_UREN} uur.`
: `- ${veilig.key} (${status}): ${m.calls} aanroepen, ${m.fail} mislukt (${pct(m.fail, m.calls)}), `
+ `gemiddeld ${m.avg_latency_ms} ms, langste ${m.maxLatencyMs} ms.`;
}).lines;
const verbindingen = limitedList(listConnections(), c => {
const veilig = t.toAi(c, { entity: 'connection' });
const uitslag = veilig.lastStatus
? `${veilig.lastStatus} (gemeten ${veilig.lastCheckedAt})`
: 'nog nooit getest';
return `- ${veilig.id} (${veilig.kind}${veilig.isActive ? ', actief' : ''}): ${uitslag}`;
}).lines;
const magAdministratie = mayScope('read');
const scans = magAdministratie ? scanBlok(t) : [];
const uitDienst = magAdministratie ? await serviceCoveredAssets() : new Set();
const bronkeuze = magAdministratie ? bronkeuzeBlok(t, uitDienst) : [];
const fouten = foutenBlok(t, magAdministratie);
return {
text: [
`Koersbronnen, afgelopen ${VENSTER_UREN} uur:`,
regels.length ? regels.join('\n') : '- geen koersbronnen ingesteld.',
'',
'Verbindingen:',
verbindingen.length ? verbindingen.join('\n') : '- geen verbindingen ingesteld.',
'',
'Wallet-scans:',
!magAdministratie
? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en scans horen bij een account.'
: (scans.length ? scans.join('\n') : '- geen account staat op automatisch scannen.'),
'',
'Bronkeuze per asset (in volgorde; wijzigen doet de gebruiker op Integraties, Bronkeuze):',
!magAdministratie
? '- niet opgevraagd: de AI-koppeling van AssetApp staat uit, en de assets horen bij de administratie.'
: (bronkeuze.length ? bronkeuze.join('\n') : '- geen assets.'),
'',
`Fouten, afgelopen ${ILOG.WINDOW_DAYS} dagen (nieuwste eerst):`,
fouten.length ? fouten.join('\n') : '- geen.',
].join('\n'),
fieldsShared: [
'key', 'isActive', 'okCount', 'failCount', 'sumLatencyMs', 'maxLatencyMs',
'kind', 'lastStatus', 'lastCheckedAt', 'id',
...(magAdministratie ? ['scanEnabled', 'status', 'scannedAt', 'addressesFound', 'proposalsCreated',
'symbol', 'capability', 'rank', 'callsPerMonth'] : []),
...(uitDienst.size ? ['value'] : []),
...(fouten.length ? ['occurredAt', 'providerKey', 'operation', 'outcome', 'httpStatus', 'errorKey', 'requestId',
...(magAdministratie ? ['subject'] : [])] : []),
],
};
},
});
const adminOverview = Object.freeze({
name: 'admin_overview',
title: 'Overzicht van de inrichting',
description:
'Geeft de inrichting van AssetApp: welke assets er zijn (met hun symbool, soort en eenheid), '
+ 'hoeveel accounts er zijn en van welke soort, welke labels bestaan en op hoeveel on-chain '
+ 'bewegingen elk label gebruikt wordt, en hoeveel gebeurtenissen '
+ 'en balansen er zijn vastgelegd. Bedoeld voor vragen over de opzet en om '
+ 'te zien wat er ontbreekt. Plus de configuratie: metaalproducten, dashboardprofielen, '
+ 'de app-instellingen en de timers. '
+ 'Accounts en labels krijgen een verwijzing zoals account:7f3a9c, '
+ 'ook in een later gesprek dezelfde; met onbewerkt delen aan is dat het rij-id met de naam '
+ 'erachter. ' + GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(adminOverview.scope);
const t = await vertalerVoor(context);
const alleAssets = listAssets();
const alleRekeningen = listAccounts();
const alleLabels = listLabels();
const gekozen = (waarde, afgeleid, bron) => (waarde != null ? `${waarde} (gekozen)` : `${afgeleid} (${bron})`);
const assets = limitedList(alleAssets, a => {
const v = t.toAi(a, { entity: 'asset' });
return `- ${v.symbol} (${v.kind}, eenheid ${v.unit}${v.subunit ? `, ondereenheid ${v.subunit}` : ''}; `
+ `categorie ${gekozen(v.taxCategory, TAX_CATEGORY_BY_ASSET_KIND[v.kind] ?? 'onbekend', 'volgt soort')}, `
+ `belastingen ${gekozen(v.taxScope, 'all', 'standaard')}, `
+ `saldo vastleggen ${gekozen(v.balanceCapture == null ? null : (v.balanceCapture ? 'aan' : 'uit'),
balanceCaptureOn({ kind: v.kind }) ? 'aan' : 'uit', 'volgt soort')})`;
}).lines;
const onbewerkt = t.precision.rawValues;
const naamErbij = v => (onbewerkt && v.name ? ` (${v.name})` : '');
const rekeningen = limitedList(alleRekeningen, a => {
const v = t.toAi(a, { entity: 'account' });
const eigen = v.isOwnCustody ? 'zelf in bewaring' : 'bij een derde partij';
return `- ${v.id}${naamErbij(v)}: soort ${v.kind}, ${eigen}, ${v.isActive ? 'actief' : 'afgesloten'}`
+ `${v.scanEnabled ? ', wordt automatisch gescand' : ''}`
+ `; categorie ${v.taxCategory ?? 'volgt asset'}, belastingen ${v.taxScope ?? 'volgt asset'}`
+ `, vermogen ${v.wealthScope ?? 'eigen'}`;
}).lines;
const gebruik = new Map(labelUsage().map(r => [r.labelId, Number(r.walletTransactions)]));
const labels = limitedList(alleLabels, l => {
const n = gebruik.get(l.id) ?? 0;
const hoe = n === 0 ? 'nergens gebruikt'
: n === 1 ? 'gebruikt op 1 beweging'
: `gebruikt op ${n} bewegingen`;
const v = t.toAi(l, { entity: 'label' });
return `- ${v.id}${naamErbij(v)}: ${hoe}`;
}).lines;
const c = counts();
const alleProducten = listMetalProducts();
const perAsset = new Map();
for (const p of alleProducten) {
const key = p.asset_symbol;
if (!perAsset.has(key)) perAsset.set(key, new Map());
const vormen = perAsset.get(key);
vormen.set(p.form, (vormen.get(p.form) ?? 0) + 1);
}
const producten = onbewerkt
? limitedList(alleProducten, p =>
`- ${p.asset_symbol} (${p.name}): ${p.form}, ${fromBaseByFactor(p.fineGrams, 'g')} g fijn`).lines
: [...perAsset].map(([symbool, vormen]) =>
`- ${symbool}: ${[...vormen].map(([vorm, n]) => `${n}× ${vorm}`).join(', ')}`);
const profielen = listProfiles().map(p =>
`- ${onbewerkt && p.name ? p.name : `profiel ${p.sortOrder}`}: `
+ `${p.isActive ? 'actief' : 'niet actief'}, ${listWidgets(p.id).length} widgets`);
const instellingen = [];
for (const scope of CONFIG.AI.SHARED_PREF_SCOPES) {
for (const key of Object.keys(PREFERENCES[scope] ?? {})) {
if (key === '*') continue;
const v = t.toAi({ scope, key, value: getPref(scope, key) }, { table: 'Preference' });
if (v.value === undefined) continue;
const waarde = v.value === null || v.value === '' ? 'leeg' : v.value;
instellingen.push(`- ${v.scope}.${v.key} = ${waarde}`);
}
}
return {
text: [
`Rekenvaluta: ${getBaseCurrency()}. Elke koers en elk bedrag in AssetApp staat in deze munt.`,
'',
`Assets (${alleAssets.length}):`,
assets.length ? assets.join('\n') : '- geen.',
'',
`Accounts (${alleRekeningen.length}):`,
rekeningen.length ? rekeningen.join('\n') : '- geen.',
'',
`Labels (${alleLabels.length}):`,
labels.length ? labels.join('\n') : '- geen.',
'',
`Vastgelegd: ${c.events} gebeurtenissen en ${c.valuations} balansen.`,
'',
`Metaalproducten (${alleProducten.length}):`,
producten.length ? producten.join('\n') : '- geen.',
'',
`Dashboardprofielen (${profielen.length}):`,
profielen.length ? profielen.join('\n') : '- geen.',
'',
`Instellingen (${instellingen.length}):`,
instellingen.length ? instellingen.join('\n') : '- geen.',
].join('\n'),
fieldsShared: ['baseCurrency', 'id', 'symbol', 'kind', 'unit', 'subunit', 'isOwnCustody', 'isActive', 'scanEnabled',
'taxCategory', 'taxScope', 'wealthScope', 'balanceCapture',
'labelId', 'form', 'sortOrder', 'profileId', 'scope', 'key', 'Preference.value',
...(onbewerkt ? ['name', 'fineGrams'] : [])],
};
},
});
const BEVINDING_TEKST = Object.freeze({
duplicate_events: 'gebeurtenissen die er twee of meer keer identiek in staan',
assets_without_balance: 'assets waarin geboekt is maar waarvoor nooit een balans is vastgelegd',
unreconciled_valuations: 'balansen die niet aansluiten op de boekingen eronder, en die nog niet zijn afgehandeld',
stale_prices: 'assets waarvan de koershistorie achterloopt op de laatste boeking, of ontbreekt',
unlinked_transactions: 'on-chain bewegingen zonder koppeling en zonder openstaand voorstel',
late_price_history: 'assets waarvan de koershistorie later begint dan de eerste boeking',
price_gaps: 'assets met een gat tussen twee koersen (dagen zonder koers, vanaf de eerste boeking), waardoor daar een oude koers wordt meegesleept',
price_stopped: 'assets in bezit waarvan de laatste koers ouder is dan de doortrekgrens van hun soort, dus nu niet te waarderen',
transfer_without_counterpart: 'verplaatsingen waarvan één kant ontbreekt zonder extern adres',
fee_without_effect: 'boekingen met kosten die geen berekening leest (geld bij rente, eenheden bij aankoop of rente)',
coverage_without_asset: 'koersbronnen die dekking claimen op een symbool dat hier geen asset is',
price_unit_swap: 'koersrijen die een factor 31 (gram tegen troy ounce) van de rij ervoor liggen',
price_outlier: 'koersrijen die een factor drie of meer van beide buren liggen, zoals een tikfout',
price_sources_disagree: 'dagen waarop twee bronnen een koers geven die ver uit elkaar ligt',
price_not_positive: 'koersrijen met een koers van nul of lager, die de app niet meetelt',
price_in_future: 'koersrijen met een moment in de toekomst',
booking_price_deviates: 'aan- en verkopen waarvan de prijs ver van de koers van die dag ligt (tikfout, datum of eenheid)',
price_origin_derivable: 'boekingen zonder herkomst waarvan de prijs de dagkoers is, in te vullen op Gezondheid',
});
const DAG_VELDEN = Object.freeze({
stale_prices: Object.freeze(['occurredAt', 'observedAt']),
unlinked_transactions: Object.freeze(['createdAt']),
price_unit_swap: Object.freeze(['observedAt']),
price_outlier: Object.freeze(['observedAt']),
price_sources_disagree: Object.freeze(['observedAt']),
price_not_positive: Object.freeze(['observedAt']),
price_in_future: Object.freeze(['observedAt']),
booking_price_deviates: Object.freeze(['occurredAt']),
price_origin_derivable: Object.freeze(['occurredAt']),
});
function opDag(waarde) {
return typeof waarde === 'string' && waarde.length >= 10 ? waarde.slice(0, 10) : waarde;
}
function grofPerBevinding(soort, rij) {
const velden = DAG_VELDEN[soort];
if (!velden) return rij;
const uit = { ...rij };
for (const veld of velden) if (veld in uit) uit[veld] = opDag(uit[veld]);
return uit;
}
const dataHealth = Object.freeze({
name: 'data_health',
title: 'Controle op de eigen administratie',
description:
'Zoekt inconsistenties in de administratie van AssetApp en geeft per soort het aantal en de '
+ 'betrokken rijen terug: dubbele boekingen, assets zonder vastgelegde balans, balansen die niet '
+ 'aansluiten op de boekingen eronder, koershistorie die achterloopt, on-chain bewegingen die '
+ 'nergens aan hangen, en verwijzingen die nergens heen wijzen. Elke bevinding is een aantal of '
+ 'een verwijzing zoals account:a1, stabiel binnen dit gesprek. ' + GEEN_BEDRAGEN,
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(dataHealth.scope);
const t = await vertalerVoor(context);
t.beginCall();
const rapport = hygieneReport();
const blokken = rapport.findings.map(f => {
const kop = `${BEVINDING_TEKST[f.kind] || f.kind} (${f.kind}, ${f.severity}): ${f.total}`;
if (!f.total) return `${kop}.`;
const { rows: zichtbaar, omitted, capped } = limitRows(f.rows);
const grof = zichtbaar.map(r => grofPerBevinding(f.kind, r.row));
const veilig = t.toAi(grof);
const rijen = veilig.map((r, i) => {
const delen = Object.entries(r).map(([k, v]) => `${k}=${v}`);
for (const [k, v] of Object.entries(zichtbaar[i].counts || {})) delen.push(`${k}=${v}`);
return ` - ${delen.join(', ')}`;
});
if (capped) rijen.push(omittedNote(omitted, CONFIG.AI.LIMITS.MAX_ROWS_PER_LIST));
return `${kop}:\n${rijen.join('\n')}`;
});
return {
text: [
rapport.total === 0
? 'Er zijn geen inconsistenties gevonden.'
: `Er zijn ${rapport.total} bevindingen, verdeeld over ${rapport.findings.filter(f => f.total).length} soorten.`,
'',
...blokken,
].join('\n'),
fieldsShared: [...new Set([
...t.fieldsShared(),
...rapport.findings.filter(f => f.rows.length).flatMap(f => f.countsFrom || []),
])].sort(),
};
},
});
export const READ_TOOLS = Object.freeze([dataHealth, integrationHealth, adminOverview]);
+13
View File
@@ -0,0 +1,13 @@
// Generated, do not edit. sha256:2e72a438d7feb6f942a8e08f41e0d5f4748ae7098b8f0f7cfcffb93bd8fcf9e0
import { getPref } from '../data/preferences.js';
import { serviceCoverage } from '../data/market.js';
export async function serviceCoveredAssets() {
let mode = 'off';
try { mode = getPref('app', 'market_spot') ?? 'off'; } catch { }
if (mode === 'off') return new Set();
try {
const r = await serviceCoverage();
return new Set(r.status === 'ok' ? r.assetIds : []);
} catch { return new Set(); }
}
+18
View File
@@ -0,0 +1,18 @@
// Generated, do not edit. sha256:a952ea9dd0f828339960be62a36693504bb931d6c638e950608c1e1b185bd27c
import { createTranslator } from '../ai/translate.js';
import { handleSecret } from '../ai/handle-secret.mjs';
import { activeName } from '../portfolios.mjs';
import { precisionSettings, precisionKey } from '../ai/precision.js';
const vertalers = new Map();
export async function vertalerVoor(context) {
const precision = precisionSettings();
const id = `${context?.session?.id ?? 'los'}::${activeName()}::${precisionKey(precision)}`;
if (!vertalers.has(id)) {
vertalers.set(id, createTranslator({ secret: await handleSecret(), precision }));
}
return vertalers.get(id);
}
export function _resetVertalers() { vertalers.clear(); }
+119
View File
@@ -0,0 +1,119 @@
// Generated, do not edit. sha256:b0dd28dca0d6350eaa3cdbb0db8611b54c802d92405f31a10da8e494e94fbaf6
import { createInterface } from 'node:readline';
import { CONFIG } from '../../shared/config.js';
import { initDatabase, closeDatabase, dbFile, databaseStatus } from '../db.js';
import { prepareLayout, activeName } from '../portfolios.mjs';
import { openDatabase } from '../db/conn.mjs';
import { logAiToolCall } from '../data/ai-audit.js';
import { isAiActive, AI_INACTIVE_TEXT } from '../ai/gate.js';
import { flushChanges } from '../data/bus.js';
import { createMcpServer } from './protocol.mjs';
import { allTools } from './tools.mjs';
import '../edition.mjs';
const naarGebruiker = (regel) => process.stderr.write(`[assetapp-mcp] ${regel}\n`);
function stopAlsUit() {
if (isAiActive()) return false;
naarGebruiker('de AI-koppeling staat uit (schuif "Actief" onder Systeem → AI-activiteit). '
+ 'Deze server doet niets en stopt; zet de schuif aan en start uw AI-programma opnieuw.');
return true;
}
try {
naarGebruiker(prepareLayout().regel);
naarGebruiker(initDatabase());
const uit = stopAlsUit();
closeDatabase();
if (uit) process.exit(0);
} catch (e) {
naarGebruiker(`kan de database niet openen: ${e.message}`);
process.exit(1);
}
const tools = allTools();
const server = createMcpServer({
tools,
logCall: logAiToolCall,
onLogError: (e) => naarGebruiker(`LET OP: een tool-aanroep is NIET vastgelegd: ${e.message}`),
});
naarGebruiker(`gereed als '${CONFIG.AI.SERVER_NAME}', ${tools.length} tool(s), sessie ${server.session.id}`);
const lezer = createInterface({ input: process.stdin, crlfDelay: Infinity });
function kijkIn(regel) {
try {
const bericht = JSON.parse(regel);
return {
id: Object.hasOwn(bericht, 'id') ? bericht.id : undefined,
method: typeof bericht.method === 'string' ? bericht.method : null,
};
} catch { return { id: undefined, method: null }; }
}
const HEEFT_DATABASE_NODIG = 'tools/call';
const INTERNE_FOUT = -32603;
function stuurFout(regel, melding) {
const { id } = kijkIn(regel);
if (id === undefined) return;
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: INTERNE_FOUT, message: melding } })}\n`);
}
function uitlegBijStand(stand) {
const naam = activeName();
if (stand.reden === 'leeg') {
return `De portefeuille '${naam}' heeft nog geen ingerichte database. Dat gebeurt zodra de app hem `
+ 'een keer opent (Systeem → Portefeuilles → Openen). Tot dan is er hier niets te lezen.';
}
if (stand.reden === 'oud') {
return `De database van portefeuille '${naam}' staat op versie ${stand.versie} en deze app verwacht `
+ `${CONFIG.SCHEMA_VERSION}. De migraties draaien als de app die portefeuille opent; doe dat eerst.`;
}
return `De database van portefeuille '${naam}' staat op versie ${stand.versie}, en deze MCP-server kent `
+ `tot en met ${CONFIG.SCHEMA_VERSION}. Is de app net bijgewerkt, herstart dan de AI-koppeling in je `
+ 'AI-client: dit proces laadt nieuwe code pas bij een herstart. Anders is de app zelf ouder dan de '
+ 'database; werk hem dan bij.';
}
let wachtrij = Promise.resolve();
lezer.on('line', (regel) => {
const tekst = regel.trim();
if (!tekst) return;
wachtrij = wachtrij.then(async () => {
try { openDatabase(dbFile()); }
catch (e) {
naarGebruiker(`kan de database niet openen voor dit bericht: ${e.message}`);
stuurFout(tekst, `De database van de actieve portefeuille is nu niet te openen: ${e.message}`);
return;
}
const stand = databaseStatus();
if (stand.klaar && !isAiActive()) {
stuurFout(tekst, AI_INACTIVE_TEXT);
naarGebruiker('de AI-koppeling is uitgezet; deze server stopt.');
try { closeDatabase(); } catch { }
process.exit(0);
}
if (!stand.klaar && kijkIn(tekst).method === HEEFT_DATABASE_NODIG) {
naarGebruiker(`de database van deze portefeuille is niet bruikbaar (${stand.reden}); bericht geweigerd`);
stuurFout(tekst, uitlegBijStand(stand));
try { closeDatabase(); } catch { }
return;
}
try {
const antwoord = await server.handleRegel(tekst);
if (antwoord) process.stdout.write(`${JSON.stringify(antwoord)}\n`);
} catch (e) {
naarGebruiker(`onverwachte fout bij het afhandelen van een bericht: ${e.message}`);
} finally {
try { flushChanges(); } catch (e) { naarGebruiker(`signalen wegschrijven mislukte: ${e.message}`); }
try { closeDatabase(); } catch (e) { naarGebruiker(`database sluiten mislukte: ${e.message}`); }
}
});
});
lezer.on('close', () => { naarGebruiker('verbinding gesloten door de client'); process.exit(0); });
+74
View File
@@ -0,0 +1,74 @@
// Generated, do not edit. sha256:31e7aeb1ea48c942e3f7c7a1b1a04f9c89a81975a5bc92fbc88810da09fb4d6c
import { CONFIG } from '../../shared/config.js';
import { assertScope } from '../ai/gate.js';
import { precisionSettings } from '../ai/precision.js';
import { activeName } from '../portfolios.mjs';
import { READ_TOOLS } from './read-tools.mjs';
import { DETAIL_TOOLS } from './detail-tool.mjs';
import { PLACES_TOOLS } from './places-tool.mjs';
import { CONTROL_TOOLS } from './control-tools.mjs';
import { WRITE_TOOLS } from './write-tools.mjs';
import { WEALTH_TOOLS } from './wealth-tool.mjs';
import { PRICE_TOOLS } from './price-tool.mjs';
import { mcpTools } from '../registers.mjs';
const NOTE_MAX = 200;
export const TOOLS = Object.freeze([
...READ_TOOLS,
...DETAIL_TOOLS,
...PLACES_TOOLS,
...WEALTH_TOOLS,
...PRICE_TOOLS,
...CONTROL_TOOLS,
...WRITE_TOOLS,
Object.freeze({
name: 'connection_test',
title: 'Verbinding testen',
description:
'Controleert of de verbinding met AssetApp werkt. Deze tool leest geen gegevens uit het '
+ 'portfolio en verandert er niets aan: hij bevestigt alleen dat het bericht is aangekomen en '
+ 'legt de aanroep vast in het activiteitenlogboek van de app, waar de eigenaar hem terugvindt.',
inputSchema: Object.freeze({
type: 'object',
properties: {
note: {
type: 'string',
maxLength: NOTE_MAX,
description: 'Optionele korte notitie die in het logboek van de app komt te staan.',
},
},
additionalProperties: false,
}),
scope: 'none',
async run(args) {
assertScope('none');
const note = typeof args?.note === 'string' ? args.note.slice(0, NOTE_MAX) : null;
const portefeuille = activeName();
const onbewerkt = precisionSettings().rawValues;
return {
text: [
`Verbinding met ${CONFIG.AI.SERVER_NAME} werkt.`,
`Open portefeuille: ${portefeuille}.`,
onbewerkt
? 'Onbewerkt delen staat AAN voor deze portefeuille: namen en bedragen komen ongefilterd '
+ 'terug, zonder verwijzingen en marges. Dat hoort alleen zo te zijn in een portefeuille '
+ 'zonder echte gegevens.'
: 'Namen en bedragen komen als verwijzing en marge terug, niet als echte waarde.',
'Er zijn geen portfolio-gegevens opgevraagd of gewijzigd.',
note
? `De notitie "${note}" staat nu in het activiteitenlogboek van de app.`
: 'Deze aanroep staat nu in het activiteitenlogboek van de app, zonder notitie.',
].join('\n'),
fieldsShared: ['portfolio'],
};
},
}),
]);
export function allTools() {
const extra = mcpTools.list().map(e => e.tool);
const dubbel = extra.find(t => TOOLS.some(c => c.name === t.name));
if (dubbel) throw new Error(`mcp tool '${dubbel.name}' is registered and also a core tool`);
return Object.freeze([...TOOLS, ...extra]);
}
+164
View File
@@ -0,0 +1,164 @@
// Generated, do not edit. sha256:a35de9757194e9058e50a8d62faae8adcfd1b35f42f9b9b14cece1c6cab04e85
import { accountHoldings, positionsByAsset } from '../data/positions.js';
import { listAccounts } from '../data/accounts.js';
import { isOwnWealth } from '../../shared/wealth-scope.js';
import { latestPricePoint } from '../data/price-history.js';
import { assertScope } from '../ai/gate.js';
import { limitedList } from '../ai/limits.js';
import { shareBucket, shareLabel } from '../ai/buckets.js';
import { shareDisclosure } from '../ai/precision.js';
import { vertalerVoor } from './session.mjs';
import { assetMetrics } from '../../shared/wealth-metrics.js';
import { isMetal, metalFactor } from '../../shared/units.js';
import { getBaseCurrency } from '../data/portfolio.js';
function verdelingsblok(rijen, totaal, t) {
if (!(totaal > 0)) return [];
const vorm = shareDisclosure(t.precision);
if (vorm === 'omit') {
return [
'De verdeling over de assets komt niet mee: met een exact totaal én exacte percentages ligt de '
+ 'waarde per asset vast, en de bedragen per regel zijn juist wél in marges. Zet één van beide '
+ 'terug op marges (of zet onbewerkt delen aan) om hem te zien.',
'',
];
}
const pctUit = pct => (vorm === 'exact' ? `${pct.toFixed(1)}%` : shareLabel(pct));
const posten = [];
let overigPct = 0;
let overigN = 0;
let zonderKoers = 0;
for (const { p, m } of rijen) {
if (m.value == null) { zonderKoers += 1; continue; }
const pct = (m.value / totaal) * 100;
const b = shareBucket(pct);
if (!b || b.below) { overigPct += pct; overigN += 1; continue; }
posten.push({ symbool: t.toAi(p, { entity: 'asset' }).symbol, pct });
}
if (!posten.length && !overigN) return [];
posten.sort((a, b) => b.pct - a.pct);
const regels = limitedList(posten, x => `- ${x.symbool}: ${pctUit(x.pct)}`).lines;
if (overigN) {
regels.push(`- overig (${overigN} ${overigN === 1 ? 'asset' : 'assets'}): ${pctUit(overigPct)}`);
}
return [
vorm === 'exact'
? 'Verdeling over de portefeuille (samen dekken ze het geheel):'
: 'Verdeling over de portefeuille (marges; samen dekken ze het geheel):',
...regels,
...(zonderKoers ? [`(${zonderKoers} zonder koers, dus niet in deze verdeling.)`] : []),
'',
];
}
function buitenTotalen(decimalen) {
const buiten = listAccounts().filter(a => !isOwnWealth(a));
if (!buiten.length) return [];
const soort = new Map(buiten.map(a => [a.id, a.wealthScope]));
const som = new Map([...new Set(buiten.map(a => a.wealthScope))].map(s => [s, { waarde: 0, zonderKoers: 0 }]));
for (const r of accountHoldings(buiten.map(a => a.id))) {
const punt = latestPricePoint(r.assetId);
const post = som.get(soort.get(r.accountId));
if (!punt || !decimalen.has(r.assetId)) { post.zonderKoers += 1; continue; }
post.waarde += (r.saldo / Math.pow(10, decimalen.get(r.assetId))) * punt.fiatPrice;
}
return ['managed', 'none'].filter(s => som.has(s)).map(s => ({ soort: s, ...som.get(s) }));
}
const BUITEN_LABEL = { managed: 'vermogen van derden', none: 'telt niet mee' };
const wealthSummary = Object.freeze({
name: 'wealth_summary',
title: 'Vermogen in cijfers',
description:
'Geeft wat de portefeuille van AssetApp nu waard is: het totaal, en per asset de hoeveelheid, '
+ 'koers, waarde, inleg en het ongerealiseerde resultaat. Staat onbewerkt delen uit, dan komen '
+ 'het totaal en de verdeling per asset terug als marges, zonder bedrag per asset. De koers is '
+ 'het laatst opgeslagen punt en geen verse marktkoers. Wijzigt niets.',
inputSchema: Object.freeze({ type: 'object', properties: {}, additionalProperties: false }),
scope: 'read',
async run(_args, context) {
assertScope(wealthSummary.scope);
const t = await vertalerVoor(context);
t.beginCall();
const onbewerkt = t.precision.rawValues;
const posities = positionsByAsset(undefined, { ownOnly: true });
const cur = getBaseCurrency();
const money = n => `${cur} ${n.toFixed(2)}`;
const totaalTekst = n => {
const uit = t.toAi({ fiatValue: n }, { entity: 'wealth', role: 'total' }).fiatValue;
return typeof uit === 'number' ? money(uit) : `${cur} ${uit}`;
};
const buiten = buitenTotalen(new Map(posities.map(p => [p.id, p.decimals])));
const buitenRegel = !buiten.length ? [] : [
`Niet in dit totaal (Vermogen op het account): ${buiten.map(b => `${BUITEN_LABEL[b.soort]} ${totaalTekst(b.waarde)}`
+ (b.zonderKoers ? ` (${b.zonderKoers} zonder koers)` : '')).join('; ')}.`,
'',
];
const buitenVelden = buiten.length ? ['wealthScope'] : [];
let totaal = 0;
let zonderKoers = 0;
const rijen = [];
for (const p of posities) {
if (p.net === 0 && p.buy_events === 0) continue;
const punt = latestPricePoint(p.id);
const m = assetMetrics(p, punt ? punt.fiatPrice : null);
if (m.value != null) totaal += m.value; else zonderKoers += 1;
rijen.push({ p, m, punt });
}
if (!rijen.length) {
if (buiten.length) {
return { text: ['Deze portefeuille heeft geen eigen bezit.', '', ...buitenRegel].join('\n').trim(),
fieldsShared: ['baseCurrency', 'fiatValue', ...buitenVelden] };
}
return { text: 'Deze portefeuille heeft nog geen bezit: er is niets om een waarde van te geven.',
fieldsShared: [] };
}
const kop = `Totale waarde nu: ${totaalTekst(totaal)}`
+ ` over ${rijen.length} ${rijen.length === 1 ? 'asset' : 'assets'}`
+ (zonderKoers ? `, waarvan ${zonderKoers} zonder koers en dus zonder waarde` : '')
+ '.';
if (!onbewerkt) {
return {
text: [kop, '', ...buitenRegel, ...verdelingsblok(rijen, totaal, t),
'Per asset komen de BEDRAGEN niet mee zolang onbewerkt delen uit staat: een waarde per asset '
+ 'met de koers erbij legt de hoeveelheid vast. Zet die schuif aan voor een portefeuille '
+ 'zonder echte gegevens.',
].join('\n'),
fieldsShared: ['baseCurrency', 'fiatValue', 'symbol', ...buitenVelden],
};
}
const regels = limitedList(rijen, ({ p, m, punt }) => {
const v = t.toAi(p, { entity: 'asset' });
const f = isMetal(p) ? metalFactor(p.unit) : 1;
const stuk = [`${v.symbol}: ${+(m.units / f).toFixed(8)} ${v.unit}`];
if (m.value != null) stuk.push(`waarde ${money(m.value)} tegen ${money(punt.fiatPrice * f)} per ${v.unit}`);
else stuk.push('geen koers, dus geen waarde');
stuk.push(`ingelegd ${money(m.invested)}`);
if (m.unrealized != null) stuk.push(`ongerealiseerd ${money(m.unrealized)}`);
if (m.avgPrice != null) stuk.push(`gemiddelde aankoopkoers ${money(m.avgPrice * f)}`);
return `- ${stuk.join(', ')}`;
}).lines;
return {
text: [kop, '', ...buitenRegel, 'Per asset:', ...regels, '',
'De koers is het laatst opgeslagen koerspunt; dit proces haalt geen verse koersen op.',
].join('\n'),
fieldsShared: ['baseCurrency', 'symbol', 'unit', 'decimals', 'amount', 'balance', 'fiatPrice', 'fiatFee',
'fiatValue', ...buitenVelden],
};
},
});
export const WEALTH_TOOLS = Object.freeze([wealthSummary]);
+540
View File
@@ -0,0 +1,540 @@
// 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,
]);