Let the agent copy nodes, also into another graph (#434)

The agent built every part again that already stood in a graph,
node by node, while the editor has Ctrl+C and Ctrl+V for it. The new
command `copy_nodes` in the area graphs copies nodes into the same
graph or another one, in one undo step with the rest of the call.

- The links between the copied nodes come along, and so do groups
  and comments that are only about copied nodes. A node belongs to a
  group by its middle, as when it is dragged. `boxesAbout` in
  annotationFollow.ts decides this.
- `made` names each copy by the id of its original, so later steps
  link to it with `$copy.<id>`. `$copy` alone is the first one.
- Without x and y the copies are arranged at the end of the call like
  new nodes. With them they keep their layout, top left corner there.
- Nodes the target graph does not take are refused by name, not left
  out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and
  `fittingPart` moved from clipboard.ts to paletteEntries.ts.
- A copied Variables node takes the variables of its behavior along
  into the target behavior (`CarryVariablesCommand`), with default,
  value, Keep and Private. A variable of that name of another kind
  there is refused.
- The relay's instructions name `copy_nodes`.

Found on the way:
- Groups made by the agent can take in a node of another group once
  the nodes are arranged. lint_graph does not count that.
- Undo after a paste leaves an empty `groups: []`, as
  AddAnnotationCommand does. It is the same as no list.

Tests:
- agent-copy (new, 3 tests) with the agent, relay, clipboard and
  palette tests: 118/118.
- Counter-probes: 6 bendings, all compile, all red.
- e2e 71 (atx): ALL PASSED, 26 checks.
- Gate (atx): CHECK EXIT 0, 327 files, 5024 tests.
- Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new
  behavior, 6 links, group, comment and speed (300) along, lint_graph
  0 everywhere, taken back with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 20:39:37 +02:00
1 parent e4b0419a53
commit fb981b5b0f
7 files changed
+386 -45

No files matched your search

+42
View File
@@ -65,6 +65,48 @@ export class AddVariableCommand implements Command {
}
}
/**
* Declares variables of one behavior in another as they are declared there (#434): default, Keep
* and Private included, for copied nodes that name them. A key the target has already is left alone.
*/
export class CarryVariablesCommand implements Command {
readonly label = 'Carry variables';
private added: string[] = [];
constructor(
private readonly assetId: string,
private readonly fromId: string,
private readonly keys: readonly string[],
) {}
execute(project: Project): void {
const from = requireBehavior(project, this.fromId);
const to = requireBehavior(project, this.assetId);
this.added = [];
for (const key of this.keys) {
const def = from.variables.find((v) => v.key === key);
if (!def) throw new CommandError(`${from.name} has no variable "${key}"`);
if (to.variables.some((v) => v.key === key)) continue;
to.variables.push(structuredClone(def));
to.props[key] = structuredClone(from.props[key] ?? def.default);
if (isKeptVariable(from, key)) setKeptVariable(to, key, true);
if (isPrivateVariable(from, key)) setPrivateVariable(to, key, true);
this.added.push(key);
}
}
undo(project: Project): void {
const to = requireBehavior(project, this.assetId);
const added = new Set(this.added);
to.variables = to.variables.filter((v) => !added.has(v.key));
for (const key of added) {
delete to.props[key];
setKeptVariable(to, key, false);
setPrivateVariable(to, key, false);
}
}
}
/** One placed object's own value for a variable, remembered so that undo can put it back. */
interface TakenOverride {
holderId: string;
+99 -2
View File
@@ -19,6 +19,7 @@ import {
ResizeNodeCommand,
SetNodeParamCommand,
} from '../../../core/commands/graphCommands';
import { PasteGraphCommand } from '../../../core/commands/pasteCommands';
import {
AddPortCommand,
MovePortCommand,
@@ -34,25 +35,33 @@ import {
SetStructTypeCommand,
SplitOutputCommand,
} from '../../../core/commands/structTypeCommands';
import { CarryVariablesCommand } from '../../../core/commands/variableCommands';
import {
type AnnotationKind,
type DeclaredPort,
declaresVariablePorts,
findAnnotation,
findNode,
type Graph,
type GraphNode,
nodeDef,
portKindOf,
variablesSourceOf,
} from '../../../core/graph';
import { copyGraph } from '../../../core/model/clipboard';
import type { PropertyBag, PropertyValue } from '../../../core/model/Properties';
import { findGraphAsset, type Project } from '../../../core/model/Project';
import { findGraphAsset, type GraphHolder, type Project } from '../../../core/model/Project';
import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds';
import { coerceValue } from '../../../core/properties/PropertySchema';
import { freeNoteBox, groupBoxAround } from '../../graph/annotationDrag';
import { boxesAbout } from '../../graph/annotationFollow';
import { arrangeCommand, layoutOf } from '../../graph/graphLayout';
import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { fittingPart, type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { pickedPortChoices } from '../../graph/portChoices';
import {
type Args,
type BuildContext,
type Built,
type CatalogCommand,
flag,
GRAPH,
@@ -122,6 +131,32 @@ export const GRAPH_COMMANDS: readonly CatalogCommand[] = [
},
shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }),
},
{
name: 'copy_nodes',
description:
'Copies nodes into the same graph or another one, as Ctrl+C and Ctrl+V do: with the links between them ' +
'and the groups and comments about them alone. made names each copy by the id of its original ' +
'("$copy.<id>"; "$copy" is the copy of the first node given), so later steps can link it. Leave x and y ' +
'out: the copies are then arranged like new nodes when the call has run. With x and y the copy keeps ' +
'its layout, its top left there. Nodes the other graph cannot hold are refused by name; the variables ' +
'a copied Variables node reads from its behavior come along into the other behavior.',
args: object(
{
from: id('Id of the asset holding the nodes.'),
nodes: ids('The nodes to copy.'),
to: id('Id of the asset to copy them into; leave out for the same one.'),
x: number('Only to place the copy by hand: its left.'),
y: number('Only to place the copy by hand: its top.'),
},
['from', 'nodes'],
),
build: copyNodes,
shows: (args, made) => ({
kind: 'nodes',
asset: (args.to as string | undefined) ?? text(args, 'from'),
ids: Object.values(made),
}),
},
{
name: 'arrange_graph',
description:
@@ -588,6 +623,68 @@ function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag {
return coerced;
}
/** `copy_nodes` (#434): the copies, where they go, and the variables that go along with them. */
function copyNodes(args: Args, context: BuildContext): Built {
const { project } = context;
const fromId = text(args, 'from');
const toId = (args.to as string | undefined) ?? fromId;
const nodeIds = listOf(args, 'nodes');
requireNodes(project, fromId, nodeIds);
const source = findGraphAsset(project, fromId)!;
const target = findGraphAsset(project, toId);
if (!target) throw new CommandError(`There is no graph asset ${toId}`);
const layout = layoutOf(project, fromId, context.measure(fromId))!;
const clip = copyGraph(source.graph, nodeIds, boxesAbout(layout, new Set(nodeIds)))!;
const { skipped } = fittingPart(project, target, clip);
if (skipped.length > 0) {
const named = skipped.map((node) => `${nodeDef(node.type)?.label ?? node.type} (${node.id})`);
throw new CommandError(`${target.name} cannot hold ${named.join(', ')} – leave them out`);
}
const carried = carriedVariables(source, target, clip.nodes);
const placed = typeof args.x === 'number' && typeof args.y === 'number';
const at = placed ? { x: args.x as number, y: args.y as number } : context.spot(toId);
const corners = [...clip.nodes, ...clip.annotations.map((a) => a.annotation)];
const shift = {
x: at.x - Math.min(...corners.map((c) => c.x)),
y: at.y - Math.min(...corners.map((c) => c.y)),
};
const paste = new PasteGraphCommand(toId, clip, shift);
const command =
carried.length > 0
? new CompoundCommand(paste.label, [new CarryVariablesCommand(toId, fromId, carried), paste])
: paste;
const copies = paste.nodeIds;
const made: Record<string, string> = { id: copies[clip.nodes.findIndex((n) => n.id === nodeIds[0])]! };
clip.nodes.forEach((node, i) => (made[node.id] = copies[i]!));
clip.annotations.forEach(({ annotation }, i) => (made[annotation.id] = paste.annotationIds[i]!));
return placed ? { command, made } : { command, made, unplaced: { asset: toId, nodes: copies } };
}
/**
* The variables of `source` that copied Variables nodes read from it, for a copy into another
* behavior that lacks them; one declared there in another kind is refused.
*/
function carriedVariables(source: GraphHolder, target: GraphHolder, nodes: readonly GraphNode[]): string[] {
if (source.type !== 'behavior' || target.type !== 'behavior' || source.id === target.id) return [];
const named = nodes.flatMap((node) =>
declaresVariablePorts(node.type) && !variablesSourceOf(node.params)
? (node.ports ?? []).map((port) => port.key)
: [],
);
const carried: string[] = [];
for (const key of new Set(named)) {
const def = source.variables.find((v) => v.key === key);
const there = target.variables.find((v) => v.key === key);
if (!def) continue;
if (!there) carried.push(key);
else if (there.kind !== def.kind)
throw new CommandError(
`"${key}" is a ${def.kind} in ${source.name} but a ${there.kind} in ${target.name} – rename one first`,
);
}
return carried;
}
function requireGraphOf(project: Project, assetId: string): Graph {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
+2 -40
View File
@@ -3,16 +3,7 @@
* copy names assets of this project, which mean nothing anywhere else.
*/
import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands';
import {
BEHAVIOR_PARAM,
findAnnotation,
findNode,
FUNCTION_PARAM,
type GraphNode,
nodeDef,
namesBehavior,
SHADER_CALL,
} from '../core/graph';
import { findAnnotation, findNode, nodeDef } from '../core/graph';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance } from '../core/model/instance';
@@ -21,12 +12,11 @@ import {
findAssetOf,
findGraphAsset,
findObjectHolder,
type GraphHolder,
type ObjectHolder,
type Project,
} from '../core/model/Project';
import { GRID_STEP } from './graph/graphView';
import { paletteEntries } from './graph/paletteEntries';
import { fittingPart } from './graph/paletteEntries';
import { type EditorState, graphSelection, objectSelection } from './state';
import { SCENE_GRID_STEP } from './viewport/sceneViewHost';
@@ -194,34 +184,6 @@ export function pasteObjects(
return true;
}
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
function fitsGraph(project: Project, asset: GraphHolder, node: GraphNode): boolean {
return paletteEntries(project, asset).some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
function fittingPart(
project: Project,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}
/**
* Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid
* step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is
+22
View File
@@ -89,6 +89,28 @@ export function followedBoxes(
return done;
}
/**
* The groups and comments that are about these nodes alone (#434), in the graph's order: a group
* holding some of them and no other node, a comment going with such a group or with nodes all among
* them.
*/
export function boxesAbout(graph: LayoutGraph, ids: ReadonlySet<string>): string[] {
const groups = new Set(
graph.boxes
.filter((box) => {
const members = box.kind === 'group' ? graph.nodes.filter((node) => holds(box, node)) : [];
return members.length > 0 && members.every((node) => ids.has(node.id));
})
.map((box) => box.id),
);
const about = (note: LayoutBox) => {
const anchor = note.kind === 'note' ? anchorOf(graph, note) : null;
if (!anchor) return false;
return 'box' in anchor ? groups.has(anchor.box.id) : anchor.nodes.every((node) => ids.has(node.id));
};
return graph.boxes.filter((box) => groups.has(box.id) || about(box)).map((box) => box.id);
}
type Anchor = { box: LayoutBox; below: boolean } | { nodes: LayoutNode[]; below: boolean };
/** What a comment is about, by where it stands now. */
+31
View File
@@ -6,6 +6,7 @@ import {
callsFunction,
createEmptyGraph,
FUNCTION_PARAM,
type GraphNode,
isTerminalType,
namesBehavior,
nextStateName,
@@ -22,6 +23,7 @@ import {
terminalNode,
} from '../../core/graph';
import type { ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import type { GraphClip } from '../../core/model/clipboard';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
@@ -188,3 +190,32 @@ export function paletteEntries(project: Project, asset: GraphHolder): PaletteEnt
}
return entries;
}
/** Whether a graph may hold `node`: its palette `entries` offer the node's kind. */
function fitsGraph(entries: readonly PaletteEntry[], node: GraphNode): boolean {
return entries.some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot (#240, #434). */
export function fittingPart(
project: Project,
asset: GraphHolder,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const entries = paletteEntries(project, asset);
const nodes = clip.nodes.filter((n) => fitsGraph(entries, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}
+185
View File
@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest';
import { isKeptVariable } from '../../src/core/model/Asset';
import { findAssetOf, findGraphAsset, type Project } from '../../src/core/model/Project';
import { runTool, type ToolHost } from '../../src/editor/agent/agentTools';
import { makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost';
/** Copying nodes through the agent's tools (#434). */
/**
* Behavior Walk, placed by hand so no group takes in a node not its own: a key held moving its owner, a Variables node reading `speed` (kept), a group round
* key and move, a comment above the Variables node, and a group round the owner alone.
*/
async function walkAndRun(fixture: SceneFixture, host: ToolHost) {
const steps = [
{ command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' },
{ command: 'create_asset', args: { type: 'behavior', name: 'Run' }, as: 'run' },
{ command: 'add_variable', args: { asset: '$walk', key: 'speed', kind: 'number' } },
{ command: 'set_variable_kept', args: { asset: '$walk', key: 'speed', kept: true } },
{ command: 'add_node', args: { asset: '$walk', node: 'OnKeyHeld', x: 0, y: 0 }, as: 'key' },
{
command: 'add_node',
args: { asset: '$walk', node: 'Move', params: { dx: -200 }, x: 300, y: 0 },
as: 'move',
},
{ command: 'add_node', args: { asset: '$walk', node: 'Owner', x: 0, y: 400 }, as: 'owner' },
{ command: 'add_node', args: { asset: '$walk', node: 'Variables', x: 0, y: 700 }, as: 'vars' },
{ command: 'add_port', args: { asset: '$walk', node: '$vars', key: 'speed' } },
{
command: 'connect',
args: { asset: '$walk', from: { node: '$key', port: 'exec' }, to: { node: '$move', port: 'exec' } },
},
{
command: 'connect',
args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'dt' } },
},
{
command: 'connect',
args: {
asset: '$walk',
from: { node: '$owner', port: 'object' },
to: { node: '$move', port: 'object' },
},
},
{ command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } },
{ command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } },
{ command: 'add_comment', args: { asset: '$walk', nodes: ['$vars'], text: 'How fast' } },
];
const result = await runTool('run_commands', { steps, label: 'walk' }, host);
expect(result.isError, text(result)).toBeFalsy();
const walk = findAssetOf(fixture.project, assetNamed(fixture, 'Walk'), 'behavior')!;
const run = findAssetOf(fixture.project, assetNamed(fixture, 'Run'), 'behavior')!;
const id = (type: string) => walk.graph.nodes.find((node) => node.type === type)!.id;
return { walk, run, key: id('OnKeyHeld'), move: id('Move'), owner: id('Owner'), vars: id('Variables') };
}
/** The project as JSON, an empty list of groups or comments as none – undo leaves one, as for Add group. */
function plain(project: Project): string {
return JSON.stringify(project, (key, value: unknown) =>
(key === 'groups' || key === 'notes') && Array.isArray(value) && value.length === 0 ? undefined : value,
);
}
function assetNamed(fixture: SceneFixture, name: string): string {
return fixture.project.assets.find((asset) => asset.name === name)!.id;
}
describe('copy_nodes', () => {
it('copies nodes into another behavior with their links, groups, comments and variables', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, run, key, move, vars } = await walkAndRun(fixture, host);
const before = plain(fixture.project);
const steps = [
{ command: 'copy_nodes', args: { from: walk.id, nodes: [key, move, vars], to: run.id }, as: 'copy' },
{ command: 'add_node', args: { asset: run.id, node: 'Owner' }, as: 'owner' },
{
command: 'connect',
args: {
asset: run.id,
from: { node: '$owner', port: 'object' },
to: { node: `$copy.${move}`, port: 'object' },
},
},
];
const result = await runTool('run_commands', { steps, label: 'copy' }, host);
expect(result.isError, text(result)).toBeFalsy();
const graph = findGraphAsset(fixture.project, run.id)!.graph;
expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Variables', 'Owner']);
const [copiedKey, copiedMove, copiedVars] = graph.nodes;
expect(copiedMove!.params.dx).toBe(-200);
expect(copiedVars!.ports).toEqual([{ key: 'speed', kind: 'number' }]);
// The link from the Owner left behind stays behind; the one drawn after the copy is there.
expect(graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`)).toEqual([
`${copiedKey!.id}.exec>${copiedMove!.id}.exec`,
`${copiedKey!.id}.dt>${copiedMove!.id}.dt`,
`${graph.nodes[3]!.id}.object>${copiedMove!.id}.object`,
]);
expect((graph.groups ?? []).map((group) => group.text)).toEqual(['Walk']);
expect((graph.notes ?? []).map((note) => note.text)).toEqual(['How fast']);
const { step, ...made } = (result.structuredContent as { made: Record<string, unknown>[] }).made[0]!;
expect(step).toBe(1);
expect(made).toMatchObject({
id: copiedKey!.id,
[key]: copiedKey!.id,
[move]: copiedMove!.id,
[vars]: copiedVars!.id,
});
// The first node's copy, three copies by their originals, the group and the comment.
expect(Object.keys(made)).toHaveLength(6);
const runNow = findAssetOf(fixture.project, run.id, 'behavior')!;
expect(runNow.variables.map((v) => [v.key, v.kind])).toEqual([['speed', 'number']]);
expect(isKeptVariable(runNow, 'speed')).toBe(true);
// The originals are untouched.
expect(walk.graph.nodes).toHaveLength(4);
await runTool('undo', {}, host);
expect(plain(fixture.project)).toBe(before);
});
it('keeps the layout of a copy placed by hand, its top left where it is told', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, key, move } = await walkAndRun(fixture, host);
const [from, to] = [key, move].map((nodeId) => walk.graph.nodes.find((n) => n.id === nodeId)!);
const steps = [{ command: 'copy_nodes', args: { from: walk.id, nodes: [move, key], x: 2000, y: 900 } }];
const result = await runTool('run_commands', { steps, label: 'copy' }, host);
expect(result.isError, text(result)).toBeFalsy();
const [copiedKey, copiedMove] = walk.graph.nodes.slice(4);
const group = walk.graph.groups!.find((g) => g.text === 'Walk' && g.x >= 2000)!;
expect(Math.min(copiedKey!.x, group.x)).toBe(2000);
expect(Math.min(copiedKey!.y, group.y)).toBe(900);
expect(copiedMove!.x - copiedKey!.x).toBe(to!.x - from!.x);
expect(copiedMove!.y - copiedKey!.y).toBe(to!.y - from!.y);
// The comment is about the Variables node left behind, the second group about the Owner.
expect(walk.graph.notes).toHaveLength(1);
expect(walk.graph.groups).toHaveLength(3);
// The copy of the first node given answers to "$copy" alone.
const made = (result.structuredContent as { made: Record<string, string>[] }).made[0]!;
expect(made.id).toBe(copiedMove!.id);
});
it('refuses nodes the other graph cannot hold, and a variable of another kind there', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, run, key, vars } = await walkAndRun(fixture, host);
const made = await runTool(
'run_commands',
{
steps: [
{ command: 'create_asset', args: { type: 'shader', name: 'Glow' } },
{ command: 'add_variable', args: { asset: run.id, key: 'speed', kind: 'text' } },
],
label: 'more',
},
host,
);
expect(made.isError, text(made)).toBeFalsy();
const before = JSON.stringify(fixture.project);
const shader = assetNamed(fixture, 'Glow');
const intoShader = await runTool(
'run_commands',
{ steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [key], to: shader } }], label: 'x' },
host,
);
expect(intoShader.isError).toBe(true);
expect(text(intoShader)).toContain(`Glow cannot hold`);
expect(text(intoShader)).toContain(`(${key}) – leave them out`);
const clash = await runTool(
'run_commands',
{ steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [vars], to: run.id } }], label: 'x' },
host,
);
expect(clash.isError).toBe(true);
expect(text(clash)).toContain('"speed" is a number in Walk but a text in Run');
expect(JSON.stringify(fixture.project)).toBe(before);
});
});
+5 -3
View File
@@ -45,9 +45,11 @@ const INSTRUCTIONS = [
'over a long link. Check with lint_graph, the crossings too: when there are more than the links',
'need, arrange_graph orders the whole graph for the fewest. Put nodes that belong together into a',
'group with add_group, and say what they do with add_comment; a comment a node lies on',
'(coveredNotes) goes back above its nodes with fit_group. Music plays nothing without an',
'instrument, and an instrument sounds only the notes its Notes nodes take in, C4 to B4 in a new',
'one: get_asset lists under silent what of a pattern, song or composition is not heard.',
'(coveredNotes) goes back above its nodes with fit_group. A part already built elsewhere is',
'copied with copy_nodes, into the same graph or another, rather than built again. Music plays',
'nothing without an instrument, and an instrument sounds only the notes its Notes nodes take',
'in, C4 to B4 in a new one: get_asset lists under silent what of a pattern, song or composition',
'is not heard.',
'apply_preset gives an instrument that sounds every note in one step. Pictures, fonts, sounds',
'and videos are files: list_files lists those the server holds, add_file_asset makes an asset',
'of one. Before deleting or reworking an asset, get_asset says under usedBy what names it.',