@datalayer/agent-runtimes 1.3.73 → 1.3.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/lib/chat/ChatFloating.d.ts +25 -2
  2. package/lib/chat/ChatFloating.js +80 -12
  3. package/lib/chat/assistant/AssistantContextMenu.d.ts +67 -0
  4. package/lib/chat/assistant/AssistantContextMenu.js +68 -0
  5. package/lib/chat/assistant/AssistantStage.d.ts +123 -5
  6. package/lib/chat/assistant/AssistantStage.js +245 -7
  7. package/lib/chat/assistant/BalloonParts.d.ts +15 -1
  8. package/lib/chat/assistant/BalloonParts.js +26 -9
  9. package/lib/chat/assistant/BalloonVisual.d.ts +65 -0
  10. package/lib/chat/assistant/BalloonVisual.js +58 -0
  11. package/lib/chat/assistant/SpeechBalloon.d.ts +68 -2
  12. package/lib/chat/assistant/SpeechBalloon.js +113 -7
  13. package/lib/chat/assistant/state.d.ts +19 -0
  14. package/lib/chat/assistant/state.js +44 -17
  15. package/lib/chat/base/ChatBase.js +51 -1
  16. package/lib/chat/indicators/TypingDots.d.ts +13 -0
  17. package/lib/chat/indicators/TypingDots.js +21 -0
  18. package/lib/chat/messages/ChatMessageList.js +1 -15
  19. package/lib/components/inspector/AgentInspector.d.ts +59 -0
  20. package/lib/components/inspector/AgentInspector.js +220 -0
  21. package/lib/components/inspector/AgentInspectorDialog.d.ts +19 -0
  22. package/lib/components/inspector/AgentInspectorDialog.js +9 -0
  23. package/lib/components/inspector/JsonTree.d.ts +24 -0
  24. package/lib/components/inspector/JsonTree.js +89 -0
  25. package/lib/components/inspector/a2aInspect.d.ts +56 -0
  26. package/lib/components/inspector/a2aInspect.js +478 -0
  27. package/lib/components/inspector/agentInspector.d.ts +179 -0
  28. package/lib/components/inspector/agentInspector.js +267 -0
  29. package/lib/components/inspector/chatInspect.d.ts +38 -0
  30. package/lib/components/inspector/chatInspect.js +65 -0
  31. package/lib/components/inspector/classify.d.ts +19 -0
  32. package/lib/components/inspector/classify.js +42 -0
  33. package/lib/components/inspector/index.d.ts +14 -0
  34. package/lib/components/inspector/index.js +18 -0
  35. package/lib/components/teams/A2ATeamGraph.d.ts +54 -3
  36. package/lib/components/teams/A2ATeamGraph.js +37 -11
  37. package/lib/components/teams/NotebookPreview.d.ts +11 -2
  38. package/lib/components/teams/NotebookPreview.js +6 -3
  39. package/lib/components/teams/NotebookPreviewView.js +9 -1
  40. package/lib/components/teams/TeamNotebook.d.ts +10 -1
  41. package/lib/components/teams/TeamNotebook.js +15 -3
  42. package/lib/components/teams/jupyterThemeVariables.d.ts +9 -0
  43. package/lib/components/teams/jupyterThemeVariables.js +441 -0
  44. package/lib/components/teams/teamConnections.d.ts +9 -1
  45. package/lib/components/teams/teamConnections.js +20 -0
  46. package/lib/components/teams/useA2ATeam.d.ts +17 -4
  47. package/lib/components/teams/useA2ATeam.js +87 -23
  48. package/lib/examples/AgentA2ATeamExample.d.ts +16 -2
  49. package/lib/examples/AgentA2ATeamExample.js +65 -13
  50. package/lib/examples/{ChatAssistantExample.d.ts → AssistantExample.d.ts} +2 -2
  51. package/lib/examples/{ChatAssistantExample.js → AssistantExample.js} +69 -7
  52. package/lib/examples/{ChatAssistantGalleryExample.d.ts → AssistantGalleryExample.d.ts} +2 -2
  53. package/lib/examples/{ChatAssistantGalleryExample.js → AssistantGalleryExample.js} +45 -13
  54. package/lib/examples/example-selector.js +4 -2
  55. package/lib/examples/exampleGroups.d.ts +9 -0
  56. package/lib/examples/exampleGroups.js +56 -0
  57. package/lib/examples/index.d.ts +1 -1
  58. package/lib/examples/index.js +1 -1
  59. package/lib/examples/main.js +3 -43
  60. package/lib/examples/utils/AssistantGalleryGrid.d.ts +6 -1
  61. package/lib/examples/utils/AssistantGalleryGrid.js +16 -3
  62. package/lib/examples/utils/assistantGallery.d.ts +51 -2
  63. package/lib/examples/utils/assistantGallery.js +32 -7
  64. package/lib/loop/apps/AppRenderer.js +1 -1
  65. package/lib/loop/core/index.d.ts +17 -0
  66. package/lib/loop/core/index.js +1 -0
  67. package/lib/loop/plugins/assistant/index.d.ts +1 -1
  68. package/lib/loop/plugins/assistant/index.js +8 -3
  69. package/lib/runtimes/browser/a2aPeer.d.ts +4 -1
  70. package/lib/types/chat.d.ts +7 -0
  71. package/package.json +1 -1
  72. package/scripts/codegen/generate_jupyter_theme_variables.mjs +57 -0
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { AssistantStageProps } from '../../chat/assistant/AssistantStage';
10
10
  import { type BalloonToolLine, type ToolLinePhase } from '../../chat/assistant/toolLine';
