@pi-unipi/unipi 2.2.7 → 2.4.0

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 (44) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/package.json +31 -25
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/btw/package.json +2 -2
  6. package/packages/cocoindex/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/compactor/src/info-screen.ts +4 -4
  9. package/packages/core/package.json +1 -1
  10. package/packages/core/utils.ts +37 -0
  11. package/packages/footer/package.json +2 -2
  12. package/packages/image/package.json +2 -2
  13. package/packages/image/src/generate.ts +34 -15
  14. package/packages/image/src/index.ts +8 -0
  15. package/packages/image/src/models.ts +28 -0
  16. package/packages/image/src/openai-images-api.ts +282 -0
  17. package/packages/image/src/register-providers.ts +220 -0
  18. package/packages/image/src/tools.ts +37 -6
  19. package/packages/image/src/tui/settings-dialog.ts +6 -1
  20. package/packages/info-screen/README.md +4 -4
  21. package/packages/info-screen/config.ts +28 -8
  22. package/packages/info-screen/core-groups.ts +5 -39
  23. package/packages/info-screen/index.ts +25 -10
  24. package/packages/info-screen/package.json +2 -2
  25. package/packages/info-screen/tui/info-overlay.ts +114 -38
  26. package/packages/info-screen/types.ts +20 -5
  27. package/packages/info-screen/usage-parser.ts +318 -128
  28. package/packages/input-shortcuts/package.json +2 -2
  29. package/packages/kanboard/package.json +2 -2
  30. package/packages/mcp/package.json +2 -2
  31. package/packages/memory/index.ts +60 -22
  32. package/packages/memory/mempalace.ts +66 -1
  33. package/packages/memory/package.json +3 -3
  34. package/packages/memory/storage.ts +75 -12
  35. package/packages/milestone/package.json +2 -2
  36. package/packages/notify/package.json +2 -2
  37. package/packages/ralph/package.json +3 -3
  38. package/packages/subagents/package.json +4 -4
  39. package/packages/unipi/bundled.js +37968 -0
  40. package/packages/updater/package.json +2 -2
  41. package/packages/utility/package.json +2 -2
  42. package/packages/utility/src/tools/env.ts +1 -22
  43. package/packages/web-api/package.json +2 -2
  44. package/packages/workflow/package.json +2 -2
@@ -5,11 +5,11 @@
5
5
  * under the "unipi.info" key.
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
- import { join } from "node:path";
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
10
  import { homedir } from "node:os";
11
- import type { InfoScreenSettings, GroupSettings } from "./types.js";
12
- import { DEFAULT_SETTINGS } from "./types.js";
11
+ import type { InfoScreenSettings, GroupSettings, BootMode } from "./types.js";
12
+ import { DEFAULT_SETTINGS, BOOT_MODES } from "./types.js";
13
13
 
14
14
  /** Settings path */
15
15
  const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
