synabun 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,1356 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Memory Explorer
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Docked sidebar that organizes memories by parent → child
6
+ // category hierarchy. Slides in from the left and pushes
7
+ // the main content area to the right via --explorer-width.
8
+
9
+ import { state, emit, on } from './state.js';
10
+ import { catColor } from './colors.js';
11
+ import { openMemoryCard } from './ui-detail.js';
12
+ import { truncate } from './utils.js';
13
+ import { scheduleGraphRemoval, cancelScheduledRemoval, buildCategorySidebar, openColorPicker, editCategoryUI, deleteCategoryUI } from './ui-sidebar.js';
14
+ import { updateCategory as apiUpdateCategory } from './api.js';
15
+ import { KEYS } from './constants.js';
16
+ import { registerAction } from './ui-keybinds.js';
17
+ import { storage } from './storage.js';
18
+ import { isGuest, hasPermission } from './ui-sync.js';
19
+
20
+ const $ = (id) => document.getElementById(id);
21
+
22
+ /** True when guest and memories permission is disabled */
23
+ function guestReadOnly() { return isGuest() && !hasPermission('memories'); }
24
+
25
+ // ─── Local state ────────────────────────
26
+ let _sortMode = 'newest'; // 'newest' | 'oldest' | 'alpha'
27
+ let _collapsed = new Set(); // category names that are collapsed
28
+ let _filterText = '';
29
+ let _visible = false;
30
+ let _width = 300; // sidebar width in px
31
+ const MIN_WIDTH = 280;
32
+ const MAX_WIDTH = 600;
33
+
34
+ // ─── SVG icons ────────────────────────
35
+ const EYE_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M2.06 12c.86-2.15 3.64-7 9.94-7s9.08 4.85 9.94 7c-.86 2.15-3.64 7-9.94 7s-9.08-4.85-9.94-7z"/><circle cx="12" cy="12" r="3"/></svg>';
36
+ const EYE_SLASH = '<svg viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2l20 20"/><path d="M6.71 6.71C3.94 8.7 2.5 11.27 2.06 12c.86 2.15 3.64 7 9.94 7 2.08 0 3.82-.6 5.23-1.49"/><path d="M10 5.07A9.77 9.77 0 0 1 12 5c6.3 0 9.08 4.85 9.94 7-.35.87-.85 1.86-1.65 2.82"/><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/></svg>';
37
+ const SVG_PENCIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
38
+ const SVG_TRASH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-.7 12.1a2 2 0 0 1-2 1.9H7.7a2 2 0 0 1-2-1.9L5 6"/></svg>';
39
+
40
+ // ─── Context menu singleton ──────────────
41
+ let _activeContextMenu = null;
42
+
43
+ function dismissContextMenu() {
44
+ if (_activeContextMenu) {
45
+ _activeContextMenu.remove();
46
+ _activeContextMenu = null;
47
+ }
48
+ }
49
+
50
+ function showCategoryContextMenu(btnEl, item, isParent, eyeState) {
51
+ dismissContextMenu();
52
+
53
+ const menu = document.createElement('div');
54
+ menu.className = 'explorer-ctx-menu';
55
+ _activeContextMenu = menu;
56
+
57
+ const isActive = state.activeCategories.has(item.name);
58
+
59
+ // ── Toggle visibility ──
60
+ const toggleItem = document.createElement('button');
61
+ toggleItem.className = 'explorer-ctx-item';
62
+ toggleItem.innerHTML = `<span class="explorer-ctx-icon">${isActive ? EYE_SLASH : EYE_OPEN}</span><span>${isActive ? 'Hide from graph' : 'Show on graph'}</span>`;
63
+ toggleItem.addEventListener('click', () => {
64
+ dismissContextMenu();
65
+ eyeState.toggle();
66
+ });
67
+ menu.appendChild(toggleItem);
68
+
69
+ // ── Edit category ──
70
+ const editItem = document.createElement('button');
71
+ editItem.className = 'explorer-ctx-item';
72
+ editItem.innerHTML = `<span class="explorer-ctx-icon">${SVG_PENCIL}</span><span>Edit category</span>`;
73
+ if (guestReadOnly()) {
74
+ editItem.disabled = true;
75
+ editItem.style.opacity = '0.35';
76
+ editItem.style.pointerEvents = 'none';
77
+ } else {
78
+ editItem.addEventListener('click', () => {
79
+ dismissContextMenu();
80
+ editCategoryUI(item.name);
81
+ });
82
+ }
83
+ menu.appendChild(editItem);
84
+
85
+ // ── Separator ──
86
+ const sep = document.createElement('div');
87
+ sep.className = 'explorer-ctx-sep';
88
+ menu.appendChild(sep);
89
+
90
+ // ── Delete category ──
91
+ const memCount = countMemories(item);
92
+ const deleteItem = document.createElement('button');
93
+ deleteItem.className = 'explorer-ctx-item explorer-ctx-item--danger';
94
+ deleteItem.innerHTML = `<span class="explorer-ctx-icon">${SVG_TRASH}</span><span>Delete category</span>`;
95
+ if (guestReadOnly()) {
96
+ deleteItem.disabled = true;
97
+ deleteItem.style.opacity = '0.35';
98
+ deleteItem.style.pointerEvents = 'none';
99
+ } else {
100
+ deleteItem.addEventListener('click', () => {
101
+ dismissContextMenu();
102
+ deleteCategoryUI(item.name, memCount);
103
+ });
104
+ }
105
+ menu.appendChild(deleteItem);
106
+
107
+ // Position relative to the button
108
+ document.body.appendChild(menu);
109
+ const btnRect = btnEl.getBoundingClientRect();
110
+ let top = btnRect.bottom + 4;
111
+ let left = btnRect.right - menu.offsetWidth;
112
+
113
+ // Keep within viewport
114
+ if (top + menu.offsetHeight > window.innerHeight - 8) {
115
+ top = btnRect.top - menu.offsetHeight - 4;
116
+ }
117
+ if (left < 8) left = 8;
118
+
119
+ menu.style.top = top + 'px';
120
+ menu.style.left = left + 'px';
121
+
122
+ // Dismiss on outside click (next tick so the current click doesn't trigger it)
123
+ requestAnimationFrame(() => {
124
+ const dismiss = (e) => {
125
+ if (!menu.contains(e.target)) {
126
+ dismissContextMenu();
127
+ document.removeEventListener('pointerdown', dismiss, true);
128
+ }
129
+ };
130
+ document.addEventListener('pointerdown', dismiss, true);
131
+ });
132
+ }
133
+
134
+ // ═══════════════════════════════════════════
135
+ // PERSISTENCE
136
+ // ═══════════════════════════════════════════
137
+
138
+ function loadPersistedState() {
139
+ try {
140
+ const raw = storage.getItem(KEYS.EXPLORER_COLLAPSED);
141
+ if (raw) _collapsed = new Set(JSON.parse(raw));
142
+ } catch {}
143
+ try {
144
+ const s = storage.getItem(KEYS.EXPLORER_SORT);
145
+ if (s && ['newest', 'oldest', 'alpha'].includes(s)) _sortMode = s;
146
+ } catch {}
147
+ try {
148
+ const v = storage.getItem(KEYS.EXPLORER_VISIBLE);
149
+ if (v === 'true') _visible = true;
150
+ } catch {}
151
+ try {
152
+ const w = parseInt(storage.getItem(KEYS.EXPLORER_WIDTH), 10);
153
+ if (w >= MIN_WIDTH && w <= MAX_WIDTH) _width = w;
154
+ } catch {}
155
+ }
156
+
157
+ function saveCollapsed() {
158
+ try { storage.setItem(KEYS.EXPLORER_COLLAPSED, JSON.stringify([..._collapsed])); } catch {}
159
+ }
160
+
161
+ function saveSort() {
162
+ try { storage.setItem(KEYS.EXPLORER_SORT, _sortMode); } catch {}
163
+ }
164
+
165
+ function saveVisible() {
166
+ try { storage.setItem(KEYS.EXPLORER_VISIBLE, String(_visible)); } catch {}
167
+ }
168
+
169
+ function saveWidth() {
170
+ try { storage.setItem(KEYS.EXPLORER_WIDTH, String(_width)); } catch {}
171
+ }
172
+
173
+ // ─── Explorer category order persistence ──
174
+ function getExplorerCatOrder() {
175
+ try { return JSON.parse(storage.getItem(KEYS.EXPLORER_CAT_ORDER) || '{}'); }
176
+ catch { return {}; }
177
+ }
178
+
179
+ function saveExplorerCatOrder(order) {
180
+ try { storage.setItem(KEYS.EXPLORER_CAT_ORDER, JSON.stringify(order)); }
181
+ catch {}
182
+ }
183
+
184
+ /**
185
+ * Sort tree items using saved order, appending unseen items alphabetically.
186
+ */
187
+ function applySavedOrder(items, savedOrder) {
188
+ if (!savedOrder || !savedOrder.length) return items;
189
+ const inOrder = savedOrder.filter(n => items.some(i => i.name === n));
190
+ const ordered = inOrder.map(n => items.find(i => i.name === n));
191
+ const remaining = items.filter(i => !savedOrder.includes(i.name));
192
+ return [...ordered, ...remaining];
193
+ }
194
+
195
+
196
+ // ═══════════════════════════════════════════
197
+ // CSS VARIABLE — drives all layout shifts
198
+ // ═══════════════════════════════════════════
199
+
200
+ function applyExplorerWidth() {
201
+ const w = _visible ? `${_width}px` : '0px';
202
+ document.documentElement.style.setProperty('--explorer-width', w);
203
+ }
204
+
205
+
206
+ // ═══════════════════════════════════════════
207
+ // SORT HELPERS
208
+ // ═══════════════════════════════════════════
209
+
210
+ function sortNodes(nodes) {
211
+ const sorted = [...nodes];
212
+ switch (_sortMode) {
213
+ case 'newest':
214
+ sorted.sort((a, b) => {
215
+ const da = a.payload.updated_at || a.payload.created_at || '';
216
+ const db = b.payload.updated_at || b.payload.created_at || '';
217
+ return db.localeCompare(da);
218
+ });
219
+ break;
220
+ case 'oldest':
221
+ sorted.sort((a, b) => {
222
+ const da = a.payload.created_at || '';
223
+ const db = b.payload.created_at || '';
224
+ return da.localeCompare(db);
225
+ });
226
+ break;
227
+ case 'alpha':
228
+ sorted.sort((a, b) => {
229
+ const la = getMemoryLabel(a).toLowerCase();
230
+ const lb = getMemoryLabel(b).toLowerCase();
231
+ return la.localeCompare(lb);
232
+ });
233
+ break;
234
+ }
235
+ return sorted;
236
+ }
237
+
238
+ function getMemoryLabel(node) {
239
+ const content = node.payload.content || '';
240
+ // Use first meaningful line
241
+ const lines = content.split('\n');
242
+ for (const line of lines) {
243
+ const trimmed = line.replace(/^#+\s*/, '').trim();
244
+ if (trimmed) return trimmed;
245
+ }
246
+ return node.id.slice(0, 8);
247
+ }
248
+
249
+ function cycleSortMode() {
250
+ const modes = ['newest', 'oldest', 'alpha'];
251
+ const idx = modes.indexOf(_sortMode);
252
+ _sortMode = modes[(idx + 1) % modes.length];
253
+ saveSort();
254
+
255
+ // Update tooltip
256
+ const sortBtn = $('explorer-sort-btn');
257
+ if (sortBtn) {
258
+ const labels = { newest: 'Sort: newest first', oldest: 'Sort: oldest first', alpha: 'Sort: A-Z' };
259
+ sortBtn.setAttribute('data-tooltip', labels[_sortMode]);
260
+ }
261
+
262
+ buildTree();
263
+ }
264
+
265
+
266
+ // ═══════════════════════════════════════════
267
+ // TREE DATA
268
+ // ═══════════════════════════════════════════
269
+
270
+ function buildTreeData() {
271
+ const saved = getExplorerCatOrder();
272
+
273
+ // Group memories by category
274
+ const byCat = {};
275
+ for (const node of state.allNodes) {
276
+ const cat = node.payload.category || 'uncategorized';
277
+ if (!byCat[cat]) byCat[cat] = [];
278
+ byCat[cat].push(node);
279
+ }
280
+
281
+ const tree = [];
282
+ const usedCats = new Set();
283
+
284
+ // Parent categories first
285
+ for (const [name, meta] of Object.entries(state.categoryMetadata)) {
286
+ if (!meta.is_parent) continue;
287
+
288
+ const children = [];
289
+ for (const [cName, cMeta] of Object.entries(state.categoryMetadata)) {
290
+ if (cMeta.parent !== name) continue;
291
+ usedCats.add(cName);
292
+ children.push({
293
+ type: 'category',
294
+ name: cName,
295
+ meta: cMeta,
296
+ memories: sortNodes(byCat[cName] || []),
297
+ });
298
+ }
299
+ // Apply saved child order (fallback: alphabetical)
300
+ const childOrder = (saved.children || {})[name];
301
+ if (childOrder && childOrder.length) {
302
+ children.splice(0, children.length, ...applySavedOrder(children, childOrder));
303
+ } else {
304
+ children.sort((a, b) => a.name.localeCompare(b.name));
305
+ }
306
+
307
+ usedCats.add(name);
308
+ tree.push({
309
+ type: 'parent',
310
+ name,
311
+ meta,
312
+ children,
313
+ memories: sortNodes(byCat[name] || []),
314
+ });
315
+ }
316
+
317
+ // Standalone categories (no parent, not a parent)
318
+ for (const [name, meta] of Object.entries(state.categoryMetadata)) {
319
+ if (usedCats.has(name)) continue;
320
+ usedCats.add(name);
321
+ tree.push({
322
+ type: 'category',
323
+ name,
324
+ meta,
325
+ memories: sortNodes(byCat[name] || []),
326
+ });
327
+ }
328
+
329
+ // Orphan memories (category not in metadata)
330
+ for (const [cat, nodes] of Object.entries(byCat)) {
331
+ if (usedCats.has(cat)) continue;
332
+ tree.push({
333
+ type: 'category',
334
+ name: cat,
335
+ meta: { color: null, is_parent: false },
336
+ memories: sortNodes(nodes),
337
+ });
338
+ }
339
+
340
+ // Apply saved top-level order (fallback: alphabetical)
341
+ if (saved.topLevel && saved.topLevel.length) {
342
+ tree.splice(0, tree.length, ...applySavedOrder(tree, saved.topLevel));
343
+ } else {
344
+ tree.sort((a, b) => a.name.localeCompare(b.name));
345
+ }
346
+
347
+ return tree;
348
+ }
349
+
350
+
351
+ // ═══════════════════════════════════════════
352
+ // FILTER
353
+ // ═══════════════════════════════════════════
354
+
355
+ function matchesFilter(node) {
356
+ if (!_filterText) return true;
357
+ const q = _filterText.toLowerCase();
358
+ const p = node.payload;
359
+ if ((p.content || '').toLowerCase().includes(q)) return true;
360
+ if ((p.category || '').toLowerCase().includes(q)) return true;
361
+ if (p.tags && p.tags.some(t => t.toLowerCase().includes(q))) return true;
362
+ if ((p.subcategory || '').toLowerCase().includes(q)) return true;
363
+ return false;
364
+ }
365
+
366
+ function countMemories(treeItem) {
367
+ let count = 0;
368
+ if (treeItem.memories) count += treeItem.memories.length;
369
+ if (treeItem.children) {
370
+ for (const child of treeItem.children) count += countMemories(child);
371
+ }
372
+ return count;
373
+ }
374
+
375
+ function countFilteredMemories(treeItem) {
376
+ let count = 0;
377
+ if (treeItem.memories) count += treeItem.memories.filter(matchesFilter).length;
378
+ if (treeItem.children) {
379
+ for (const child of treeItem.children) count += countFilteredMemories(child);
380
+ }
381
+ return count;
382
+ }
383
+
384
+
385
+ // ═══════════════════════════════════════════
386
+ // DOM RENDERING
387
+ // ═══════════════════════════════════════════
388
+
389
+ function buildTree() {
390
+ const container = $('explorer-tree');
391
+ if (!container) return;
392
+
393
+ const treeData = buildTreeData();
394
+ container.innerHTML = '';
395
+
396
+ let totalMemories = 0;
397
+ let visibleMemories = 0;
398
+
399
+ for (const item of treeData) {
400
+ const { el, total, visible } = renderTreeItem(item, 0);
401
+ if (el) container.appendChild(el);
402
+ totalMemories += total;
403
+ visibleMemories += visible;
404
+ }
405
+
406
+ // Update footer
407
+ const countEl = $('explorer-count');
408
+ if (countEl) {
409
+ if (_filterText) {
410
+ countEl.textContent = `${visibleMemories} / ${totalMemories} memories`;
411
+ } else {
412
+ countEl.textContent = `${totalMemories} memories`;
413
+ }
414
+ }
415
+
416
+ // Highlight selected node
417
+ if (state.selectedNodeId) {
418
+ highlightNode(state.selectedNodeId, false);
419
+ }
420
+
421
+ // Wire drag-and-drop (only once per container element)
422
+ if (!container._dragWired) {
423
+ initExplorerDrag(container);
424
+ container._dragWired = true;
425
+ }
426
+ }
427
+
428
+ function renderTreeItem(item, depth) {
429
+ const isParent = item.type === 'parent';
430
+
431
+ // For categories, check if any memories match filter
432
+ const filteredMemories = item.memories ? item.memories.filter(matchesFilter) : [];
433
+ const filteredChildCount = item.children
434
+ ? item.children.reduce((sum, c) => sum + countFilteredMemories(c), 0)
435
+ : 0;
436
+ const totalFiltered = filteredMemories.length + filteredChildCount;
437
+
438
+ const totalMemories = countMemories(item);
439
+
440
+ // Skip if filter active and nothing matches
441
+ if (_filterText && totalFiltered === 0) {
442
+ return { el: null, total: totalMemories, visible: 0 };
443
+ }
444
+
445
+ const el = document.createElement('div');
446
+ el.className = `explorer-node ${isParent ? 'explorer-parent' : 'explorer-category'}`;
447
+ el.dataset.cat = item.name;
448
+
449
+ const expanded = !_collapsed.has(item.name);
450
+ el.dataset.expanded = String(expanded);
451
+
452
+ // Category row
453
+ const row = document.createElement('div');
454
+ row.className = 'explorer-row';
455
+ row.style.paddingLeft = `${10 + depth * 16}px`;
456
+
457
+ // Chevron
458
+ const hasContent = (filteredMemories.length > 0) ||
459
+ (item.children && item.children.some(c => !_filterText || countFilteredMemories(c) > 0));
460
+ const chevron = document.createElement('span');
461
+ chevron.className = 'explorer-chevron';
462
+ chevron.textContent = hasContent ? '\u25B6' : '';
463
+ row.appendChild(chevron);
464
+
465
+ // Color dot (click to change color)
466
+ const dot = document.createElement('span');
467
+ dot.className = 'explorer-dot';
468
+ const color = catColor(item.name);
469
+ dot.style.background = color;
470
+ dot.setAttribute('data-tooltip', 'Color');
471
+ dot.setAttribute('data-tooltip-pos', 'right');
472
+ dot.style.cursor = 'pointer';
473
+ dot.addEventListener('click', (e) => {
474
+ e.stopPropagation();
475
+ openColorPicker(item.name, el);
476
+ });
477
+ row.appendChild(dot);
478
+
479
+ // Label
480
+ const label = document.createElement('span');
481
+ label.className = 'explorer-label';
482
+ label.textContent = item.name;
483
+ label.setAttribute('data-tooltip', item.meta?.description || item.name);
484
+ label.setAttribute('data-tooltip-pos', 'right');
485
+ row.appendChild(label);
486
+
487
+ // Badge (count)
488
+ const badge = document.createElement('span');
489
+ badge.className = 'explorer-badge';
490
+ if (_filterText) {
491
+ badge.textContent = `${totalFiltered}/${totalMemories}`;
492
+ } else {
493
+ badge.textContent = String(totalMemories);
494
+ }
495
+ row.appendChild(badge);
496
+
497
+ // Visibility state indicator (dimmed row when hidden)
498
+ const isActive = state.activeCategories.has(item.name);
499
+ if (!isActive) el.classList.add('explorer-hidden-cat');
500
+
501
+ // "..." context menu button
502
+ const moreBtn = document.createElement('button');
503
+ moreBtn.className = 'explorer-more';
504
+ moreBtn.textContent = '\u22EF'; // ⋯
505
+ moreBtn.setAttribute('data-tooltip', 'Options');
506
+ moreBtn.setAttribute('data-tooltip-pos', 'left');
507
+ moreBtn.addEventListener('click', (e) => {
508
+ e.stopPropagation();
509
+ const eyeState = {
510
+ toggle: () => {
511
+ const catName = item.name;
512
+ const childNames = isParent
513
+ ? Object.entries(state.categoryMetadata)
514
+ .filter(([, m]) => m.parent === catName)
515
+ .map(([n]) => n)
516
+ : [];
517
+ const targets = [catName, ...childNames];
518
+
519
+ if (state.activeCategories.has(catName)) {
520
+ targets.forEach(c => state.activeCategories.delete(c));
521
+ el.classList.add('explorer-hidden-cat');
522
+ scheduleGraphRemoval();
523
+ } else {
524
+ targets.forEach(c => state.activeCategories.add(c));
525
+ el.classList.remove('explorer-hidden-cat');
526
+ cancelScheduledRemoval();
527
+ emit('graph:refresh');
528
+ }
529
+ emit('categories-changed');
530
+ }
531
+ };
532
+ showCategoryContextMenu(moreBtn, item, isParent, eyeState);
533
+ });
534
+ row.appendChild(moreBtn);
535
+
536
+ el.appendChild(row);
537
+
538
+ // Click to toggle collapse
539
+ row.addEventListener('click', (e) => {
540
+ if (e.target.closest('.explorer-more')) return;
541
+ if (e.target.closest('.explorer-dot')) return;
542
+ e.stopPropagation();
543
+ const wasExpanded = el.dataset.expanded === 'true';
544
+ el.dataset.expanded = String(!wasExpanded);
545
+ if (wasExpanded) {
546
+ _collapsed.add(item.name);
547
+ } else {
548
+ _collapsed.delete(item.name);
549
+ }
550
+ saveCollapsed();
551
+ });
552
+
553
+ // Children container
554
+ const childrenEl = document.createElement('div');
555
+ childrenEl.className = 'explorer-children';
556
+
557
+ let visibleCount = 0;
558
+
559
+ // Render child categories
560
+ if (item.children) {
561
+ for (const child of item.children) {
562
+ const { el: childEl, visible } = renderTreeItem(child, depth + 1);
563
+ if (childEl) childrenEl.appendChild(childEl);
564
+ visibleCount += visible;
565
+ }
566
+ }
567
+
568
+ // Render memories directly in this category
569
+ for (const mem of filteredMemories) {
570
+ const memEl = renderMemoryItem(mem, depth + 1);
571
+ childrenEl.appendChild(memEl);
572
+ visibleCount++;
573
+ }
574
+
575
+ el.appendChild(childrenEl);
576
+
577
+ return { el, total: totalMemories, visible: visibleCount };
578
+ }
579
+
580
+ function renderMemoryItem(node, depth) {
581
+ const el = document.createElement('div');
582
+ el.className = 'explorer-node explorer-memory';
583
+ el.dataset.id = node.id;
584
+
585
+ if (node.id === state.selectedNodeId) {
586
+ el.classList.add('selected');
587
+ }
588
+
589
+ const row = document.createElement('div');
590
+ row.className = 'explorer-row';
591
+ row.style.paddingLeft = `${10 + depth * 16}px`;
592
+
593
+ // Memory icon (small dot/circle)
594
+ const icon = document.createElement('span');
595
+ icon.className = 'explorer-memory-icon';
596
+ icon.textContent = '\u25CB'; // ○
597
+ row.appendChild(icon);
598
+
599
+ // Label (first line of content)
600
+ const label = document.createElement('span');
601
+ label.className = 'explorer-label';
602
+ const preview = getMemoryLabel(node);
603
+ label.textContent = truncate(preview, 60);
604
+ const created = node.payload.created_at ? new Date(node.payload.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
605
+ row.setAttribute('data-tooltip', created ? `${preview}\n${created}` : preview);
606
+ row.setAttribute('data-tooltip-pos', 'right');
607
+ row.appendChild(label);
608
+
609
+ // Importance indicator
610
+ if (node.payload.importance && node.payload.importance >= 5) {
611
+ const imp = document.createElement('span');
612
+ imp.className = 'explorer-importance';
613
+ const val = node.payload.importance;
614
+ imp.textContent = '\u25CF'.repeat(Math.min(val, 10));
615
+ imp.setAttribute('data-tooltip', `Importance: ${val}/10`);
616
+ imp.setAttribute('data-tooltip-pos', 'right');
617
+ if (val >= 8) imp.classList.add('explorer-importance--high');
618
+ row.appendChild(imp);
619
+ }
620
+
621
+ el.appendChild(row);
622
+
623
+ // Click to open detail panel + navigate graph
624
+ row.addEventListener('click', (e) => {
625
+ e.stopPropagation();
626
+ state.selectedNodeId = node.id;
627
+ openMemoryCard(node);
628
+ emit('graph:navigate', { node, zoom: 'close' });
629
+ emit('node-selected', node);
630
+ highlightNode(node.id, false);
631
+ });
632
+
633
+ // Drag to terminal — feed memory as context to CLI
634
+ if (!guestReadOnly()) {
635
+ el.draggable = true;
636
+ el.addEventListener('dragstart', (e) => {
637
+ e.dataTransfer.setData('application/x-synabun-memory', node.id);
638
+ e.dataTransfer.effectAllowed = 'copy';
639
+ el.classList.add('dragging');
640
+ });
641
+ el.addEventListener('dragend', () => {
642
+ el.classList.remove('dragging');
643
+ });
644
+ }
645
+
646
+ return el;
647
+ }
648
+
649
+
650
+ // ═══════════════════════════════════════════
651
+ // HIGHLIGHT / SCROLL-INTO-VIEW
652
+ // ═══════════════════════════════════════════
653
+
654
+ function highlightNode(nodeId, scrollIntoView = true) {
655
+ const container = $('explorer-tree');
656
+ if (!container) return;
657
+
658
+ // Remove previous highlight
659
+ container.querySelectorAll('.explorer-memory.selected').forEach(el => {
660
+ el.classList.remove('selected');
661
+ });
662
+
663
+ if (!nodeId) return;
664
+
665
+ // Find and highlight
666
+ const memEl = container.querySelector(`.explorer-memory[data-id="${CSS.escape(nodeId)}"]`);
667
+ if (!memEl) return;
668
+
669
+ memEl.classList.add('selected');
670
+
671
+ if (scrollIntoView) {
672
+ // Expand parent categories if needed
673
+ let parent = memEl.parentElement;
674
+ while (parent && parent !== container) {
675
+ if (parent.classList.contains('explorer-node') && parent.dataset.expanded === 'false') {
676
+ parent.dataset.expanded = 'true';
677
+ _collapsed.delete(parent.dataset.cat);
678
+ }
679
+ parent = parent.parentElement;
680
+ }
681
+ saveCollapsed();
682
+
683
+ // Scroll into view
684
+ memEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
685
+ }
686
+ }
687
+
688
+
689
+ // ═══════════════════════════════════════════
690
+ // TOGGLE / SHOW / HIDE
691
+ // ═══════════════════════════════════════════
692
+
693
+ export function toggleExplorer() {
694
+ const panel = $('explorer-panel');
695
+ if (!panel) return;
696
+
697
+ _visible = !_visible;
698
+ panel.classList.toggle('open', _visible);
699
+ saveVisible();
700
+
701
+ // Toggle unified title bar style + solid containers
702
+ const titleBar = document.getElementById('title-bar');
703
+ if (titleBar) titleBar.classList.toggle('explorer-active', _visible);
704
+ document.body.classList.toggle('explorer-open', _visible);
705
+
706
+ // Set CSS variable to push content
707
+ applyExplorerWidth();
708
+
709
+ // Tell graphs to resize after transition
710
+ setTimeout(() => {
711
+ window.dispatchEvent(new Event('resize'));
712
+ }, 320);
713
+
714
+ // Sync menu checkmark
715
+ const menuEl = $('menu-toggle-explorer');
716
+ if (menuEl) menuEl.classList.toggle('active', _visible);
717
+ const tbBtn = $('topright-memory-explorer-btn');
718
+ if (tbBtn) tbBtn.classList.toggle('active', _visible);
719
+
720
+ // Build tree on first show
721
+ if (_visible && !panel.dataset.built) {
722
+ buildTree();
723
+ panel.dataset.built = '1';
724
+ }
725
+ }
726
+
727
+ // ═══════════════════════════════════════════
728
+ // COLLAPSE ALL / EXPAND ALL
729
+ // ═══════════════════════════════════════════
730
+
731
+ function collapseAll() {
732
+ const container = $('explorer-tree');
733
+ if (!container) return;
734
+ container.querySelectorAll('.explorer-node[data-cat]').forEach(el => {
735
+ el.dataset.expanded = 'false';
736
+ _collapsed.add(el.dataset.cat);
737
+ });
738
+ saveCollapsed();
739
+ }
740
+
741
+ function expandAll() {
742
+ const container = $('explorer-tree');
743
+ if (!container) return;
744
+ container.querySelectorAll('.explorer-node[data-cat]').forEach(el => {
745
+ el.dataset.expanded = 'true';
746
+ });
747
+ _collapsed.clear();
748
+ saveCollapsed();
749
+ }
750
+
751
+
752
+ // ═══════════════════════════════════════════
753
+ // RESIZE HANDLE (right edge drag)
754
+ // ═══════════════════════════════════════════
755
+
756
+ function initResizeHandle() {
757
+ const handle = $('explorer-resize-handle');
758
+ const panel = $('explorer-panel');
759
+ if (!handle || !panel) return;
760
+
761
+ let dragging = false;
762
+
763
+ handle.addEventListener('mousedown', (e) => {
764
+ e.preventDefault();
765
+ dragging = true;
766
+ handle.classList.add('active');
767
+ document.body.style.cursor = 'col-resize';
768
+ document.body.style.userSelect = 'none';
769
+ });
770
+
771
+ document.addEventListener('mousemove', (e) => {
772
+ if (!dragging) return;
773
+ const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, e.clientX));
774
+ _width = newWidth;
775
+ panel.style.width = `${_width}px`;
776
+ applyExplorerWidth();
777
+ });
778
+
779
+ document.addEventListener('mouseup', () => {
780
+ if (!dragging) return;
781
+ dragging = false;
782
+ handle.classList.remove('active');
783
+ document.body.style.cursor = '';
784
+ document.body.style.userSelect = '';
785
+ saveWidth();
786
+ // Trigger graph resize
787
+ window.dispatchEvent(new Event('resize'));
788
+ });
789
+ }
790
+
791
+
792
+ // ═══════════════════════════════════════════
793
+ // DRAG-AND-DROP REORDERING
794
+ // ═══════════════════════════════════════════
795
+
796
+ let _dragEl = null; // .explorer-node being dragged
797
+ let _dragType = null; // 'parent' | 'category'
798
+ let _dragName = null; // category name from data-cat
799
+ let _startY = 0;
800
+ let _startX = 0;
801
+ let _cloneEl = null; // floating clone (body-appended)
802
+ let _initialRect = null;
803
+ let _didDragMove = false;
804
+ let _reparenting = false; // guard for concurrent API calls
805
+
806
+ function wouldCreateCycle(draggedCat, targetParent) {
807
+ if (draggedCat === targetParent) return true;
808
+ let cur = targetParent;
809
+ while (cur) {
810
+ if (cur === draggedCat) return true;
811
+ cur = (state.categoryMetadata[cur] || {}).parent;
812
+ }
813
+ return false;
814
+ }
815
+
816
+ function clearDragIndicators(container) {
817
+ container.querySelectorAll('.explorer-drop-before, .explorer-drop-after').forEach(el => {
818
+ el.classList.remove('explorer-drop-before', 'explorer-drop-after');
819
+ });
820
+ container.querySelectorAll('.explorer-drop-reparent').forEach(el => {
821
+ el.classList.remove('explorer-drop-reparent');
822
+ });
823
+ }
824
+
825
+ function createExplorerDragClone(row) {
826
+ const rect = row.getBoundingClientRect();
827
+ const clone = document.createElement('div');
828
+ clone.className = 'explorer-drag-clone';
829
+ // Copy relevant children (dot + label)
830
+ const dot = row.querySelector('.explorer-dot');
831
+ const label = row.querySelector('.explorer-label');
832
+ if (dot) clone.appendChild(dot.cloneNode(true));
833
+ if (label) {
834
+ const lbl = label.cloneNode(true);
835
+ lbl.style.maxWidth = '180px';
836
+ clone.appendChild(lbl);
837
+ }
838
+ clone.style.width = Math.min(rect.width, 240) + 'px';
839
+ clone.style.left = rect.left + 'px';
840
+ clone.style.top = rect.top + 'px';
841
+ clone.style.transform = 'translate(0px, 0px) scale(1.04) rotate(0.8deg)';
842
+ document.body.appendChild(clone);
843
+ return clone;
844
+ }
845
+
846
+ function getExplorerDropTarget(container, y) {
847
+ const topLevelNodes = [...container.querySelectorAll(':scope > .explorer-node[data-cat]')];
848
+
849
+ if (_dragType === 'parent') {
850
+ // Dragging a parent — reorder among top-level siblings only
851
+ for (const node of topLevelNodes) {
852
+ if (node === _dragEl) continue;
853
+ const row = node.querySelector(':scope > .explorer-row');
854
+ if (!row) continue;
855
+ const rect = row.getBoundingClientRect();
856
+ const mid = rect.top + rect.height / 2;
857
+ if (y >= rect.top - 8 && y <= rect.bottom + 8) {
858
+ return { type: 'reorder-toplevel', node, position: y < mid ? 'before' : 'after' };
859
+ }
860
+ }
861
+ return null;
862
+ }
863
+
864
+ // Dragging a child category — can reorder among siblings or reparent
865
+ const currentParentEl = _dragEl.parentElement?.closest('.explorer-node[data-cat]');
866
+ const currentParentName = currentParentEl?.dataset.cat || null;
867
+
868
+ // Check: is cursor above all top-level items? → make top-level
869
+ if (topLevelNodes.length > 0) {
870
+ const firstRow = topLevelNodes[0]?.querySelector(':scope > .explorer-row');
871
+ if (firstRow) {
872
+ const firstRect = firstRow.getBoundingClientRect();
873
+ const containerRect = container.getBoundingClientRect();
874
+ if (y < firstRect.top && y >= containerRect.top) {
875
+ return { type: 'make-toplevel' };
876
+ }
877
+ }
878
+ }
879
+
880
+ // Check all visible category nodes
881
+ const allCatNodes = [...container.querySelectorAll('.explorer-node[data-cat]')];
882
+ for (const node of allCatNodes) {
883
+ if (node === _dragEl) continue;
884
+ const row = node.querySelector(':scope > .explorer-row');
885
+ if (!row) continue;
886
+ const rect = row.getBoundingClientRect();
887
+ if (y < rect.top - 8 || y > rect.bottom + 8) continue;
888
+
889
+ const mid = rect.top + rect.height / 2;
890
+ const nodeCat = node.dataset.cat;
891
+ const nodeMeta = state.categoryMetadata[nodeCat] || {};
892
+ const isParentNode = node.classList.contains('explorer-parent');
893
+
894
+ // If hovering a parent node → reparent into it
895
+ if (isParentNode && !wouldCreateCycle(_dragName, nodeCat)) {
896
+ return { type: 'reparent', node, targetName: nodeCat };
897
+ }
898
+
899
+ // If hovering a sibling (same parent) → reorder
900
+ const nodeParentEl = node.parentElement?.closest('.explorer-node[data-cat]');
901
+ const nodeParentName = nodeParentEl?.dataset.cat || null;
902
+ if (nodeParentName === currentParentName) {
903
+ return { type: 'reorder-sibling', node, position: y < mid ? 'before' : 'after', parentName: currentParentName };
904
+ }
905
+
906
+ // If hovering a category under a different parent → reparent to that parent
907
+ if (nodeParentName && nodeParentName !== currentParentName && !wouldCreateCycle(_dragName, nodeParentName)) {
908
+ return { type: 'reparent', node: nodeParentEl, targetName: nodeParentName };
909
+ }
910
+
911
+ // If hovering a top-level standalone category → reorder at top level
912
+ if (!nodeParentName && !isParentNode) {
913
+ return { type: 'reorder-toplevel', node, position: y < mid ? 'before' : 'after' };
914
+ }
915
+ }
916
+
917
+ return null;
918
+ }
919
+
920
+ function initExplorerDrag(container) {
921
+ container.addEventListener('pointerdown', (e) => {
922
+ // Only trigger on grip handle
923
+ const gripEl = e.target.closest('.explorer-grip');
924
+ if (!gripEl) return;
925
+
926
+ const nodeEl = gripEl.closest('.explorer-node[data-cat]');
927
+ if (!nodeEl) return;
928
+ if (nodeEl.classList.contains('explorer-memory')) return;
929
+ if (_filterText) return; // no drag during search
930
+ if (_reparenting) return; // API call in flight
931
+
932
+ e.preventDefault();
933
+ e.stopPropagation();
934
+
935
+ _dragEl = nodeEl;
936
+ _dragType = nodeEl.classList.contains('explorer-parent') ? 'parent' : 'category';
937
+ _dragName = nodeEl.dataset.cat;
938
+ _startY = e.clientY;
939
+ _startX = e.clientX;
940
+ _didDragMove = false;
941
+
942
+ nodeEl.setPointerCapture(e.pointerId);
943
+
944
+ function onMove(ev) {
945
+ if (!_dragEl) return;
946
+ if (!_didDragMove && Math.abs(ev.clientY - _startY) < 5 && Math.abs(ev.clientX - _startX) < 5) return;
947
+
948
+ if (!_didDragMove) {
949
+ _didDragMove = true;
950
+ _initialRect = _dragEl.getBoundingClientRect();
951
+
952
+ // Create floating clone from the row
953
+ const row = _dragEl.querySelector(':scope > .explorer-row');
954
+ _cloneEl = createExplorerDragClone(row);
955
+
956
+ // Ghost the source
957
+ _dragEl.classList.add('explorer-drag-source');
958
+ container.classList.add('explorer-dragging');
959
+
960
+ // Set gap size based on row height
961
+ const rowH = row.getBoundingClientRect().height + 4;
962
+ container.style.setProperty('--explorer-drag-gap', rowH + 'px');
963
+ }
964
+
965
+ // Move clone
966
+ const dx = ev.clientX - _startX;
967
+ const dy = ev.clientY - _startY;
968
+ _cloneEl.style.transform = `translate(${dx}px, ${dy}px) scale(1.04) rotate(0.8deg)`;
969
+
970
+ // Show drop indicator
971
+ clearDragIndicators(container);
972
+ const target = getExplorerDropTarget(container, ev.clientY);
973
+ if (target) {
974
+ if (target.type === 'reparent') {
975
+ target.node.classList.add('explorer-drop-reparent');
976
+ } else if (target.type === 'reorder-toplevel' || target.type === 'reorder-sibling') {
977
+ target.node.classList.add(target.position === 'before' ? 'explorer-drop-before' : 'explorer-drop-after');
978
+ } else if (target.type === 'make-toplevel') {
979
+ const first = container.querySelector(':scope > .explorer-node[data-cat]');
980
+ if (first && first !== _dragEl) first.classList.add('explorer-drop-before');
981
+ }
982
+ }
983
+ }
984
+
985
+ async function onUp(ev) {
986
+ nodeEl.removeEventListener('pointermove', onMove);
987
+ nodeEl.removeEventListener('pointerup', onUp);
988
+ nodeEl.removeEventListener('pointercancel', onUp);
989
+
990
+ if (!_dragEl) return;
991
+
992
+ const droppedName = _dragName;
993
+ const didMove = _didDragMove;
994
+
995
+ if (didMove) {
996
+ const target = getExplorerDropTarget(container, ev.clientY);
997
+
998
+ // ── Spring-snap animation ──
999
+ if (_cloneEl && target && _initialRect) {
1000
+ let gapEl = null, gapPosition = null;
1001
+ if (target.type === 'reorder-toplevel' || target.type === 'reorder-sibling') {
1002
+ gapEl = target.node;
1003
+ gapPosition = target.position;
1004
+ } else if (target.type === 'make-toplevel') {
1005
+ gapEl = container.querySelector(':scope > .explorer-node[data-cat]');
1006
+ gapPosition = 'before';
1007
+ } else if (target.type === 'reparent') {
1008
+ gapEl = target.node;
1009
+ gapPosition = 'after';
1010
+ }
1011
+
1012
+ if (gapEl) {
1013
+ const gapRect = gapEl.getBoundingClientRect();
1014
+ const rowH = _initialRect.height + 4;
1015
+ const targetTop = gapPosition === 'before'
1016
+ ? gapRect.top - rowH
1017
+ : gapRect.bottom;
1018
+ const targetDy = targetTop - _initialRect.top;
1019
+ const targetDx = gapRect.left - _initialRect.left;
1020
+
1021
+ _cloneEl.style.transition = 'transform 0.22s cubic-bezier(0.22, 1.15, 0.36, 1), opacity 0.14s ease 0.06s';
1022
+ _cloneEl.style.transform = `translate(${targetDx}px, ${targetDy}px) scale(1) rotate(0deg)`;
1023
+ _cloneEl.style.opacity = '0';
1024
+ await new Promise(r => setTimeout(r, 220));
1025
+ }
1026
+ } else if (_cloneEl && _initialRect) {
1027
+ // No valid target — snap back
1028
+ _cloneEl.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0, 1), opacity 0.12s ease';
1029
+ _cloneEl.style.transform = 'translate(0px, 0px) scale(1) rotate(0deg)';
1030
+ _cloneEl.style.opacity = '0';
1031
+ await new Promise(r => setTimeout(r, 200));
1032
+ }
1033
+
1034
+ // ── Execute drop action ──
1035
+ if (target) {
1036
+ const saved = getExplorerCatOrder();
1037
+
1038
+ if (target.type === 'reorder-toplevel') {
1039
+ // Read current top-level order from DOM
1040
+ const topNames = [...container.querySelectorAll(':scope > .explorer-node[data-cat]')]
1041
+ .map(el => el.dataset.cat)
1042
+ .filter(n => n !== droppedName);
1043
+ const refIdx = topNames.indexOf(target.node.dataset.cat);
1044
+ const insertIdx = target.position === 'before' ? refIdx : refIdx + 1;
1045
+ topNames.splice(insertIdx, 0, droppedName);
1046
+ saved.topLevel = topNames;
1047
+ saveExplorerCatOrder(saved);
1048
+ buildTree();
1049
+
1050
+ } else if (target.type === 'reorder-sibling' && target.parentName) {
1051
+ // Read current child order within the parent
1052
+ const parentEl = container.querySelector(`.explorer-node[data-cat="${CSS.escape(target.parentName)}"]`);
1053
+ if (parentEl) {
1054
+ const childContainer = parentEl.querySelector(':scope > .explorer-children');
1055
+ if (childContainer) {
1056
+ const childNames = [...childContainer.querySelectorAll(':scope > .explorer-node[data-cat]')]
1057
+ .map(el => el.dataset.cat)
1058
+ .filter(n => n !== droppedName);
1059
+ const refIdx = childNames.indexOf(target.node.dataset.cat);
1060
+ const insertIdx = target.position === 'before' ? refIdx : refIdx + 1;
1061
+ childNames.splice(insertIdx, 0, droppedName);
1062
+ if (!saved.children) saved.children = {};
1063
+ saved.children[target.parentName] = childNames;
1064
+ saveExplorerCatOrder(saved);
1065
+ buildTree();
1066
+ }
1067
+ }
1068
+
1069
+ } else if (target.type === 'reparent') {
1070
+ const newParent = target.targetName;
1071
+ const currentMeta = state.categoryMetadata[droppedName] || {};
1072
+ if (currentMeta.parent !== newParent) {
1073
+ _reparenting = true;
1074
+ try {
1075
+ const data = await apiUpdateCategory(droppedName, { parent: newParent });
1076
+ // Apply response to state
1077
+ if (data && data.categories) {
1078
+ state.allCategoryNames = data.categories.map(c => c.name);
1079
+ state.categoryDescriptions = {};
1080
+ state.categoryMetadata = {};
1081
+ data.categories.forEach(c => {
1082
+ state.categoryDescriptions[c.name] = c.description;
1083
+ state.categoryMetadata[c.name] = {
1084
+ parent: c.parent,
1085
+ color: c.color,
1086
+ is_parent: c.is_parent,
1087
+ logo: c.logo,
1088
+ };
1089
+ });
1090
+ }
1091
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
1092
+ buildCategorySidebar(presentCats);
1093
+ emit('graph:refresh');
1094
+ buildTree();
1095
+ } catch (err) {
1096
+ console.error('Explorer: error reparenting category:', err);
1097
+ } finally {
1098
+ _reparenting = false;
1099
+ }
1100
+ }
1101
+
1102
+ } else if (target.type === 'make-toplevel') {
1103
+ const currentMeta = state.categoryMetadata[droppedName] || {};
1104
+ if (currentMeta.parent) {
1105
+ _reparenting = true;
1106
+ try {
1107
+ const data = await apiUpdateCategory(droppedName, { parent: '' });
1108
+ if (data && data.categories) {
1109
+ state.allCategoryNames = data.categories.map(c => c.name);
1110
+ state.categoryDescriptions = {};
1111
+ state.categoryMetadata = {};
1112
+ data.categories.forEach(c => {
1113
+ state.categoryDescriptions[c.name] = c.description;
1114
+ state.categoryMetadata[c.name] = {
1115
+ parent: c.parent,
1116
+ color: c.color,
1117
+ is_parent: c.is_parent,
1118
+ logo: c.logo,
1119
+ };
1120
+ });
1121
+ }
1122
+ const presentCats = new Set(state.allNodes.map(n => n.payload.category));
1123
+ buildCategorySidebar(presentCats);
1124
+ emit('graph:refresh');
1125
+ buildTree();
1126
+ } catch (err) {
1127
+ console.error('Explorer: error making category top-level:', err);
1128
+ } finally {
1129
+ _reparenting = false;
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+
1136
+ // ── Cleanup ──
1137
+ clearDragIndicators(container);
1138
+ if (_dragEl) _dragEl.classList.remove('explorer-drag-source');
1139
+ if (_cloneEl) { _cloneEl.remove(); _cloneEl = null; }
1140
+ container.classList.remove('explorer-dragging');
1141
+ container.style.removeProperty('--explorer-drag-gap');
1142
+ _dragEl = null;
1143
+ _dragType = null;
1144
+ _dragName = null;
1145
+ _initialRect = null;
1146
+
1147
+ // ── Settle animation ──
1148
+ if (didMove && droppedName) {
1149
+ const settled = container.querySelector(`.explorer-node[data-cat="${CSS.escape(droppedName)}"]`);
1150
+ if (settled) {
1151
+ settled.classList.add('explorer-just-dropped');
1152
+ settled.addEventListener('animationend', () => settled.classList.remove('explorer-just-dropped'), { once: true });
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ nodeEl.addEventListener('pointermove', onMove);
1158
+ nodeEl.addEventListener('pointerup', onUp);
1159
+ nodeEl.addEventListener('pointercancel', onUp);
1160
+ });
1161
+ }
1162
+
1163
+
1164
+ // ═══════════════════════════════════════════
1165
+ // INIT
1166
+ // ═══════════════════════════════════════════
1167
+
1168
+ export function initExplorer() {
1169
+ loadPersistedState();
1170
+
1171
+ // Apply persisted width to the panel
1172
+ const panel = $('explorer-panel');
1173
+ if (panel) {
1174
+ panel.style.width = `${_width}px`;
1175
+ }
1176
+
1177
+ // Menu toggle
1178
+ const menuEl = $('menu-toggle-explorer');
1179
+ if (menuEl) {
1180
+ menuEl.addEventListener('click', () => toggleExplorer());
1181
+ }
1182
+
1183
+ // Logo click toggles explorer
1184
+ const logo = $('titlebar-logo');
1185
+ if (logo) {
1186
+ logo.addEventListener('click', () => toggleExplorer());
1187
+ }
1188
+
1189
+ // Toolbar button
1190
+ const toolbarBtn = $('topright-memory-explorer-btn');
1191
+ if (toolbarBtn) {
1192
+ toolbarBtn.addEventListener('click', () => toggleExplorer());
1193
+ }
1194
+
1195
+ // Collapse all / expand all
1196
+ const collapseAllBtn = $('explorer-collapse-all');
1197
+ if (collapseAllBtn) collapseAllBtn.addEventListener('click', collapseAll);
1198
+
1199
+ const expandAllBtn = $('explorer-expand-all');
1200
+ if (expandAllBtn) expandAllBtn.addEventListener('click', expandAll);
1201
+
1202
+ // Sort button
1203
+ const sortBtn = $('explorer-sort-btn');
1204
+ if (sortBtn) {
1205
+ // Set initial tooltip
1206
+ const labels = { newest: 'Sort: newest first', oldest: 'Sort: oldest first', alpha: 'Sort: A-Z' };
1207
+ sortBtn.setAttribute('data-tooltip', labels[_sortMode]);
1208
+ sortBtn.addEventListener('click', cycleSortMode);
1209
+ }
1210
+
1211
+ // Filter input
1212
+ const filterInput = $('explorer-filter-input');
1213
+ const filterClear = $('explorer-filter-clear');
1214
+ if (filterInput) {
1215
+ filterInput.addEventListener('input', () => {
1216
+ _filterText = filterInput.value.trim();
1217
+ filterClear.style.display = _filterText ? '' : 'none';
1218
+ buildTree();
1219
+ });
1220
+ }
1221
+ if (filterClear) {
1222
+ filterClear.style.display = 'none';
1223
+ filterClear.addEventListener('click', () => {
1224
+ _filterText = '';
1225
+ if (filterInput) filterInput.value = '';
1226
+ filterClear.style.display = 'none';
1227
+ buildTree();
1228
+ });
1229
+ }
1230
+
1231
+ // Label toggle
1232
+ const labelToggle = $('explorer-label-toggle');
1233
+ if (labelToggle) {
1234
+ // Sync initial style
1235
+ labelToggle.style.color = state.labelsVisible ? 'var(--t-primary)' : 'var(--t-muted)';
1236
+ labelToggle.addEventListener('click', () => {
1237
+ state.labelsVisible = !state.labelsVisible;
1238
+ labelToggle.style.color = state.labelsVisible ? 'var(--t-primary)' : 'var(--t-muted)';
1239
+ emit('graph:refresh');
1240
+ });
1241
+ }
1242
+
1243
+ // Label size button + slider
1244
+ const labelSizeBtn = $('explorer-label-size');
1245
+ const sliderRow = $('explorer-label-slider-row');
1246
+ const slider = $('explorer-label-slider');
1247
+ if (labelSizeBtn && sliderRow && slider) {
1248
+ slider.value = state.labelSizeMultiplier;
1249
+ labelSizeBtn.addEventListener('click', () => {
1250
+ const open = sliderRow.style.display !== 'none';
1251
+ sliderRow.style.display = open ? 'none' : '';
1252
+ });
1253
+ slider.addEventListener('input', () => {
1254
+ state.labelSizeMultiplier = parseFloat(slider.value);
1255
+ emit('graph:refresh');
1256
+ });
1257
+ }
1258
+
1259
+ // Select all / deselect all categories
1260
+ const selectAllBtn = $('explorer-select-all');
1261
+ const deselectAllBtn = $('explorer-deselect-all');
1262
+ if (selectAllBtn) {
1263
+ selectAllBtn.addEventListener('click', () => {
1264
+ Object.keys(state.categoryMetadata).forEach(c => state.activeCategories.add(c));
1265
+ cancelScheduledRemoval();
1266
+ emit('graph:refresh');
1267
+ emit('categories-changed');
1268
+ buildTree();
1269
+ });
1270
+ }
1271
+ if (deselectAllBtn) {
1272
+ deselectAllBtn.addEventListener('click', () => {
1273
+ state.activeCategories.clear();
1274
+ scheduleGraphRemoval();
1275
+ emit('categories-changed');
1276
+ buildTree();
1277
+ });
1278
+ }
1279
+
1280
+ // Right-edge resize
1281
+ initResizeHandle();
1282
+
1283
+ // Keyboard shortcut: toggle explorer (via central keybinds)
1284
+ registerAction('toggle-explorer', () => toggleExplorer());
1285
+
1286
+ // ── Event bus listeners ──
1287
+
1288
+ // Rebuild tree when data changes
1289
+ on('data-loaded', () => {
1290
+ if (_visible) buildTree();
1291
+ else {
1292
+ if (panel) delete panel.dataset.built;
1293
+ }
1294
+ });
1295
+
1296
+ on('data:reload', () => {
1297
+ if (_visible) buildTree();
1298
+ else {
1299
+ if (panel) delete panel.dataset.built;
1300
+ }
1301
+ });
1302
+
1303
+ on('categories-changed', () => {
1304
+ if (_visible) buildTree();
1305
+ else {
1306
+ if (panel) delete panel.dataset.built;
1307
+ }
1308
+ });
1309
+
1310
+ // Also listen to sidebar's colon-separated event (edit/delete/create)
1311
+ on('categories:changed', () => {
1312
+ if (_visible) buildTree();
1313
+ else {
1314
+ if (panel) delete panel.dataset.built;
1315
+ }
1316
+ });
1317
+
1318
+ // Rebuild when permissions arrive or change (context menu items may differ)
1319
+ on('session:info', () => {
1320
+ if (_visible) buildTree();
1321
+ });
1322
+ on('permissions:changed', () => {
1323
+ if (_visible) buildTree();
1324
+ });
1325
+
1326
+ // Sync selection from graph click
1327
+ on('node-selected', (node) => {
1328
+ if (node && _visible) {
1329
+ highlightNode(node.id, true);
1330
+ }
1331
+ });
1332
+
1333
+ // Search integration
1334
+ on('search:apply', () => {
1335
+ if (_visible) buildTree();
1336
+ });
1337
+
1338
+ on('search:clear', () => {
1339
+ if (_visible) buildTree();
1340
+ });
1341
+
1342
+ // Restore visibility if it was open last time
1343
+ if (_visible) {
1344
+ if (panel) {
1345
+ panel.classList.add('open');
1346
+ applyExplorerWidth();
1347
+ const titleBar = document.getElementById('title-bar');
1348
+ if (titleBar) titleBar.classList.add('explorer-active');
1349
+ document.body.classList.add('explorer-open');
1350
+ const menuItem = $('menu-toggle-explorer');
1351
+ if (menuItem) menuItem.classList.add('active');
1352
+ const tbBtnInit = $('topright-memory-explorer-btn');
1353
+ if (tbBtnInit) tbBtnInit.classList.add('active');
1354
+ }
1355
+ }
1356
+ }