11
- import { type AssistantState, type BalloonApproval } from '../../chat/assistant/state';
11
+ import { type AssistantState, type BalloonApproval, type BalloonHistoryMessage } from '../../chat/assistant/state';
12
12
  /** What the gallery poses a character in: a state, or stepped aside. */
13
13
  export type GalleryPose = AssistantState | 'aside';
14
14
  /** Every state, in the order an assistant lives them, then stepped aside. */
@@ -28,6 +28,12 @@ export declare function sampleSaying(index: number): {
28
28
  text: string;
29
29
  more: boolean;
30
30
  };
31
+ /**
32
+ * The conversation so far, after `count` sample exchanges (wrapping), as the
33
+ * `history` balloon lists it: every message, the person's and the agent's,
34
+ * read by `balloonHistoryOf` as the floating assistant reads the chat.
35
+ */
36
+ export declare function sampleHistory(count: number): BalloonHistoryMessage[];
31
37
  /** The tool the gallery calls (T-23): Odoo's, as Accounting calls it. */
32
38
  export declare const SAMPLE_TOOL = "list_invoices";
33
39
  /** The sample tool's line, at a phase: "Using list_invoices…", "Done: …", "… failed". */
@@ -69,6 +75,45 @@ export declare const SAMPLE_NOTEBOOK: {
69
75
  }[];
70
76
  })[];
71
77
  };
78
+ /** The sample notebook as a peer gives one: an A2A artifact. */
79
+ export declare const SAMPLE_NOTEBOOK_ARTIFACT: {
80
+ mediaType: string;
81
+ name: string;
82
+ filename: string;
83
+ data: {
84
+ nbformat: number;
85
+ nbformat_minor: number;
86
+ metadata: {
87
+ kernelspec: {
88
+ name: string;
89
+ display_name: string;
90
+ language: string;
91
+ };
92
+ language_info: {
93
+ name: string;
94
+ };
95
+ };
96
+ cells: ({
97
+ id: string;
98
+ cell_type: string;
99
+ metadata: {};
100
+ source: string;
101
+ execution_count?: undefined;
102
+ outputs?: undefined;
103
+ } | {
104
+ id: string;
105
+ cell_type: string;
106
+ metadata: {};
107
+ execution_count: number;
108
+ source: string;
109
+ outputs: {
110
+ output_type: string;
111
+ name: string;
112
+ text: string;
113
+ }[];
114
+ })[];
115
+ };
116
+ };
72
117
  /** What the gallery says with the notebook it was given. */
73
118
  export declare const SAMPLE_NOTEBOOK_SAYING = "Here is the notebook Accounting gave: the open invoices.";
