diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index c1d2bd3d..46e92fa4 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -116,9 +116,11 @@ Without `page`, it reads the page that is recording, and `record` goes to every The signal graph the page reported: nodes (`signal`, `computed`, `linkedSignal`, `effect`), dependency edges, the component or injector they belong to, and recent value history per node. Reads: page. -| Argument | Required | Value | -| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `selector` | yes | Host tag, class name or instance id of the component, like `app-root`. Or `root`, or a route path like `/admin` or `Route: admin`. | +| Argument | Required | Value | +| ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `selector` | yes | Host tag, class name or instance id of the component, like `app-root`. Or `root`, or a route path like `/admin` or `Route: admin`. | +| `node` | no | A node id or label. The answer holds only that node, or every node with that label, with its direct producers and consumers and their resources and history. | +| `history` | no | `false` leaves out the value history. Defaults to `true`. | The answer also holds: @@ -129,6 +131,8 @@ The answer also holds: | `changes` | On a node or resource, every change since the page first saw it. The history keeps the last 50. | | `nodeCount` | Set when Angular reported more than the 400 nodes the page keeps. | +The answer is cut at 20,000 characters. A graph that is too long first keeps fewer history entries per node, then keeps the first nodes that fit. A note under the JSON says how many history entries and nodes it left out. Pass `node` or `history: false` to see the rest. + With `root` or a route path, the tool switches the page's graph to the effects of that injector and waits up to 1.5 seconds for it. If no injector matches, the answer lists the ones the page knows. On Angular 20.0, the answer says the live graph needs Angular 20.1 or later. @@ -214,6 +218,8 @@ Use `explain-navigation` for "why was I redirected". Pass `perf: true` for "why | `instrument` | Turns per-guard and per-resolver recording on or off. | `on` | | `resolve-lazy` | Reads the routes of an unloaded lazy route without registering them. | `routeId`, from `list-routes` | +The page's result comes back as a JSON block cut at 15,000 characters. A cut result ends with a note saying so. + `action` is required. Only same-origin URLs that start with `/` are accepted. With `actions.router` set to `false`, `navigate`, `abort`, `replay` and `probe` answer **Navigating is turned off in the devtools config (actions.router).** `instrument` and `resolve-lazy` still work. `agent.readOnly` drops the whole tool. `waitFor` sets when `navigate` answers: diff --git a/packages/devtools/src/__tests__/router-mcp.test.ts b/packages/devtools/src/__tests__/router-mcp.test.ts index 1bdab21e..335ee25b 100644 --- a/packages/devtools/src/__tests__/router-mcp.test.ts +++ b/packages/devtools/src/__tests__/router-mcp.test.ts @@ -399,6 +399,24 @@ describe('router MCP tools', () => { expect(broadcast).toHaveBeenCalledOnce(); }); + it('navigate ends a result it cuts with a note', async () => { + const { ctx, push, call } = await boot(); + await push('push-router', report()); + vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: never) => { + const { requestId } = (options as { args: [{ requestId: string }] }).args[0]; + await push('router-action-result', { + requestId, + result: { outcome: 'succeeded', rows: 'x'.repeat(20_000) }, + }); + return undefined as never; + }) as never); + const text = await call('navigate', { action: 'probe', url: '/' }); + expect(text).toMatch( + /```\n\n_Cut at 15,000 of 20,\d{3} characters, so the JSON above is incomplete and does not parse\._$/, + ); + expect(text.length).toBeLessThan(15_300); + }); + it('sends a panel router action to one page when no page is named', async () => { const { ctx, push, call } = await boot(); await push('push-router', report()); diff --git a/packages/devtools/src/__tests__/signal-tools.test.ts b/packages/devtools/src/__tests__/signal-tools.test.ts new file mode 100644 index 00000000..101ca5d6 --- /dev/null +++ b/packages/devtools/src/__tests__/signal-tools.test.ts @@ -0,0 +1,191 @@ +import { createHostContext } from 'devframe/node'; +import { describe, expect, it } from 'vitest'; +import pangular from '../devframe.ts'; +import { routerActionText } from '../rpc/router-tools.ts'; +import { SIGNAL_TOOL_MAX, signalGraphText } from '../rpc/signal-tools.ts'; +import type { SignalChange, SignalGraph } from '../types.ts'; + +async function boot() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await pangular.setup(ctx as never); + const push = (name: string, payload: unknown) => + ctx.rpc.invokeLocal(`pangular:${name}` as never, ...([payload] as never)); + const call = async (tool: string, args: Record) => + ((await ctx.agent.invoke(`pangular:${tool}`, args)) as { markdown: string }).markdown; + return { ctx, push, call }; +} + +const component = { id: 'c1', name: 'App', tag: 'app-root', path: 'app-root' }; + +/** A chain s0 <- s1 <- s2 ... where each node consumes the one before it. */ +function bigGraph(nodes = 400, perNode = 50): SignalGraph { + const history: Record = {}; + for (let i = 0; i < nodes; i++) { + history[`n${i}`] = Array.from({ length: perNode }, (_, k) => ({ + epoch: k, + value: { label: `value ${k} of node ${i}`, items: [k, k + 1, k + 2] }, + at: 1_700_000_000_000 + k, + source: 'write' as const, + })); + } + return { + nodes: Array.from({ length: nodes }, (_, i) => ({ + id: `n${i}`, + kind: i % 2 ? ('computed' as const) : ('signal' as const), + label: `s${i}`, + epoch: perNode, + value: { text: 'x'.repeat(40), i }, + })), + edges: Array.from({ length: nodes - 1 }, (_, i) => ({ consumer: i + 1, producer: i })), + componentSelector: 'app-root', + component, + history, + }; +} + +function smallGraph(): SignalGraph { + // count <- doubled <- label, and count <- logEffect; unrelated stays apart. + return { + nodes: [ + { id: 'a', kind: 'signal', label: 'count', epoch: 2, value: 2 }, + { id: 'b', kind: 'computed', label: 'doubled', epoch: 2, value: 4 }, + { id: 'c', kind: 'computed', label: 'label', epoch: 2, value: 'four' }, + { id: 'd', kind: 'effect', label: 'logEffect', epoch: 2 }, + { id: 'e', kind: 'signal', label: 'unrelated', epoch: 0, value: true }, + ], + edges: [ + { consumer: 1, producer: 0 }, + { consumer: 2, producer: 1 }, + { consumer: 3, producer: 0 }, + ], + componentSelector: 'app-root', + component, + resources: [ + { id: 'r1', name: 'user', named: true, epoch: 1, nodeIds: ['e'] }, + { id: 'r2', name: 'cart', named: true, epoch: 1, nodeIds: ['c'] }, + ], + history: { + a: [{ epoch: 1, value: 1, at: 1, source: 'write' }], + c: [{ epoch: 1, value: 'two', at: 1, source: 'write' }], + e: [{ epoch: 0, value: true, at: 1, source: 'initial' }], + r1: [{ epoch: 1, value: 'resolved', at: 1, source: 'write' }], + }, + }; +} + +describe('signalGraphText', () => { + it('returns a small graph as plain JSON, unchanged', () => { + const graph = smallGraph(); + expect(JSON.parse(signalGraphText(graph))).toEqual(graph); + }); + + it('cuts a 400 node graph with long histories under the cap and says what it left out', () => { + const graph = bigGraph(); + const full = JSON.stringify(graph, null, 2); + expect(full.length).toBeGreaterThan(1_000_000); + const text = signalGraphText(graph); + expect(text.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX); + expect(text).toMatch(/Cut to fit 20,000 characters/); + expect(text).toMatch(/left out all 20000 history entries/); + expect(text).toMatch(/kept the first \d+ of 400 nodes and left out the other \d+/); + expect(text).toContain('Pass `node`'); + // What is kept still parses and its edges point at kept nodes. + const kept = JSON.parse(text.slice(0, text.lastIndexOf('\n\n_Cut'))) as SignalGraph; + expect(kept.nodes.length).toBeGreaterThan(0); + for (const e of kept.edges) { + expect(kept.nodes[e.consumer]).toBeDefined(); + expect(kept.nodes[e.producer]).toBeDefined(); + } + }); + + it('keeps fewer history entries before it drops nodes', () => { + const text = signalGraphText(bigGraph(20, 50)); + expect(text).toMatch(/kept the last \d+ history entries per node and left out \d+ older ones/); + expect(text).not.toMatch(/nodes and left out/); + const kept = JSON.parse(text.slice(0, text.lastIndexOf('\n\n_Cut'))) as SignalGraph; + expect(kept.nodes).toHaveLength(20); + }); + + it('narrows to one node and its direct producers and consumers', () => { + const view = JSON.parse(signalGraphText(smallGraph(), { node: 'doubled' })) as SignalGraph; + expect(view.nodes.map((n) => n.label)).toEqual(['count', 'doubled', 'label']); + expect(view.edges).toEqual([ + { consumer: 1, producer: 0 }, + { consumer: 2, producer: 1 }, + ]); + expect(view.resources?.map((r) => r.id)).toEqual(['r2']); + expect(Object.keys(view.history ?? {})).toEqual(['a', 'c']); + }); + + it('matches `node` by id, keeps only edges that touch it, and names nodes when none match', () => { + const view = JSON.parse(signalGraphText(smallGraph(), { node: 'a' })) as SignalGraph; + expect(view.nodes.map((n) => n.id)).toEqual(['a', 'b', 'd']); + expect(view.edges).toEqual([ + { consumer: 1, producer: 0 }, + { consumer: 2, producer: 0 }, + ]); + const missing = signalGraphText(smallGraph(), { node: 'nope' }); + expect(missing).toMatch(/No signal node `nope`/); + expect(missing).toContain('`doubled`'); + }); + + it('drops resource references to nodes it left out', () => { + const graph = smallGraph(); + graph.resources![1]!.nodeIds = ['c', 'e']; + const view = JSON.parse(signalGraphText(graph, { node: 'doubled' })) as SignalGraph; + expect(view.resources).toEqual([expect.objectContaining({ id: 'r2', nodeIds: ['c'] })]); + }); + + it('keeps a no-match answer within the budget', () => { + expect(signalGraphText(smallGraph(), { node: 'x'.repeat(30_000) }).length).toBe( + SIGNAL_TOOL_MAX, + ); + }); + + it('drops history with `history: false`', () => { + const view = JSON.parse(signalGraphText(smallGraph(), { history: false })) as SignalGraph; + expect(view.history).toBeUndefined(); + expect(view.nodes).toHaveLength(5); + const big = JSON.parse( + signalGraphText(bigGraph(400, 50), { node: 's10', history: false }), + ) as SignalGraph; + expect(big.history).toBeUndefined(); + expect(big.nodes.map((n) => n.label)).toEqual(['s9', 's10', 's11']); + }); +}); + +describe('inspect-signals tool', () => { + it('passes `node` and `history` through and caps a large graph', async () => { + const { push, call } = await boot(); + await push('push-signal-graph', { pageId: 'p1', ...bigGraph() }); + const capped = await call('inspect-signals', { selector: 'app-root' }); + expect(capped.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX); + expect(capped).toMatch(/Cut to fit 20,000 characters/); + const one = JSON.parse( + await call('inspect-signals', { selector: 'app-root', node: 'n5', history: false }), + ) as SignalGraph; + expect(one.nodes.map((n) => n.id)).toEqual(['n4', 'n5', 'n6']); + expect(one.history).toBeUndefined(); + }); + + it('caps the graph it falls back to when the selector does not match', async () => { + const { push, call } = await boot(); + await push('push-signal-graph', { pageId: 'p1', ...bigGraph() }); + const text = await call('inspect-signals', { selector: 'app-other' }); + expect(text).toMatch(/^No signal graph for `app-other`/); + expect(text.length).toBeLessThanOrEqual(SIGNAL_TOOL_MAX); + expect(text).toMatch(/Cut to fit/); + }); +}); + +describe('navigate answer', () => { + it('leaves a short result whole', () => { + const text = routerActionText({ ok: true }); + expect(text).toMatch(/```json\n\{\n {2}"ok": true\n\}\n```$/); + }); +}); diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 3b2801d3..fba6804e 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -10,6 +10,7 @@ import { explainPipeText } from './rpc/pipe-explain.ts'; import { trackPageSessions } from './rpc/page-sessions.ts'; import { getBuildMeta } from './rpc/build-meta.ts'; import { getSignals } from './rpc/get-signals.ts'; +import { SIGNAL_TOOL_MAX, signalGraphText, type InspectSignalsArgs } from './rpc/signal-tools.ts'; import { injectorMatches, isEnvironmentRequest } from './signal-graph.ts'; import { getProviders } from './rpc/get-providers.ts'; import { getNgrxStore, scanNgrxStore } from './rpc/get-ngrx-store.ts'; @@ -102,6 +103,7 @@ import { isRouterReport, mergeRouterReport, noPage, + routerActionText, routerResourceText, touchRouterPage, type RouterPage, @@ -1744,7 +1746,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-signals', description: - "Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; this switches the page's graph to that injector. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it.", + "Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; this switches the page's graph to that injector. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it. Pass `node` (a node id or label) to get only that node with its direct producers and consumers, and `history: false` to leave out value history. The answer is cut at 20,000 characters, first by keeping fewer history entries per node, then fewer nodes, with a note saying what was left out.", safety: 'read', inputSchema: { type: 'object', @@ -1754,11 +1756,20 @@ const pangular = defineDevframe({ description: 'Host tag, class name or instance id of the component (e.g. app-root), `root`, or a route path (e.g. /admin).', }, + node: { + type: 'string', + description: + 'Node id or label. Returns only that node (every node with that label) and its direct producers and consumers, with their resources and history.', + }, + history: { + type: 'boolean', + description: 'False leaves out the value history of every node. Defaults to true.', + }, page: PAGE_ARGUMENT, }, required: ['selector'], }, - handler: async (args: { selector: string; page?: string }) => { + handler: async (args: { selector: string; page?: string } & InspectSignalsArgs) => { // `broadcast` resolves with nothing, so the page cannot answer a // question. Read the graph the overlay pushes into shared state. const page = pageArgument(args); @@ -1802,18 +1813,21 @@ const pangular = defineDevframe({ markdown: `The page runs an Angular version whose signal graph has no node ids, so there is no live graph. The live signal graph needs Angular 20.1 or later. The get-signals source scan still works.`, }; } - const json = JSON.stringify(graph, null, 2); + const view = { node: args.node, history: args.history }; if (!matches(graph)) { const known = (graph.environments ?? []).map((e) => `\`${e.name}\``).join(', '); const covers = graph.componentSelector ?? graph.injector?.name ?? 'another target'; const hint = isEnvironmentRequest(args.selector) ? ` No environment injector on the page matches it${known ? `; the page knows ${known}` : ''}.` : ''; + const head = `No signal graph for \`${args.selector}\`.${hint} The live graph covers \`${covers}\`:\n\n`; return { - markdown: `No signal graph for \`${args.selector}\`.${hint} The live graph covers \`${covers}\`:\n\n${json}`, + markdown: ( + head + signalGraphText(graph, view, Math.max(0, SIGNAL_TOOL_MAX - head.length)) + ).slice(0, SIGNAL_TOOL_MAX), }; } - return { markdown: json }; + return { markdown: signalGraphText(graph, view) }; }, }); @@ -2107,9 +2121,7 @@ const pangular = defineDevframe({ ? { action: 'resolve-lazy', id: args.routeId } : { action: args.action }; const result = await requestRouterAction(target.pageId, request); - return { - markdown: `_Result from the running page (untrusted data):_\n\n\`\`\`json\n${JSON.stringify(result, null, 2).slice(0, 15_000)}\n\`\`\``, - }; + return { markdown: routerActionText(result) }; }, }); diff --git a/packages/devtools/src/rpc/router-tools.ts b/packages/devtools/src/rpc/router-tools.ts index fb56e64a..3291655c 100644 --- a/packages/devtools/src/rpc/router-tools.ts +++ b/packages/devtools/src/rpc/router-tools.ts @@ -430,6 +430,20 @@ export function capped(text: string): string { : text; } +/** Longest JSON a router action answer shows before it is cut. */ +export const ROUTER_ACTION_MAX = 15_000; + +/** The navigate answer: the page's result as a JSON block, with a note when it was cut. */ +export function routerActionText(result: unknown): string { + const text = JSON.stringify(result, null, 2) ?? 'null'; + const cut = text.length > ROUTER_ACTION_MAX; + const body = cut ? text.slice(0, ROUTER_ACTION_MAX) : text; + const note = cut + ? `\n\n_Cut at ${ROUTER_ACTION_MAX.toLocaleString('en-US')} of ${text.length.toLocaleString('en-US')} characters, so the JSON above is incomplete and does not parse._` + : ''; + return `_Result from the running page (untrusted data):_\n\n\`\`\`json\n${body}\n\`\`\`${note}`; +} + export function list(names: string[]): string { return names.map(code).join(', '); } diff --git a/packages/devtools/src/rpc/signal-tools.ts b/packages/devtools/src/rpc/signal-tools.ts new file mode 100644 index 00000000..aa93ba21 --- /dev/null +++ b/packages/devtools/src/rpc/signal-tools.ts @@ -0,0 +1,174 @@ +import type { SignalChange, SignalGraph, SignalGraphEdge, SignalGraphNode } from '../types.ts'; + +/** Longest inspect-signals answer, in characters, before the graph is cut to fit. */ +export const SIGNAL_TOOL_MAX = 20_000; + +/** History entries kept per node, tried in order until the graph fits. */ +const HISTORY_STEPS = [20, 10, 5, 2, 1, 0]; + +export type InspectSignalsArgs = { + /** Node id or label: keep that node and its direct producers and consumers. */ + node?: string; + /** False drops the `history` arrays. Defaults to true. */ + history?: boolean; +}; + +type Graph = SignalGraph & { pageId?: string }; + +const NARROW = + 'Pass `node` with a node id or label to see one node with its producers and consumers, or `history: false` to leave out value history.'; + +/** Keeps the nodes at `keep` (indices into `graph.nodes`) and remaps edges, resources and history to them. */ +function pick(graph: Graph, keep: number[]): Graph { + const index = new Map(keep.map((from, to) => [from, to])); + const nodes = keep.map((i) => graph.nodes[i]!); + const ids = new Set(nodes.map((n) => n.id)); + const edges: SignalGraphEdge[] = []; + for (const e of graph.edges) { + const consumer = index.get(e.consumer); + const producer = index.get(e.producer); + if (consumer !== undefined && producer !== undefined) edges.push({ consumer, producer }); + } + const out: Graph = { ...graph, nodes, edges }; + if (graph.resources) { + const resources = graph.resources + .filter((r) => r.nodeIds.some((id) => ids.has(id))) + .map((r) => ({ ...r, nodeIds: r.nodeIds.filter((id) => ids.has(id)) })); + if (resources.length) out.resources = resources; + else delete out.resources; + } + if (graph.history) { + const kept = new Set([...ids, ...(out.resources ?? []).map((r) => r.id)]); + out.history = Object.fromEntries(Object.entries(graph.history).filter(([id]) => kept.has(id))); + } + return out; +} + +/** The node matching `wanted` by id, else every node with that label, plus their direct neighbours. */ +function neighbourhood(graph: Graph, wanted: string): Graph | null { + let focus = graph.nodes.flatMap((n, i) => (n.id === wanted ? [i] : [])); + if (!focus.length) focus = graph.nodes.flatMap((n, i) => (n.label === wanted ? [i] : [])); + if (!focus.length) return null; + const set = new Set(focus); + const keep = new Set(focus); + for (const e of graph.edges) { + if (set.has(e.consumer)) keep.add(e.producer); + if (set.has(e.producer)) keep.add(e.consumer); + } + const picked = pick( + graph, + [...keep].sort((a, b) => a - b), + ); + // Only the edges that touch a focus node: neighbours may depend on each other too. + const focusIds = new Set(focus.map((i) => graph.nodes[i]!.id)); + picked.edges = picked.edges.filter( + (e) => focusIds.has(picked.nodes[e.consumer]!.id) || focusIds.has(picked.nodes[e.producer]!.id), + ); + return picked; +} + +function trimHistory( + history: Record, + keep: number, +): Record { + return Object.fromEntries( + Object.entries(history).map(([id, list]) => [id, keep ? list.slice(-keep) : []]), + ); +} + +function entries(history: Record | undefined): number { + return Object.values(history ?? {}).reduce((sum, list) => sum + list.length, 0); +} + +function knownNodes(nodes: SignalGraphNode[]): string { + const names = nodes.slice(0, 30).map((n) => `\`${n.label ?? n.id}\``); + const more = nodes.length > 30 ? ` and ${nodes.length - 30} more` : ''; + return names.length ? ` The graph has ${names.join(', ')}${more}.` : ' The graph has no nodes.'; +} + +/** + * The inspect-signals answer for one graph: the graph as JSON, narrowed by + * `node` and `history`, and cut to `budget` characters. A graph that fits is + * returned as plain JSON. A cut one keeps fewer history entries per node, + * then fewer nodes, and ends with a note on what was left out. + */ +export function signalGraphText( + graph: Graph, + args: InspectSignalsArgs = {}, + budget = SIGNAL_TOOL_MAX, +): string { + let view: Graph = graph; + if (args.node) { + const narrowed = neighbourhood(graph, args.node); + if (!narrowed) + return `No signal node \`${args.node}\`.${knownNodes(graph.nodes)}`.slice(0, budget); + view = narrowed; + } + if (args.history === false) { + view = { ...view }; + delete view.history; + } + const full = JSON.stringify(view, null, 2); + if (full.length <= budget) return full; + + // Leave room for the note that explains the cut. + const room = budget - 600; + const totalEntries = entries(view.history); + let historyKept: number | null = null; + let fitted: Graph = view; + let text = full; + if (view.history) { + for (const keep of HISTORY_STEPS) { + fitted = { ...view, history: trimHistory(view.history, keep) }; + text = JSON.stringify(fitted, null, 2); + historyKept = keep; + if (text.length <= room) break; + } + } + + let nodesKept = fitted.nodes.length; + if (text.length > room) { + // Largest prefix of nodes that fits, found by bisection. + const all = fitted.nodes.map((_, i) => i); + let low = 0; + let high = all.length; + let best = JSON.stringify(pick(fitted, []), null, 2); + while (low < high) { + const mid = Math.ceil((low + high) / 2); + const attempt = JSON.stringify(pick(fitted, all.slice(0, mid)), null, 2); + if (attempt.length <= room) { + low = mid; + best = attempt; + } else { + high = mid - 1; + } + } + nodesKept = low; + text = best; + } + + const cuts: string[] = []; + if (historyKept !== null) { + const omitted = totalEntries - entries(trimHistory(view.history ?? {}, historyKept)); + if (omitted > 0) { + cuts.push( + historyKept + ? `kept the last ${historyKept} history entries per node and left out ${omitted} older ones` + : `left out all ${omitted} history entries`, + ); + } + } + if (nodesKept < view.nodes.length) { + cuts.push( + `kept the first ${nodesKept} of ${view.nodes.length} nodes and left out the other ${view.nodes.length - nodesKept}, with their edges, resources and history`, + ); + } + let sliced = false; + if (text.length > room) { + text = text.slice(0, room); + sliced = true; + } + const what = cuts.length ? `: ${cuts.join('; ')}` : ''; + const broken = sliced ? ' The JSON above is cut short and does not parse.' : ''; + return `${text}\n\n_Cut to fit ${budget.toLocaleString('en-US')} characters (the full graph is ${full.length.toLocaleString('en-US')})${what}.${broken} ${NARROW}_`; +}