@@ -44,9 +44,11 @@ function readSettingsFile(): Record<string, unknown> {
44
44
  * Write the full settings file.
45
45
  */
46
46
  function writeSettingsFile(data: Record<string, unknown>): void {
47
- const dir = require("node:path").dirname(SETTINGS_PATH);
47
+ // These were require() calls in an ESM module, which throws under Node's
48
+ // module-format detection as soon as the directory is missing.
49
+ const dir = dirname(SETTINGS_PATH);
48
50
  if (!existsSync(dir)) {
49
- require("node:fs").mkdirSync(dir, { recursive: true });
51
+ mkdirSync(dir, { recursive: true });
50
52
  }
51
53
  writeFileSync(SETTINGS_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
52
54
  }
@@ -68,7 +70,7 @@ export function getInfoSettings(): InfoScreenSettings {
68
70
  const info = unipi.info as Record<string, unknown>;
69
71
 
70
72
  cachedSettings = {
71
- showOnBoot: typeof info.showOnBoot === "boolean" ? info.showOnBoot : DEFAULT_SETTINGS.showOnBoot,
73
+ bootMode: parseBootMode(info),
72
74
  bootTimeoutMs: typeof info.bootTimeoutMs === "number" ? info.bootTimeoutMs : DEFAULT_SETTINGS.bootTimeoutMs,
73
75
  groups: isRecord(info.groups) ? parseGroupSettings(info.groups) : {},
74
76
  };
@@ -76,6 +78,24 @@ export function getInfoSettings(): InfoScreenSettings {
76
78
  return cachedSettings;
77
79
  }
78
80
 
81
+ /**
82
+ * Resolve the boot mode, migrating the legacy `showOnBoot` boolean.
83
+ *
84
+ * `showOnBoot: false` maps to "off". `showOnBoot: true` maps to "on" rather
85
+ * than the new "auto-close" default, so an existing config keeps behaving the
86
+ * way its owner configured it.
87
+ */
88
+ function parseBootMode(info: Record<string, unknown>): BootMode {
89
+ const raw = info.bootMode;
90
+ if (typeof raw === "string" && (BOOT_MODES as string[]).includes(raw)) {
91
+ return raw as BootMode;
92
+ }
93
+ if (typeof info.showOnBoot === "boolean") {
94
+ return info.showOnBoot ? "on" : "off";
95
+ }
96
+ return DEFAULT_SETTINGS.bootMode;
97
+ }
98
+
79
99
  /**
80
100
  * Parse group settings from raw object.
81
101
  */
@@ -118,7 +138,7 @@ export function saveInfoSettings(settings: InfoScreenSettings): void {
118
138
  }
119
139
 
120
140
  (file[SETTINGS_KEY] as Record<string, unknown>).info = {
121
- showOnBoot: settings.showOnBoot,
141
+ bootMode: settings.bootMode,
122
142
  bootTimeoutMs: settings.bootTimeoutMs,
123
143
  groups: settings.groups,
124
144
  };
@@ -9,8 +9,9 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
9
9
  import { join, basename } from "node:path";
10
10
  import { homedir } from "node:os";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { getPiVersion } from "@pi-unipi/core";
12
13
  import { infoRegistry } from "./registry.js";
13
- import { parseUsageStats, formatTokens, formatCost } from "./usage-parser.js";
14
+ import { parseUsageStatsAsync, formatTokens, formatCost } from "./usage-parser.js";
14
15
  import type { InfoGroup } from "./types.js";
15
16
 
16
17
  /**
@@ -27,43 +28,6 @@ function getPackageVersion(packageDir: string): string {
27
28
  }
28
29
  }
29
30
 
30
- /**
31
- * Get pi version from its package.json.
32
- */
33
- function getPiVersion(): string {
34
- // Try to find pi's package.json in various locations
35
- const possiblePaths = [
36
- // Global npm install
37
- join(homedir(), ".local", "share", "mise", "installs", "node", "24.14.1", "lib", "node_modules", "@earendil-works", "pi-coding-agent", "package.json"),
38
- // Alternative locations
39
- join(homedir(), ".local", "share", "mise", "installs", "node", "lib", "node_modules", "@earendil-works", "pi-coding-agent", "package.json"),
40
- ];
41
-
42
- for (const pkgPath of possiblePaths) {
43
- try {
44
- if (existsSync(pkgPath)) {
45
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
46
- return pkg?.version ?? "unknown";
47
- }
48
- } catch {
49
- // Continue to next path
50
- }
51
- }
52
-
53
- // Fallback: try to run pi --version
54
- try {
55
- const { execSync } = require("node:child_process");
56
- const version = execSync("pi --version 2>/dev/null", { encoding: "utf-8" }).trim();
57
- // Extract version number from output like "pi v0.42.4"
58
- const match = version.match(/v([\d.]+)/);
59
- if (match) return match[1];
60
- } catch {
61
- // Ignore
62
- }
63
-
64
- return "unknown";
65
- }
66
-
67
31
  /**
68
32
  * Discover loaded extensions by scanning filesystem.
69
33
  */
@@ -433,7 +397,9 @@ export function registerCoreGroups(): void {
433
397
  ],
434
398
  },
435
399
  dataProvider: async () => {
436
- const stats = parseUsageStats();
400
+ // Async variant yields to the event loop between files, so a cold parse
401
+ // cannot block keystrokes or the initial paint.
402
+ const stats = await parseUsageStatsAsync();
437
403
 
438
404
  // Find top model for each period
439
405
  const findTopModel = (modelStats: Record<string, { tokens: number; cost: number; sessions: number }> | undefined) => {
@@ -34,6 +34,10 @@ export default function (pi: ExtensionAPI) {
34
34
  // Start load tracking
35
35
  startLoadTracking();
36
36
 
37
+ // Whether an info overlay is currently on screen. Module announcements only
38
+ // trigger a re-fetch when something is actually displaying the result.
39
+ let overlayVisible = false;
40
+
37
41
  // Debounced MODULE_READY handling — batch module announcements
38
42
  // to prevent layout shift from rapid per-module cache invalidation.
39
43
  let moduleReadyBatch: Array<{ name: string; version: string; tools?: string[]; loadTimeMs?: number }> = [];
@@ -60,14 +64,18 @@ export default function (pi: ExtensionAPI) {
60
64
  }
61
65
  }
62
66
 
63
- // Single cache invalidation for all modules
67
+ // Single cache invalidation for all modules.
68
+ //
69
+ // Only re-fetch while an overlay is actually on screen. Otherwise this
70
+ // ran every module announcement even with the dashboard disabled,
71
+ // doing work nobody would see.
64
72
  infoRegistry.invalidateCache("overview");
65
- infoRegistry.getGroupData("overview");
73
+ if (hasTools) infoRegistry.invalidateCache("tools");
66
74
 
67
- if (hasTools) {
68
- infoRegistry.invalidateCache("tools");
69
- infoRegistry.getGroupData("tools");
70
- }
75
+ if (!overlayVisible) return;
76
+
77
+ infoRegistry.getGroupData("overview");
78
+ if (hasTools) infoRegistry.getGroupData("tools");
71
79
  }
72
80
 
73
81
  // Listen for module announcements — track and trigger reactive updates
@@ -107,16 +115,22 @@ export default function (pi: ExtensionAPI) {
107
115
  * Cache-first: opens with whatever data is cached (even empty).
108
116
  * Background: each group fetches independently, overlay re-renders reactively.
109
117
  */
110
- function showOverlay(ctx: ExtensionContext): void {
118
+ function showOverlay(ctx: ExtensionContext, autoCloseMs?: number): void {
111
119
  ctx.ui.custom<void>(
112
120
  (tui, theme, _keybindings, done) => {
113
121
  const overlay = new InfoOverlay();
114
122
  overlay.setTheme(theme);
123
+ overlayVisible = true;
115
124
  overlay.onClose = () => {
125
+ overlayVisible = false;
116
126
  overlay.destroy();
117
127
  done();
118
128
  };
119
129
  overlay.requestRender = () => tui.requestRender();
130
+ // Boot dashboard dismisses itself; any keypress cancels the timer.
131
+ if (autoCloseMs && autoCloseMs > 0) {
132
+ overlay.startBootTimer(autoCloseMs);
133
+ }
120
134
  return {
121
135
  render: (w: number) => overlay.render(w),
122
136
  invalidate: () => overlay.invalidate(),
@@ -142,9 +156,10 @@ export default function (pi: ExtensionAPI) {
142
156
  pi.on("session_start", async (event, ctx) => {
143
157
  const settings = getInfoSettings();
144
158
 
145
- if (settings.showOnBoot && event.reason === "startup") {
146
- // Open immediately — cache-first, no waiting
147
- showOverlay(ctx);
159
+ if (settings.bootMode !== "off" && event.reason === "startup") {
160
+ // Open immediately — cache-first, no waiting. In "auto-close" mode the
161
+ // overlay dismisses itself after bootTimeoutMs; any keypress cancels it.
162
+ showOverlay(ctx, settings.bootMode === "auto-close" ? settings.bootTimeoutMs : 0);
148
163
  }
149
164
 
150
165
  finishLoadTracking();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.2.0"
36
+ "@pi-unipi/core": "2.4.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -15,6 +15,14 @@ import { getInfoSettings } from "../config.js";
15
15
  import type { InfoGroup, GroupData } from "../types.js";
16
16
  import { boxInnerWidth } from "@pi-unipi/core";
17
17
 
18
+ /**
19
+ * How long to wait before warming the non-visible tabs.
20
+ *
21
+ * Long enough that startup and the first paint finish first, short enough that
22
+ * a tab switch a second later is already warm.
23
+ */
24
+ const PREFETCH_DELAY_MS = 1500;
25
+
18
26
  /** Tab color palette */
19
27
  const TAB_FG: Array<"accent" | "success" | "warning" | "error"> = [
20
28
  "accent",
@@ -48,6 +56,10 @@ export class InfoOverlay implements Component {
48
56
  private lastGlobalUpdate = 0;
49
57
  private unsubscribers: Array<() => void> = [];
50
58
  private _destroyed = false;
59
+ /** Groups whose fetch has already been kicked off (lazy-load bookkeeping). */
60
+ private fetched = new Set<string>();
61
+ private prefetchTimer: ReturnType<typeof setTimeout> | null = null;
62
+ private bootTimer: ReturnType<typeof setTimeout> | null = null;
51
63
 
52
64
  onClose?: () => void;
53
65
  requestRender?: () => void;
@@ -89,31 +101,55 @@ export class InfoOverlay implements Component {
89
101
  })
90
102
  );
91
103
 
92
- // Start background fetch for all groups (non-blocking)
93
- this.fetchAllBackground();
104
+ // Fetch the visible tab now; everything else waits for idle.
105
+ this.fetchActiveGroup();
106
+ this.schedulePrefetch();
107
+ }
108
+
109
+ /** Fetch one group, tracking its loading state. Safe to call repeatedly. */
110
+ private fetchGroup(groupId: string): void {
111
+ if (this._destroyed) return;
112
+ if (this.fetched.has(groupId)) return;
113
+ this.fetched.add(groupId);
114
+ infoRegistry.getGroupData(groupId).then(() => {
115
+ this.groupLoading.set(groupId, false);
116
+ }).catch(() => {
117
+ this.groupLoading.set(groupId, false);
118
+ });
94
119
  }
95
120
 
96
121
  /**
97
- * Fetch all groups in background. Each resolves independently.
122
+ * Fetch the currently visible group.
98
123
  *
99
- * Each fetch is deferred to a macrotask (setTimeout 0) so the constructor
100
- * returns immediately. Without this, getGroupData() runs each group's
101
- * dataProvider synchronously up to its first `await` before yielding —
102
- * heavy providers (usage stats parse 1GB+ of session files, memory scans)
103
- * blocked the session_start handler for seconds.
124
+ * Deferred to a macrotask because an async dataProvider still runs
125
+ * synchronously up to its first `await`; calling it inline would put that
126
+ * work back on the constructor's caller (session_start).
104
127
  */
105
- private fetchAllBackground(): void {
106
- for (const group of this.groups) {
107
- // Defer each fetch to a macrotask so the overlay constructs instantly.
108
- setTimeout(() => {
109
- if (this._destroyed) return;
110
- infoRegistry.getGroupData(group.id).then(() => {
111
- this.groupLoading.set(group.id, false);
112
- }).catch(() => {
113
- this.groupLoading.set(group.id, false);
114
- });
115
- }, 0);
116
- }
128
+ private fetchActiveGroup(): void {
129
+ const group = this.groups[this.activeTabIndex];
130
+ if (!group) return;
131
+ setTimeout(() => this.fetchGroup(group.id), 0);
132
+ }
133
+
134
+ /**
135
+ * Warm the remaining tabs once the app is idle.
136
+ *
137
+ * Fetching every group up front cost seconds of startup for panels the user
138
+ * may never open. Prefetching after a delay keeps tab switches instant
139
+ * without paying for them before the first prompt is ready.
140
+ */
141
+ private schedulePrefetch(): void {
142
+ if (this.prefetchTimer) return;
143
+ this.prefetchTimer = setTimeout(() => {
144
+ this.prefetchTimer = null;
145
+ if (this._destroyed) return;
146
+ for (const group of this.groups) {
147
+ if (group.id === this.groups[this.activeTabIndex]?.id) continue;
148
+ this.fetchGroup(group.id);
149
+ }
150
+ }, PREFETCH_DELAY_MS);
151
+ // Never hold the process open just to warm a panel.
152
+ this.prefetchTimer.unref?.();
117
153
  }
118
154
 
119
155
  /**
@@ -127,29 +163,30 @@ export class InfoOverlay implements Component {
127
163
  this.applyOrder();
128
164
  }
129
165
 
130
- // Ensure every group has real (non-empty) data.
131
- // Registration notifications inject `{}` to trigger re-sync; we must
132
- // not treat that as fetched data or the stats render as "—".
166
+ // Adopt any data the registry already has. Registration notifications
167
+ // inject `{}` to trigger a re-sync; that is not real data and must not be
168
+ // treated as fetched, or the stats render as "—".
169
+ //
170
+ // Groups are NOT fetched here: doing so would defeat lazy loading, since
171
+ // syncGroups() runs on every render. Fetches are driven by tab visibility
172
+ // (fetchActiveGroup) and the idle prefetch instead.
133
173
  for (const group of this.groups) {
134
174
  const existing = this.groupData.get(group.id);
135
175
  const hasRealData = existing && Object.keys(existing).length > 0;
136
- if (!hasRealData) {
137
- const cached = infoRegistry.getCachedData(group.id);
138
- if (cached && Object.keys(cached).length > 0) {
139
- this.groupData.set(group.id, cached);
140
- } else if (!this.groupLoading.get(group.id)) {
141
- this.groupLoading.set(group.id, true);
142
- infoRegistry.getGroupData(group.id).then((data) => {
143
- this.groupData.set(group.id, data);
144
- this.groupLoading.set(group.id, false);
145
- this.lastGlobalUpdate = Date.now();
146
- this.requestRender?.();
147
- }).catch(() => {
148
- this.groupLoading.set(group.id, false);
149
- });
150
- }
176
+ if (hasRealData) continue;
177
+
178
+ const cached = infoRegistry.getCachedData(group.id);
179
+ if (cached && Object.keys(cached).length > 0) {
180
+ this.groupData.set(group.id, cached);
151
181
  }
152
182
  }
183
+
184
+ // A late-arriving group may now be the visible one, and the prefetch pass
185
+ // may have already run — make sure the active tab still gets its data.
186
+ if (hadNewGroups) {
187
+ this.fetchActiveGroup();
188
+ this.schedulePrefetch();
189
+ }
153
190
  }
154
191
 
155
192
  private applyOrder(): void {
@@ -169,23 +206,59 @@ export class InfoOverlay implements Component {
169
206
  */
170
207
  destroy(): void {
171
208
  this._destroyed = true;
209
+ this.cancelBootTimer();
210
+ if (this.prefetchTimer) {
211
+ clearTimeout(this.prefetchTimer);
212
+ this.prefetchTimer = null;
213
+ }
172
214
  for (const unsub of this.unsubscribers) {
173
215
  unsub();
174
216
  }
175
217
  this.unsubscribers = [];
176
218
  }
177
219
 
220
+ /** Stop the boot auto-close timer, if one is pending. */
221
+ private cancelBootTimer(): void {
222
+ if (this.bootTimer) {
223
+ clearTimeout(this.bootTimer);
224
+ this.bootTimer = null;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Auto-close the overlay after `ms`, unless the user interacts first.
230
+ *
231
+ * Used when the overlay is shown on boot: the dashboard is informational, so
232
+ * it should get out of the way on its own rather than requiring a keypress.
233
+ */
234
+ startBootTimer(ms: number): void {
235
+ this.cancelBootTimer();
236
+ if (!Number.isFinite(ms) || ms <= 0) return;
237
+ this.bootTimer = setTimeout(() => {
238
+ this.bootTimer = null;
239
+ if (this._destroyed) return;
240
+ this.destroy();
241
+ this.onClose?.();
242
+ }, ms);
243
+ this.bootTimer.unref?.();
244
+ }
245
+
178
246
  invalidate(): void {
179
247
  this.syncGroups();
180
248
  }
181
249
 
182
250
  handleInput(data: string): void {
251
+ // Any keypress means the user is driving; stop the boot auto-close.
252
+ this.cancelBootTimer();
253
+
183
254
  if (data === "\x1b[C" || data === "l") {
184
255
  this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
185
256
  this.scrollOffset = 0;
257
+ this.fetchActiveGroup();
186
258
  } else if (data === "\x1b[D" || data === "h") {
187
259
  this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
188
260
  this.scrollOffset = 0;
261
+ this.fetchActiveGroup();
189
262
  } else if (data === "\x1b[B" || data === "j") {
190
263
  this.scrollOffset++;
191
264
  } else if (data === "\x1b[A" || data === "k") {
@@ -211,12 +284,15 @@ export class InfoOverlay implements Component {
211
284
  if (!group) return;
212
285
  this.groupLoading.set(group.id, true);
213
286
  this.requestRender?.();
287
+ // Explicit refresh must bypass the lazy-load guard.
288
+ this.fetched.add(group.id);
214
289
  infoRegistry.refreshGroup(group.id);
215
290
  }
216
291
 
217
292
  private refreshAll(): void {
218
293
  for (const group of this.groups) {
219
294
  this.groupLoading.set(group.id, true);
295
+ this.fetched.add(group.id);
220
296
  }
221
297
  this.requestRender?.();
222
298
  infoRegistry.refreshAll();
@@ -47,11 +47,26 @@ export interface InfoGroup {
47
47
  dataProvider: () => Promise<GroupData>;
48
48
  }
49
49
 
50
+ /** How the dashboard behaves at startup. */
51
+ export type BootMode = "on" | "off" | "auto-close";
52
+
53
+ /** All valid boot modes, in the order the settings UI cycles them. */
54
+ export const BOOT_MODES: BootMode[] = ["on", "auto-close", "off"];
55
+
50
56
  /** Settings for info-screen in settings.json */
51
57
  export interface InfoScreenSettings {
52
- /** Whether to show dashboard on boot */
53
- showOnBoot: boolean;
54
- /** Timeout in ms waiting for modules at boot */
58
+ /**
59
+ * What the dashboard does at startup:
60
+ * - "on": show it and leave it up until dismissed (q/Esc)
61
+ * - "off": do not show it at all (no data is fetched)
62
+ * - "auto-close": show it, then close after `bootTimeoutMs`
63
+ */
64
+ bootMode: BootMode;
65
+ /**
66
+ * How long the boot dashboard stays up in "auto-close" mode, in ms.
67
+ * Any keypress cancels the timer and keeps the overlay open.
68
+ * Does not apply to the overlay opened via /unipi:info.
69
+ */
55
70
  bootTimeoutMs: number;
56
71
  /** Per-group settings */
57
72
  groups: Record<string, GroupSettings>;
@@ -69,8 +84,8 @@ export interface GroupSettings {
69
84
 
70
85
  /** Default settings */
71
86
  export const DEFAULT_SETTINGS: InfoScreenSettings = {
72
- showOnBoot: true,
73
- bootTimeoutMs: 8000,
87
+ bootMode: "auto-close",
88
+ bootTimeoutMs: 2000,
74
89
  groups: {},
75
90
  groupOrder: [],
76
91
  };