aimeat 1.23.2 → 1.23.3

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 (154) hide show
  1. package/dist/locales/en.json +536 -21
  2. package/dist/locales/fi.json +548 -33
  3. package/dist/public/components/ContactCard.js +28 -0
  4. package/dist/public/css/views/admin.css +12 -0
  5. package/dist/public/css/views/agents-detail.css +62 -10
  6. package/dist/public/css/views/landing.css +87 -0
  7. package/dist/public/css/views/offers.css +96 -0
  8. package/dist/public/css/views/portfolio.css +18 -0
  9. package/dist/public/css/views/profile.css +153 -8
  10. package/dist/public/css/views/start.css +96 -0
  11. package/dist/public/img/business-hero.png +0 -0
  12. package/dist/public/img/toimitus.png +0 -0
  13. package/dist/public/js/components/tag-cloud.js +46 -31
  14. package/dist/public/js/components/tag-editor.js +2 -2
  15. package/dist/public/js/recents.js +40 -0
  16. package/dist/public/js/services/admin.js +6 -0
  17. package/dist/public/js/services/agents.js +34 -0
  18. package/dist/public/js/services/offers.js +152 -0
  19. package/dist/public/js/services/packages.js +1 -0
  20. package/dist/public/spa.html +32 -2
  21. package/dist/public/views/admin/subdomains-tab.js +182 -0
  22. package/dist/public/views/admin.js +2 -0
  23. package/dist/public/views/business.js +119 -0
  24. package/dist/public/views/how-it-works.js +159 -0
  25. package/dist/public/views/landing.js +235 -0
  26. package/dist/public/views/portfolio.js +120 -23
  27. package/dist/public/views/pricing.js +75 -0
  28. package/dist/public/views/profile/access-tab.js +124 -51
  29. package/dist/public/views/profile/agents/agent-card.js +68 -37
  30. package/dist/public/views/profile/agents/shared-board.js +56 -22
  31. package/dist/public/views/profile/agents/tab-activity.js +24 -12
  32. package/dist/public/views/profile/agents/tab-agent-config.js +31 -1
  33. package/dist/public/views/profile/agents/tab-data-access.js +9 -13
  34. package/dist/public/views/profile/agents/tab-integration.js +27 -1
  35. package/dist/public/views/profile/agents/tab-messages.js +6 -11
  36. package/dist/public/views/profile/agents/tab-quality.js +45 -5
  37. package/dist/public/views/profile/agents-tab.js +41 -6
  38. package/dist/public/views/profile/apps-tab.js +7 -1
  39. package/dist/public/views/profile/knowledge-tab.js +4 -1
  40. package/dist/public/views/profile/landing-page.js +427 -123
  41. package/dist/public/views/profile/memory-tab.js +409 -211
  42. package/dist/public/views/profile/nodes-tab.js +18 -3
  43. package/dist/public/views/profile/offers-tab.js +310 -0
  44. package/dist/public/views/profile/organisms-tab.js +289 -28
  45. package/dist/public/views/profile/packages-tab.js +152 -16
  46. package/dist/public/views/profile/portfolio-tab.js +98 -31
  47. package/dist/public/views/profile.js +2 -0
  48. package/dist/public/views/start-flows.js +281 -0
  49. package/dist/public/views/start.js +258 -0
  50. package/dist/src/generated/api-types.d.ts +856 -17
  51. package/dist/src/generated/api-types.d.ts.map +1 -1
  52. package/dist/src/generated/prisma-postgres/edge.js +15 -3
  53. package/dist/src/generated/prisma-postgres/index-browser.js +12 -0
  54. package/dist/src/generated/prisma-postgres/index.d.ts +1476 -160
  55. package/dist/src/generated/prisma-postgres/index.js +15 -3
  56. package/dist/src/generated/prisma-postgres/package.json +1 -1
  57. package/dist/src/generated/prisma-postgres/schema.prisma +11 -0
  58. package/dist/src/generated/prisma-postgres/wasm.js +15 -3
  59. package/dist/src/middleware/subdomain.d.ts +28 -0
  60. package/dist/src/middleware/subdomain.d.ts.map +1 -0
  61. package/dist/src/middleware/subdomain.js +32 -0
  62. package/dist/src/middleware/subdomain.js.map +1 -0
  63. package/dist/src/models/offer-schemas.d.ts +229 -0
  64. package/dist/src/models/offer-schemas.d.ts.map +1 -0
  65. package/dist/src/models/offer-schemas.js +100 -0
  66. package/dist/src/models/offer-schemas.js.map +1 -0
  67. package/dist/src/routes/agent-tasks.d.ts.map +1 -1
  68. package/dist/src/routes/agent-tasks.js +33 -0
  69. package/dist/src/routes/agent-tasks.js.map +1 -1
  70. package/dist/src/routes/agents.d.ts.map +1 -1
  71. package/dist/src/routes/agents.js +146 -1
  72. package/dist/src/routes/agents.js.map +1 -1
  73. package/dist/src/routes/apps-backup.d.ts +20 -0
  74. package/dist/src/routes/apps-backup.d.ts.map +1 -0
  75. package/dist/src/routes/apps-backup.js +165 -0
  76. package/dist/src/routes/apps-backup.js.map +1 -0
  77. package/dist/src/routes/ghii.d.ts.map +1 -1
  78. package/dist/src/routes/ghii.js +4 -0
  79. package/dist/src/routes/ghii.js.map +1 -1
  80. package/dist/src/routes/instances.d.ts +2 -1
  81. package/dist/src/routes/instances.d.ts.map +1 -1
  82. package/dist/src/routes/instances.js +59 -1
  83. package/dist/src/routes/instances.js.map +1 -1
  84. package/dist/src/routes/packages.d.ts.map +1 -1
  85. package/dist/src/routes/packages.js +32 -0
  86. package/dist/src/routes/packages.js.map +1 -1
  87. package/dist/src/routes/permissions.d.ts +12 -0
  88. package/dist/src/routes/permissions.d.ts.map +1 -1
  89. package/dist/src/routes/permissions.js +35 -3
  90. package/dist/src/routes/permissions.js.map +1 -1
  91. package/dist/src/routes/portal.d.ts +3 -0
  92. package/dist/src/routes/portal.d.ts.map +1 -1
  93. package/dist/src/routes/portal.js +12 -0
  94. package/dist/src/routes/portal.js.map +1 -1
  95. package/dist/src/routes/public-stats.d.ts +15 -0
  96. package/dist/src/routes/public-stats.d.ts.map +1 -0
  97. package/dist/src/routes/public-stats.js +90 -0
  98. package/dist/src/routes/public-stats.js.map +1 -0
  99. package/dist/src/routes/sse.d.ts +7 -2
  100. package/dist/src/routes/sse.d.ts.map +1 -1
  101. package/dist/src/routes/sse.js +35 -5
  102. package/dist/src/routes/sse.js.map +1 -1
  103. package/dist/src/routes/subdomains.d.ts +33 -0
  104. package/dist/src/routes/subdomains.d.ts.map +1 -0
  105. package/dist/src/routes/subdomains.js +208 -0
  106. package/dist/src/routes/subdomains.js.map +1 -0
  107. package/dist/src/server-bootstrap/routes-loader.d.ts.map +1 -1
  108. package/dist/src/server-bootstrap/routes-loader.js +12 -1
  109. package/dist/src/server-bootstrap/routes-loader.js.map +1 -1
  110. package/dist/src/server.d.ts.map +1 -1
  111. package/dist/src/server.js +4 -0
  112. package/dist/src/server.js.map +1 -1
  113. package/dist/src/services/apps-backup-export.d.ts +22 -0
  114. package/dist/src/services/apps-backup-export.d.ts.map +1 -0
  115. package/dist/src/services/apps-backup-export.js +136 -0
  116. package/dist/src/services/apps-backup-export.js.map +1 -0
  117. package/dist/src/services/apps-backup-import.d.ts +120 -0
  118. package/dist/src/services/apps-backup-import.d.ts.map +1 -0
  119. package/dist/src/services/apps-backup-import.js +259 -0
  120. package/dist/src/services/apps-backup-import.js.map +1 -0
  121. package/dist/src/services/component-registrar.d.ts +17 -0
  122. package/dist/src/services/component-registrar.d.ts.map +1 -1
  123. package/dist/src/services/component-registrar.js +63 -5
  124. package/dist/src/services/component-registrar.js.map +1 -1
  125. package/dist/src/services/handbooks/appdev.d.ts +1 -1
  126. package/dist/src/services/handbooks/appdev.d.ts.map +1 -1
  127. package/dist/src/services/handbooks/appdev.js +5 -2
  128. package/dist/src/services/handbooks/appdev.js.map +1 -1
  129. package/dist/src/storage/interface.d.ts +11 -1
  130. package/dist/src/storage/interface.d.ts.map +1 -1
  131. package/dist/src/storage/interface.js.map +1 -1
  132. package/dist/src/storage/providers/mongodb/index.d.ts +10 -1
  133. package/dist/src/storage/providers/mongodb/index.d.ts.map +1 -1
  134. package/dist/src/storage/providers/mongodb/index.js +76 -0
  135. package/dist/src/storage/providers/mongodb/index.js.map +1 -1
  136. package/dist/src/storage/providers/sqlite/index.d.ts +8 -1
  137. package/dist/src/storage/providers/sqlite/index.d.ts.map +1 -1
  138. package/dist/src/storage/providers/sqlite/index.js +61 -0
  139. package/dist/src/storage/providers/sqlite/index.js.map +1 -1
  140. package/dist/src/storage/providers/sqlite/schema.d.ts.map +1 -1
  141. package/dist/src/storage/providers/sqlite/schema.js +11 -0
  142. package/dist/src/storage/providers/sqlite/schema.js.map +1 -1
  143. package/dist/src/storage/repositories/index.d.ts +1 -0
  144. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  145. package/dist/src/storage/repositories/package.repository.d.ts +2 -0
  146. package/dist/src/storage/repositories/package.repository.d.ts.map +1 -1
  147. package/dist/src/storage/repositories/subdomain-site.repository.d.ts +19 -0
  148. package/dist/src/storage/repositories/subdomain-site.repository.d.ts.map +1 -0
  149. package/dist/src/storage/repositories/subdomain-site.repository.js +2 -0
  150. package/dist/src/storage/repositories/subdomain-site.repository.js.map +1 -0
  151. package/dist/static/app-catalog.html +1139 -28
  152. package/package.json +1 -1
  153. package/prisma/schema.postgres.prisma +11 -0
  154. package/prisma/schema.prisma +11 -0
