aimeat 3.3.0 → 3.3.2

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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/.env.example +10 -1
  3. package/dist/build-stamp.json +4 -4
  4. package/dist/locales/en.json +9263 -9225
  5. package/dist/locales/es.json +9255 -9217
  6. package/dist/locales/fi.json +9263 -9225
  7. package/dist/public/css/views/admin.css +935 -860
  8. package/dist/public/css/views/chat.css +393 -388
  9. package/dist/public/js/services/admin.js +12 -0
  10. package/dist/public/robots.node.txt +4 -0
  11. package/dist/public/views/admin/config-tab.js +333 -185
  12. package/dist/public/views/admin/usage-tab.js +373 -307
  13. package/dist/public/views/chat/parts.js +25 -6
  14. package/dist/public/views/profile/landing-page.cards.js +732 -700
  15. package/dist/public/views/profile/landing-page.js +6 -2
  16. package/dist/scripts/audit-mcp-schemas.js +22 -6
  17. package/dist/scripts/audit-mcp-schemas.js.map +1 -1
  18. package/dist/scripts/check-ai-disclosure.d.ts +1 -1
  19. package/dist/scripts/check-ai-disclosure.d.ts.map +1 -1
  20. package/dist/scripts/check-ai-disclosure.js +19 -3
  21. package/dist/scripts/check-ai-disclosure.js.map +1 -1
  22. package/dist/scripts/check-config-coverage.d.ts +2 -0
  23. package/dist/scripts/check-config-coverage.d.ts.map +1 -0
  24. package/dist/scripts/check-config-coverage.js +129 -0
  25. package/dist/scripts/check-config-coverage.js.map +1 -0
  26. package/dist/src/auth/ai-gate.d.ts +25 -0
  27. package/dist/src/auth/ai-gate.d.ts.map +1 -0
  28. package/dist/src/auth/ai-gate.js +21 -0
  29. package/dist/src/auth/ai-gate.js.map +1 -0
  30. package/dist/src/cli/connect/mcp/tools/apps.d.ts.map +1 -1
  31. package/dist/src/cli/connect/mcp/tools/apps.js +102 -20
  32. package/dist/src/cli/connect/mcp/tools/apps.js.map +1 -1
  33. package/dist/src/cli/connect/tool-call-defs-agent.d.ts.map +1 -1
  34. package/dist/src/cli/connect/tool-call-defs-agent.js +38 -9
  35. package/dist/src/cli/connect/tool-call-defs-agent.js.map +1 -1
  36. package/dist/src/cli/connect/tool-call-defs-apps.d.ts.map +1 -1
  37. package/dist/src/cli/connect/tool-call-defs-apps.js +199 -103
  38. package/dist/src/cli/connect/tool-call-defs-apps.js.map +1 -1
  39. package/dist/src/cli/connect/tool-call-defs-core.d.ts.map +1 -1
  40. package/dist/src/cli/connect/tool-call-defs-core.js +149 -26
  41. package/dist/src/cli/connect/tool-call-defs-core.js.map +1 -1
  42. package/dist/src/cli/connect/tool-call-defs-organism.d.ts.map +1 -1
  43. package/dist/src/cli/connect/tool-call-defs-organism.js +20 -3
  44. package/dist/src/cli/connect/tool-call-defs-organism.js.map +1 -1
  45. package/dist/src/cli/connect/tool-call-defs-packages.d.ts +23 -0
  46. package/dist/src/cli/connect/tool-call-defs-packages.d.ts.map +1 -0
  47. package/dist/src/cli/connect/tool-call-defs-packages.js +45 -0
  48. package/dist/src/cli/connect/tool-call-defs-packages.js.map +1 -0
  49. package/dist/src/cli/connect/tool-call-defs-workflows.d.ts +14 -0
  50. package/dist/src/cli/connect/tool-call-defs-workflows.d.ts.map +1 -0
  51. package/dist/src/cli/connect/tool-call-defs-workflows.js +75 -0
  52. package/dist/src/cli/connect/tool-call-defs-workflows.js.map +1 -0
  53. package/dist/src/cli/connect/tool-call.d.ts.map +1 -1
  54. package/dist/src/cli/connect/tool-call.js +54 -1
  55. package/dist/src/cli/connect/tool-call.js.map +1 -1
  56. package/dist/src/config-types-ai.d.ts +13 -0
  57. package/dist/src/config-types-ai.d.ts.map +1 -1
  58. package/dist/src/config.d.ts.map +1 -1
  59. package/dist/src/config.js +2 -0
  60. package/dist/src/config.js.map +1 -1
  61. package/dist/src/generated/api-types.d.ts +196 -0
  62. package/dist/src/generated/api-types.d.ts.map +1 -1
  63. package/dist/src/mcp/annotations.d.ts.map +1 -1
  64. package/dist/src/mcp/annotations.js +8 -0
  65. package/dist/src/mcp/annotations.js.map +1 -1
  66. package/dist/src/mcp/catalog/definitions/organisms-workspaces-apps.d.ts.map +1 -1
  67. package/dist/src/mcp/catalog/definitions/organisms-workspaces-apps.js +25 -8
  68. package/dist/src/mcp/catalog/definitions/organisms-workspaces-apps.js.map +1 -1
  69. package/dist/src/mcp/catalog/definitions/packages.d.ts +17 -0
  70. package/dist/src/mcp/catalog/definitions/packages.d.ts.map +1 -0
  71. package/dist/src/mcp/catalog/definitions/packages.js +46 -0
  72. package/dist/src/mcp/catalog/definitions/packages.js.map +1 -0
  73. package/dist/src/mcp/catalog/definitions.d.ts.map +1 -1
  74. package/dist/src/mcp/catalog/definitions.js +2 -0
  75. package/dist/src/mcp/catalog/definitions.js.map +1 -1
  76. package/dist/src/mcp/catalog/scopes.d.ts.map +1 -1
  77. package/dist/src/mcp/catalog/scopes.js +2 -0
  78. package/dist/src/mcp/catalog/scopes.js.map +1 -1
  79. package/dist/src/mcp/catalog/surfaces.d.ts.map +1 -1
  80. package/dist/src/mcp/catalog/surfaces.js +2 -0
  81. package/dist/src/mcp/catalog/surfaces.js.map +1 -1
  82. package/dist/src/routes/admin-usage.d.ts.map +1 -1
  83. package/dist/src/routes/admin-usage.js +57 -0
  84. package/dist/src/routes/admin-usage.js.map +1 -1
  85. package/dist/src/routes/ai.d.ts.map +1 -1
  86. package/dist/src/routes/ai.js +4 -15
  87. package/dist/src/routes/ai.js.map +1 -1
  88. package/dist/src/routes/bootstrap.d.ts +2 -1
  89. package/dist/src/routes/bootstrap.d.ts.map +1 -1
  90. package/dist/src/routes/bootstrap.js +7 -18
  91. package/dist/src/routes/bootstrap.js.map +1 -1
  92. package/dist/src/routes/chat.d.ts +3 -0
  93. package/dist/src/routes/chat.d.ts.map +1 -1
  94. package/dist/src/routes/chat.js +20 -0
  95. package/dist/src/routes/chat.js.map +1 -1
  96. package/dist/src/routes/llm-proxy.d.ts +34 -0
  97. package/dist/src/routes/llm-proxy.d.ts.map +1 -0
  98. package/dist/src/routes/llm-proxy.js +258 -0
  99. package/dist/src/routes/llm-proxy.js.map +1 -0
  100. package/dist/src/routes/sitemaps.d.ts +22 -0
  101. package/dist/src/routes/sitemaps.d.ts.map +1 -0
  102. package/dist/src/routes/sitemaps.js +67 -0
  103. package/dist/src/routes/sitemaps.js.map +1 -0
  104. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  105. package/dist/src/server-bootstrap/routes-loader.js +2 -0
  106. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  107. package/dist/src/services/ai-completion.d.ts +76 -0
  108. package/dist/src/services/ai-completion.d.ts.map +1 -1
  109. package/dist/src/services/ai-completion.js +104 -68
  110. package/dist/src/services/ai-completion.js.map +1 -1
  111. package/dist/src/services/chat-session.d.ts +3 -0
  112. package/dist/src/services/chat-session.d.ts.map +1 -1
  113. package/dist/src/services/chat-session.js +4 -0
  114. package/dist/src/services/chat-session.js.map +1 -1
  115. package/dist/src/services/config-schema.d.ts +4 -0
  116. package/dist/src/services/config-schema.d.ts.map +1 -1
  117. package/dist/src/services/config-schema.js +33 -0
  118. package/dist/src/services/config-schema.js.map +1 -1
  119. package/dist/src/services/goose-acp.d.ts.map +1 -1
  120. package/dist/src/services/goose-acp.js +11 -0
  121. package/dist/src/services/goose-acp.js.map +1 -1
  122. package/dist/src/services/handbooks/appdev.d.ts +1 -1
  123. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  124. package/dist/src/services/handbooks/appdev.js +7 -1
  125. package/dist/src/services/handbooks/appdev.js.map +1 -1
  126. package/dist/src/services/openrouter.d.ts +23 -0
  127. package/dist/src/services/openrouter.d.ts.map +1 -1
  128. package/dist/src/services/openrouter.js +32 -1
  129. package/dist/src/services/openrouter.js.map +1 -1
  130. package/dist/src/services/usage-summary.d.ts +15 -0
  131. package/dist/src/services/usage-summary.d.ts.map +1 -1
  132. package/dist/src/services/usage-summary.js +15 -0
  133. package/dist/src/services/usage-summary.js.map +1 -1
  134. package/dist/src/storage/providers/postgres-kysely/methods/wallet.d.ts.map +1 -1
  135. package/dist/src/storage/providers/postgres-kysely/methods/wallet.js +23 -4
  136. package/dist/src/storage/providers/postgres-kysely/methods/wallet.js.map +1 -1
  137. package/dist/src/storage/providers/sqlite/methods/work.d.ts +5 -0
  138. package/dist/src/storage/providers/sqlite/methods/work.d.ts.map +1 -1
  139. package/dist/src/storage/providers/sqlite/methods/work.js +34 -22
  140. package/dist/src/storage/providers/sqlite/methods/work.js.map +1 -1
  141. package/package.json +4 -1