74
119
  /** The approval the gallery waits on (T-23): answered in the balloon. */
@@ -77,7 +122,7 @@ export declare function sampleApproval(onApprove: () => void, onDeny: () => void
77
122
  * The balloon for a pose: waiting holds the approval, paused says so, and
78
123
  * any other pose says the sample's words when asked to (`saying`).
79
124
  */
80
- export declare function balloonForPose(pose: GalleryPose, { saying, approval, tool, attachment, }: {
125
+ export declare function balloonForPose(pose: GalleryPose, { saying, approval, tool, attachment, history, visual, }: {
81
126
  saying?: {
82
127
  text: string;
83
128
  more: boolean;
@@ -87,4 +132,8 @@ export declare function balloonForPose(pose: GalleryPose, { saying, approval, to
87
132
  tool?: BalloonToolLine;
88
133
  /** What goes with the words in a `current` balloon: a notebook. */
89
134
  attachment?: NonNullable<AssistantStageProps['balloon']>['attachment'];
135
+ /** The conversation so far, listed in a `history` balloon. */
136
+ history?: readonly BalloonHistoryMessage[];
137
+ /** The large visual the attachment is the compact form of: expandable. */
138
+ visual?: NonNullable<AssistantStageProps['balloon']>['visual'];
90
139
  }): AssistantStageProps['balloon'];
@@ -3,7 +3,7 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { toolLineText, } from '../../chat/assistant/toolLine';
6
- import { ASSISTANT_WORDS, latestSaying, } from '../../chat/assistant/state';
6
+ import { ASSISTANT_WORDS, balloonHistoryOf, latestSaying, } from '../../chat/assistant/state';
7
7
  /** Every state, in the order an assistant lives them, then stepped aside. */
8
8
  export const GALLERY_POSES = [
9
9
  'idle',
@@ -74,6 +74,22 @@ export function sampleSaying(index) {
74
74
  }
75
75
  return { text: saying.text, more: saying.more };
76
76
  }
77
+ /**
78
+ * The conversation so far, after `count` sample exchanges (wrapping), as the
79
+ * `history` balloon lists it: every message, the person's and the agent's,
80
+ * read by `balloonHistoryOf` as the floating assistant reads the chat.
81
+ */
82
+ export function sampleHistory(count) {
83
+ const items = [];
84
+ for (let turn = 0; turn < count; turn += 1) {
85
+ const conversation = SAMPLE_CONVERSATIONS[turn % SAMPLE_CONVERSATIONS.length];
86
+ for (const item of conversation) {
87
+ const message = item;
88
+ items.push({ ...message, id: `${turn}-${message.id}` });
89
+ }
90
+ }
91
+ return balloonHistoryOf(items);
92
+ }
77
93
  /** The tool the gallery calls (T-23): Odoo's, as Accounting calls it. */
78
94
  export const SAMPLE_TOOL = 'list_invoices';
79
95
  /** The sample tool's line, at a phase: "Using list_invoices…", "Done: …", "… failed". */
@@ -112,6 +128,13 @@ export const SAMPLE_NOTEBOOK = {
112
128
  },
113
129
  ],
114
130
  };
131
+ /** The sample notebook as a peer gives one: an A2A artifact. */
132
+ export const SAMPLE_NOTEBOOK_ARTIFACT = {
133
+ mediaType: 'application/x-ipynb+json',
134
+ name: 'Open invoices',
135
+ filename: 'open-invoices.ipynb',
136
+ data: SAMPLE_NOTEBOOK,
137
+ };
115
138
  /** What the gallery says with the notebook it was given. */
116
139
  export const SAMPLE_NOTEBOOK_SAYING = 'Here is the notebook Accounting gave: the open invoices.';
117
140
  /** The approval the gallery waits on (T-23): answered in the balloon. */
@@ -129,9 +152,12 @@ export function sampleApproval(onApprove, onDeny) {
129
152
  * The balloon for a pose: waiting holds the approval, paused says so, and
130
153
  * any other pose says the sample's words when asked to (`saying`).
131
154
  */
132
- export function balloonForPose(pose, { saying, approval, tool, attachment, }) {
155
+ export function balloonForPose(pose, { saying, approval, tool, attachment, history, visual, }) {
156
+ const listed = history && history.length > 0 ? { history } : {};
157
+ // What goes with the words stays while a tool runs, in either display.
158
+ const given = attachment ? { attachment, ...(visual ? { visual } : {}) } : {};
133
159
  if (pose === 'waiting') {
134
- return { text: ASSISTANT_WORDS.approval, approval };
160
+ return { text: ASSISTANT_WORDS.approval, approval, ...listed };
135
161
  }
136
162
  if (pose === 'paused') {
137
163
  return { text: ASSISTANT_WORDS.paused };
@@ -144,10 +170,9 @@ export function balloonForPose(pose, { saying, approval, tool, attachment, }) {
144
170
  text: toolLineText(tool),
145
171
  tool,
146
172
  busy: tool.phase === 'running',
173
+ ...listed,
174
+ ...given,
147
175
  };
148
176
  }
149
- if (saying && attachment) {
150
- return { ...saying, attachment };
151
- }
152
- return saying;
177
+ return saying ? { ...saying, ...listed, ...given } : undefined;
153
178
  }
@@ -209,7 +209,7 @@ export function appDatalayerCreatePayload(app, instance) {
209
209
  transport: 'ag-ui',
210
210
  agent_spec_id: agentIdOf(app),
211
211
  app_spec: dumpAppspec(app),
212
- // Code Mode calls every tool through `execute_code`, which an
212
+ // Codemode calls every tool through `execute_code`, which an
213
213
  // application without a shell is refused: its tools are called
214
214
  // one by one instead, each decided by its rules.
215
215
  enable_codemode: Boolean(app.permissions?.computer?.shell),
@@ -531,6 +531,23 @@ export type AssistantCharacterContribution = {
531
531
  character: AssistantCharacter | AssistantCharacterData;
532
532
  };
533
533
  export declare const LoopAssistantCharacter: import("@datalayer/reactor").ContributionPoint<AssistantCharacterContribution>;
534
+ /**
535
+ * An entry of the floating assistant's own menu (right-click, Shift+F10, a
536
+ * long press): a plugin's, beside what the assistant offers itself. Its
537
+ * `group` is the heading it is listed under; `checked` makes it one of a
538
+ * choice.
539
+ */
540
+ export type AssistantMenuContribution = {
541
+ /** Stable id, for the registry and the graph. */
542
+ id: string;
543
+ label: string;
544
+ description?: string;
545
+ onSelect: () => void;
546
+ checked?: boolean;
547
+ disabled?: boolean;
548
+ group?: string;
549
+ };
550
+ export declare const LoopAssistantMenu: import("@datalayer/reactor").ContributionPoint<AssistantMenuContribution>;
534
551
  export { LoopCanvasBlock, type CanvasBlockContribution } from './canvasBlocks';
535
552
  /**
536
553
  * The per-example chat extras a host feeds the loop's conversation live.
@@ -142,6 +142,7 @@ export const LoopChatHeader = defineContributionPoint('loop.chat.header');
142
142
  export const LoopAgentBlueprint = defineContributionPoint('loop.agent.blueprint');
143
143
  export const LoopChatSuggestion = defineContributionPoint('loop.chat.suggestion');
144
144
  export const LoopAssistantCharacter = defineContributionPoint('loop.assistant.character');
145
+ export const LoopAssistantMenu = defineContributionPoint('loop.assistant.menu');
145
146
  // A block the Canvas may place (LOOP C-12), in a module of its own so that
146
147
  // the palette is read without the rest of the contracts.
147
148
  export { LoopCanvasBlock } from './canvasBlocks';
@@ -68,6 +68,6 @@ export declare function assistantStateOfTurn(turn: ChatTurnSnapshot, presence: P
68
68
  arriving?: boolean;
69
69
  leaving?: boolean;
70
70
  }): AssistantState;
71
- export declare function LoopAssistant({ app, person, appId, decisions, balloon: display, }: LoopAssistantConfig): JSX.Element | null;
71
+ export declare function LoopAssistant({ app, person, appId, decisions, balloon: displayGiven, }: LoopAssistantConfig): JSX.Element | null;
72
72
  export declare const LoopAssistantPlugin: import("@datalayer/reactor").ReactorPlugin<LoopAssistantConfig, unknown, unknown>;
73
73
  export default LoopAssistantPlugin;
@@ -13,7 +13,7 @@ import { ASSISTANT_WORDS, assistantStateOf, keepAway, keptAway, latestSaying, }
13
13
  import { useApproveToolRequest, useRejectToolRequest, useToolApprovalsQuery, } from '../../../hooks/useToolApprovals';
14
14
  import { approvalsOfApp, ruleOfApproval } from '../app-rules/AppRulesCard';
15
15
  import { useViewportDrag } from '../../../chat/useViewportDrag';
16
- import { LoopAssistantCharacter, LoopChatLayout, LoopChatTurn, LoopSlots, } from '../../core';
16
+ import { LoopAssistantCharacter, LoopAssistantMenu, LoopChatLayout, LoopChatTurn, LoopSlots, } from '../../core';
17
17
  import { assistantCharacterFor } from '../assistant-characters';
18
18
  export const LOOP_ASSISTANT_PLUGIN_NAME = '@datalayer/loop-plugin-assistant';
19
19
  /** The character's size, in pixels, as the chat's own assistant. */
@@ -69,9 +69,14 @@ function focusComposer() {
69
69
  const composer = document.querySelector('[data-chat-composer] textarea, [data-chat-composer] [contenteditable="true"]');
70
70
  composer?.focus();
71
71
  }
72
- export function LoopAssistant({ app, person, appId, decisions, balloon: display = 'history', }) {
72
+ export function LoopAssistant({ app, person, appId, decisions, balloon: displayGiven = 'history', }) {
73
+ // The balloon's display, as its menu chose it; the Appspec's until then.
74
+ const [chosenDisplay, setChosenDisplay] = useState();
75
+ const display = chosenDisplay ?? displayGiven;
73
76
  const contributed = useContributions(LoopAssistantCharacter).map(entry => entry.value);
74
77
  const chosen = assistantCharacterFor(contributed, { app, person });
78
+ // What the enabled plugins add to its menu.
79
+ const menuContributions = useContributions(LoopAssistantMenu).map(entry => entry.value);
75
80
  const turnEntries = useContributions(LoopChatTurn);
76
81
  const turn = useSignalValue(turnEntries[0]?.value.turn ?? NO_TURN);
77
82
  const presence = useSignalValue(turnEntries[0]?.value.presence ?? NO_PRESENCE);
@@ -199,7 +204,7 @@ export function LoopAssistant({ app, person, appId, decisions, balloon: display
199
204
  };
200
205
  return (_jsxs(_Fragment, { children: [appId && signedIn ? (_jsx(WaitingApprovals, { appId: appId, onWaiting: setApproval })) : null, _jsx(AssistantStage, { character: chosen.character, state: state, size: SIZE, place: drag.position
201
206
  ? { left: `${drag.position.left}px`, top: `${drag.position.top}px` }
202
- : { right: '24px', bottom: '24px' }, stageRef: stageRef, onDragStart: drag.onHandlePointerDown, open: open, onToggle: onToggle, balloon: balloon, balloonDisplay: display, insist: insist, onDismiss: onDismiss, decide: decide })] }));
207
+ : { right: '24px', bottom: '24px' }, stageRef: stageRef, onDragStart: drag.onHandlePointerDown, open: open, onToggle: onToggle, balloon: balloon, balloonDisplay: display, insist: insist, onDismiss: onDismiss, decide: decide, onResetPosition: drag.position ? drag.reset : undefined, onBalloonDisplayChange: setChosenDisplay, about: appId ? { name: appId, spec: appId } : undefined, contextMenu: menuContributions })] }));
203
208
  }
204
209
  export const LoopAssistantPlugin = definePlugin({
205
210
  name: LOOP_ASSISTANT_PLUGIN_NAME,
@@ -43,7 +43,10 @@ export type ConnectA2APeerOptions = {
43
43
  url: string;
44
44
  /** A key granted to the peer's route, sent as a bearer token. */
45
45
  key?: string;
46
- /** The fetch to use, for tests and hosts that wrap the network. */
46
+ /**
47
+ * The fetch to use, for tests and hosts that wrap the network — such as
48
+ * the Agent Inspector's `inspectA2AFetch`, which records the traffic.
49
+ */
47
50
  fetch?: typeof globalThis.fetch;
48
51
  };
49
52
  /**
@@ -8,6 +8,7 @@ import type { ComponentType, ReactNode } from 'react';
8
8
  import type { ICollaborationProvider } from '@datalayer/jupyter-react';
9
9
  import type { ChatMessage, MessageHandler } from './messages';
10
10
  import type { Protocol, ProtocolConfig } from './protocol';
11
+ import type { AgentInspectorSink } from '../components/inspector/agentInspector';
11
12
  import type { McpServerSelection } from './inference';
12
13
  import type { MCPServerTool } from './mcp';
13
14
  import type { AgentRuntimeConfig } from './config';
@@ -1238,6 +1239,12 @@ export interface ChatBaseProps {
1238
1239
  * ```
1239
1240
  */
1240
1241
  onToolCallComplete?: (context: ToolCallCompleteContext) => void;
1242
+ /**
1243
+ * The Agent Inspector's sink: the agent's turns (its model, its tokens)
1244
+ * and its tool calls, from start to end, are recorded there. Without it,
1245
+ * the sink of the nearest `AgentInspectorProvider`, if any.
1246
+ */
1247
+ inspector?: AgentInspectorSink | null;
1241
1248
  /**
1242
1249
  * Whether to render the top-of-chat tool approval banner (and its review
1243
1250
  * dialog) when `pendingApprovals` is non-empty. The banner/dialog render
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.3.73",
3
+ "version": "1.3.75",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ /**
7
+ * Writes src/components/teams/jupyterThemeVariables.ts: JupyterLab's light
8
+ * and dark theme variables (`--jp-*`), read from the theme extensions'
9
+ * variables.css, so a notebook preview can carry its own mode on its own
10
+ * subtree — JupyterLab writes them for the whole page.
11
+ *
12
+ * node scripts/codegen/generate_jupyter_theme_variables.mjs
13
+ */
14
+ import { readFileSync, writeFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ function variables(pkg) {
20
+ const css = readFileSync(require.resolve(`${pkg}/style/variables.css`), 'utf8')
21
+ .replace(/\/\*[\s\S]*?\*\//g, '');
22
+ const out = {};
23
+ for (const block of css.matchAll(/:root\s*\{([\s\S]*?)\}/g)) {
24
+ for (const declaration of block[1].split(';')) {
25
+ const match = /^\s*(--jp-[\w-]+)\s*:\s*([\s\S]+?)\s*$/.exec(declaration);
26
+ if (match) {
27
+ out[match[1]] = match[2].replace(/\s+/g, ' ');
28
+ }
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ const light = variables('@jupyterlab/theme-light-extension');
35
+ const dark = variables('@jupyterlab/theme-dark-extension');
36
+ const target = new URL('../../src/components/teams/jupyterThemeVariables.ts', import.meta.url);
37
+ writeFileSync(
38
+ target,
39
+ `/*
40
+ * Copyright (c) 2025-2026 Datalayer, Inc.
41
+ * Distributed under the terms of the Modified BSD License.
42
+ */
43
+
44
+ /**
45
+ * JupyterLab's theme variables, light and dark, as its theme extensions state
46
+ * them. Generated by scripts/codegen/generate_jupyter_theme_variables.mjs: do
47
+ * not edit.
48
+ *
49
+ * @module components/teams/jupyterThemeVariables
50
+ */
51
+
52
+ export const JUPYTER_LIGHT_VARIABLES: Readonly<Record<string, string>> = ${JSON.stringify(light, null, 2)};
53
+
54
+ export const JUPYTER_DARK_VARIABLES: Readonly<Record<string, string>> = ${JSON.stringify(dark, null, 2)};
55
+ `,
56
+ );
57
+ console.log(`light ${Object.keys(light).length}, dark ${Object.keys(dark).length}`);