Files
brk/website_next/ask/tools/session/executor.js
T
2026-07-29 18:42:09 +02:00

884 lines
28 KiB
JavaScript

import { executeApi } from "../api/execute.js";
import { reusableArguments } from "../api/routing.js";
import { createChartArtifact } from "../chart.js";
import { resolveChartUnit } from "../chart/units.js";
import { readMetric } from "../data.js";
import { metricVariants, searchMetrics } from "../metrics/index.js";
import { renderData, renderEvidence } from "../render.js";
import { normalize } from "../text.js";
import { schemaSourceQueries } from "./evidence.js";
const CHART_VIEWS = new Set(["line", "area", "stacked", "bar", "dots"]);
const CHART_SCALES = new Set(["linear", "log"]);
const SOURCE_USAGE_TERMS = new Set([
"called",
"usage",
"usages",
]);
const SOURCE_DEFINITION_TERMS = new Set([
"declared",
"defined",
"definition",
"implemented",
"implementation",
]);
/** @param {unknown} value @param {string} name */
function requiredString(value, name) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`The AI did not provide ${name}`);
}
return value.trim();
}
/** @param {unknown} value */
function uniqueRefs(value) {
if (
!Array.isArray(value) ||
!value.length ||
value.some((ref) => typeof ref !== "string")
) {
throw new Error("The AI did not select verified evidence");
}
return [...new Set(value)];
}
/** @param {string} value */
function label(value) {
return normalize(value);
}
/** @param {string | undefined} value */
function inlineCode(value) {
if (!value) return [];
return value
.split("`")
.filter((_, index) => index % 2 === 1)
.map((term) => term.trim())
.filter(Boolean);
}
/** @param {string | undefined} value */
function sourceSubject(value) {
const subject = inlineCode(value).find((term) =>
!term.includes("/") && !term.includes("\\")
);
if (!subject) return undefined;
return subject.match(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1] ?? subject;
}
/** @param {string} value */
function querySubject(value) {
const query = value.trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(query)) return query;
return query.match(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1] ??
query.match(
/\b(?:[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+|[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*)\b/,
)?.[0];
}
/** @param {string} value */
function sourceFocus(value) {
const words = new Set(normalize(value).split(" "));
if ([...words].some((word) => SOURCE_USAGE_TERMS.has(word))) {
return /** @type {const} */ ("usage");
}
if ([...words].some((word) => SOURCE_DEFINITION_TERMS.has(word))) {
return /** @type {const} */ ("definition");
}
return undefined;
}
/** @param {string} content @param {string} field */
function computesField(content, field) {
const code = content
.replace(/\/\*[\s\S]*?\*\//g, "")
.split("\n")
.map((line) => line.split("//")[0])
.join("\n");
return normalize(code).includes(field) &&
["+=", "-=", "*=", "/=", " + ", " - ", " * ", " / "]
.some((operator) => code.includes(operator));
}
/** @param {any} result @param {string} query */
function rankedSchemaMatches(result, query) {
const parts = query.trim().split(/\s+/);
const field = normalize(parts.at(-1) ?? "");
const owner = normalize(parts.slice(0, -1).join(" "));
const terms = normalize(query).split(" ").filter(Boolean);
return result.matches.filter((match) =>
normalize(match.content).includes(field)
).sort((left, right) => {
const leftContent = normalize(left.content);
const rightContent = normalize(right.content);
const leftOwner = owner && leftContent.includes(owner) ? 1 : 0;
const rightOwner = owner && rightContent.includes(owner) ? 1 : 0;
const leftComputes = computesField(left.content, field) ? 1 : 0;
const rightComputes = computesField(right.content, field) ? 1 : 0;
const leftPath = terms.filter((term) =>
normalize(left.path).split(" ").includes(term)
).length;
const rightPath = terms.filter((term) =>
normalize(right.path).split(" ").includes(term)
).length;
return rightComputes - leftComputes ||
rightOwner - leftOwner ||
rightPath - leftPath ||
Number(right.score ?? 0) - Number(left.score ?? 0);
});
}
/** @param {string} value */
function regexEscape(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** @param {string} content @param {string} subject */
function containsSymbol(content, subject) {
return new RegExp(`\\b${regexEscape(subject)}\\b`).test(content);
}
/** @param {string} content @param {string} subject */
function declaresSymbol(content, subject) {
return new RegExp(
`\\b(?:fn|function|class|struct|enum|trait|interface)\\s+${
regexEscape(subject)
}\\b`,
).test(content);
}
/** @param {{ content: string, startLine: number }} source @param {string} subject */
function declarationLine(source, subject) {
const declaration = new RegExp(
`\\b(?:fn|function|class|struct|enum|trait|interface)\\s+${
regexEscape(subject)
}\\b`,
);
const index = source.content.split("\n").findIndex((line) =>
declaration.test(line)
);
return index < 0 ? source.startLine : source.startLine + index;
}
/** @param {any[]} excerpts @param {string} subject */
function usageCallers(excerpts, subject) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(subject)) return [];
const call = new RegExp(`\\b${regexEscape(subject)}\\s*\\(`);
const declaration = /\b(?:fn|function|def)\s+([A-Za-z_][A-Za-z0-9_]*)/;
const callers = [];
for (const excerpt of excerpts) {
let owner;
let ownerType;
for (const line of excerpt.content.split("\n")) {
const implemented = line.match(
/\bimpl(?:<[^>]*>)?\s+(?:[A-Za-z_][A-Za-z0-9_:<>]*\s+for\s+)?([A-Za-z_][A-Za-z0-9_]*)/,
)?.[1];
if (implemented) ownerType = implemented;
const declarationMatch = line.match(declaration);
const declared = declarationMatch?.[1];
if (declared) {
const indented = /^\s/.test(line);
const module = excerpt.path.split("/").at(-1)?.replace(/\.[^.]+$/, "");
owner = ownerType
? `${ownerType}::${declared}`
: indented && module
? `${module}::${declared}`
: declared;
}
if (
!owner ||
owner === subject ||
declared ||
!call.test(line.split("//")[0])
) continue;
callers.push({ name: owner, source: excerpt });
}
}
return [...new Map(
callers.map((caller) => [
`${caller.source.path}:${caller.name}`,
caller,
]),
).values()];
}
export class CapabilityExecutor {
/**
* @param {Object} options
* @param {string} options.question
* @param {any} options.evidence
* @param {import("../refs.js").AskRefs} options.refs
* @param {import("../source/index.js").AskSource} options.source
*/
constructor({ question, evidence, refs, source }) {
this.question = question;
this.evidence = evidence;
this.refs = refs;
this.source = source;
}
/** @param {unknown} value @param {"metric" | "source" | "guide"} kind */
selected(value, kind) {
return uniqueRefs(value).map((ref) => ({
ref,
value: this.refs.get(ref, kind),
}));
}
/** @param {Record<string, unknown>} arguments_ */
answerGeneral(arguments_) {
const answer = requiredString(arguments_.answer, "an answer");
const topic = requiredString(arguments_.topic, "an answer topic");
return {
done: true,
output: answer,
sourceContext: this.evidence.context.source,
knowledgeContext: {
title: topic.slice(0, 160),
description: answer,
},
};
}
/** @param {Record<string, unknown>} arguments_ */
guideExample(arguments_) {
const [{ value: guide }] = this.selected(arguments_.refs, "guide");
const example = requiredString(guide.example, "a guide example");
return {
done: true,
output: example,
sourceContext: this.evidence.context.source,
knowledgeContext: {
title: guide.title,
description: example,
},
};
}
/** @param {Record<string, unknown>} arguments_ */
clarify(arguments_) {
return {
done: true,
output: requiredString(arguments_.question, "a clarification question"),
};
}
/** @param {Record<string, unknown>} arguments_ */
describeCapabilities(arguments_) {
const capabilities = Array.isArray(arguments_.capabilities)
? arguments_.capabilities.filter((value) => typeof value === "string")
: [];
if (!capabilities.length) {
throw new Error("The assistant capabilities are unavailable");
}
return {
done: true,
output: `I can:\n\n${capabilities.map((value) => `- ${value}`).join("\n")}`,
};
}
/** @param {Record<string, unknown>} arguments_ */
async explain(arguments_) {
const selected = [
...uniqueRefs(arguments_.refs),
...(Array.isArray(arguments_.metrics)
? uniqueRefs(arguments_.metrics)
: []),
];
const sources = [];
const metrics = [];
const guides = [];
for (const ref of selected) {
const kind = this.refs.kind(ref);
if (kind === "source") sources.push(this.refs.get(ref, "source"));
else if (kind === "metric") metrics.push(this.refs.get(ref, "metric"));
else if (kind === "guide") guides.push(this.refs.get(ref, "guide"));
else throw new Error(`${ref} cannot ground an explanation`);
}
return {
done: true,
metricPaths: metrics.map((metric) => metric.path),
sourceContext: sources,
grounding: {
question: this.question,
metrics: metrics.map((metric) => ({
name: label(metric.name),
path: metric.path,
unit: metric.suggestedUnit,
})),
facts: guides.map((guide) => guide.description).filter(Boolean),
excerpts: sources,
},
};
}
/** @param {Record<string, unknown>} arguments_ @param {(status: string) => void} onStatus */
async searchSource(arguments_, onStatus) {
const query = requiredString(arguments_.query, "a source search query");
const requestedFocus = sourceFocus(this.question);
const structuredSubjects =
this.evidence.context.knowledge?.subjects ?? [];
const candidateSubject = querySubject(query);
const explicitCodeSubject = candidateSubject &&
(
inlineCode(this.question).includes(candidateSubject) ||
this.question.includes(`${candidateSubject}(`)
);
const exactSubject = candidateSubject &&
(
!structuredSubjects.length ||
structuredSubjects.includes(candidateSubject) ||
explicitCodeSubject
)
? candidateSubject
: undefined;
const schemaQueries = schemaSourceQueries(
this.question,
this.evidence.apiCandidates ?? [],
);
const subject = exactSubject ??
structuredSubjects[0] ??
sourceSubject(this.evidence.context.knowledge?.description);
const context = this.evidence.context.source;
const subjectContext = subject
? context.filter((/** @type {{ content: string }} */ source) =>
source.content.includes(subject)
)
: [];
const scopedContext = subjectContext.length ? subjectContext : context;
const paths = [...new Set(scopedContext.map(
(/** @type {{ path: string }} */ { path }) => path,
))].slice(0, 2);
const contextualQuery = subject
? `${query} ${subject}`
: query;
const searches = [
{ query, path: undefined, focus: requestedFocus },
...schemaQueries.map((schemaQuery) => ({
query: schemaQuery,
path: undefined,
focus: /** @type {const} */ ("implementation"),
})),
...(contextualQuery === query
? []
: [{
query: contextualQuery,
path: undefined,
focus: requestedFocus,
}]),
...(subject
? [{
query: subject,
path: undefined,
focus: requestedFocus ?? /** @type {const} */ ("implementation"),
}]
: []),
...paths.map((path) => ({
query: contextualQuery,
path,
focus: requestedFocus,
})),
...(subject
? [{
query: subject,
path: undefined,
focus: /** @type {const} */ ("usage"),
}]
: []),
];
onStatus("Searching source…");
const results = await Promise.all(
searches.map(({ query: scopedQuery, path, focus }) =>
this.source.search(
scopedQuery,
path,
focus,
({ loaded, total }) =>
onStatus(`Indexing source · ${loaded} / ${total}`),
)
),
);
const scopedResults = results.filter((_, index) => searches[index].path);
const schemaResults = results.slice(1, 1 + schemaQueries.length);
const contextualIndex = 1 + schemaQueries.length;
const contextualResult = contextualQuery === query
? undefined
: results[contextualIndex];
const subjectResult = subject
? results[
contextualIndex + (contextualQuery === query ? 0 : 1)
]
: undefined;
const usageResult = subject ? results.at(-1) : undefined;
const rawResult = results[0];
const definition = requestedFocus === "definition" && exactSubject
? subjectResult?.matches.find((/** @type {any} */ match) =>
declaresSymbol(match.content, exactSubject)
)
: undefined;
if (definition) {
const source = {
...definition,
revision: subjectResult.revision,
};
const line = declarationLine(source, exactSubject);
const description = `\`${exactSubject}\` is defined in \`${source.path}\` at line ${line}.`;
return {
done: true,
output: renderEvidence({
facts: [description],
sources: [source],
excerpts: [],
}),
sourceContext: [source],
knowledgeContext: {
title: exactSubject,
description,
subjects: [exactSubject],
},
};
}
const seeded = this.evidence.sourceOptions.map(
(/** @type {{ source: any }} */ { source }) => source,
);
const schemaMatches = schemaResults.flatMap((result, index) =>
rankedSchemaMatches(result, schemaQueries[index]).slice(0, 2)
.map((/** @type {any} */ match) => ({
...match,
revision: result.revision,
}))
);
if (schemaQueries.length && !schemaMatches.length) {
return {
done: true,
output: "I could not find enough verified source evidence to answer that.",
};
}
const candidates = [...new Map([
...subjectContext,
...(paths.length ? [] : seeded),
...schemaMatches,
...(subjectResult?.matches.slice(0, 3).map(
(/** @type {any} */ match) => ({
...match,
revision: subjectResult.revision,
}),
) ?? []),
...(usageResult?.matches.slice(0, 6).map(
(/** @type {any} */ match) => ({
...match,
revision: usageResult.revision,
}),
) ?? []),
...scopedResults.flatMap((result) =>
result.matches.slice(0, 1).map((/** @type {any} */ match) => ({
...match,
revision: result.revision,
}))
),
...(contextualResult?.matches.slice(0, 3).map(
(/** @type {any} */ match) => ({
...match,
revision: contextualResult.revision,
}),
) ?? []),
...(paths.length ? seeded : []),
...rawResult.matches.slice(0, 3).map((/** @type {any} */ match) => ({
...match,
revision: rawResult.revision,
})),
].map((excerpt) => [
`${excerpt.revision}:${excerpt.path}:${excerpt.startLine}`,
excerpt,
])).values()];
const contextualExplanation = subjectContext.length &&
requestedFocus !== "usage";
const excerpts = (contextualExplanation
? subjectContext
: exactSubject
? candidates.filter(({ content }) =>
containsSymbol(content, exactSubject)
)
: candidates).slice(0, 3);
const callers = subject
? usageCallers(
(usageResult?.matches ?? []).map((/** @type {any} */ match) => ({
...match,
revision: usageResult.revision,
})),
subject,
)
: [];
if (requestedFocus === "usage" && subject && callers.length) {
const sources = [...new Map(
callers.map(({ source }) => [
`${source.revision}:${source.path}:${source.startLine}`,
source,
]),
).values()].slice(0, 3);
const names = callers.map(({ name }) => name);
const description = `\`${subject}\` is called by ${
names.map((name) => `\`${name}\``).join(", ")
}.`;
return {
done: true,
output: renderEvidence({
facts: [description],
sources,
excerpts: [],
}),
sourceContext: sources,
knowledgeContext: {
title: subject,
description,
subjects: names.map((name) => name.split("::").at(-1) ?? name),
},
};
}
if (!excerpts.length) {
return {
done: true,
output: "I could not find enough verified source evidence to answer that.",
};
}
return {
done: true,
sourceContext: excerpts,
grounding: {
question: this.question,
metrics: [],
facts: [],
contextFacts: [
...(this.evidence.context.knowledge?.description
? [this.evidence.context.knowledge.description]
: []),
...(callers.length
? [
`Verified functions that call \`${subject}\`: ${
callers.map(({ name }) => `\`${name}\``).join(", ")
}.`,
]
: []),
],
excerpts,
...(subject ? { subjects: [subject] } : {}),
},
};
}
/** @param {Record<string, unknown>} arguments_ */
async listVariants(arguments_) {
const [{ value: metric }] = this.selected(arguments_.refs, "metric");
if (metric.origin === "variant") {
return this.selectVariant(arguments_);
}
const variants = await metricVariants(metric, this.question);
if (!variants) {
return {
done: true,
output: `I found one **${label(metric.name)}** series and no cohort variants.`,
metricPaths: [metric.path],
};
}
const groups = variants.groups
.map((group) =>
group.examples.length === 1 && group.examples[0] === group.family
? label(group.family)
: `${label(group.family)}: ${group.examples.map(label).join(", ")}`
)
.join("; ");
return {
done: true,
output: `**${label(metric.name)}** has ${variants.totalSeries} available series. Variant groups: ${groups}.`,
metricPaths: [metric.path],
};
}
/** @param {Record<string, unknown>} arguments_ @param {(status: string) => void} onStatus */
async findChartMetrics(arguments_, onStatus) {
const query = requiredString(arguments_.query, "a metric search query");
const context = this.evidence.context.knowledge;
const queries = [...new Set([
query,
this.question,
context?.title,
context?.description,
].filter((value) => typeof value === "string" && value.trim()))];
onStatus("Searching metrics…");
const metrics = (await searchMetrics(queries, 12))
.filter(({ matchedTerms }) => Number(matchedTerms ?? 0) > 0)
.filter((metric, index, values) =>
values.findIndex(({ name }) => name === metric.name) === index
)
.slice(0, 6);
if (!metrics.length) {
return {
done: true,
output: "I could not find a chart metric matching that description.",
};
}
return {
done: true,
output: `I found these chart metrics:\n\n${
metrics.map((metric) =>
`- **${label(metric.name)}**${
metric.suggestedUnit ? ` · ${metric.suggestedUnit}` : ""
}`
).join("\n")
}`,
metricPaths: metrics.map(({ path }) => path),
};
}
/** @param {Record<string, unknown>} arguments_ */
selectVariant(arguments_) {
const [{ value: metric }] = this.selected(arguments_.refs, "metric");
return {
done: true,
output: `Selected **${metric.label ?? label(metric.name)}**.`,
metricPaths: [metric.path],
};
}
/** @param {Record<string, unknown>} arguments_ @param {"latest" | "at" | "range"} mode */
async read(arguments_, mode) {
const selected = this.selected(arguments_.refs, "metric");
if (mode === "at") requiredString(arguments_.at, "a block height or date");
const results = await Promise.all(
selected.map(({ value: metric }) =>
readMetric(metric, { ...arguments_, mode })
),
);
return {
done: true,
output: renderData(results),
metricPaths: selected.map(({ value }) => value.path),
};
}
/** @param {any[]} chosen @param {any} [existing] @param {"add" | "remove" | "replace"} [operation] */
chart(chosen, existing, operation) {
const prior = /** @type {any[]} */ (existing?.chart.series ?? []);
const added = chosen.map((metric) => ({
path: metric.path,
label: metric.label ?? label(metric.name),
}));
const selectedPaths = new Set(added.map(({ path }) => path));
const series = !existing || operation === "replace"
? added
: operation === "remove"
? prior.filter(({ path }) => !selectedPaths.has(path))
: [
...prior,
...added.filter(({ path }) =>
!prior.some((item) => item.path === path)
),
];
if (!series.length) throw new Error("A chart needs at least one series");
const { unit, conflicts } = resolveChartUnit(
chosen,
existing?.chart.unit,
operation ?? "replace",
);
if (conflicts.length) {
return {
done: true,
output: `Those metrics use different units (${conflicts.map((value) => value === "number" ? "**unitless**" : `**${value.toUpperCase()}**`).join(" and ")}), so they need separate charts. Which one should I chart?`,
artifacts: [],
};
}
const artifact = createChartArtifact({
title: series.map((item) => item.label).join(" and "),
unit,
view: existing?.chart.view,
scale: existing?.chart.scale,
series,
});
const known = new Map(
[...this.evidence.context.metrics, ...chosen]
.map((metric) => [metric.path, metric]),
);
return {
done: true,
output: `${existing ? "Updated" : "Built"} **${artifact.chart.title}** with ${artifact.chart.series.map((item) => item.label).join(", ")}.`,
artifacts: [artifact],
metricPaths: [...new Set([
...artifact.chart.series.map(({ path }) => path),
...(operation === "remove" || operation === "replace"
? chosen.map(({ path }) => path)
: []),
])].filter((path) => known.has(path)),
};
}
/** @param {Record<string, unknown>} arguments_ */
buildChart(arguments_) {
const selected = this.selected(arguments_.refs, "metric")
.map(({ value }) => value);
const chosen = [...new Map([
...(arguments_.includeContext === true
? this.evidence.context.metrics
: []),
...selected,
].map((metric) => [metric.path, metric])).values()];
return this.chart(chosen);
}
/**
* @param {Record<string, unknown>} arguments_
* @param {"add" | "remove" | "replace"} operation
*/
editChart(arguments_, operation) {
const active = this.evidence.context.chart;
if (!active) throw new Error("There is no active chart to update");
const chosen = this.selected(arguments_.refs, "metric")
.map(({ value }) => value);
return this.chart(chosen, active, operation);
}
/** @param {Record<string, unknown>} arguments_ */
styleChart(arguments_) {
const active = this.evidence.context.chart;
if (!active) throw new Error("There is no active chart to update");
const styles = Array.isArray(arguments_.styles)
? arguments_.styles.filter((style) => typeof style === "string")
: [];
const view = styles.find((style) => CHART_VIEWS.has(style));
const scale = styles.find((style) => CHART_SCALES.has(style));
if (!view && !scale) throw new Error("The AI did not select a chart style");
const artifact = createChartArtifact({
title: active.chart.title,
unit: active.chart.unit,
view: view ?? active.chart.view,
scale: scale ?? active.chart.scale,
series: active.chart.series,
});
return {
done: true,
output: `Updated **${artifact.chart.title}** to use ${[
view ? `${view} view` : "",
scale ? `${scale} scale` : "",
].filter(Boolean).join(" and ")}.`,
artifacts: [artifact],
metricPaths: artifact.chart.series.map(({ path }) => path),
};
}
/**
* @param {Record<string, unknown>} arguments_
* @param {(status: string) => void} onStatus
* @param {AbortSignal} signal
*/
async callApi(arguments_, onStatus, signal) {
const ref = requiredString(arguments_.ref, "an API reference");
const operation = this.refs.get(ref, "api");
const supplied = arguments_.arguments &&
typeof arguments_.arguments === "object" &&
!Array.isArray(arguments_.arguments)
? /** @type {Record<string, unknown>} */ (arguments_.arguments)
: {};
const reused = reusableArguments(operation, this.evidence.context.api);
onStatus("Reading API…");
const result = await executeApi(
operation,
{ ...(reused ?? {}), ...supplied },
signal,
);
return {
done: true,
apiContext: {
key: operation.key,
arguments: result.arguments,
},
apiGrounding: {
question: this.question,
previousFields:
this.evidence.context.api?.operation.key === operation.key
? this.evidence.context.api.fields ?? []
: [],
previousArguments: this.evidence.context.api?.arguments ?? {},
previousRecords:
this.evidence.context.api?.operation.key === operation.key
? this.evidence.context.api.records ?? []
: [],
...result,
},
};
}
/**
* @param {{ name: string, arguments: Record<string, unknown> }} call
* @param {(status: string) => void} onStatus
* @param {AbortSignal} signal
*/
async execute(call, onStatus, signal) {
signal.throwIfAborted();
if (call.name === "answer_general") {
return this.answerGeneral(call.arguments);
}
if (call.name === "show_guide_example") {
return this.guideExample(call.arguments);
}
if (call.name === "describe_capabilities") {
return this.describeCapabilities(call.arguments);
}
if (call.name === "clarify") return this.clarify(call.arguments);
if (call.name === "explain_metric_calculation") {
return await this.explain(call.arguments);
}
if (call.name === "search_source") {
return await this.searchSource(call.arguments, onStatus);
}
if (call.name === "list_metric_cohorts_variants") {
return await this.listVariants(call.arguments);
}
if (call.name === "find_chart_metrics") {
return await this.findChartMetrics(call.arguments, onStatus);
}
if (call.name === "select_metric_variant") {
return this.selectVariant(call.arguments);
}
if (call.name === "read_latest_metric") {
onStatus("Reading data…");
return await this.read(call.arguments, "latest");
}
if (call.name === "read_metric_at") {
onStatus("Reading data…");
return await this.read(call.arguments, "at");
}
if (call.name === "read_metric_range") {
onStatus("Reading data…");
return await this.read(call.arguments, "range");
}
if (call.name === "build_metric_chart") {
onStatus("Building chart…");
return this.buildChart(call.arguments);
}
if (call.name === "add_chart_series") {
onStatus("Updating chart…");
return this.editChart(call.arguments, "add");
}
if (call.name === "remove_chart_series") {
onStatus("Updating chart…");
return this.editChart(call.arguments, "remove");
}
if (call.name === "replace_chart_series") {
onStatus("Updating chart…");
return this.editChart(call.arguments, "replace");
}
if (call.name === "set_chart_view_scale") {
onStatus("Updating chart…");
return this.styleChart(call.arguments);
}
if (call.name === "call_api") {
return await this.callApi(call.arguments, onStatus, signal);
}
throw new Error(`Unsupported AI capability: ${call.name}`);
}
}