@@ -1,700 +1,732 @@
1
- /**
2
- * @file public/views/profile/landing-page.cards.js
3
- * @description Profile home dashboard cards, home sub-components, and the sidebar group model. Extracted from landing-page.js to satisfy max-file-lines.
4
- * @version-history
5
- * 2026-07-19 — Re-add the orphaned OpenRouter Settings item (route id 'generator') to the Build & Share
6
- * group — it lost its menu entry when the Generator feature was removed, leaving the AI-provider key
7
- * config reachable only by deep link.
8
- * 2026-07-19 — AppDev tab (KB UI): learned-pitfall + template management surface, start-prompt copy, model badge
9
- * v1.2.0 — 2026-07-16 — Drop the per-item emoji icons from SIDEBAR_GROUPS and the Inbox nav
10
- * button — the sidebar renders label-only now.
11
- * v1.1.0 — 2026-07-16 — Contacts tab in the Activity sidebar group.
12
- * v1.0.0 — 2026-07-13 — Extracted from views/profile/landing-page.js (max-file-lines)
13
- */
14
- import { h } from "preact";
15
- import { OpenItemsList } from '/components/OpenItemsList.js';
16
- import { useState, useEffect, useCallback, useRef } from "preact/hooks";
17
- import htm from "htm";
18
- const html = htm.bind(h);
19
- import { t, getLocale } from "/js/i18n.js";
20
- import { escHtml, fmtMoney } from "/js/utils.js";
21
- import { getNodeUrl } from "/js/services/auth.js";
22
- import { listAgents } from "/js/services/agents.js";
23
- import { listAllSchedules } from "/js/services/schedules.js";
24
- import * as orgService from "/js/services/organisms.js";
25
- import { onLiveUpdate } from "/lib/live-updates.js";
26
- import { listRecents } from "/js/recents.js";
27
- import { listInbox } from "/js/services/messages.js";
28
- import { apiGet } from "/js/api.js";
29
- import { checkHelloMcp } from "/js/services/hello-mcp.js";
30
- import { InstructionsDialog } from "/views/profile/ai-setup-guide.js";
31
- import { UsageChart, colorForIndex } from "/components/UsageChart.js";
32
- import { minidenticon } from "/lib/minidenticons.min.js";
33
- import { PresencePill } from "./landing-page.modals.js";
34
- import { swallowed } from '/js/swallowed.js';
35
- import {
36
- relTime, fmtClock, openProfileTab, gotoWorkspace, gotoOrganism, gotoOrganismsList,
37
- fmtBytes, fmtUsd, fmtCompact,
38
- } from "./landing-page.helpers.js";
39
-
40
- /* ───── Home dashboard cards ───── */
41
-
42
- /* "Waiting for you" — everything that needs the user's decision, aggregated across organisms:
43
- * pending publish approvals (per workspace), pending join requests (orgs they manage), and
44
- * incoming organism invitations. Renders nothing when there is nothing to do. */
45
- export function WaitingForYou() {
46
- const [items, setItems] = useState(null);
47
- // ONE aggregated request replaces the old per-org fan-out (listOrganisms → per-org listApprovals +
48
- // listJoinRequests + listWorkspaces + a final listMyInvitations). The server returns the same flat
49
- // {kind:'review'|'join'|'invite', …} items this widget renders.
50
- const load = useCallback(async () => { setItems(await orgService.getWaiting()); }, []);
51
- useEffect(() => { load(); }, [load]);
52
- const liveRef = useRef(load); liveRef.current = load;
53
- // Only re-run the per-organism approvals/join-requests fan-out when ORGANISMS actually
54
- // change — not on every unrelated event (agent churn, memory, etc.), which on an account in
55
- // many organisms turned into a hundreds-of-requests storm.
56
- useEffect(() => onLiveUpdate(['organisms'], () => liveRef.current()), []);
57
-
58
- if (!items || items.length === 0) return null;
59
- return html`
60
- <div class="pf-waiting">
61
- <div class="pf-waiting-title">${'📨 '}${t('profile.landing.waitingTitle') || 'Waiting for you'}</div>
62
- ${items.map((it, i) => html`
63
- <div class="pf-waiting-row" key=${i}>
64
- <span class="pf-waiting-text">
65
- ${it.kind === 'review' ? html`
66
- <b>${(t('profile.landing.draftsToReview') || '{n} drafts to review').replace('{n}', String(it.n))}</b>
67
- <span class="pf-waiting-where"> · ${escHtml(it.orgName)} / ${escHtml(it.wsName)}</span>` : null}
68
- ${it.kind === 'join' ? html`
69
- <b>${it.n === 1 ? (t('profile.landing.joinReqOne') || '1 join request') : (t('profile.landing.joinReqMany') || '{n} join requests').replace('{n}', String(it.n))}</b>
70
- <span class="pf-waiting-where"> · ${escHtml(it.orgName)}</span>` : null}
71
- ${it.kind === 'invite' ? html`
72
- <b>${t('profile.landing.inviteWaiting') || 'You’re invited'}</b>
73
- <span class="pf-waiting-where"> · ${escHtml(it.orgName)}</span>` : null}
74
- </span>
75
- ${it.kind === 'review' ? html`
76
- <button class="btn-outline btn-sm" onClick=${() => (it.wsId ? gotoWorkspace(it.orgId, it.wsId, 'review') : gotoOrganism(it.orgId))}>${t('profile.landing.reviewBtn') || 'Review'}</button>` : null}
77
- ${it.kind === 'join' ? html`
78
- <button class="btn-outline btn-sm" onClick=${() => gotoOrganism(it.orgId, 'members')}>${t('profile.landing.viewBtn') || 'View'}</button>` : null}
79
- ${it.kind === 'invite' ? html`
80
- <button class="btn-outline btn-sm" onClick=${() => gotoOrganismsList()}>${t('profile.landing.viewBtn') || 'View'}</button>` : null}
81
- </div>
82
- `)}
83
- </div>
84
- `;
85
- }
86
-
87
- /* "Continue" — the last opened things across types (workspace / app / organism), with real
88
- * display names. Backed by /js/recents.js (device-local). Renders nothing when empty. */
89
- const RECENT_ICONS = { workspace: '🗂', app: '▦', organism: '🏢', board: '📋' };
90
- export function ContinueCard() {
91
- const [items] = useState(() => listRecents(5));
92
- if (!items.length) return null;
93
- const openItem = (it) => {
94
- if (it.type === 'workspace' && it.data?.orgId) gotoWorkspace(it.data.orgId, it.data.wsId);
95
- else if (it.type === 'organism' && it.data?.orgId) gotoOrganism(it.data.orgId);
96
- else if (it.type === 'app' && it.data?.filename) window.open(`/v1/apps/${encodeURIComponent(it.data.owner)}/${encodeURIComponent(it.data.filename)}?mode=inline`, '_blank');
97
- };
98
- return html`
99
- <div class="pf-home-card">
100
- <div class="pf-home-card-title">${t('profile.landing.continueTitle') || 'Continue'}</div>
101
- ${items.map(it => html`
102
- <button class="pf-home-row" key=${it.type + it.id} onClick=${() => openItem(it)}>
103
- <span class="pf-home-row-ico">${RECENT_ICONS[it.type] || '•'}</span>
104
- <span class="pf-home-row-label">${escHtml(it.label)}</span>
105
- <span class="pf-home-row-meta">${relTime(it.at)}</span>
106
- </button>
107
- `)}
108
- </div>
109
- `;
110
- }
111
-
112
- /* "Agents" — who has been active today, who is idle, and the next scheduled run. */
113
- export function AgentsCard({ owner, initialAgents }) {
114
- // The Home /v1/owner/home composite already resolves the owner's agent list (initialAgents) — seed from
115
- // it and skip the mount /v1/agents fetch (dropping that duplicate). The next-scheduled-job row still
116
- // needs the schedules call (not in the composite); live-update refreshes both.
117
- // Excluded from the HOME card (both still live in the Agents tab):
118
- // session-* — per-session scratch identities, never user-facing.
119
- // app — the built-in agent registration creates for in-app calls. Counting it made a
120
- // brand-new account read "1 agent active today" when the person had connected
121
- // nothing, which is a claim about work that never happened (UX-remake v3, P5).
122
- const seedAgents = (list) => (Array.isArray(list) ? list : [])
123
- .filter(a => !String(a.name || '').startsWith('session-') && String(a.name || '') !== 'app')
124
- .slice().sort((a, b) => String(b.last_seen || '').localeCompare(String(a.last_seen || '')));
125
- const [agents, setAgents] = useState(initialAgents ? seedAgents(initialAgents) : null);
126
- const [nextJob, setNextJob] = useState(null);
127
- const loadAgents = useCallback(async () => {
128
- try { setAgents(seedAgents(await listAgents(owner))); } catch (err) { swallowed('landing-page.cards', err); setAgents([]); }
129
- }, [owner]);
130
- const loadSchedules = useCallback(async () => {
131
- try {
132
- const r = await listAllSchedules();
133
- const all = [...(r?.data?.managed || []), ...(r?.data?.extensions || []), ...(r?.data?.agentInternal || [])]
134
- .filter(s => s.enabled !== false && s.nextRunAt && new Date(s.nextRunAt).getTime() > Date.now())
135
- .sort((a, b) => String(a.nextRunAt).localeCompare(String(b.nextRunAt)));
136
- setNextJob(all[0] || null);
137
- } catch (err) { swallowed('landing-page.cards: seedAgents', err); }
138
- }, []);
139
- const load = useCallback(async () => { await Promise.all([loadAgents(), loadSchedules()]); }, [loadAgents, loadSchedules]);
140
- useEffect(() => { if (initialAgents) setAgents(seedAgents(initialAgents)); }, [initialAgents]);
141
- useEffect(() => { loadSchedules(); }, [loadSchedules]); // schedules aren't in the composite
142
- const liveRef = useRef(load); liveRef.current = load;
143
- useEffect(() => onLiveUpdate(['agents', 'agent-tasks', 'schedules'], () => liveRef.current()), []);
144
-
145
- if (!agents || (agents.length === 0 && !nextJob)) return null;
146
- const todayStr = new Date().toDateString();
147
- const isToday = (s) => s && new Date(s).toDateString() === todayStr;
148
- const activeToday = agents.filter(a => isToday(a.last_seen)).length;
149
- return html`
150
- <div class="pf-home-card">
151
- <button class="pf-home-card-title pf-home-card-link" onClick=${() => openProfileTab('agents')}>
152
- ${t('profile.landing.agentsTitle') || 'Agents'}
153
- ${activeToday > 0 ? html`<span class="pf-home-card-note"> · ${(t('profile.landing.activeTodayCount') || '{n} active today').replace('{n}', String(activeToday))}</span>` : null}
154
- </button>
155
- ${agents.slice(0, 3).map(a => html`
156
- <button class="pf-home-row" key=${a.gaii || a.name}
157
- onClick=${() => {
158
- // eslint-disable-next-line aimeat/no-silent-catch -- a browser refusing sessionStorage here IS the answer: the tab still opens, it just does not preselect this agent
159
- try { sessionStorage.setItem('aimeat.agents.open', a.name); } catch { /* noop */ }
160
- openProfileTab('agents');
161
- }}>
162
- <span class="pf-home-row-ico">${'🤖'}</span>
163
- <span class="pf-home-row-label">${escHtml(a.display_name || a.name)}</span>
164
- <span class="pf-home-row-meta ${isToday(a.last_seen) ? 'pf-ok' : ''}">
165
- ${a.last_seen ? (isToday(a.last_seen) ? (t('profile.landing.agentActiveToday') || 'active today') : relTime(a.last_seen)) : '—'}
166
- </span>
167
- </button>
168
- `)}
169
- ${nextJob ? html`
170
- <button class="pf-home-row" key="nextjob" onClick=${() => openProfileTab('scheduler')}>
171
- <span class="pf-home-row-ico">⏰</span>
172
- <span class="pf-home-row-label">${escHtml(nextJob.name || nextJob.id || '')}</span>
173
- <span class="pf-home-row-meta">${(t('profile.landing.nextRunAt') || 'next run {time}').replace('{time}', fmtClock(nextJob.nextRunAt))}</span>
174
- </button>` : null}
175
- </div>
176
- `;
177
- }
178
-
179
- /* "Usage" — quota usage bars (memory / storage / micro-memory) + resource counts. Backed by the
180
- * cached GET /v1/owner/usage endpoint (60s server-side TTL), so it's cheap to refetch on each
181
- * live-update. Surfaces the same kind of quota bar the Memory tab shows, for the whole account. */
182
-
183
- export function UsageCard({ switchTab, initialUsage }) {
184
- // The Home landing is the only place this renders, and its /v1/owner/home composite already carries the
185
- // usage summary — so we seed from initialUsage and never mount-fetch /v1/owner/usage (dropping that
186
- // duplicate). We still refresh on live-update for freshness after a real change.
187
- const [u, setU] = useState(initialUsage ?? null);
188
- const load = useCallback(async () => {
189
- try { const r = await apiGet('/v1/owner/usage'); setU(r?.data || null); } catch (err) { swallowed('landing-page.cards', err); setU(null); }
190
- }, []);
191
- useEffect(() => { if (initialUsage) setU(initialUsage); }, [initialUsage]);
192
- const liveRef = useRef(load); liveRef.current = load;
193
- useEffect(() => onLiveUpdate(['memory', 'files', 'agents', 'apps', 'organisms'], () => liveRef.current()), []);
194
-
195
- if (!u) return null;
196
-
197
- const bar = (label, q, usedText) => {
198
- const pct = q.percent >= 0 ? Math.max(0, Math.min(100, q.percent)) : 0;
199
- return html`
200
- <div class="pf-usage-row">
201
- <div class="pf-usage-head">
202
- <span class="pf-usage-label">${label}</span>
203
- <span class="text-meta-sm">${usedText}</span>
204
- </div>
205
- <div class="pf-usage-bar"><div class="pf-usage-fill ${pct >= 90 ? 'pf-usage-fill--danger' : ''}" style=${`width:${pct}%`}></div></div>
206
- </div>`;
207
- };
208
-
209
- const chip = (label, value, tab) => html`
210
- <button class="pf-usage-chip" onClick=${tab ? () => switchTab(tab) : undefined} disabled=${!tab}>
211
- <span class="pf-usage-chip-val">${value}</span>
212
- <span class="pf-usage-chip-label">${label}</span>
213
- </button>`;
214
-
215
- const c = u.counts;
216
- return html`
217
- <div class="pf-home-card pf-usage-card">
218
- <div class="pf-home-card-title">${t('profile.landing.usageTitle') || 'Usage & quotas'}</div>
219
- ${bar(t('profile.landing.usageMemory') || 'Memory', u.memory,
220
- `${u.memory.used_keys}/${u.memory.max_keys} ${t('profile.memory.keysWord') || 'keys'} · ${fmtBytes(u.memory.used_bytes)} / ${fmtBytes(u.memory.max_bytes)}`)}
221
- ${bar(t('profile.landing.usageStorage') || 'Files', u.storage,
222
- `${u.storage.used_files} ${t('profile.landing.usageFilesWord') || 'files'} · ${fmtBytes(u.storage.used_bytes)} / ${fmtBytes(u.storage.max_bytes)}`)}
223
- ${bar(t('profile.landing.usageMicro') || 'Micro-memory', u.micro_memory,
224
- `${u.micro_memory.used_sets}/${u.micro_memory.max_sets} ${t('profile.landing.usageSetsWord') || 'sets'} · ${fmtBytes(u.micro_memory.used_bytes)} / ${fmtBytes(u.micro_memory.max_bytes)}`)}
225
- <div class="pf-usage-chips">
226
- ${chip(t('profile.landing.usageAgents') || 'Agents', c.agents, 'agents')}
227
- ${chip(t('profile.landing.usageOrganisms') || 'Organisms', c.organisms, 'organisms')}
228
- ${chip(t('profile.landing.usageApps') || 'Apps', `${c.apps.used}/${c.apps.max}`, 'apps')}
229
- ${chip(t('profile.landing.usageEcoApps') || 'Connected apps', c.ecosystem_apps, 'ecosystem')}
230
- ${chip(t('profile.landing.usageExtensions') || 'Extensions', `${c.extensions.used}/${c.extensions.max}`, 'extensions')}
231
- ${chip(t('profile.landing.usageCortexes') || 'Cortexes', c.cortexes, 'extensions')}
232
- ${chip(t('profile.landing.usageServices') || 'Services', `${c.services.used}/${c.services.max}`, 'offers')}
233
- </div>
234
- </div>
235
- `;
236
- }
237
-
238
- /* "AI spend" — token/cost analytics for the owner's AI apps over the last 24h / 7d / 30d,
239
- * plus a per-app stacked bar of the last 30 days. Backed by GET /v1/ai/usage/history (reads the
240
- * retained per-day ai-usage records). Hidden until there is any spend, so it never shows an empty
241
- * chart to users who don't run AI apps. */
242
-
243
- /* "Commerce" — the owner's marketplace status: purchases (checkout sessions), sales received,
244
- * and morsels moved, from /v1/commerce. Hidden when commerce is disabled on the node (503). */
245
- export function CommerceCard() {
246
- const [stats, setStats] = useState(null);
247
- const load = useCallback(async () => {
248
- try {
249
- const [s, o] = await Promise.all([
250
- apiGet('/v1/commerce/checkout-sessions?limit=100'),
251
- apiGet('/v1/commerce/orders?limit=100'),
252
- ]);
253
- const sessions = s?.data?.sessions ?? [];
254
- const orders = o?.data?.orders ?? [];
255
- const completed = sessions.filter((x) => x.status === 'completed');
256
- // Currencies never mix: morsels and each money code (minor units) total separately.
257
- const sumBy = (list, pick) => {
258
- const by = {};
259
- for (const x of list) {
260
- const cur = x.currency || 'morsel';
261
- by[cur] = (by[cur] || 0) + (pick(x) || 0);
262
- }
263
- return by;
264
- };
265
- setStats({
266
- bought: completed.length,
267
- open: sessions.filter((x) => x.status === 'open').length,
268
- spentBy: sumBy(completed, (x) => x.receipt && x.receipt.charged),
269
- sold: orders.length,
270
- earnedBy: sumBy(orders, (x) => x.receipt && x.receipt.earned),
271
- });
272
- } catch (err) { swallowed('landing-page.cards', err); setStats(null); /* commerce disabled or unreachable render nothing */ }
273
- }, []);
274
- useEffect(() => { load(); }, [load]);
275
- const liveRef = useRef(load); liveRef.current = load;
276
- useEffect(() => onLiveUpdate(['agent-tasks', 'memory'], () => liveRef.current()), []);
277
-
278
- if (!stats) return null;
279
- const chip = (label, main, sub) => html`
280
- <div class="pf-ai-win">
281
- <span class="pf-ai-win-label">${label}</span>
282
- <span class="pf-ai-win-cost">${main}</span>
283
- <span class="pf-ai-win-sub">${sub}</span>
284
- </div>`;
285
- const morsels = t('profile.landing.commerceMorsels') || 'morsels';
286
- // "10 morsels · 15.00 EUR" — money amounts are micro-units, never summed with morsels.
287
- const fmtTotals = (by) => Object.entries(by)
288
- .map(([cur, n]) => cur === 'morsel' ? `${n} ${morsels}` : fmtMoney(n, cur))
289
- .join(' · ') || `0 ${morsels}`;
290
- return html`
291
- <div class="pf-home-card pf-commerce-card">
292
- <div class="pf-home-card-title">${t('profile.landing.commerceTitle') || 'Commerce'}</div>
293
- <div class="pf-ai-windows">
294
- ${chip(t('profile.landing.commerceBought') || 'Purchases', String(stats.bought), fmtTotals(stats.spentBy))}
295
- ${chip(t('profile.landing.commerceSold') || 'Sales', String(stats.sold), fmtTotals(stats.earnedBy))}
296
- ${chip(t('profile.landing.commerceOpen') || 'Open carts', String(stats.open), t('profile.landing.commerceOpenSub') || 'checkout sessions')}
297
- </div>
298
- </div>`;
299
- }
300
-
301
- export function AiSpendCard() {
302
- const [data, setData] = useState(null);
303
- const load = useCallback(async () => {
304
- try { const r = await apiGet('/v1/ai/usage/history?days=30'); setData(r?.data || null); }
305
- catch (err) { swallowed('landing-page.cards', err); setData(null); }
306
- }, []);
307
- useEffect(() => { load(); }, [load]);
308
- const liveRef = useRef(load); liveRef.current = load;
309
- useEffect(() => onLiveUpdate(['apps', 'memory'], () => liveRef.current()), []);
310
-
311
- if (!data || !Array.isArray(data.days) || data.days.length === 0) return null;
312
-
313
- const { days, apps = [], windows } = data;
314
- const labels = days.map((d) => d.date.slice(5));
315
- const datasets = apps.map((app, i) => ({
316
- label: app,
317
- data: days.map((d) => (d.per_app && d.per_app[app] ? d.per_app[app].cost_usd : 0) || 0),
318
- backgroundColor: colorForIndex(i),
319
- }));
320
-
321
- const d30 = (windows && windows.d30) || { cost_usd: 0, per_app: {} };
322
- const totalCost = d30.cost_usd || 0;
323
- const topApps = Object.entries(d30.per_app || {})
324
- .sort((a, b) => b[1].cost_usd - a[1].cost_usd).slice(0, 5);
325
-
326
- const win = (label, w) => html`
327
- <div class="pf-ai-win">
328
- <span class="pf-ai-win-label">${label}</span>
329
- <span class="pf-ai-win-cost">${fmtUsd(w && w.cost_usd)}</span>
330
- <span class="pf-ai-win-sub">${fmtCompact(w && w.tokens)} ${t('profile.landing.aiTokensWord') || 'tokens'}</span>
331
- </div>`;
332
-
333
- return html`
334
- <div class="pf-home-card pf-ai-card">
335
- <div class="pf-home-card-title">${t('profile.landing.aiSpendTitle') || 'AI apps spend'}</div>
336
- <div class="pf-ai-windows">
337
- ${win(t('profile.landing.aiWin24h') || 'Today', windows && windows.d1)}
338
- ${win(t('profile.landing.aiWin7d') || '7 days', windows && windows.d7)}
339
- ${win(t('profile.landing.aiWin30d') || '30 days', windows && windows.d30)}
340
- </div>
341
- ${datasets.length > 0 && html`
342
- <div class="pf-ai-chart">
343
- <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${180}
344
- legend=${false} yFormat=${fmtUsd} />
345
- </div>`}
346
- ${topApps.length > 0 && html`
347
- <div class="pf-ai-apps">
348
- <div class="pf-ai-apps-head">${t('profile.landing.aiWhereMoney') || 'Where it went (30d)'}</div>
349
- ${topApps.map(([app, m]) => {
350
- const pct = totalCost > 0 ? Math.round((m.cost_usd / totalCost) * 100) : 0;
351
- return html`
352
- <div class="pf-ai-app-row" key=${app}>
353
- <span class="pf-ai-app-dot" style=${`background:${colorForIndex(apps.indexOf(app))}`}></span>
354
- <span class="pf-ai-app-name">${app}</span>
355
- <span class="pf-ai-app-cost">${fmtUsd(m.cost_usd)}</span>
356
- <span class="pf-ai-app-pct">${pct}%</span>
357
- </div>`;
358
- })}
359
- </div>`}
360
- </div>`;
361
- }
362
-
363
- /* "Agent LLM usage" — the owner's own agent LLM ledger (priced per-call usage of the owner's
364
- * agents), grouped by model. A DIFFERENT system from AiSpendCard (which shows AI-apps spend) — the
365
- * two are never summed. Backed by the owner-scoped GET /v1/ledger/usage?group_by=model. Hidden
366
- * until there is any agent LLM spend, so users who don't run agents never see an empty card. */
367
- export function AgentLedgerCard() {
368
- const [data, setData] = useState(null);
369
- const load = useCallback(async () => {
370
- try { const r = await apiGet('/v1/ledger/usage?group_by=model'); setData(r?.data || null); }
371
- catch (err) { swallowed('landing-page.cards', err); setData(null); }
372
- }, []);
373
- useEffect(() => { load(); }, [load]);
374
- const liveRef = useRef(load); liveRef.current = load;
375
- useEffect(() => onLiveUpdate(['agents', 'agent-tasks'], () => liveRef.current()), []);
376
-
377
- if (!data || !data.totals || data.totals.calls === 0) return null;
378
-
379
- const { totals, groups = [] } = data;
380
- const topModels = [...groups].sort((a, b) => (b.cost_usd || 0) - (a.cost_usd || 0)).slice(0, 5);
381
-
382
- const tile = (label, value) => html`
383
- <div class="pf-ai-win">
384
- <span class="pf-ai-win-label">${label}</span>
385
- <span class="pf-ai-win-cost">${value}</span>
386
- </div>`;
387
-
388
- return html`
389
- <div class="pf-home-card pf-ai-card">
390
- <div class="pf-home-card-title">${t('profile.landing.agentLedgerTitle') || 'Agent LLM usage'}</div>
391
- <div class="pf-ai-windows">
392
- ${tile(t('profile.landing.agentLedgerCost') || 'Cost', fmtUsd(totals.cost_usd))}
393
- ${tile(t('profile.landing.agentLedgerTokens') || 'Tokens', fmtCompact(totals.total_tokens))}
394
- ${tile(t('profile.landing.agentLedgerCalls') || 'LLM calls', fmtCompact(totals.calls))}
395
- </div>
396
- ${topModels.length > 0 && html`
397
- <div class="pf-ai-apps">
398
- <div class="pf-ai-apps-head">${t('profile.landing.agentLedgerByModel') || 'By model'}</div>
399
- ${topModels.map((g) => html`
400
- <div class="pf-ai-app-row" key=${g.key}>
401
- <span class="pf-ai-app-name">${g.key}</span>
402
- <span class="pf-ai-app-meta">${(g.providers && g.providers.length) ? g.providers.join(', ') + ' · ' : ''}${fmtCompact(g.total_tokens)} ${t('profile.landing.aiTokensWord') || 'tokens'} · ${fmtCompact(g.calls)}</span>
403
- <span class="pf-ai-app-cost">${fmtUsd(g.cost_usd)}</span>
404
- </div>`)}
405
- </div>`}
406
- </div>`;
407
- }
408
-
409
- /* ───── Sub-components ───── */
410
-
411
- /* The MCP-connected mark. DERIVED from the Hello MCP proof key on every read, never stored and
412
- * never settable by the user: only their AI can produce it, by writing through the connection.
413
- * Renders nothing at all until the read resolves, and nothing when unproven — an unproven state
414
- * belongs in the next-steps list as an action, not in the identity card as a complaint. */
415
- function McpConnectedBadge() {
416
- const [proven, setProven] = useState(undefined);
417
- useEffect(() => {
418
- let cancelled = false;
419
- checkHelloMcp()
420
- .then(r => { if (!cancelled) setProven(r.passed); })
421
- .catch((err) => { swallowed('landing-page.cards: McpConnectedBadge', err); });
422
- return () => { cancelled = true; };
423
- }, []);
424
- if (!proven) return null;
425
- return html`<div class="pf-federation-badge pf-mcp-badge">
426
- <span class="pf-fed-dot"></span>${t('profile.mcpConnected') || 'MCP connected'}
427
- </div>`;
428
- }
429
-
430
- export function ProfileCard({ tier, stats, session, onEditProfile, switchTab }) {
431
- const NODE_URL = getNodeUrl();
432
- const [instrOpen, setInstrOpen] = useState(false);
433
- const isNew = tier === 'new';
434
- const isExperienced = tier === 'experienced';
435
- const avatarSvg = minidenticon(typeof session.owner === 'string' && session.owner ? session.owner : 'user');
436
-
437
- // Stats are NAVIGATION, not decoration \u2014 each one opens its own section.
438
- const stat = (icon, val, labelKey, tabId, green) => html`
439
- <button class="pf-lp-stat pf-lp-stat-link" onClick=${() => switchTab?.(tabId)}>
440
- ${icon} <span class="pf-lp-stat-val${green ? ' pf-lp-stat-green' : ''}">${val}</span> ${t(labelKey)}
441
- </button>`;
442
-
443
- return html`
444
- <div class="pf-lp-card">
445
- <div class="pf-lp-card-header">
446
- <div class="pf-lp-avatar" role="button" tabindex="0" title=${t('profile.landing.editProfile')}
447
- onClick=${() => onEditProfile?.()} dangerouslySetInnerHTML=${{ __html: avatarSvg }}></div>
448
- <div class="pf-lp-info">
449
- <div class="pf-lp-name-row">
450
- <span class="pf-lp-name">${escHtml(session.displayName || session.owner)}</span>
451
- </div>
452
- <div class="pf-lp-ghii">${escHtml(session.ghii || '')}</div>
453
- <div class="pf-lp-node">${t('profile.node')}: ${escHtml(NODE_URL)}</div>
454
- <${McpConnectedBadge} />
455
- ${typeof stats.nodes === 'number' && stats.nodes > 0
456
- ? html`<div class="pf-federation-badge">
457
- <span class="pf-fed-dot"></span>
458
- ${t('profile.federation.statusConnected').replace('{count}', String(stats.nodes))}
459
- </div>`
460
- : html`<div class="pf-federation-badge pf-federation-standalone">
461
- ${t('profile.federation.statusStandalone')}
462
- </div>`
463
- }
464
- </div>
465
- <div class="pf-lp-actions">
466
- <${PresencePill} />
467
- <button class="btn-outline btn-sm" onClick=${() => setInstrOpen(true)}
468
- title=${t('setup.instrBtnHint') || 'The block to paste into your AI chat’s instructions, and where it goes in your tool'}>
469
- ${t('setup.instrBtn') || 'AI chat instructions'}</button>
470
- <button class="btn-outline btn-sm" onClick=${() => onEditProfile?.()}>
471
- ${t('profile.landing.profileBtn') || 'Profile'}</button>
472
- </div>
473
- </div>
474
- <div class="pf-lp-stats">
475
- ${isNew ? html`
476
- ${stats.memory > 0 && stat('\u{1F9E0}', stats.memory, 'profile.stats.memories', 'memory')}
477
- ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0)
478
- && stat('\u{1F48E}', stats.balance, 'profile.stats.morsels', 'wallet', true)}
479
- ` : html`
480
- ${stats.apps > 0 && stat('\u{1F4F1}', stats.apps, 'profile.stats.apps', 'apps')}
481
- ${stats.memory > 0 && stat('\u{1F9E0}', stats.memory, 'profile.stats.memories', 'memory')}
482
- ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0)
483
- && stat('\u{1F48E}', stats.balance, 'profile.stats.morsels', 'wallet', true)}
484
- ${stats.services > 0 && stat('\u{1F50C}', stats.services, 'profile.stats.services', 'actions')}
485
- ${isExperienced && stats.agents > 0 && stat('\u{1F916}', stats.agents, 'profile.stats.agents', 'agents')}
486
- `}
487
- </div>
488
- <${InstructionsDialog} open=${instrOpen} onClose=${() => setInstrOpen(false)} />
489
- </div>
490
- `;
491
- }
492
-
493
- /* "Suggested next steps" — a curated, value-first card pointing at the genuinely useful
494
- * but under-used surfaces (replaces the old four-path onboarding hero that flashed for
495
- * everyone because tier starts 'new' before stats load). First item highlighted:
496
- * 1. Write self-organizing notes → Notebook (always; the highest-value habit)
497
- * 2. Create your portfolio → Portfolio — ONLY if the user hasn't published one yet
498
- * (under-used; tell others who you are)
499
- * 3. Build an app → the app catalog's create flow (prompt builder), in the portal's
500
- * current language (?lang=) with the builder auto-opened (?create=1) ONLY if the
501
- * user has no app of their own yet
502
- * 4. Use agents others shared → the Offers "Do" surface (always)
503
- * The two conditional steps render only once their data is KNOWN to be "missing" (apps
504
- * loaded → 0; portfolio config fetched → not enabled), so an existing user never sees a
505
- * step flash in then disappear. Each step carries its own `go()` so it can switch a tab
506
- * OR open an external page. */
507
- export function NextSteps({ switchTab, hasApps }) {
508
- // hasPortfolio: undefined = loading, true = published config exists, false = none yet.
509
- const [hasPortfolio, setHasPortfolio] = useState(undefined);
510
- // Hello MCP outranks everything else while it is unproven: until the connection is verified,
511
- // every other suggestion here is advice the user cannot act on properly. undefined = still
512
- // reading, so the step never flashes in for someone who already passed.
513
- const [mcpProven, setMcpProven] = useState(undefined);
514
- useEffect(() => {
515
- let cancelled = false;
516
- checkHelloMcp()
517
- .then(r => { if (!cancelled) setMcpProven(r.passed); })
518
- .catch((err) => { swallowed('landing-page.cards: helloMcp', err); if (!cancelled) setMcpProven(true); });
519
- return () => { cancelled = true; };
520
- }, []);
521
- useEffect(() => {
522
- let cancelled = false;
523
- apiGet('/v1/portfolio/config')
524
- .then(r => { if (!cancelled) setHasPortfolio(!!(r?.data?.config?.enabled)); })
525
- .catch((err) => { swallowed('landing-page.cards', err); if (!cancelled) setHasPortfolio(false); });
526
- return () => { cancelled = true; };
527
- }, []);
528
-
529
- const buildAppUrl = `/app-catalog.html?lang=${encodeURIComponent(getLocale())}&create=1`;
530
- const steps = [];
531
- // First and most important until it passes, then gone: a proven connection is the thing the
532
- // rest of the product is used through.
533
- if (mcpProven === false) steps.push({ icon: '\u{1F50C}', key: 'helloMcp', go: () => switchTab('mcp') });
534
- steps.push({ icon: '\u{1F9E0}', key: 'writeNotes', go: () => switchTab('notebook') });
535
- if (hasPortfolio === false) steps.push({ icon: '\u{1F3A8}', key: 'portfolio', go: () => switchTab('portfolio') });
536
- if (hasApps === false) steps.push({ icon: '\u{26A1}', key: 'buildApp', go: () => window.open(buildAppUrl, '_blank', 'noopener') });
537
- steps.push({ icon: '\u{1F91D}', key: 'useSharedAgents', go: () => switchTab('offers') });
538
-
539
- return html`
540
- <div class="pf-next">
541
- <div class="pf-next-title">${t('profile.landing.nextTitle')}</div>
542
- <div class="pf-next-grid">
543
- ${steps.map((s, i) => html`
544
- <button class=${'pf-next-card' + (i === 0 ? ' pf-next-card--primary' : '')} key=${s.key}
545
- onClick=${s.go}>
546
- <span class="pf-next-ico">${s.icon}</span>
547
- <span class="pf-next-body">
548
- <span class="pf-next-card-title">${t('profile.landing.next.' + s.key + 'Title')}</span>
549
- <span class="pf-next-card-desc">${t('profile.landing.next.' + s.key + 'Desc')}</span>
550
- </span>
551
- <span class="pf-next-arrow" aria-hidden="true">→</span>
552
- </button>
553
- `)}
554
- </div>
555
- <${OpenItemsList} />
556
- </div>
557
- `;
558
- }
559
-
560
- /* Onboarding promo — shown only while the user has fewer than 3 apps, and dismissable for good.
561
- * After that the same content lives on the Extensions page; for a seasoned user it was dead space. */
562
- export function CortexSection({ switchTab, onDismiss }) {
563
- return html`
564
- <div class="pf-landing-section pf-promo">
565
- <div class="pf-menu-title">${t('profile.landing.cortexSectionTitle')}
566
- <button class="pf-promo-dismiss" title=${t('profile.landing.promoDismiss') || 'Hide'}
567
- onClick=${(e) => { e.stopPropagation(); onDismiss?.(); }}>✕</button>
568
- </div>
569
- <div class="pf-cortex-grid">
570
- <div class="pf-cortex-card" onClick=${() => switchTab('extensions')}>
571
- <div class="pf-cortex-header">
572
- <span>\u{1F4CA}</span><span>${t('profile.landing.cortexCharts')}</span>
573
- </div>
574
- <p class="pf-cortex-desc">${t('profile.landing.cortexChartsDesc')}</p>
575
- </div>
576
- <div class="pf-cortex-card" onClick=${() => switchTab('extensions')}>
577
- <div class="pf-cortex-header">
578
- <span>\u{1F3A8}</span><span>${t('profile.landing.cortexCanvas')}</span>
579
- </div>
580
- <p class="pf-cortex-desc">${t('profile.landing.cortexCanvasDesc')}</p>
581
- </div>
582
- </div>
583
- </div>
584
- `;
585
- }
586
-
587
- /* (AppStrip removed \u2014 the cross-type "Continue" card replaced it: raw filenames in a horizontal
588
- * scroller duplicated the Apps tab and read as a file listing.) */
589
-
590
- /* ───── Inbox nav button — fixed under Home (non-movable), with an unread badge ───── */
591
-
592
- export function InboxNavButton({ active, onClick }) {
593
- const [unread, setUnread] = useState(0);
594
- const load = useCallback(async () => {
595
- try { const d = await listInbox(); setUnread(d?.unread || 0); } catch (err) { swallowed('landing-page.cards: InboxNavButton', err); }
596
- }, []);
597
- useEffect(() => { load(); }, [load]);
598
- const ref = useRef(load); ref.current = load;
599
- useEffect(() => onLiveUpdate(['messages'], () => ref.current()), []);
600
- return html`
601
- <button class="pf-side-item${active ? ' pf-side-item--active' : ''}" onClick=${onClick}>
602
- <span class="pf-side-label">${t('profile.tabs.inbox')}</span>
603
- ${unread > 0 ? html`<span class="pf-side-badge">${unread}</span>` : null}
604
- </button>`;
605
- }
606
-
607
- /* ───── Persistent sidebar groups (replaces the tier-adaptive menu) ─────
608
- * Every tab is always present and grouped into stable sections — no activity-based
609
- * hiding, so humans and agentic developers can predict where each tab lives.
610
- * Group titles reuse existing i18n keys; tab labels reuse profile.tabs.* / *.tabLabel. */
611
- /* Grouping follows the information-refinement pipeline and usage frequency, not an
612
- * abstract taxonomy (the old Daily/Personal/Technical groups are gone). Badges remain
613
- * reserved for action-required counts only — never static totals. */
614
- export const SIDEBAR_GROUPS = [
615
- { titleKey: 'profile.landing.menuInformation', items: [ // find-anything → raw → curated → governed
616
- { id: 'discover', labelKey: 'discover.tabLabel' },
617
- { id: 'organisms', labelKey: 'profile.tabs.organisms' },
618
- { id: 'memory', labelKey: 'profile.tabs.memory' },
619
- { id: 'notebook', labelKey: 'profile.tabs.notebook' },
620
- { id: 'living', labelKey: 'profile.tabs.living' },
621
- { id: 'knowledge', labelKey: 'knowledge.tabLabel' },
622
- { id: 'boards', labelKey: 'profile.tabs.boards' },
623
- ] },
624
- { titleKey: 'profile.landing.menuAutomation', items: [ // agents + their infrastructure
625
- { id: 'agents', labelKey: 'profile.tabs.agents' },
626
- { id: 'ecosystem', labelKey: 'profile.tabs.ecosystem' },
627
- { id: 'offers', labelKey: 'profile.tabs.offers' },
628
- { id: 'scheduler', labelKey: 'profile.tabs.scheduler' },
629
- { id: 'workflows', labelKey: 'profile.tabs.workflows' },
630
- { id: 'actions', labelKey: 'profile.tabs.services' },
631
- { id: 'mcp', labelKey: 'profile.tabs.mcp' },
632
- ] },
633
- { titleKey: 'profile.landing.menuActivity', items: [ // communication + events
634
- { id: 'contacts', labelKey: 'contacts.tabLabel' },
635
- { id: 'notifications', labelKey: 'profile.tabs.notifications' },
636
- { id: 'email', labelKey: 'profile.tabs.email' },
637
- { id: 'chatsessions', labelKey: 'profile.tabs.chatSessions' },
638
- ] },
639
- { titleKey: 'profile.landing.menuBusiness', items: [ // the company and its money
640
- { id: 'companies', labelKey: 'profile.tabs.companies' },
641
- { id: 'pnl', labelKey: 'profile.tabs.pnl' },
642
- { id: 'usage', labelKey: 'profile.tabs.usage' },
643
- ] },
644
- { titleKey: 'profile.landing.menuBuildShare', items: [
645
- { id: 'apps', labelKey: 'profile.tabs.apps' },
646
- { id: 'appdev', labelKey: 'profile.tabs.appDev' },
647
- /* foundry removed from the menu 2026-06-10 (owner: not in use). The tab module and
648
- * its route id still exist — restore by re-adding this item. */
649
- { id: 'extensions', labelKey: 'profile.tabs.extensions' },
650
- { id: 'libraries', labelKey: 'librariesTab.tabLabel' },
651
- { id: 'capabilities', labelKey: 'capabilities.tabLabel' },
652
- { id: 'skills', labelKey: 'skills.tabLabel' },
653
- { id: 'packages', labelKey: 'profile.tabs.packages' },
654
- { id: 'portfolio', labelKey: 'portfolio.tabLabel' },
655
- /* AI-provider (OpenRouter) key + spend budget. Route id is still 'generator' — the Generator tab
656
- * was repurposed to host only these settings when the Generator feature was removed (2026-07-18).
657
- * Re-added to the menu 2026-07-19 after it was orphaned (reachable only by ?tab=generator). */
658
- { id: 'generator', labelKey: 'profile.generator.openrouter.title' },
659
- { id: 'calibrator', labelKey: 'profile.calibrator.tabLabel' },
660
- /* TODO(owner 2026-06-10): "work" placement is undecided — parked at the bottom of
661
- * Build & Share until re-evaluated. */
662
- { id: 'work', labelKey: 'profile.tabs.work' },
663
- ] },
664
- { titleKey: 'profile.landing.menuAccount', items: [
665
- { id: 'wallet', labelKey: 'profile.tabs.wallet' },
666
- { id: 'dataWallet', labelKey: 'profile.tabs.dataWallet' },
667
- { id: 'access', labelKey: 'profile.tabs.access' },
668
- ] },
669
- /* Operator-only: the group AND its routes are gated on the operator role (open()
670
- * refuses these ids for non-operators; the underlying APIs enforce server-side).
671
- * nodeStats left the menu — it lives as a tab on the Nodes page now. */
672
- { titleKey: 'profile.landing.menuInfra', adminOnly: true, items: [
673
- { id: 'federation', labelKey: 'profile.tabs.federation' },
674
- { id: 'nodes', labelKey: 'profile.tabs.nodes' },
675
- { id: 'security', labelKey: 'profile.tabs.security' },
676
- ] },
677
- ];
678
-
679
- /**
680
- * The BASIC menu: what a person needs before they have built anything on the node. 38 items in
681
- * one flat list was a measured wall for a non-technical newcomer (UX-remake v3, K5), and the
682
- * chat-first model says the profile's deeper surfaces are for technical users. Everything else
683
- * is one "Show all tools" toggle away, and the toggle state is remembered — nothing is removed.
684
- * Operator-only groups are never in the basic set (they are role-gated anyway).
685
- */
686
- export const BASIC_TAB_IDS = new Set([
687
- 'companies', // your company: address, legal identity, sender
688
- 'organisms', 'memory', 'notebook', // where the work and knowledge live
689
- 'agents', 'mcp', // the chat/agent connection
690
- 'notifications', 'contacts', // what happened, who with
691
- 'apps', 'portfolio', // what you made
692
- 'wallet', 'access', // account
693
- ]);
694
-
695
- // Flat item lookup (pinned section renders items by id).
696
- export const SIDEBAR_ITEM_BY_ID = Object.fromEntries(SIDEBAR_GROUPS.flatMap(g => g.items.map(it => [it.id, it])));
697
- export const INFRA_TAB_IDS = new Set(['federation', 'nodes', 'nodeStats', 'security']);
698
- // Defaults stay inside BASIC_TAB_IDS: a default pin to a tool the basic menu hides (scheduler,
699
- // until 2026-08-07) contradicted the two-level menu the moment it shipped.
700
- export const DEFAULT_PINS = ['organisms', 'agents', 'memory', 'mcp'];
1
+ /**
2
+ * @file public/views/profile/landing-page.cards.js
3
+ * @description Profile home dashboard cards, home sub-components, and the sidebar group model. Extracted from landing-page.js to satisfy max-file-lines.
4
+ * @version-history
5
+ * 2026-07-19 — Re-add the orphaned OpenRouter Settings item (route id 'generator') to the Build & Share
6
+ * group — it lost its menu entry when the Generator feature was removed, leaving the AI-provider key
7
+ * config reachable only by deep link.
8
+ * 2026-07-19 — AppDev tab (KB UI): learned-pitfall + template management surface, start-prompt copy, model badge
9
+ * v1.2.0 — 2026-07-16 — Drop the per-item emoji icons from SIDEBAR_GROUPS and the Inbox nav
10
+ * button — the sidebar renders label-only now.
11
+ * v1.1.0 — 2026-07-16 — Contacts tab in the Activity sidebar group.
12
+ * v1.0.0 — 2026-07-13 — Extracted from views/profile/landing-page.js (max-file-lines)
13
+ */
14
+ import { h } from "preact";
15
+ import { OpenItemsList } from '/components/OpenItemsList.js';
16
+ import { useState, useEffect, useCallback, useRef } from "preact/hooks";
17
+ import htm from "htm";
18
+ const html = htm.bind(h);
19
+ import { t, getLocale } from "/js/i18n.js";
20
+ import { escHtml, fmtMoney } from "/js/utils.js";
21
+ import { getNodeUrl } from "/js/services/auth.js";
22
+ import { listAgents } from "/js/services/agents.js";
23
+ import { listAllSchedules } from "/js/services/schedules.js";
24
+ import * as orgService from "/js/services/organisms.js";
25
+ import { onLiveUpdate } from "/lib/live-updates.js";
26
+ import { listRecents } from "/js/recents.js";
27
+ import { listInbox } from "/js/services/messages.js";
28
+ import { apiGet } from "/js/api.js";
29
+ import { checkHelloMcp } from "/js/services/hello-mcp.js";
30
+ import { InstructionsDialog } from "/views/profile/ai-setup-guide.js";
31
+ import { UsageChart, colorForIndex } from "/components/UsageChart.js";
32
+ import { minidenticon } from "/lib/minidenticons.min.js";
33
+ import { PresencePill } from "./landing-page.modals.js";
34
+ import { swallowed } from '/js/swallowed.js';
35
+ import {
36
+ relTime, fmtClock, openProfileTab, gotoWorkspace, gotoOrganism, gotoOrganismsList,
37
+ fmtBytes, fmtUsd, fmtCompact,
38
+ } from "./landing-page.helpers.js";
39
+
40
+ /* ───── Home dashboard cards ───── */
41
+
42
+ /* "Waiting for you" — everything that needs the user's decision, aggregated across organisms:
43
+ * pending publish approvals (per workspace), pending join requests (orgs they manage), and
44
+ * incoming organism invitations. Renders nothing when there is nothing to do. */
45
+ export function WaitingForYou() {
46
+ const [items, setItems] = useState(null);
47
+ // ONE aggregated request replaces the old per-org fan-out (listOrganisms → per-org listApprovals +
48
+ // listJoinRequests + listWorkspaces + a final listMyInvitations). The server returns the same flat
49
+ // {kind:'review'|'join'|'invite', …} items this widget renders.
50
+ const load = useCallback(async () => { setItems(await orgService.getWaiting()); }, []);
51
+ useEffect(() => { load(); }, [load]);
52
+ const liveRef = useRef(load); liveRef.current = load;
53
+ // Only re-run the per-organism approvals/join-requests fan-out when ORGANISMS actually
54
+ // change — not on every unrelated event (agent churn, memory, etc.), which on an account in
55
+ // many organisms turned into a hundreds-of-requests storm.
56
+ useEffect(() => onLiveUpdate(['organisms'], () => liveRef.current()), []);
57
+
58
+ if (!items || items.length === 0) return null;
59
+ return html`
60
+ <div class="pf-waiting">
61
+ <div class="pf-waiting-title">${'📨 '}${t('profile.landing.waitingTitle') || 'Waiting for you'}</div>
62
+ ${items.map((it, i) => html`
63
+ <div class="pf-waiting-row" key=${i}>
64
+ <span class="pf-waiting-text">
65
+ ${it.kind === 'review' ? html`
66
+ <b>${(t('profile.landing.draftsToReview') || '{n} drafts to review').replace('{n}', String(it.n))}</b>
67
+ <span class="pf-waiting-where"> · ${escHtml(it.orgName)} / ${escHtml(it.wsName)}</span>` : null}
68
+ ${it.kind === 'join' ? html`
69
+ <b>${it.n === 1 ? (t('profile.landing.joinReqOne') || '1 join request') : (t('profile.landing.joinReqMany') || '{n} join requests').replace('{n}', String(it.n))}</b>
70
+ <span class="pf-waiting-where"> · ${escHtml(it.orgName)}</span>` : null}
71
+ ${it.kind === 'invite' ? html`
72
+ <b>${t('profile.landing.inviteWaiting') || 'You’re invited'}</b>
73
+ <span class="pf-waiting-where"> · ${escHtml(it.orgName)}</span>` : null}
74
+ </span>
75
+ ${it.kind === 'review' ? html`
76
+ <button class="btn-outline btn-sm" onClick=${() => (it.wsId ? gotoWorkspace(it.orgId, it.wsId, 'review') : gotoOrganism(it.orgId))}>${t('profile.landing.reviewBtn') || 'Review'}</button>` : null}
77
+ ${it.kind === 'join' ? html`
78
+ <button class="btn-outline btn-sm" onClick=${() => gotoOrganism(it.orgId, 'members')}>${t('profile.landing.viewBtn') || 'View'}</button>` : null}
79
+ ${it.kind === 'invite' ? html`
80
+ <button class="btn-outline btn-sm" onClick=${() => gotoOrganismsList()}>${t('profile.landing.viewBtn') || 'View'}</button>` : null}
81
+ </div>
82
+ `)}
83
+ </div>
84
+ `;
85
+ }
86
+
87
+ /* "Continue" — the last opened things across types (workspace / app / organism), with real
88
+ * display names. Backed by /js/recents.js (device-local). Renders nothing when empty. */
89
+ const RECENT_ICONS = { workspace: '🗂', app: '▦', organism: '🏢', board: '📋' };
90
+ export function ContinueCard() {
91
+ const [items] = useState(() => listRecents(5));
92
+ if (!items.length) return null;
93
+ const openItem = (it) => {
94
+ if (it.type === 'workspace' && it.data?.orgId) gotoWorkspace(it.data.orgId, it.data.wsId);
95
+ else if (it.type === 'organism' && it.data?.orgId) gotoOrganism(it.data.orgId);
96
+ else if (it.type === 'app' && it.data?.filename) window.open(`/v1/apps/${encodeURIComponent(it.data.owner)}/${encodeURIComponent(it.data.filename)}?mode=inline`, '_blank');
97
+ };
98
+ return html`
99
+ <div class="pf-home-card">
100
+ <div class="pf-home-card-title">${t('profile.landing.continueTitle') || 'Continue'}</div>
101
+ ${items.map(it => html`
102
+ <button class="pf-home-row" key=${it.type + it.id} onClick=${() => openItem(it)}>
103
+ <span class="pf-home-row-ico">${RECENT_ICONS[it.type] || '•'}</span>
104
+ <span class="pf-home-row-label">${escHtml(it.label)}</span>
105
+ <span class="pf-home-row-meta">${relTime(it.at)}</span>
106
+ </button>
107
+ `)}
108
+ </div>
109
+ `;
110
+ }
111
+
112
+ /* "Agents" — who has been active today, who is idle, and the next scheduled run. */
113
+ export function AgentsCard({ owner, initialAgents }) {
114
+ // The Home /v1/owner/home composite already resolves the owner's agent list (initialAgents) — seed from
115
+ // it and skip the mount /v1/agents fetch (dropping that duplicate). The next-scheduled-job row still
116
+ // needs the schedules call (not in the composite); live-update refreshes both.
117
+ // Excluded from the HOME card (both still live in the Agents tab):
118
+ // session-* — per-session scratch identities, never user-facing.
119
+ // app — the built-in agent registration creates for in-app calls. Counting it made a
120
+ // brand-new account read "1 agent active today" when the person had connected
121
+ // nothing, which is a claim about work that never happened (UX-remake v3, P5).
122
+ const seedAgents = (list) => (Array.isArray(list) ? list : [])
123
+ .filter(a => !String(a.name || '').startsWith('session-') && String(a.name || '') !== 'app')
124
+ .slice().sort((a, b) => String(b.last_seen || '').localeCompare(String(a.last_seen || '')));
125
+ const [agents, setAgents] = useState(initialAgents ? seedAgents(initialAgents) : null);
126
+ const [nextJob, setNextJob] = useState(null);
127
+ const loadAgents = useCallback(async () => {
128
+ try { setAgents(seedAgents(await listAgents(owner))); } catch (err) { swallowed('landing-page.cards', err); setAgents([]); }
129
+ }, [owner]);
130
+ const loadSchedules = useCallback(async () => {
131
+ try {
132
+ const r = await listAllSchedules();
133
+ const all = [...(r?.data?.managed || []), ...(r?.data?.extensions || []), ...(r?.data?.agentInternal || [])]
134
+ .filter(s => s.enabled !== false && s.nextRunAt && new Date(s.nextRunAt).getTime() > Date.now())
135
+ .sort((a, b) => String(a.nextRunAt).localeCompare(String(b.nextRunAt)));
136
+ setNextJob(all[0] || null);
137
+ } catch (err) { swallowed('landing-page.cards: seedAgents', err); }
138
+ }, []);
139
+ const load = useCallback(async () => { await Promise.all([loadAgents(), loadSchedules()]); }, [loadAgents, loadSchedules]);
140
+ useEffect(() => { if (initialAgents) setAgents(seedAgents(initialAgents)); }, [initialAgents]);
141
+ useEffect(() => { loadSchedules(); }, [loadSchedules]); // schedules aren't in the composite
142
+ const liveRef = useRef(load); liveRef.current = load;
143
+ useEffect(() => onLiveUpdate(['agents', 'agent-tasks', 'schedules'], () => liveRef.current()), []);
144
+
145
+ if (!agents || (agents.length === 0 && !nextJob)) return null;
146
+ const todayStr = new Date().toDateString();
147
+ const isToday = (s) => s && new Date(s).toDateString() === todayStr;
148
+ const activeToday = agents.filter(a => isToday(a.last_seen)).length;
149
+ return html`
150
+ <div class="pf-home-card">
151
+ <button class="pf-home-card-title pf-home-card-link" onClick=${() => openProfileTab('agents')}>
152
+ ${t('profile.landing.agentsTitle') || 'Agents'}
153
+ ${activeToday > 0 ? html`<span class="pf-home-card-note"> · ${(t('profile.landing.activeTodayCount') || '{n} active today').replace('{n}', String(activeToday))}</span>` : null}
154
+ </button>
155
+ ${agents.slice(0, 3).map(a => html`
156
+ <button class="pf-home-row" key=${a.gaii || a.name}
157
+ onClick=${() => {
158
+ // eslint-disable-next-line aimeat/no-silent-catch -- a browser refusing sessionStorage here IS the answer: the tab still opens, it just does not preselect this agent
159
+ try { sessionStorage.setItem('aimeat.agents.open', a.name); } catch { /* noop */ }
160
+ openProfileTab('agents');
161
+ }}>
162
+ <span class="pf-home-row-ico">${'🤖'}</span>
163
+ <span class="pf-home-row-label">${escHtml(a.display_name || a.name)}</span>
164
+ <span class="pf-home-row-meta ${isToday(a.last_seen) ? 'pf-ok' : ''}">
165
+ ${a.last_seen ? (isToday(a.last_seen) ? (t('profile.landing.agentActiveToday') || 'active today') : relTime(a.last_seen)) : '—'}
166
+ </span>
167
+ </button>
168
+ `)}
169
+ ${nextJob ? html`
170
+ <button class="pf-home-row" key="nextjob" onClick=${() => openProfileTab('scheduler')}>
171
+ <span class="pf-home-row-ico">⏰</span>
172
+ <span class="pf-home-row-label">${escHtml(nextJob.name || nextJob.id || '')}</span>
173
+ <span class="pf-home-row-meta">${(t('profile.landing.nextRunAt') || 'next run {time}').replace('{time}', fmtClock(nextJob.nextRunAt))}</span>
174
+ </button>` : null}
175
+ </div>
176
+ `;
177
+ }
178
+
179
+ /* "Usage" — quota usage bars (memory / storage / micro-memory) + resource counts. Backed by the
180
+ * cached GET /v1/owner/usage endpoint (60s server-side TTL), so it's cheap to refetch on each
181
+ * live-update. Surfaces the same kind of quota bar the Memory tab shows, for the whole account. */
182
+
183
+ export function UsageCard({ switchTab, initialUsage }) {
184
+ // The Home landing is the only place this renders, and its /v1/owner/home composite already carries the
185
+ // usage summary — so we seed from initialUsage and never mount-fetch /v1/owner/usage (dropping that
186
+ // duplicate). We still refresh on live-update for freshness after a real change.
187
+ const [u, setU] = useState(initialUsage ?? null);
188
+ const load = useCallback(async () => {
189
+ try { const r = await apiGet('/v1/owner/usage'); setU(r?.data || null); } catch (err) { swallowed('landing-page.cards', err); setU(null); }
190
+ }, []);
191
+ useEffect(() => { if (initialUsage) setU(initialUsage); }, [initialUsage]);
192
+ const liveRef = useRef(load); liveRef.current = load;
193
+ useEffect(() => onLiveUpdate(['memory', 'files', 'agents', 'apps', 'organisms'], () => liveRef.current()), []);
194
+
195
+ if (!u) return null;
196
+
197
+ const bar = (label, q, usedText) => {
198
+ const pct = q.percent >= 0 ? Math.max(0, Math.min(100, q.percent)) : 0;
199
+ return html`
200
+ <div class="pf-usage-row">
201
+ <div class="pf-usage-head">
202
+ <span class="pf-usage-label">${label}</span>
203
+ <span class="text-meta-sm">${usedText}</span>
204
+ </div>
205
+ <div class="pf-usage-bar"><div class="pf-usage-fill ${pct >= 90 ? 'pf-usage-fill--danger' : ''}" style=${`width:${pct}%`}></div></div>
206
+ </div>`;
207
+ };
208
+
209
+ /**
210
+ * The AI limit, which is a limit like the others and was the only one a person could not see.
211
+ *
212
+ * Two different facts, so two different shapes. Own key: there is no house limit, and a bar
213
+ * would imply one. Node key: a bar, because it fills once and never refills — the grant is
214
+ * granted a single time per person and nothing renews it.
215
+ */
216
+ const aiLine = (ai) => {
217
+ if (!ai) return null;
218
+ if (ai.own_key) {
219
+ return html`
220
+ <div class="pf-usage-row">
221
+ <div class="pf-usage-head">
222
+ <span class="pf-usage-label">${t('profile.landing.usageAi')}</span>
223
+ <span class="text-meta-sm">${t('profile.landing.usageAiOwnKey')}</span>
224
+ </div>
225
+ </div>`;
226
+ }
227
+ if (!(ai.granted_usd > 0)) {
228
+ return html`
229
+ <div class="pf-usage-row">
230
+ <div class="pf-usage-head">
231
+ <span class="pf-usage-label">${t('profile.landing.usageAi')}</span>
232
+ <span class="text-meta-sm">${t('profile.landing.usageAiNoGrant')}</span>
233
+ </div>
234
+ </div>`;
235
+ }
236
+ return bar(t('profile.landing.usageAi'), ai,
237
+ `$${(ai.remaining_usd ?? 0).toFixed(2)} ${t('profile.landing.usageAiLeftOf')} $${(ai.granted_usd ?? 0).toFixed(2)}`);
238
+ };
239
+
240
+ const chip = (label, value, tab) => html`
241
+ <button class="pf-usage-chip" onClick=${tab ? () => switchTab(tab) : undefined} disabled=${!tab}>
242
+ <span class="pf-usage-chip-val">${value}</span>
243
+ <span class="pf-usage-chip-label">${label}</span>
244
+ </button>`;
245
+
246
+ const c = u.counts;
247
+ return html`
248
+ <div class="pf-home-card pf-usage-card">
249
+ <div class="pf-home-card-title">${t('profile.landing.usageTitle') || 'Usage & quotas'}</div>
250
+ ${bar(t('profile.landing.usageMemory') || 'Memory', u.memory,
251
+ `${u.memory.used_keys}/${u.memory.max_keys} ${t('profile.memory.keysWord') || 'keys'} · ${fmtBytes(u.memory.used_bytes)} / ${fmtBytes(u.memory.max_bytes)}`)}
252
+ ${bar(t('profile.landing.usageStorage') || 'Files', u.storage,
253
+ `${u.storage.used_files} ${t('profile.landing.usageFilesWord') || 'files'} · ${fmtBytes(u.storage.used_bytes)} / ${fmtBytes(u.storage.max_bytes)}`)}
254
+ ${bar(t('profile.landing.usageMicro') || 'Micro-memory', u.micro_memory,
255
+ `${u.micro_memory.used_sets}/${u.micro_memory.max_sets} ${t('profile.landing.usageSetsWord') || 'sets'} · ${fmtBytes(u.micro_memory.used_bytes)} / ${fmtBytes(u.micro_memory.max_bytes)}`)}
256
+ ${aiLine(u.ai)}
257
+ <div class="pf-usage-chips">
258
+ ${chip(t('profile.landing.usageAgents') || 'Agents', c.agents, 'agents')}
259
+ ${chip(t('profile.landing.usageOrganisms') || 'Organisms', c.organisms, 'organisms')}
260
+ ${chip(t('profile.landing.usageApps') || 'Apps', `${c.apps.used}/${c.apps.max}`, 'apps')}
261
+ ${chip(t('profile.landing.usageEcoApps') || 'Connected apps', c.ecosystem_apps, 'ecosystem')}
262
+ ${chip(t('profile.landing.usageExtensions') || 'Extensions', `${c.extensions.used}/${c.extensions.max}`, 'extensions')}
263
+ ${chip(t('profile.landing.usageCortexes') || 'Cortexes', c.cortexes, 'extensions')}
264
+ ${chip(t('profile.landing.usageServices') || 'Services', `${c.services.used}/${c.services.max}`, 'offers')}
265
+ </div>
266
+ </div>
267
+ `;
268
+ }
269
+
270
+ /* "AI spend" token/cost analytics for the owner's AI apps over the last 24h / 7d / 30d,
271
+ * plus a per-app stacked bar of the last 30 days. Backed by GET /v1/ai/usage/history (reads the
272
+ * retained per-day ai-usage records). Hidden until there is any spend, so it never shows an empty
273
+ * chart to users who don't run AI apps. */
274
+
275
+ /* "Commerce" the owner's marketplace status: purchases (checkout sessions), sales received,
276
+ * and morsels moved, from /v1/commerce. Hidden when commerce is disabled on the node (503). */
277
+ export function CommerceCard() {
278
+ const [stats, setStats] = useState(null);
279
+ const load = useCallback(async () => {
280
+ try {
281
+ const [s, o] = await Promise.all([
282
+ apiGet('/v1/commerce/checkout-sessions?limit=100'),
283
+ apiGet('/v1/commerce/orders?limit=100'),
284
+ ]);
285
+ const sessions = s?.data?.sessions ?? [];
286
+ const orders = o?.data?.orders ?? [];
287
+ const completed = sessions.filter((x) => x.status === 'completed');
288
+ // Currencies never mix: morsels and each money code (minor units) total separately.
289
+ const sumBy = (list, pick) => {
290
+ const by = {};
291
+ for (const x of list) {
292
+ const cur = x.currency || 'morsel';
293
+ by[cur] = (by[cur] || 0) + (pick(x) || 0);
294
+ }
295
+ return by;
296
+ };
297
+ setStats({
298
+ bought: completed.length,
299
+ open: sessions.filter((x) => x.status === 'open').length,
300
+ spentBy: sumBy(completed, (x) => x.receipt && x.receipt.charged),
301
+ sold: orders.length,
302
+ earnedBy: sumBy(orders, (x) => x.receipt && x.receipt.earned),
303
+ });
304
+ } catch (err) { swallowed('landing-page.cards', err); setStats(null); /* commerce disabled or unreachable — render nothing */ }
305
+ }, []);
306
+ useEffect(() => { load(); }, [load]);
307
+ const liveRef = useRef(load); liveRef.current = load;
308
+ useEffect(() => onLiveUpdate(['agent-tasks', 'memory'], () => liveRef.current()), []);
309
+
310
+ if (!stats) return null;
311
+ const chip = (label, main, sub) => html`
312
+ <div class="pf-ai-win">
313
+ <span class="pf-ai-win-label">${label}</span>
314
+ <span class="pf-ai-win-cost">${main}</span>
315
+ <span class="pf-ai-win-sub">${sub}</span>
316
+ </div>`;
317
+ const morsels = t('profile.landing.commerceMorsels') || 'morsels';
318
+ // "10 morsels · 15.00 EUR" — money amounts are micro-units, never summed with morsels.
319
+ const fmtTotals = (by) => Object.entries(by)
320
+ .map(([cur, n]) => cur === 'morsel' ? `${n} ${morsels}` : fmtMoney(n, cur))
321
+ .join(' · ') || `0 ${morsels}`;
322
+ return html`
323
+ <div class="pf-home-card pf-commerce-card">
324
+ <div class="pf-home-card-title">${t('profile.landing.commerceTitle') || 'Commerce'}</div>
325
+ <div class="pf-ai-windows">
326
+ ${chip(t('profile.landing.commerceBought') || 'Purchases', String(stats.bought), fmtTotals(stats.spentBy))}
327
+ ${chip(t('profile.landing.commerceSold') || 'Sales', String(stats.sold), fmtTotals(stats.earnedBy))}
328
+ ${chip(t('profile.landing.commerceOpen') || 'Open carts', String(stats.open), t('profile.landing.commerceOpenSub') || 'checkout sessions')}
329
+ </div>
330
+ </div>`;
331
+ }
332
+
333
+ export function AiSpendCard() {
334
+ const [data, setData] = useState(null);
335
+ const load = useCallback(async () => {
336
+ try { const r = await apiGet('/v1/ai/usage/history?days=30'); setData(r?.data || null); }
337
+ catch (err) { swallowed('landing-page.cards', err); setData(null); }
338
+ }, []);
339
+ useEffect(() => { load(); }, [load]);
340
+ const liveRef = useRef(load); liveRef.current = load;
341
+ useEffect(() => onLiveUpdate(['apps', 'memory'], () => liveRef.current()), []);
342
+
343
+ if (!data || !Array.isArray(data.days) || data.days.length === 0) return null;
344
+
345
+ const { days, apps = [], windows } = data;
346
+ const labels = days.map((d) => d.date.slice(5));
347
+ const datasets = apps.map((app, i) => ({
348
+ label: app,
349
+ data: days.map((d) => (d.per_app && d.per_app[app] ? d.per_app[app].cost_usd : 0) || 0),
350
+ backgroundColor: colorForIndex(i),
351
+ }));
352
+
353
+ const d30 = (windows && windows.d30) || { cost_usd: 0, per_app: {} };
354
+ const totalCost = d30.cost_usd || 0;
355
+ const topApps = Object.entries(d30.per_app || {})
356
+ .sort((a, b) => b[1].cost_usd - a[1].cost_usd).slice(0, 5);
357
+
358
+ const win = (label, w) => html`
359
+ <div class="pf-ai-win">
360
+ <span class="pf-ai-win-label">${label}</span>
361
+ <span class="pf-ai-win-cost">${fmtUsd(w && w.cost_usd)}</span>
362
+ <span class="pf-ai-win-sub">${fmtCompact(w && w.tokens)} ${t('profile.landing.aiTokensWord') || 'tokens'}</span>
363
+ </div>`;
364
+
365
+ return html`
366
+ <div class="pf-home-card pf-ai-card">
367
+ <div class="pf-home-card-title">${t('profile.landing.aiSpendTitle') || 'AI apps spend'}</div>
368
+ <div class="pf-ai-windows">
369
+ ${win(t('profile.landing.aiWin24h') || 'Today', windows && windows.d1)}
370
+ ${win(t('profile.landing.aiWin7d') || '7 days', windows && windows.d7)}
371
+ ${win(t('profile.landing.aiWin30d') || '30 days', windows && windows.d30)}
372
+ </div>
373
+ ${datasets.length > 0 && html`
374
+ <div class="pf-ai-chart">
375
+ <${UsageChart} stacked labels=${labels} datasets=${datasets} height=${180}
376
+ legend=${false} yFormat=${fmtUsd} />
377
+ </div>`}
378
+ ${topApps.length > 0 && html`
379
+ <div class="pf-ai-apps">
380
+ <div class="pf-ai-apps-head">${t('profile.landing.aiWhereMoney') || 'Where it went (30d)'}</div>
381
+ ${topApps.map(([app, m]) => {
382
+ const pct = totalCost > 0 ? Math.round((m.cost_usd / totalCost) * 100) : 0;
383
+ return html`
384
+ <div class="pf-ai-app-row" key=${app}>
385
+ <span class="pf-ai-app-dot" style=${`background:${colorForIndex(apps.indexOf(app))}`}></span>
386
+ <span class="pf-ai-app-name">${app}</span>
387
+ <span class="pf-ai-app-cost">${fmtUsd(m.cost_usd)}</span>
388
+ <span class="pf-ai-app-pct">${pct}%</span>
389
+ </div>`;
390
+ })}
391
+ </div>`}
392
+ </div>`;
393
+ }
394
+
395
+ /* "Agent LLM usage" — the owner's own agent LLM ledger (priced per-call usage of the owner's
396
+ * agents), grouped by model. A DIFFERENT system from AiSpendCard (which shows AI-apps spend) — the
397
+ * two are never summed. Backed by the owner-scoped GET /v1/ledger/usage?group_by=model. Hidden
398
+ * until there is any agent LLM spend, so users who don't run agents never see an empty card. */
399
+ export function AgentLedgerCard() {
400
+ const [data, setData] = useState(null);
401
+ const load = useCallback(async () => {
402
+ try { const r = await apiGet('/v1/ledger/usage?group_by=model'); setData(r?.data || null); }
403
+ catch (err) { swallowed('landing-page.cards', err); setData(null); }
404
+ }, []);
405
+ useEffect(() => { load(); }, [load]);
406
+ const liveRef = useRef(load); liveRef.current = load;
407
+ useEffect(() => onLiveUpdate(['agents', 'agent-tasks'], () => liveRef.current()), []);
408
+
409
+ if (!data || !data.totals || data.totals.calls === 0) return null;
410
+
411
+ const { totals, groups = [] } = data;
412
+ const topModels = [...groups].sort((a, b) => (b.cost_usd || 0) - (a.cost_usd || 0)).slice(0, 5);
413
+
414
+ const tile = (label, value) => html`
415
+ <div class="pf-ai-win">
416
+ <span class="pf-ai-win-label">${label}</span>
417
+ <span class="pf-ai-win-cost">${value}</span>
418
+ </div>`;
419
+
420
+ return html`
421
+ <div class="pf-home-card pf-ai-card">
422
+ <div class="pf-home-card-title">${t('profile.landing.agentLedgerTitle') || 'Agent LLM usage'}</div>
423
+ <div class="pf-ai-windows">
424
+ ${tile(t('profile.landing.agentLedgerCost') || 'Cost', fmtUsd(totals.cost_usd))}
425
+ ${tile(t('profile.landing.agentLedgerTokens') || 'Tokens', fmtCompact(totals.total_tokens))}
426
+ ${tile(t('profile.landing.agentLedgerCalls') || 'LLM calls', fmtCompact(totals.calls))}
427
+ </div>
428
+ ${topModels.length > 0 && html`
429
+ <div class="pf-ai-apps">
430
+ <div class="pf-ai-apps-head">${t('profile.landing.agentLedgerByModel') || 'By model'}</div>
431
+ ${topModels.map((g) => html`
432
+ <div class="pf-ai-app-row" key=${g.key}>
433
+ <span class="pf-ai-app-name">${g.key}</span>
434
+ <span class="pf-ai-app-meta">${(g.providers && g.providers.length) ? g.providers.join(', ') + ' · ' : ''}${fmtCompact(g.total_tokens)} ${t('profile.landing.aiTokensWord') || 'tokens'} · ${fmtCompact(g.calls)}</span>
435
+ <span class="pf-ai-app-cost">${fmtUsd(g.cost_usd)}</span>
436
+ </div>`)}
437
+ </div>`}
438
+ </div>`;
439
+ }
440
+
441
+ /* ───── Sub-components ───── */
442
+
443
+ /* The MCP-connected mark. DERIVED from the Hello MCP proof key on every read, never stored and
444
+ * never settable by the user: only their AI can produce it, by writing through the connection.
445
+ * Renders nothing at all until the read resolves, and nothing when unproven — an unproven state
446
+ * belongs in the next-steps list as an action, not in the identity card as a complaint. */
447
+ function McpConnectedBadge() {
448
+ const [proven, setProven] = useState(undefined);
449
+ useEffect(() => {
450
+ let cancelled = false;
451
+ checkHelloMcp()
452
+ .then(r => { if (!cancelled) setProven(r.passed); })
453
+ .catch((err) => { swallowed('landing-page.cards: McpConnectedBadge', err); });
454
+ return () => { cancelled = true; };
455
+ }, []);
456
+ if (!proven) return null;
457
+ return html`<div class="pf-federation-badge pf-mcp-badge">
458
+ <span class="pf-fed-dot"></span>${t('profile.mcpConnected') || 'MCP connected'}
459
+ </div>`;
460
+ }
461
+
462
+ export function ProfileCard({ tier, stats, session, onEditProfile, switchTab }) {
463
+ const NODE_URL = getNodeUrl();
464
+ const [instrOpen, setInstrOpen] = useState(false);
465
+ const isNew = tier === 'new';
466
+ const isExperienced = tier === 'experienced';
467
+ const avatarSvg = minidenticon(typeof session.owner === 'string' && session.owner ? session.owner : 'user');
468
+
469
+ // Stats are NAVIGATION, not decoration \u2014 each one opens its own section.
470
+ const stat = (icon, val, labelKey, tabId, green) => html`
471
+ <button class="pf-lp-stat pf-lp-stat-link" onClick=${() => switchTab?.(tabId)}>
472
+ ${icon} <span class="pf-lp-stat-val${green ? ' pf-lp-stat-green' : ''}">${val}</span> ${t(labelKey)}
473
+ </button>`;
474
+
475
+ return html`
476
+ <div class="pf-lp-card">
477
+ <div class="pf-lp-card-header">
478
+ <div class="pf-lp-avatar" role="button" tabindex="0" title=${t('profile.landing.editProfile')}
479
+ onClick=${() => onEditProfile?.()} dangerouslySetInnerHTML=${{ __html: avatarSvg }}></div>
480
+ <div class="pf-lp-info">
481
+ <div class="pf-lp-name-row">
482
+ <span class="pf-lp-name">${escHtml(session.displayName || session.owner)}</span>
483
+ </div>
484
+ <div class="pf-lp-ghii">${escHtml(session.ghii || '')}</div>
485
+ <div class="pf-lp-node">${t('profile.node')}: ${escHtml(NODE_URL)}</div>
486
+ <${McpConnectedBadge} />
487
+ ${typeof stats.nodes === 'number' && stats.nodes > 0
488
+ ? html`<div class="pf-federation-badge">
489
+ <span class="pf-fed-dot"></span>
490
+ ${t('profile.federation.statusConnected').replace('{count}', String(stats.nodes))}
491
+ </div>`
492
+ : html`<div class="pf-federation-badge pf-federation-standalone">
493
+ ${t('profile.federation.statusStandalone')}
494
+ </div>`
495
+ }
496
+ </div>
497
+ <div class="pf-lp-actions">
498
+ <${PresencePill} />
499
+ <button class="btn-outline btn-sm" onClick=${() => setInstrOpen(true)}
500
+ title=${t('setup.instrBtnHint') || 'The block to paste into your AI chat’s instructions, and where it goes in your tool'}>
501
+ ${t('setup.instrBtn') || 'AI chat instructions'}</button>
502
+ <button class="btn-outline btn-sm" onClick=${() => onEditProfile?.()}>
503
+ ${t('profile.landing.profileBtn') || 'Profile'}</button>
504
+ </div>
505
+ </div>
506
+ <div class="pf-lp-stats">
507
+ ${isNew ? html`
508
+ ${stats.memory > 0 && stat('\u{1F9E0}', stats.memory, 'profile.stats.memories', 'memory')}
509
+ ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0)
510
+ && stat('\u{1F48E}', stats.balance, 'profile.stats.morsels', 'wallet', true)}
511
+ ` : html`
512
+ ${stats.apps > 0 && stat('\u{1F4F1}', stats.apps, 'profile.stats.apps', 'apps')}
513
+ ${stats.memory > 0 && stat('\u{1F9E0}', stats.memory, 'profile.stats.memories', 'memory')}
514
+ ${(stats.balance != null && stats.balance !== '-' && stats.balance > 0)
515
+ && stat('\u{1F48E}', stats.balance, 'profile.stats.morsels', 'wallet', true)}
516
+ ${stats.services > 0 && stat('\u{1F50C}', stats.services, 'profile.stats.services', 'actions')}
517
+ ${isExperienced && stats.agents > 0 && stat('\u{1F916}', stats.agents, 'profile.stats.agents', 'agents')}
518
+ `}
519
+ </div>
520
+ <${InstructionsDialog} open=${instrOpen} onClose=${() => setInstrOpen(false)} />
521
+ </div>
522
+ `;
523
+ }
524
+
525
+ /* "Suggested next steps" — a curated, value-first card pointing at the genuinely useful
526
+ * but under-used surfaces (replaces the old four-path onboarding hero that flashed for
527
+ * everyone because tier starts 'new' before stats load). First item highlighted:
528
+ * 1. Write self-organizing notes → Notebook (always; the highest-value habit)
529
+ * 2. Create your portfolio → Portfolio — ONLY if the user hasn't published one yet
530
+ * (under-used; tell others who you are)
531
+ * 3. Build an app the app catalog's create flow (prompt builder), in the portal's
532
+ * current language (?lang=) with the builder auto-opened (?create=1) — ONLY if the
533
+ * user has no app of their own yet
534
+ * 4. Use agents others shared the Offers "Do" surface (always)
535
+ * The two conditional steps render only once their data is KNOWN to be "missing" (apps
536
+ * loaded 0; portfolio config fetched not enabled), so an existing user never sees a
537
+ * step flash in then disappear. Each step carries its own `go()` so it can switch a tab
538
+ * OR open an external page. */
539
+ export function NextSteps({ switchTab, hasApps }) {
540
+ // hasPortfolio: undefined = loading, true = published config exists, false = none yet.
541
+ const [hasPortfolio, setHasPortfolio] = useState(undefined);
542
+ // Hello MCP outranks everything else while it is unproven: until the connection is verified,
543
+ // every other suggestion here is advice the user cannot act on properly. undefined = still
544
+ // reading, so the step never flashes in for someone who already passed.
545
+ const [mcpProven, setMcpProven] = useState(undefined);
546
+ useEffect(() => {
547
+ let cancelled = false;
548
+ checkHelloMcp()
549
+ .then(r => { if (!cancelled) setMcpProven(r.passed); })
550
+ .catch((err) => { swallowed('landing-page.cards: helloMcp', err); if (!cancelled) setMcpProven(true); });
551
+ return () => { cancelled = true; };
552
+ }, []);
553
+ useEffect(() => {
554
+ let cancelled = false;
555
+ apiGet('/v1/portfolio/config')
556
+ .then(r => { if (!cancelled) setHasPortfolio(!!(r?.data?.config?.enabled)); })
557
+ .catch((err) => { swallowed('landing-page.cards', err); if (!cancelled) setHasPortfolio(false); });
558
+ return () => { cancelled = true; };
559
+ }, []);
560
+
561
+ const buildAppUrl = `/app-catalog.html?lang=${encodeURIComponent(getLocale())}&create=1`;
562
+ const steps = [];
563
+ // First and most important until it passes, then gone: a proven connection is the thing the
564
+ // rest of the product is used through.
565
+ if (mcpProven === false) steps.push({ icon: '\u{1F50C}', key: 'helloMcp', go: () => switchTab('mcp') });
566
+ steps.push({ icon: '\u{1F9E0}', key: 'writeNotes', go: () => switchTab('notebook') });
567
+ if (hasPortfolio === false) steps.push({ icon: '\u{1F3A8}', key: 'portfolio', go: () => switchTab('portfolio') });
568
+ if (hasApps === false) steps.push({ icon: '\u{26A1}', key: 'buildApp', go: () => window.open(buildAppUrl, '_blank', 'noopener') });
569
+ steps.push({ icon: '\u{1F91D}', key: 'useSharedAgents', go: () => switchTab('offers') });
570
+
571
+ return html`
572
+ <div class="pf-next">
573
+ <div class="pf-next-title">${t('profile.landing.nextTitle')}</div>
574
+ <div class="pf-next-grid">
575
+ ${steps.map((s, i) => html`
576
+ <button class=${'pf-next-card' + (i === 0 ? ' pf-next-card--primary' : '')} key=${s.key}
577
+ onClick=${s.go}>
578
+ <span class="pf-next-ico">${s.icon}</span>
579
+ <span class="pf-next-body">
580
+ <span class="pf-next-card-title">${t('profile.landing.next.' + s.key + 'Title')}</span>
581
+ <span class="pf-next-card-desc">${t('profile.landing.next.' + s.key + 'Desc')}</span>
582
+ </span>
583
+ <span class="pf-next-arrow" aria-hidden="true">→</span>
584
+ </button>
585
+ `)}
586
+ </div>
587
+ <${OpenItemsList} />
588
+ </div>
589
+ `;
590
+ }
591
+
592
+ /* Onboarding promo — shown only while the user has fewer than 3 apps, and dismissable for good.
593
+ * After that the same content lives on the Extensions page; for a seasoned user it was dead space. */
594
+ export function CortexSection({ switchTab, onDismiss }) {
595
+ return html`
596
+ <div class="pf-landing-section pf-promo">
597
+ <div class="pf-menu-title">${t('profile.landing.cortexSectionTitle')}
598
+ <button class="pf-promo-dismiss" title=${t('profile.landing.promoDismiss') || 'Hide'}
599
+ onClick=${(e) => { e.stopPropagation(); onDismiss?.(); }}>✕</button>
600
+ </div>
601
+ <div class="pf-cortex-grid">
602
+ <div class="pf-cortex-card" onClick=${() => switchTab('extensions')}>
603
+ <div class="pf-cortex-header">
604
+ <span>\u{1F4CA}</span><span>${t('profile.landing.cortexCharts')}</span>
605
+ </div>
606
+ <p class="pf-cortex-desc">${t('profile.landing.cortexChartsDesc')}</p>
607
+ </div>
608
+ <div class="pf-cortex-card" onClick=${() => switchTab('extensions')}>
609
+ <div class="pf-cortex-header">
610
+ <span>\u{1F3A8}</span><span>${t('profile.landing.cortexCanvas')}</span>
611
+ </div>
612
+ <p class="pf-cortex-desc">${t('profile.landing.cortexCanvasDesc')}</p>
613
+ </div>
614
+ </div>
615
+ </div>
616
+ `;
617
+ }
618
+
619
+ /* (AppStrip removed \u2014 the cross-type "Continue" card replaced it: raw filenames in a horizontal
620
+ * scroller duplicated the Apps tab and read as a file listing.) */
621
+
622
+ /* ───── Inbox nav button — fixed under Home (non-movable), with an unread badge ───── */
623
+
624
+ export function InboxNavButton({ active, onClick }) {
625
+ const [unread, setUnread] = useState(0);
626
+ const load = useCallback(async () => {
627
+ try { const d = await listInbox(); setUnread(d?.unread || 0); } catch (err) { swallowed('landing-page.cards: InboxNavButton', err); }
628
+ }, []);
629
+ useEffect(() => { load(); }, [load]);
630
+ const ref = useRef(load); ref.current = load;
631
+ useEffect(() => onLiveUpdate(['messages'], () => ref.current()), []);
632
+ return html`
633
+ <button class="pf-side-item${active ? ' pf-side-item--active' : ''}" onClick=${onClick}>
634
+ <span class="pf-side-label">${t('profile.tabs.inbox')}</span>
635
+ ${unread > 0 ? html`<span class="pf-side-badge">${unread}</span>` : null}
636
+ </button>`;
637
+ }
638
+
639
+ /* ───── Persistent sidebar groups (replaces the tier-adaptive menu) ─────
640
+ * Every tab is always present and grouped into stable sections — no activity-based
641
+ * hiding, so humans and agentic developers can predict where each tab lives.
642
+ * Group titles reuse existing i18n keys; tab labels reuse profile.tabs.* / *.tabLabel. */
643
+ /* Grouping follows the information-refinement pipeline and usage frequency, not an
644
+ * abstract taxonomy (the old Daily/Personal/Technical groups are gone). Badges remain
645
+ * reserved for action-required counts only — never static totals. */
646
+ export const SIDEBAR_GROUPS = [
647
+ { titleKey: 'profile.landing.menuInformation', items: [ // find-anything raw curated governed
648
+ { id: 'discover', labelKey: 'discover.tabLabel' },
649
+ { id: 'organisms', labelKey: 'profile.tabs.organisms' },
650
+ { id: 'memory', labelKey: 'profile.tabs.memory' },
651
+ { id: 'notebook', labelKey: 'profile.tabs.notebook' },
652
+ { id: 'living', labelKey: 'profile.tabs.living' },
653
+ { id: 'knowledge', labelKey: 'knowledge.tabLabel' },
654
+ { id: 'boards', labelKey: 'profile.tabs.boards' },
655
+ ] },
656
+ { titleKey: 'profile.landing.menuAutomation', items: [ // agents + their infrastructure
657
+ { id: 'agents', labelKey: 'profile.tabs.agents' },
658
+ { id: 'ecosystem', labelKey: 'profile.tabs.ecosystem' },
659
+ { id: 'offers', labelKey: 'profile.tabs.offers' },
660
+ { id: 'scheduler', labelKey: 'profile.tabs.scheduler' },
661
+ { id: 'workflows', labelKey: 'profile.tabs.workflows' },
662
+ { id: 'actions', labelKey: 'profile.tabs.services' },
663
+ { id: 'mcp', labelKey: 'profile.tabs.mcp' },
664
+ ] },
665
+ { titleKey: 'profile.landing.menuActivity', items: [ // communication + events
666
+ { id: 'contacts', labelKey: 'contacts.tabLabel' },
667
+ { id: 'notifications', labelKey: 'profile.tabs.notifications' },
668
+ { id: 'email', labelKey: 'profile.tabs.email' },
669
+ { id: 'chatsessions', labelKey: 'profile.tabs.chatSessions' },
670
+ ] },
671
+ { titleKey: 'profile.landing.menuBusiness', items: [ // the company and its money
672
+ { id: 'companies', labelKey: 'profile.tabs.companies' },
673
+ { id: 'pnl', labelKey: 'profile.tabs.pnl' },
674
+ { id: 'usage', labelKey: 'profile.tabs.usage' },
675
+ ] },
676
+ { titleKey: 'profile.landing.menuBuildShare', items: [
677
+ { id: 'apps', labelKey: 'profile.tabs.apps' },
678
+ { id: 'appdev', labelKey: 'profile.tabs.appDev' },
679
+ /* foundry removed from the menu 2026-06-10 (owner: not in use). The tab module and
680
+ * its route id still exist restore by re-adding this item. */
681
+ { id: 'extensions', labelKey: 'profile.tabs.extensions' },
682
+ { id: 'libraries', labelKey: 'librariesTab.tabLabel' },
683
+ { id: 'capabilities', labelKey: 'capabilities.tabLabel' },
684
+ { id: 'skills', labelKey: 'skills.tabLabel' },
685
+ { id: 'packages', labelKey: 'profile.tabs.packages' },
686
+ { id: 'portfolio', labelKey: 'portfolio.tabLabel' },
687
+ /* AI-provider (OpenRouter) key + spend budget. Route id is still 'generator' the Generator tab
688
+ * was repurposed to host only these settings when the Generator feature was removed (2026-07-18).
689
+ * Re-added to the menu 2026-07-19 after it was orphaned (reachable only by ?tab=generator). */
690
+ { id: 'generator', labelKey: 'profile.generator.openrouter.title' },
691
+ { id: 'calibrator', labelKey: 'profile.calibrator.tabLabel' },
692
+ /* TODO(owner 2026-06-10): "work" placement is undecided — parked at the bottom of
693
+ * Build & Share until re-evaluated. */
694
+ { id: 'work', labelKey: 'profile.tabs.work' },
695
+ ] },
696
+ { titleKey: 'profile.landing.menuAccount', items: [
697
+ { id: 'wallet', labelKey: 'profile.tabs.wallet' },
698
+ { id: 'dataWallet', labelKey: 'profile.tabs.dataWallet' },
699
+ { id: 'access', labelKey: 'profile.tabs.access' },
700
+ ] },
701
+ /* Operator-only: the group AND its routes are gated on the operator role (open()
702
+ * refuses these ids for non-operators; the underlying APIs enforce server-side).
703
+ * nodeStats left the menu — it lives as a tab on the Nodes page now. */
704
+ { titleKey: 'profile.landing.menuInfra', adminOnly: true, items: [
705
+ { id: 'federation', labelKey: 'profile.tabs.federation' },
706
+ { id: 'nodes', labelKey: 'profile.tabs.nodes' },
707
+ { id: 'security', labelKey: 'profile.tabs.security' },
708
+ ] },
709
+ ];
710
+
711
+ /**
712
+ * The BASIC menu: what a person needs before they have built anything on the node. 38 items in
713
+ * one flat list was a measured wall for a non-technical newcomer (UX-remake v3, K5), and the
714
+ * chat-first model says the profile's deeper surfaces are for technical users. Everything else
715
+ * is one "Show all tools" toggle away, and the toggle state is remembered — nothing is removed.
716
+ * Operator-only groups are never in the basic set (they are role-gated anyway).
717
+ */
718
+ export const BASIC_TAB_IDS = new Set([
719
+ 'companies', // your company: address, legal identity, sender
720
+ 'organisms', 'memory', 'notebook', // where the work and knowledge live
721
+ 'agents', 'mcp', // the chat/agent connection
722
+ 'notifications', 'contacts', // what happened, who with
723
+ 'apps', 'portfolio', // what you made
724
+ 'wallet', 'access', // account
725
+ ]);
726
+
727
+ // Flat item lookup (pinned section renders items by id).
728
+ export const SIDEBAR_ITEM_BY_ID = Object.fromEntries(SIDEBAR_GROUPS.flatMap(g => g.items.map(it => [it.id, it])));
729
+ export const INFRA_TAB_IDS = new Set(['federation', 'nodes', 'nodeStats', 'security']);
730
+ // Defaults stay inside BASIC_TAB_IDS: a default pin to a tool the basic menu hides (scheduler,
731
+ // until 2026-08-07) contradicted the two-level menu the moment it shipped.
732
+ export const DEFAULT_PINS = ['organisms', 'agents', 'memory', 'mcp'];