@@ -11,6 +11,25 @@
11
11
  * <Modal> component (Escape/backdrop close + header ✕).
12
12
  * v1.5.0 — 2026-06-02 — Component unification (#1): "Copy URLs" button uses canonical
13
13
  * <CopyButton> (toast preserved via onCopied).
14
+ * v2.0.0 — 2026-06-10 — Structural rework: (1) keys render as COLLAPSIBLE GROUPS by first
15
+ * segment (organism.<uuid> groups resolve the uuid to the organism's name; remainders
16
+ * shortened with '›' + middle-ellipsis, full key in title + expanded detail); (2) the
17
+ * per-row click-to-cycle visibility pill is GONE — list shows a static VisibilityPill,
18
+ * editing happens in the edit modal (entries) / an explicit select (files), and the
19
+ * "Skip → public" fallback was removed; (3) top-level tabs Mine · Public · Remote nodes
20
+ * replace the inline browse panels; (4) search is type-to-filter (client-side over
21
+ * key/tags/value); (5) tag cloud capped at top-10-by-use with "show all"; (6) shield
22
+ * icon only on rows that actually have sharing rules; rules popover shows localized
23
+ * visibility from the record's REAL owner (backend fix in permissions.ts); (7) edit
24
+ * modal: tall JSON editor + parse validation (invalid JSON can't save; valid JSON is
25
+ * stored parsed, not as a string); (8) "no sharing groups" dead end now offers a
26
+ * "Create a group →" jump to the Access tab; Delete separated to the row's far right;
27
+ * discover/pull copy buttons neutralized.
28
+ * v2.1.0 — 2026-06-10 — Visibility one-click-away again, deliberately: clicking the row's
29
+ * badge opens a small popover with all options INCLUDING the sharing groups (the Access
30
+ * group-share brought into Memory; "Create a group →" when none exist). Plus bulk edit:
31
+ * per-row checkboxes + a bulk bar (change visibility incl. group / delete with confirm /
32
+ * clear). The detail-row select and edit-modal select remain as alternate paths.
14
33
  */
15
34
  import { h } from 'preact';
16
35
  import { useState, useEffect, useRef } from 'preact/hooks';
@@ -26,6 +45,7 @@ import { listPeers } from '/js/services/federation.js';
26
45
  import { getKeyPermissions, listConsents, grantConsent, revokeConsent } from '/js/services/consent.js';
27
46
  import { getNodeUrl } from '/js/services/auth.js';
28
47
  import { listGroups } from '/js/services/sharing-groups.js';
48
+ import { listOrganisms } from '/js/services/organisms.js';
29
49
  import TagCloud from '/js/components/tag-cloud.js';
30
50
  import TagEditor from '/js/components/tag-editor.js';
31
51
  import { Modal, useConfirm } from '/components/Modal.js';
@@ -48,10 +68,17 @@ export default function MemoryTab({ session, showToast, onStats }) {
48
68
  const [agents, setAgents] = useState([]);
49
69
  const [selectedAgent, setSelectedAgent] = useState('');
50
70
  const [fedConsents, setFedConsents] = useState({}); // { memoryKey: consentId }
71
+ const [allConsents, setAllConsents] = useState([]); // every active consent — drives the per-row shield
51
72
  const [togglingFed, setTogglingFed] = useState(null);
52
73
  const [groups, setGroups] = useState([]);
53
- const [groupPickerFor, setGroupPickerFor] = useState(null);
54
- const [browseMode, setBrowseMode] = useState(null); // 'home' | 'remote' | null
74
+ // Top-level view: own list / public discovery / remote nodes (home node when federated).
75
+ const [mainTab, setMainTab] = useState('own'); // 'own' | 'discover' | 'remote'
76
+ const [browseMode, setBrowseMode] = useState(null); // 'home' | 'remote' | 'discover' | null
77
+ const [filterText, setFilterText] = useState('');
78
+ const [orgNames, setOrgNames] = useState({}); // organism uuid → display name
79
+ const [collapsedGroups, setCollapsedGroups] = useState(() => {
80
+ try { return new Set(JSON.parse(sessionStorage.getItem('aimeat.mem.groups.collapsed') || '[]')); } catch { return new Set(); }
81
+ });
55
82
  const [remoteEntries, setRemoteEntries] = useState(null);
56
83
  const [remotePeers, setRemotePeers] = useState([]);
57
84
  const [selectedPeer, setSelectedPeer] = useState('');
@@ -67,18 +94,38 @@ export default function MemoryTab({ session, showToast, onStats }) {
67
94
  const [expandedDiscover, setExpandedDiscover] = useState(null);
68
95
 
69
96
  useEffect(() => {
70
- if (session) { loadAgents(); loadMemories(); loadFiles(); loadFedConsents(); loadGroups(); }
97
+ if (session) { loadAgents(); loadMemories(); loadFiles(); loadFedConsents(); loadGroups(); loadOrgNames(); }
71
98
  }, [session]);
72
99
 
100
+ // Resolve organism UUIDs to display names for the grouped key list — nobody
101
+ // recognizes e97781a5… but everyone recognizes "Ultima-V Remake".
102
+ async function loadOrgNames() {
103
+ try {
104
+ const resp = await listOrganisms({ member: session.owner });
105
+ const map = {};
106
+ for (const o of (resp?.data?.organisms || [])) map[o.id] = o.name;
107
+ setOrgNames(map);
108
+ } catch { /* names are a nicety — ids still render */ }
109
+ }
110
+
111
+ function toggleGroupCollapsed(id) {
112
+ setCollapsedGroups(prev => {
113
+ const next = new Set(prev);
114
+ if (next.has(id)) next.delete(id); else next.add(id);
115
+ try { sessionStorage.setItem('aimeat.mem.groups.collapsed', JSON.stringify([...next])); } catch { /* noop */ }
116
+ return next;
117
+ });
118
+ }
119
+
73
120
  useEffect(() => {
74
121
  if (session) { loadMemories(); }
75
122
  }, [selectedAgent]);
76
123
 
77
124
  async function loadFedConsents() {
78
125
  try {
79
- const allConsents = await listConsents();
126
+ const consents = await listConsents();
80
127
  const map = {};
81
- for (const c of allConsents) {
128
+ for (const c of consents) {
82
129
  if (c.scope === 'federation') {
83
130
  const pat = c.data_pattern || c.pattern || '';
84
131
  // Direct key match (no wildcard) or exact pattern
@@ -88,9 +135,22 @@ export default function MemoryTab({ session, showToast, onStats }) {
88
135
  }
89
136
  }
90
137
  setFedConsents(map);
138
+ setAllConsents(consents.filter(c => !c.status || c.status === 'active'));
91
139
  } catch { /* ignore */ }
92
140
  }
93
141
 
142
+ // Does any active consent pattern cover this key? Drives the per-row shield:
143
+ // shown only when rules exist, so its presence carries information.
144
+ function keyHasRules(key) {
145
+ return allConsents.some(c => {
146
+ const pat = c.data_pattern || c.pattern || '';
147
+ if (!pat) return false;
148
+ if (pat === '*' || pat === key) return true;
149
+ if (pat.endsWith('*')) return key.startsWith(pat.slice(0, -1));
150
+ return false;
151
+ });
152
+ }
153
+
94
154
  async function handleShareToFederation(key) {
95
155
  setTogglingFed(key);
96
156
  try {
@@ -183,7 +243,12 @@ export default function MemoryTab({ session, showToast, onStats }) {
183
243
  }
184
244
 
185
245
  async function handleSaveEdit(key, value, visibility, version, groupId) {
186
- const body = { value, visibility, version };
246
+ // The editor works on text; a value that parses as JSON is stored as the parsed
247
+ // object (not a string of JSON) so readers get back what the writer stored.
248
+ let v = value;
249
+ const s = String(value || '').trim();
250
+ if (/^[[{]/.test(s)) { try { v = JSON.parse(s); } catch { /* modal validates; keep raw as fallback */ } }
251
+ const body = { value: v, visibility, version };
187
252
  if (visibility === 'group' && groupId) body.group_id = groupId;
188
253
  const resp = await memoryService.updateMemoryFull(key, body);
189
254
  if (resp.ok === false) { showToast(resp.error?.message || t('profile.error'), true); return; }
@@ -192,15 +257,6 @@ export default function MemoryTab({ session, showToast, onStats }) {
192
257
  loadMemories();
193
258
  }
194
259
 
195
- async function handleSearch(query) {
196
- if (!query) { loadMemories(); return; }
197
- try {
198
- const agentGaii = selectedAgent || undefined;
199
- const list = await memoryService.searchMemory(query, agentGaii);
200
- setMemories(Array.isArray(list) ? list : []);
201
- } catch { showToast(t('profile.memory.searchFailed'), true); }
202
- }
203
-
204
260
  async function handleUploadFiles(fileItems, visibility, tags) {
205
261
  if (!fileItems || fileItems.length === 0) return;
206
262
  let ok = 0, fail = 0;
@@ -264,19 +320,72 @@ export default function MemoryTab({ session, showToast, onStats }) {
264
320
  } catch { setKeyRulesPopover(null); }
265
321
  }
266
322
 
267
- /* ── Cycle visibility: private → owner → group → public → private ── */
268
- const cycleVis = ['private', 'owner', 'group', 'public'];
269
- const visColor = { private: '#c084fc', owner: '#60a5fa', group: '#10b981', public: '#4ade80' };
270
- const visBg = { private: 'rgba(150,100,200,.2)', owner: 'rgba(100,150,255,.2)', group: 'rgba(16,185,129,.2)', public: 'rgba(0,200,100,.2)' };
323
+ /* Visibility is edited via an explicit select inside the EXPANDED detail (and in the
324
+ edit modal) — the old per-row click-to-cycle pill meant one stray click in the list
325
+ could publish a memory. The list rows show a static badge only. */
326
+ const VIS_OPTIONS = ['private', 'owner', 'group', 'public'];
327
+
328
+ // Quick visibility change from the expanded detail. 'group' needs a group pick,
329
+ // so it routes to the edit modal where the group select lives.
330
+ async function handleQuickVis(m, newVis) {
331
+ if (newVis === (m.visibility || 'private')) return;
332
+ if (newVis === 'group') {
333
+ setEditModal({ key: m.key, value: typeof m.value === 'object' ? JSON.stringify(m.value, null, 2) : String(m.value || ''), visibility: 'group', version: m.version, isJson: typeof m.value === 'object' });
334
+ return;
335
+ }
336
+ const resp = await memoryService.updateMemoryVisibility(m.key, newVis, m.version);
337
+ if (resp.ok === false) showToast(resp.error?.message || t('profile.error'), true);
338
+ loadMemories();
339
+ }
271
340
 
272
- async function handleUpdateVisibility(key, newVis, version, groupId) {
273
- // Optimistic update — change locally first, then persist
274
- setMemories(prev => prev.map(m => m.key === key ? { ...m, visibility: newVis, version: (m.version || 1) + 1 } : m));
275
- const resp = await memoryService.updateMemoryVisibility(key, newVis, version, groupId);
276
- if (resp.ok === false) {
277
- showToast(resp.error?.message || t('profile.error'), true);
278
- loadMemories(); // Revert on error
341
+ // Badge-click popover: pick a visibility (incl. a sharing group) right in the row.
342
+ // Deliberate two clicks — open, then choose — so a stray click can't publish anything.
343
+ const [visPopoverFor, setVisPopoverFor] = useState(null);
344
+ async function applyVis(m, newVis, groupId) {
345
+ setVisPopoverFor(null);
346
+ if (newVis === (m.visibility || 'private') && !groupId) return;
347
+ const resp = await memoryService.updateMemoryVisibility(m.key, newVis, m.version, groupId);
348
+ if (resp.ok === false) showToast(resp.error?.message || t('profile.error'), true);
349
+ loadMemories();
350
+ }
351
+
352
+ // Bulk edit: checkbox-select rows, then change visibility (or delete) for all at once.
353
+ const [selectedKeys, setSelectedKeys] = useState(new Set());
354
+ const [bulkVis, setBulkVis] = useState('private');
355
+ const toggleSelected = (key) => setSelectedKeys(prev => {
356
+ const next = new Set(prev);
357
+ if (next.has(key)) next.delete(key); else next.add(key);
358
+ return next;
359
+ });
360
+
361
+ async function applyBulkVis() {
362
+ const targets = (memories || []).filter(m => selectedKeys.has(m.key));
363
+ const groupId = bulkVis.startsWith('group:') ? bulkVis.slice(6) : undefined;
364
+ const vis = groupId ? 'group' : bulkVis;
365
+ let ok = 0, fail = 0;
366
+ for (const m of targets) {
367
+ try {
368
+ const resp = await memoryService.updateMemoryVisibility(m.key, vis, m.version, groupId);
369
+ if (resp.ok === false) fail++; else ok++;
370
+ } catch { fail++; }
279
371
  }
372
+ showToast((t('profile.memory.bulkDone') || '{n} updated').replace('{n}', String(ok)) + (fail ? ` · ${fail} ${t('profile.error') || 'failed'}` : ''), fail > 0);
373
+ setSelectedKeys(new Set());
374
+ loadMemories();
375
+ }
376
+
377
+ function bulkDelete() {
378
+ const n = selectedKeys.size;
379
+ confirm((t('profile.memory.bulkDeleteConfirm') || 'Delete {n} memory entries? This cannot be undone.').replace('{n}', String(n)), async () => {
380
+ let ok = 0;
381
+ for (const key of selectedKeys) {
382
+ try { const r = await memoryService.deleteMemory(key); if (r.ok !== false) ok++; } catch { /* count below */ }
383
+ }
384
+ showToast((t('profile.memory.bulkDeleted') || '{n} deleted').replace('{n}', String(ok)));
385
+ setSelectedKeys(new Set());
386
+ setExpandedMem(null);
387
+ loadMemories();
388
+ }, { danger: true });
280
389
  }
281
390
 
282
391
  async function handleUpdateMemoryTags(key, tags, version) {
@@ -330,22 +439,59 @@ export default function MemoryTab({ session, showToast, onStats }) {
330
439
  }
331
440
  }
332
441
 
442
+ /* ── Key grouping helpers: keys are already hierarchical (agents.*, organism.<uuid>.*,
443
+ notif.*) — render collapsible groups instead of a flat list of near-identical rows. */
444
+ const shortTok = (tok) => (tok.length >= 18 ? tok.slice(0, 4) + '…' + tok.slice(-5) : tok);
445
+
446
+ function groupOfKey(key) {
447
+ if (key.startsWith('organism.')) {
448
+ const uuid = key.split('.')[1] || '';
449
+ return { id: 'organism.' + uuid, kind: 'organism', uuid };
450
+ }
451
+ const dot = key.indexOf('.');
452
+ if (dot < 0) return { id: '_other', kind: 'other' };
453
+ return { id: key.slice(0, dot), kind: 'plain' };
454
+ }
455
+
456
+ function groupLabel(g) {
457
+ if (g.kind === 'organism') return 'organism: ' + (orgNames[g.uuid] || shortTok(g.uuid));
458
+ if (g.kind === 'other') return t('profile.memory.groupOther');
459
+ return g.id;
460
+ }
461
+
462
+ // Shortened remainder inside a group: strip the group prefix, drop the 'w.' workspace
463
+ // marker, middle-ellipsize uuid-ish tokens, split the leading container with '›'.
464
+ // The full key stays in the row's title attribute (and in the expanded detail).
465
+ function displayRemainder(key, g) {
466
+ let rest = g.kind === 'organism' ? key.slice(('organism.' + g.uuid + '.').length)
467
+ : g.kind === 'plain' ? key.slice(g.id.length + 1)
468
+ : key;
469
+ if (rest.startsWith('w.')) rest = rest.slice(2);
470
+ const toks = rest.split('.').map(shortTok);
471
+ if (toks.length > 1 && toks[0].startsWith('ws-')) return toks[0] + ' › ' + toks.slice(1).join('.');
472
+ if (toks.length > 1 && toks[toks.length - 1].includes('…')) return toks.slice(0, -1).join('.') + ' › ' + toks[toks.length - 1];
473
+ return toks.join('.');
474
+ }
475
+
333
476
  const renderEntries = () => {
334
- const searchRef = useRef(null);
335
477
  if (!memories) return html`<${Spinner} text=${t('profile.memory.loading')} />`;
336
478
 
337
- // Collect all unique tags across memories
338
- const allMemTags = new Set();
479
+ // Tag counts across memories — the cloud shows the most-used first, capped at 10.
480
+ const tagCounts = new Map();
339
481
  for (const m of memories) {
340
- if (m.tags) for (const tag of m.tags) allMemTags.add(tag);
482
+ if (m.tags) for (const tag of m.tags) tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
341
483
  }
342
-
343
- // Filter by selected tags, then sort
344
- const filtered = sortEntries(
345
- memTagFilter.size === 0 ? memories : memories.filter(m =>
346
- m.tags && [...memTagFilter].every(tag => m.tags.includes(tag))
347
- )
348
- );
484
+ const tagsByFreq = [...tagCounts.keys()].sort((a, b) => (tagCounts.get(b) - tagCounts.get(a)) || a.localeCompare(b));
485
+
486
+ // Filter: selected tags AND the live type-to-filter text (key, tags, value).
487
+ const ft = filterText.trim().toLowerCase();
488
+ const filtered = sortEntries(memories.filter(m => {
489
+ if (memTagFilter.size > 0 && !(m.tags && [...memTagFilter].every(tag => m.tags.includes(tag)))) return false;
490
+ if (!ft) return true;
491
+ if (m.key.toLowerCase().includes(ft)) return true;
492
+ if (m.tags && m.tags.some(tag => tag.toLowerCase().includes(ft))) return true;
493
+ try { return JSON.stringify(m.value).toLowerCase().includes(ft); } catch { return false; }
494
+ }));
349
495
 
350
496
  const toggleMemTag = (tag) => {
351
497
  setMemTagFilter(prev => {
@@ -355,12 +501,126 @@ export default function MemoryTab({ session, showToast, onStats }) {
355
501
  });
356
502
  };
357
503
 
504
+ // Group in sorted order (group order = first appearance, so sort semantics hold).
505
+ const groupsOrdered = [];
506
+ const byId = new Map();
507
+ for (const m of filtered) {
508
+ const g = groupOfKey(m.key);
509
+ let entry = byId.get(g.id);
510
+ if (!entry) { entry = { ...g, items: [] }; byId.set(g.id, entry); groupsOrdered.push(entry); }
511
+ entry.items.push(m);
512
+ }
513
+ // An active filter force-expands all groups — a hit hidden in a collapsed group reads as "no hit".
514
+ const filtering = !!ft || memTagFilter.size > 0;
515
+
516
+ const renderRow = (m, g) => html`
517
+ <div key=${m.key}>
518
+ <div class="mem-item mem-item--grouped" onClick=${() => setExpandedMem(expandedMem === m.key ? null : m.key)}>
519
+ <input type="checkbox" class="mem-row-check" checked=${selectedKeys.has(m.key)}
520
+ onClick=${(e) => e.stopPropagation()} onChange=${() => toggleSelected(m.key)} />
521
+ <span class="mem-key" title=${m.key}>${escHtml(displayRemainder(m.key, g))}</span>
522
+ <span class="mem-time" title="${m.created_at ? new Date(m.created_at).toLocaleString() : ''} / ${m.updated_at ? new Date(m.updated_at).toLocaleString() : ''}">
523
+ ${formatRelativeTime(m.updated_at || m.created_at)}
524
+ </span>
525
+ <${VisibilityPill} visibility=${m.visibility || 'private'}
526
+ onClick=${(e) => { e.stopPropagation(); setVisPopoverFor(visPopoverFor === m.key ? null : m.key); }} />
527
+ ${keyHasRules(m.key) && html`<span class="shield-icon" title=${t('permissions.sharingRules')} onClick=${(e) => { e.stopPropagation(); loadKeyPerms(m.key); }}>\u{1F6E1}️</span>`}
528
+ ${fedConsents[m.key] && html`<span class="badge badge-success pf-fed-badge">${t('profile.memory.syncedToFederation')}</span>`}
529
+ </div>
530
+ ${visPopoverFor === m.key && html`
531
+ <div class="mem-vis-pop" onClick=${(e) => e.stopPropagation()}>
532
+ ${VIS_OPTIONS.filter(v => v !== 'group').map(v => html`
533
+ <button key=${v} class="mem-vis-opt ${(m.visibility || 'private') === v ? 'mem-vis-opt--current' : ''}"
534
+ onClick=${() => applyVis(m, v)}>${t('knowledge.visibility.' + v)}</button>
535
+ `)}
536
+ ${groups.length > 0
537
+ ? groups.map(grp => html`
538
+ <button key=${grp.id} class="mem-vis-opt ${m.visibility === 'group' && m.group_id === grp.id ? 'mem-vis-opt--current' : ''}"
539
+ onClick=${() => applyVis(m, 'group', grp.id)}>${t('knowledge.visibility.group')}: ${escHtml(grp.name)}</button>
540
+ `)
541
+ : html`<button class="mem-vis-opt mem-vis-opt--dim" onClick=${() => {
542
+ try { sessionStorage.setItem('aimeat.access.focus', 'groups'); } catch { /* noop */ }
543
+ window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'access' } }));
544
+ }}>${t('profile.memory.createGroupBtn')}</button>`}
545
+ </div>
546
+ `}
547
+ ${expandedMem === m.key && html`
548
+ <div class="mem-detail">
549
+ <div class="mem-detail-key" title=${m.key}>${escHtml(m.key)}</div>
550
+ <pre>${typeof m.value === 'object' ? JSON.stringify(m.value, null, 2) : String(m.value || '')}</pre>
551
+ <div class="mem-detail-visrow mb-half">
552
+ <span class="text-meta-sm">${t('profile.memory.visLabel')}</span>
553
+ <select class="input-field mem-vis-select" value=${m.visibility || 'private'}
554
+ onClick=${(e) => e.stopPropagation()}
555
+ onChange=${(e) => handleQuickVis(m, e.target.value)}>
556
+ ${VIS_OPTIONS.map(v => html`<option key=${v} value=${v}>${t('knowledge.visibility.' + v)}</option>`)}
557
+ </select>
558
+ </div>
559
+ <div class="mb-half">
560
+ <button class="btn-outline btn-sm" onClick=${(e) => { e.stopPropagation(); setEditingMemTags(editingMemTags === m.key ? null : m.key); }}>
561
+ ${t('tags.editTags') || 'Edit tags'}
562
+ </button>
563
+ <button class="btn-outline btn-sm" onClick=${(e) => { e.stopPropagation(); if (keyRulesPopover?.key === m.key) setKeyRulesPopover(null); else loadKeyPerms(m.key); }}>
564
+ \u{1F6E1}️ ${t('permissions.sharingRules')}
565
+ </button>
566
+ </div>
567
+ ${editingMemTags === m.key && html`
568
+ <div class="mb-half">
569
+ <${TagEditor} tags=${m.tags || []} onSave=${(tags) => handleUpdateMemoryTags(m.key, tags, m.version)} />
570
+ </div>
571
+ `}
572
+ ${editingMemTags !== m.key && m.tags?.length > 0 && html`<div class="text-meta-sm mb-half">${m.tags.join(', ')}</div>`}
573
+ ${keyRulesPopover && keyRulesPopover.key === m.key && html`
574
+ <div class="key-rules-box">
575
+ <div class="flex-between mb-half">
576
+ <strong class="text-caption">\u{1F6E1}️ ${t('permissions.sharingRules')}</strong>
577
+ <button class="btn-outline btn-sm" onClick=${() => setKeyRulesPopover(null)}>✕</button>
578
+ </div>
579
+ <div class="text-meta-sm mb-half">${t('profile.memory.visLabel')} ${t('knowledge.visibility.' + keyRulesPopover.visibility) || keyRulesPopover.visibility}</div>
580
+ ${keyRulesPopover.rules.length === 0
581
+ ? html`<div class="text-meta pf-italic">${t('permissions.noRules')}</div>`
582
+ : keyRulesPopover.rules.map(r => html`
583
+ <div class="pf-rule-row">
584
+ ${recipientBadge(r.recipient)}
585
+ <span class="text-code text-meta-sm">${escHtml(r.data_pattern)}</span>
586
+ <span class="text-meta-sm pf-ml-auto">${escHtml(r.scope || '-')}</span>
587
+ </div>`)
588
+ }
589
+ </div>
590
+ `}
591
+ <div class="mem-actions">
592
+ <button class="btn-sm" onClick=${() => setEditModal({ key: m.key, value: typeof m.value === 'object' ? JSON.stringify(m.value, null, 2) : String(m.value || ''), visibility: m.visibility || 'private', version: m.version, isJson: typeof m.value === 'object' })}>${t('profile.memory.editBtn')}</button>
593
+ ${fedConsents[m.key]
594
+ ? html`<button class="btn-outline btn-sm" disabled=${togglingFed === m.key}
595
+ onClick=${() => handleStopSharing(m.key)}>
596
+ ${togglingFed === m.key ? '...' : t('profile.memory.stopSharing')}
597
+ </button>`
598
+ : html`<button class="btn-ghost btn-sm" disabled=${togglingFed === m.key}
599
+ onClick=${() => handleShareToFederation(m.key)}>
600
+ ${togglingFed === m.key ? '...' : t('profile.memory.shareToFederation')}
601
+ </button>`
602
+ }
603
+ ${session.federated && html`
604
+ <button class="btn-ghost" onClick=${() => doPull(m.key)} title=${t('profile.memory.pullFromHome')}>
605
+ ↓ ${t('profile.memory.pullFromHome')}
606
+ </button>
607
+ <button class="btn-ghost" onClick=${() => doPush(m.key)} title=${t('profile.memory.pushToHome')}>
608
+ ↑ ${t('profile.memory.pushToHome')}
609
+ </button>
610
+ `}
611
+ <button class="btn-danger mem-delete-btn" onClick=${() => handleDeleteMemory(m.key)}>${t('profile.memory.deleteBtn')}</button>
612
+ </div>
613
+ </div>
614
+ `}
615
+ </div>
616
+ `;
617
+
358
618
  return html`
359
619
  <div class="action-bar">
360
620
  <div class="search-bar">
361
- <input type="text" ref=${searchRef} class="input-field" placeholder=${t('profile.memory.search')} onKeyDown=${e => e.key === 'Enter' && handleSearch(e.target.value)} />
362
- <button class="btn-sm" onClick=${() => handleSearch(searchRef.current?.value)}>${t('profile.memory.searchBtn')}</button>
363
- <button class="btn-outline btn-sm" onClick=${() => { if (searchRef.current) searchRef.current.value = ''; loadMemories(); }}>${t('profile.memory.clearBtn')}</button>
621
+ <input type="text" class="input-field" placeholder=${t('profile.memory.filterType')}
622
+ value=${filterText} onInput=${e => setFilterText(e.target.value)} />
623
+ ${filterText && html`<button class="btn-ghost btn-sm" onClick=${() => setFilterText('')}>✕</button>`}
364
624
  </div>
365
625
  <div class="mem-sort-bar">
366
626
  <label class="text-meta-sm">${t('profile.memory.sortLabel')}</label>
@@ -372,102 +632,36 @@ export default function MemoryTab({ session, showToast, onStats }) {
372
632
  </div>
373
633
  <button class="btn-primary" onClick=${() => setShowMemForm(!showMemForm)}>${t('profile.memory.newBtn')}</button>
374
634
  </div>
375
- <${TagCloud} tags=${[...allMemTags]} selected=${memTagFilter} onToggle=${toggleMemTag} onClear=${() => setMemTagFilter(new Set())} />
635
+ <${TagCloud} tags=${tagsByFreq} selected=${memTagFilter} onToggle=${toggleMemTag} onClear=${() => setMemTagFilter(new Set())} limit=${10} />
376
636
  ${showMemForm && html`<${MemoryForm} onSave=${handleCreateMemory} onCancel=${() => setShowMemForm(false)} groups=${groups} />`}
637
+ ${selectedKeys.size > 0 && html`
638
+ <div class="mem-bulkbar">
639
+ <span class="mem-bulkbar-count">${(t('profile.memory.bulkSelected') || '{n} selected').replace('{n}', String(selectedKeys.size))}</span>
640
+ <select class="input-field mem-vis-select" value=${bulkVis} onChange=${e => setBulkVis(e.target.value)}>
641
+ ${VIS_OPTIONS.filter(v => v !== 'group').map(v => html`<option key=${v} value=${v}>${t('knowledge.visibility.' + v)}</option>`)}
642
+ ${groups.map(grp => html`<option key=${grp.id} value=${'group:' + grp.id}>${t('knowledge.visibility.group')}: ${grp.name}</option>`)}
643
+ </select>
644
+ <button class="btn-outline btn-sm" onClick=${applyBulkVis}>${t('profile.memory.bulkApply') || 'Change visibility'}</button>
645
+ <button class="btn-danger btn-sm" onClick=${bulkDelete}>${t('profile.memory.deleteBtn')}</button>
646
+ <button class="btn-ghost btn-sm" onClick=${() => setSelectedKeys(new Set())}>✕ ${t('profile.memory.bulkClear') || 'Clear selection'}</button>
647
+ </div>
648
+ `}
377
649
  ${filtered.length === 0
378
650
  ? html`<div class="empty">${memories.length > 0 ? (t('tags.noMatch') || 'No items match selected tags') : t('profile.memory.empty')}</div>`
379
- : filtered.map(m => html`
380
- <div>
381
- <div class="mem-item" onClick=${() => setExpandedMem(expandedMem === m.key ? null : m.key)}>
382
- <span class="mem-key">${escHtml(m.key)}</span>
383
- <span class="mem-time" title="${m.created_at ? new Date(m.created_at).toLocaleString() : ''} / ${m.updated_at ? new Date(m.updated_at).toLocaleString() : ''}">
384
- ${formatRelativeTime(m.updated_at || m.created_at)}
385
- </span>
386
- ${(() => {
387
- const vis = m.visibility || 'private';
388
- const nextVis = cycleVis[(cycleVis.indexOf(vis) + 1) % cycleVis.length];
389
- return html`<button class="kpkg-vis-pill"
390
- onClick=${(e) => { e.stopPropagation(); if (nextVis === 'group') { setGroupPickerFor(m.key); } else { handleUpdateVisibility(m.key, nextVis, m.version); } }}
391
- title="${t('knowledge.visibility.' + vis)} → ${t('knowledge.visibility.' + nextVis)}"
392
- style="background:${visBg[vis]};color:${visColor[vis]};border-color:${visColor[vis]}"
393
- >${t('knowledge.visibility.' + vis)} ▾</button>`;
394
- })()}
395
- <span class="shield-icon" title=${t('permissions.sharingRules')} onClick=${(e) => { e.stopPropagation(); loadKeyPerms(m.key); }}>\u{1F6E1}\uFE0F</span>
396
- ${fedConsents[m.key] && html`<span class="badge badge-success pf-fed-badge">${t('profile.memory.syncedToFederation')}</span>`}
397
- </div>
398
- ${groupPickerFor === m.key && html`
399
- <div class="agd-group-picker" onClick=${(e) => e.stopPropagation()}>
400
- <div class="text-caption mb-xs">${t('profile.memory.selectGroup')}</div>
401
- ${groups.length === 0
402
- ? html`<div class="text-meta-sm">${t('profile.memory.noGroups')}</div>`
403
- : groups.map(g => html`
404
- <button class="btn-ghost agd-group-option" onClick=${() => {
405
- handleUpdateVisibility(m.key, 'group', m.version, g.id);
406
- setGroupPickerFor(null);
407
- }}>${g.name} (${g.members?.length || 0} ${t('profile.access.sharingGroups.members')})</button>
408
- `)
409
- }
410
- <button class="btn-outline btn-sm mt-xs" onClick=${() => { setGroupPickerFor(null); handleUpdateVisibility(m.key, 'public', m.version); }}>${t('profile.memory.skipGroup')}</button>
411
- </div>
412
- `}
413
- ${expandedMem === m.key && html`
414
- <div class="mem-detail">
415
- <pre>${typeof m.value === 'object' ? JSON.stringify(m.value, null, 2) : String(m.value || '')}</pre>
416
- <div class="mb-half">
417
- <button class="btn-outline btn-sm" onClick=${(e) => { e.stopPropagation(); setEditingMemTags(editingMemTags === m.key ? null : m.key); }}>
418
- ${t('tags.editTags') || 'Edit tags'}
419
- </button>
420
- </div>
421
- ${editingMemTags === m.key && html`
422
- <div class="mb-half">
423
- <${TagEditor} tags=${m.tags || []} onSave=${(tags) => handleUpdateMemoryTags(m.key, tags, m.version)} />
424
- </div>
425
- `}
426
- ${editingMemTags !== m.key && m.tags?.length > 0 && html`<div class="text-meta-sm mb-half">${m.tags.join(', ')}</div>`}
427
- ${keyRulesPopover && keyRulesPopover.key === m.key && html`
428
- <div class="key-rules-box">
429
- <div class="flex-between mb-half">
430
- <strong class="text-caption">\u{1F6E1}\uFE0F ${t('permissions.sharingRules')}</strong>
431
- <button class="btn-outline btn-sm" onClick=${() => setKeyRulesPopover(null)}>\u2715</button>
432
- </div>
433
- <div class="text-meta-sm mb-half">Visibility: ${keyRulesPopover.visibility}</div>
434
- ${keyRulesPopover.rules.length === 0
435
- ? html`<div class="text-meta pf-italic">${t('permissions.noRules')}</div>`
436
- : keyRulesPopover.rules.map(r => html`
437
- <div class="pf-rule-row">
438
- ${recipientBadge(r.recipient)}
439
- <span class="text-code text-meta-sm">${escHtml(r.data_pattern)}</span>
440
- <span class="text-meta-sm pf-ml-auto">${escHtml(r.scope || '-')}</span>
441
- </div>`)
442
- }
443
- </div>
444
- `}
445
- <div class="mem-actions">
446
- <button class="btn-sm" onClick=${() => setEditModal({ key: m.key, value: typeof m.value === 'object' ? JSON.stringify(m.value, null, 2) : String(m.value || ''), visibility: m.visibility || 'private', version: m.version })}>${t('profile.memory.editBtn')}</button>
447
- <button class="btn-danger" onClick=${() => handleDeleteMemory(m.key)}>${t('profile.memory.deleteBtn')}</button>
448
- ${fedConsents[m.key]
449
- ? html`<button class="btn-outline btn-sm" disabled=${togglingFed === m.key}
450
- onClick=${() => handleStopSharing(m.key)}>
451
- ${togglingFed === m.key ? '...' : t('profile.memory.stopSharing')}
452
- </button>`
453
- : html`<button class="btn-ghost btn-sm" disabled=${togglingFed === m.key}
454
- onClick=${() => handleShareToFederation(m.key)}>
455
- ${togglingFed === m.key ? '...' : t('profile.memory.shareToFederation')}
456
- </button>`
457
- }
458
- ${session.federated && html`
459
- <button class="btn-ghost" onClick=${() => doPull(m.key)} title=${t('profile.memory.pullFromHome')}>
460
- ↓ ${t('profile.memory.pullFromHome')}
461
- </button>
462
- <button class="btn-ghost" onClick=${() => doPush(m.key)} title=${t('profile.memory.pushToHome')}>
463
- ↑ ${t('profile.memory.pushToHome')}
464
- </button>
465
- `}
466
- </div>
651
+ : groupsOrdered.map(g => {
652
+ const collapsed = !filtering && collapsedGroups.has(g.id);
653
+ return html`
654
+ <div class="mem-group" key=${g.id}>
655
+ <button class="mem-group-header" onClick=${() => toggleGroupCollapsed(g.id)}>
656
+ <span class="pf-chevron ${collapsed ? '' : 'pf-chevron-open'}">▼</span>
657
+ <span class="mem-group-name">${escHtml(groupLabel(g))}</span>
658
+ <span class="mem-group-count">${g.items.length === 1 ? (t('profile.memory.keysOne') || '1 key') : (t('profile.memory.keysCount') || '{n} keys').replace('{n}', String(g.items.length))}</span>
659
+ ${g.kind === 'organism' && orgNames[g.uuid] && html`<span class="mem-group-sub">${shortTok(g.uuid)}</span>`}
660
+ </button>
661
+ ${!collapsed && g.items.map(m => renderRow(m, g))}
467
662
  </div>
468
- `}
469
- </div>
470
- `)
663
+ `;
664
+ })
471
665
  }`;
472
666
  };
473
667
 
@@ -517,7 +711,6 @@ export default function MemoryTab({ session, showToast, onStats }) {
517
711
  const fKey = f.key || f.name;
518
712
  const isImage = f.mime_type?.startsWith('image');
519
713
  const vis = f.visibility || 'private';
520
- const nextVis = cycleVis[(cycleVis.indexOf(vis) + 1) % cycleVis.length];
521
714
  return html`
522
715
  <div class="file-card">
523
716
  ${isImage
@@ -528,11 +721,11 @@ export default function MemoryTab({ session, showToast, onStats }) {
528
721
  <div class="file-name">${escHtml(fKey)}</div>
529
722
  <div class="file-meta">
530
723
  ${f.size ? Math.round(f.size / 1024) + ' KB' : ''}
531
- <button class="kpkg-vis-pill"
532
- onClick=${(e) => { e.stopPropagation(); handleUpdateFileVisibility(fKey, nextVis); }}
533
- title="${t('knowledge.visibility.' + vis)} → ${t('knowledge.visibility.' + nextVis)}"
534
- style="background:${visBg[vis]};color:${visColor[vis]};border-color:${visColor[vis]}"
535
- >${t('knowledge.visibility.' + vis)} ▾</button>
724
+ <select class="input-field mem-vis-select" value=${vis} title=${t('profile.files.visLabel')}
725
+ onClick=${(e) => e.stopPropagation()}
726
+ onChange=${(e) => handleUpdateFileVisibility(fKey, e.target.value)}>
727
+ ${VIS_OPTIONS.map(v => html`<option key=${v} value=${v}>${t('knowledge.visibility.' + v)}</option>`)}
728
+ </select>
536
729
  </div>
537
730
  ${editingFileTags === fKey
538
731
  ? html`<${TagEditor} tags=${f.tags || []} onSave=${(tags) => handleUpdateFileTags(fKey, tags)} />`
@@ -709,10 +902,8 @@ export default function MemoryTab({ session, showToast, onStats }) {
709
902
  <div class="mem-browse-panel">
710
903
  <div class="mem-browse-header">
711
904
  <div>
712
- <div class="section-title">${t('profile.memory.discoverTitle')}</div>
713
905
  <div class="section-desc">${t('profile.memory.discoverDesc')}</div>
714
906
  </div>
715
- <button class="btn-outline btn-sm" onClick=${closeBrowse}>${t('profile.memory.cancelBtn')}</button>
716
907
  </div>
717
908
  <div class="mem-discover-search mb-half">
718
909
  <input type="text" class="input-field" placeholder=${t('profile.memory.discoverSearchPlaceholder')}
@@ -749,7 +940,7 @@ export default function MemoryTab({ session, showToast, onStats }) {
749
940
  ${entry.tags?.length > 0 && html`<span class="text-meta-sm mem-browse-tags" title=${entry.tags.join(', ')}>${entry.tags.join(', ')}</span>`}
750
941
  <span class="mem-time">${formatRelativeTime(entry.updated_at || entry.created_at)}</span>
751
942
  </div>
752
- <button class="btn-primary btn-sm"
943
+ <button class="btn-outline btn-sm"
753
944
  disabled=${copyingKeys.has(entry.key)}
754
945
  onClick=${(e) => { e.stopPropagation(); handleCopyEntry(entry.owner_gaii, entry.key); }}>
755
946
  ${copyingKeys.has(entry.key) ? '...' : t('profile.memory.discoverCopy')}
@@ -769,17 +960,14 @@ export default function MemoryTab({ session, showToast, onStats }) {
769
960
  }
770
961
 
771
962
  const isHome = browseMode === 'home';
772
- const title = isHome ? t('profile.memory.browseHome') : t('profile.memory.browseRemote');
773
963
  const desc = isHome ? t('profile.memory.browseHomeDesc') : t('profile.memory.browseRemoteDesc');
774
964
 
775
965
  return html`
776
966
  <div class="mem-browse-panel">
777
967
  <div class="mem-browse-header">
778
968
  <div>
779
- <div class="section-title">${title}</div>
780
969
  <div class="section-desc">${desc}</div>
781
970
  </div>
782
- <button class="btn-outline btn-sm" onClick=${closeBrowse}>${t('profile.memory.cancelBtn')}</button>
783
971
  </div>
784
972
 
785
973
  ${!isHome && !selectedPeer && html`
@@ -822,7 +1010,7 @@ export default function MemoryTab({ session, showToast, onStats }) {
822
1010
  <${VisibilityPill} visibility=${entry.visibility} />
823
1011
  ${entry.tags?.length > 0 && html`<span class="text-meta-sm mem-browse-tags" title=${entry.tags.join(', ')}>${entry.tags.join(', ')}</span>`}
824
1012
  </div>
825
- <button class="btn-primary btn-sm"
1013
+ <button class="btn-outline btn-sm"
826
1014
  disabled=${pullingKeys.has(entry.key)}
827
1015
  onClick=${() => handlePullRemoteEntry(entry.key)}>
828
1016
  ${pullingKeys.has(entry.key) ? '...' : t('profile.memory.pullEntry')}
@@ -836,55 +1024,55 @@ export default function MemoryTab({ session, showToast, onStats }) {
836
1024
  `;
837
1025
  };
838
1026
 
1027
+ // Three states of the same list = tabs, not inline panels with their own Cancel buttons.
1028
+ const pickMainTab = (id) => {
1029
+ setMainTab(id);
1030
+ if (id === 'discover') initDiscover();
1031
+ else if (id === 'remote') { if (session.federated) loadBrowseHome(); else initBrowseRemote(); }
1032
+ else closeBrowse();
1033
+ };
1034
+
839
1035
  return html`
840
1036
  <div class="section-title">${t('profile.memory.title')}</div>
841
1037
  <div class="section-desc">${t('profile.memory.desc')}</div>
842
1038
 
843
- <div class="mem-browse-buttons mb-1">
844
- <button class="btn-outline btn-sm" onClick=${initDiscover}>
845
- ${t('profile.memory.discoverTitle')}
846
- </button>
847
- ${session.federated && html`
848
- <button class="btn-outline btn-sm" onClick=${loadBrowseHome}>
849
- ${t('profile.memory.browseHome')}
850
- </button>
851
- `}
852
- ${!session.federated && html`
853
- <button class="btn-outline btn-sm" onClick=${initBrowseRemote}>
854
- ${t('profile.memory.browseRemote')}
855
- </button>
856
- `}
1039
+ <div class="sub-tabs">
1040
+ <button class="sub-tab ${mainTab === 'own' ? 'active' : ''}" onClick=${() => pickMainTab('own')}>${t('profile.memory.tabOwn')}</button>
1041
+ <button class="sub-tab ${mainTab === 'discover' ? 'active' : ''}" onClick=${() => pickMainTab('discover')}>${t('profile.memory.tabPublic')}</button>
1042
+ <button class="sub-tab ${mainTab === 'remote' ? 'active' : ''}" onClick=${() => pickMainTab('remote')}>${session.federated ? t('profile.memory.browseHome') : t('profile.memory.tabRemote')}</button>
857
1043
  </div>
1044
+
858
1045
  ${session.federated && html`
859
1046
  <div class="alert alert-info mb-half">
860
1047
  <span class="alert-msg">${t('profile.memory.federatedSession')}</span>
861
1048
  </div>
862
1049
  `}
863
1050
 
864
- ${renderBrowsePanel()}
1051
+ ${mainTab !== 'own' ? renderBrowsePanel() : html`
1052
+ ${agents.length > 1 && html`
1053
+ <div class="agent-selector mb-half">
1054
+ <label class="text-meta pf-mr-half">${t('profile.memory.agent') || 'Agent'}:</label>
1055
+ <select class="input-field pf-select-inline"
1056
+ value=${selectedAgent} onChange=${e => { setSelectedAgent(e.target.value); setExpandedMem(null); setMemTagFilter(new Set()); }}>
1057
+ <option value="">${t('profile.memory.defaultAgent') || 'Default agent'}</option>
1058
+ ${agents.map(a => html`<option key=${a.gaii} value=${a.gaii}>${escHtml(a.name || a.gaii)}${a.display_name ? ` — ${escHtml(a.display_name)}` : ''}</option>`)}
1059
+ </select>
1060
+ </div>
1061
+ `}
865
1062
 
866
- ${agents.length > 1 && html`
867
- <div class="agent-selector mb-half">
868
- <label class="text-meta pf-mr-half">${t('profile.memory.agent') || 'Agent'}:</label>
869
- <select class="input-field pf-select-inline"
870
- value=${selectedAgent} onChange=${e => { setSelectedAgent(e.target.value); setExpandedMem(null); setMemTagFilter(new Set()); }}>
871
- <option value="">${t('profile.memory.defaultAgent') || 'Default agent'}</option>
872
- ${agents.map(a => html`<option key=${a.gaii} value=${a.gaii}>${escHtml(a.name || a.gaii)}${a.display_name ? ` — ${escHtml(a.display_name)}` : ''}</option>`)}
873
- </select>
1063
+ <div class="sub-tabs">
1064
+ <button class="sub-tab ${memSubTab === 'entries' ? 'active' : ''}" onClick=${() => setMemSubTab('entries')}>${t('profile.memory.entries')}</button>
1065
+ <button class="sub-tab ${memSubTab === 'files' ? 'active' : ''}" onClick=${() => setMemSubTab('files')}>${t('profile.memory.files')}</button>
874
1066
  </div>
1067
+ ${memSubTab === 'entries' ? renderEntries() : renderFilesList()}
875
1068
  `}
876
1069
 
877
- <div class="sub-tabs">
878
- <button class="sub-tab ${memSubTab === 'entries' ? 'active' : ''}" onClick=${() => setMemSubTab('entries')}>${t('profile.memory.entries')}</button>
879
- <button class="sub-tab ${memSubTab === 'files' ? 'active' : ''}" onClick=${() => setMemSubTab('files')}>${t('profile.memory.files')}</button>
880
- </div>
881
- ${memSubTab === 'entries' ? renderEntries() : renderFilesList()}
882
-
883
1070
  ${editModal && html`<${EditMemoryModal}
884
1071
  memKey=${editModal.key}
885
1072
  initialValue=${editModal.value}
886
1073
  initialVisibility=${editModal.visibility}
887
1074
  initialVersion=${editModal.version}
1075
+ isJson=${editModal.isJson}
888
1076
  groups=${groups}
889
1077
  onSave=${(v, vis, ver, gId) => handleSaveEdit(editModal.key, v, vis, ver, gId)}
890
1078
  onCancel=${() => setEditModal(null)} />`}
@@ -926,16 +1114,23 @@ function MemoryForm({ onSave, onCancel, groups }) {
926
1114
  <select class="input-field" value=${vis} onChange=${e => setVis(e.target.value)}>
927
1115
  <option value="private">${t('profile.memory.visPrivate')}</option>
928
1116
  <option value="shared">${t('profile.memory.visShared')}</option>
929
- <option value="group">Group</option>
1117
+ <option value="group">${t('knowledge.visibility.group')}</option>
930
1118
  <option value="public">${t('profile.memory.visPublic')}</option>
931
1119
  </select>
932
1120
  </div>
933
1121
  ${vis === 'group' && html`
934
1122
  <div class="form-row"><label>${t('profile.memory.selectGroup')}</label>
935
- <select class="input-field" value=${groupId} onChange=${e => setGroupId(e.target.value)}>
936
- <option value="">-- ${t('profile.memory.selectGroup')} --</option>
937
- ${(groups || []).map(g => html`<option value=${g.id}>${g.name}</option>`)}
938
- </select>
1123
+ ${(groups || []).length === 0
1124
+ ? html`
1125
+ <div class="text-meta-sm mb-xs">${t('profile.memory.noGroups')}</div>
1126
+ <button type="button" class="btn-outline btn-sm" onClick=${() => {
1127
+ window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'access' } }));
1128
+ }}>${t('profile.memory.createGroupBtn')}</button>`
1129
+ : html`
1130
+ <select class="input-field" value=${groupId} onChange=${e => setGroupId(e.target.value)}>
1131
+ <option value="">-- ${t('profile.memory.selectGroup')} --</option>
1132
+ ${(groups || []).map(g => html`<option value=${g.id}>${g.name}</option>`)}
1133
+ </select>`}
939
1134
  </div>
940
1135
  `}
941
1136
  <div class="form-row"><label>${t('profile.memory.tagsLabel')}</label><input class="input-field" placeholder=${t('profile.memory.tagsPlaceholder')} value=${tags} onInput=${e => setTags(e.target.value)} /></div>
@@ -1069,51 +1264,54 @@ function FileUploadForm({ onUpload, onCancel }) {
1069
1264
  </div>`;
1070
1265
  }
1071
1266
 
1072
- function EditMemoryModal({ memKey, initialValue, initialVisibility, initialVersion, onSave, onCancel, groups }) {
1267
+ function EditMemoryModal({ memKey, initialValue, initialVisibility, initialVersion, isJson, onSave, onCancel, groups }) {
1073
1268
  const [value, setValue] = useState(initialValue);
1074
1269
  const [vis, setVis] = useState(initialVisibility || 'private');
1075
1270
  const [groupId, setGroupId] = useState('');
1076
- const [showGroupSelect, setShowGroupSelect] = useState(false);
1077
- const cycleVis = ['private', 'owner', 'group', 'public'];
1078
- const visColor = { private: '#c084fc', owner: '#60a5fa', group: '#10b981', public: '#4ade80' };
1079
- const visBg = { private: 'rgba(150,100,200,.2)', owner: 'rgba(100,150,255,.2)', group: 'rgba(16,185,129,.2)', public: 'rgba(0,200,100,.2)' };
1080
- const nextVis = cycleVis[(cycleVis.indexOf(vis) + 1) % cycleVis.length];
1081
- const handleVisCycle = () => {
1082
- if (nextVis === 'group') {
1083
- setShowGroupSelect(true);
1084
- } else {
1085
- setVis(nextVis);
1086
- setShowGroupSelect(false);
1087
- setGroupId('');
1088
- }
1089
- };
1271
+
1272
+ // Broken JSON in a memory key crashes the agent that reads it — validate before save.
1273
+ // Validation applies when the stored value was an object, or the draft clearly is one.
1274
+ const looksJson = isJson || /^[[{]/.test(String(value || '').trim());
1275
+ let jsonError = null;
1276
+ if (looksJson) {
1277
+ try { JSON.parse(value); } catch (e) { jsonError = e.message; }
1278
+ }
1279
+ const groupMissing = vis === 'group' && !groupId && (groups || []).length === 0;
1280
+ const canSave = !jsonError && !(vis === 'group' && !groupId);
1281
+
1090
1282
  return html`
1091
1283
  <${Modal} open=${true} onClose=${onCancel} title=${`${t('profile.memory.editTitle')}: ${memKey}`}>
1092
1284
  <div class="form-row flex-row mb-half">
1093
1285
  <label class="pf-label-inline">${t('profile.memory.visLabel')}</label>
1094
- <button class="kpkg-vis-pill"
1095
- onClick=${handleVisCycle}
1096
- title="${t('knowledge.visibility.' + vis)} → ${t('knowledge.visibility.' + nextVis)}"
1097
- style="background:${visBg[vis]};color:${visColor[vis]};border-color:${visColor[vis]}"
1098
- >${t('knowledge.visibility.' + vis)} ▾</button>
1286
+ <select class="input-field mem-vis-select" value=${vis}
1287
+ onChange=${e => { setVis(e.target.value); if (e.target.value !== 'group') setGroupId(''); }}>
1288
+ ${['private', 'owner', 'group', 'public'].map(v => html`<option key=${v} value=${v}>${t('knowledge.visibility.' + v)}</option>`)}
1289
+ </select>
1099
1290
  </div>
1100
- ${showGroupSelect && html`
1291
+ ${vis === 'group' && html`
1101
1292
  <div class="form-row mb-half">
1102
1293
  <label>${t('profile.memory.selectGroup')}</label>
1103
- <select class="input-field" value=${groupId} onChange=${e => {
1104
- setGroupId(e.target.value);
1105
- if (e.target.value) { setVis('group'); setShowGroupSelect(false); }
1106
- }}>
1107
- <option value="">-- ${t('profile.memory.selectGroup')} --</option>
1108
- ${(groups || []).map(g => html`<option value=${g.id}>${g.name}</option>`)}
1109
- </select>
1110
- ${(groups || []).length === 0 && html`<div class="text-meta-sm">${t('profile.memory.noGroups')}</div>`}
1111
- <button class="btn-outline btn-sm mt-xs" onClick=${() => { setVis('public'); setShowGroupSelect(false); setGroupId(''); }}>${t('profile.memory.skipGroup')}</button>
1294
+ ${(groups || []).length === 0
1295
+ ? html`
1296
+ <div class="text-meta-sm mb-xs">${t('profile.memory.noGroups')}</div>
1297
+ <button class="btn-outline btn-sm" onClick=${() => {
1298
+ onCancel();
1299
+ try { sessionStorage.setItem('aimeat.access.focus', 'groups'); } catch { /* noop */ }
1300
+ window.dispatchEvent(new CustomEvent('aimeat-open-tab', { detail: { tabId: 'access' } }));
1301
+ }}>${t('profile.memory.createGroupBtn')}</button>`
1302
+ : html`
1303
+ <select class="input-field" value=${groupId} onChange=${e => setGroupId(e.target.value)}>
1304
+ <option value="">-- ${t('profile.memory.selectGroup')} --</option>
1305
+ ${(groups || []).map(g => html`<option value=${g.id}>${g.name}</option>`)}
1306
+ </select>`}
1112
1307
  </div>
1113
1308
  `}
1114
- <textarea class="input-field" rows="6" value=${value} onInput=${e => setValue(e.target.value)}></textarea>
1309
+ <textarea class="input-field mem-edit-textarea ${jsonError ? 'mem-edit-textarea--error' : ''}" rows="14"
1310
+ value=${value} onInput=${e => setValue(e.target.value)}></textarea>
1311
+ ${jsonError && html`<div class="mem-json-error">${t('profile.memory.invalidJson')} — ${jsonError}</div>`}
1115
1312
  <div class="form-actions mt-1">
1116
- <button class="btn-primary" onClick=${() => onSave(value, vis, initialVersion, vis === 'group' ? groupId : undefined)}>${t('profile.save')}</button>
1313
+ <button class="btn-primary" disabled=${!canSave || groupMissing}
1314
+ onClick=${() => onSave(value, vis, initialVersion, vis === 'group' ? groupId : undefined)}>${t('profile.save')}</button>
1117
1315
  <button class="btn-outline" onClick=${onCancel}>${t('profile.cancel')}</button>
1118
1316
  </div>
1119
1317
  <//>`;