Versie 0.2.0
This commit is contained in:
commit
e77d8fd373
495 files changed
+81026
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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 }; } };
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -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); });
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
@@ -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,
|
||||
]);
|
||||
Reference in new issue
Block a user