claude-code-memory-explorer 0.17.0 → 1.0.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.
package/hub-app.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "manifest": 1,
3
+ "id": "memory",
4
+ "name": "Memory Diagnoser",
5
+ "icon": "database",
6
+ "run": { "entry": "server.js", "defaultPort": 3544 },
7
+ "loading": {
8
+ "verbs": [
9
+ "Recalling memories…",
10
+ "Walking the tree…",
11
+ "Dusting off notes…",
12
+ "Reading CLAUDE.md…",
13
+ "Following imports…",
14
+ "Tracing the context…",
15
+ "Flipping through pages…",
16
+ "Connecting the dots…",
17
+ "Checking what sticks…"
18
+ ]
19
+ },
20
+ "actions": {
21
+ "handles": {
22
+ "project.memory": { "params": { "project": "string?" }, "url": "?project={project}", "mode": "message" }
23
+ }
24
+ }
25
+ }
package/lib/net-guard.js CHANGED
@@ -36,6 +36,7 @@ const PORT_RE = /^[0-9]{1,5}$/;
36
36
  // The port is validated but not compared — the listen(0) fallbacks make the real
37
37
  // port dynamic, so only the host is allowlisted.
38
38
  const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
39
+ const KEEP_ALIVE_MS = 65000;
39
40
 
40
41
  function isLoopbackAddress(host) {
41
42
  if (!host) return false;
@@ -249,6 +250,10 @@ function createNetGuard(config = {}) {
249
250
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
250
251
  const makeServer = (handler) => {
251
252
  const s = http.createServer(serverOpts, handler);
253
+ // Node's 5 s default closes idle sockets that the hub proxy and the browser would reuse, and a
254
+ // fresh loopback connect is the one that some Windows network filters drop. The hub's agent
255
+ // drops its idle sockets before this, so it never reuses one the server is closing.
256
+ s.keepAliveTimeout = KEEP_ALIVE_MS;
252
257
  if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
253
258
  return s;
254
259
  };
@@ -281,4 +286,4 @@ function createNetGuard(config = {}) {
281
286
  return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
282
287
  }
283
288
 
284
- module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
289
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, KEEP_ALIVE_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-memory-explorer",
3
- "version": "0.17.0",
3
+ "version": "1.0.0",
4
4
  "description": "Memory explorer dashboard for Claude Code",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -46,6 +46,7 @@
46
46
  "license": "MIT",
47
47
  "files": [
48
48
  "server.js",
49
+ "hub-app.json",
49
50
  "lib/",
50
51
  "public/",
51
52
  "README.md",
package/public/app.js CHANGED
@@ -145,15 +145,17 @@ function setColorTheme(id) {
145
145
  syncColorThemeMenu(id);
146
146
  }
147
147
 
148
- function buildThemeMenu() {
148
+ function buildThemeMenu(themes = COLOR_THEMES) {
149
149
  const menu = document.getElementById('themeMenu');
150
- menu.innerHTML = COLOR_THEMES.map(
151
- ([id, label]) =>
152
- `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
150
+ menu.innerHTML = themes
151
+ .map(
152
+ ([id, label]) =>
153
+ `<button type="button" class="theme-menu-item theme-swatch-${id}" data-theme-id="${id}"
153
154
  onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
154
- <span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${label}
155
+ <span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${esc(label)}
155
156
  </button>`,
156
- ).join('');
157
+ )
158
+ .join('');
157
159
  }
158
160
 
159
161
  // biome-ignore lint/correctness/noUnusedVariables: called from topbar markup
@@ -453,6 +455,8 @@ const SHORTCUT_PAIRS = [
453
455
  { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
454
456
  { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
455
457
  { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
458
+ { keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
459
+ { keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
456
460
  ],
457
461
  },
458
462
  ],
@@ -490,7 +494,7 @@ function buildHelpShortcuts() {
490
494
  function toggleHelpModal() {
491
495
  const list = document.getElementById('helpShortcuts');
492
496
  if (!list.childElementCount) list.innerHTML = buildHelpShortcuts();
493
- list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
497
+ list.classList.toggle('sc-standalone', hub.status === 'standalone');
494
498
  document.getElementById('helpModal').classList.toggle('open');
495
499
  }
496
500
 
@@ -2137,117 +2141,47 @@ document.addEventListener('keydown', (e) => {
2137
2141
 
2138
2142
  // #region HUB_INTEGRATION
2139
2143
 
2140
- (async function initHub() {
2141
- const cfg = await fetch('/hub-config')
2142
- .then((r) => r.json())
2143
- .catch(() => ({}));
2144
- if (!cfg.enabled) return;
2145
- window.__HUB__ = cfg;
2146
- // e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
2147
- // 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
2148
- // these tests only decide whether a press is the hub's to handle.
2149
- document.addEventListener('keydown', (e) => {
2150
- if (!isHubKey(e)) return;
2151
- e.preventDefault();
2152
- hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
2153
- });
2154
- })();
2155
-
2156
- // The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
2157
- // hub:keys sends none, and the fallback filter below is what such a hub expects.
2158
- let hubKeys = null;
2159
-
2160
- // A copy of the hub's comboOf(): its names must match the hub:keys list.
2161
- function hubCombo(e) {
2162
- const lower = (e.key || '').toLowerCase();
2163
- const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
2164
- const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
2165
- const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
2166
- return [...mods, key].filter(Boolean).join('+');
2167
- }
2168
-
2169
- function isHubKey(e) {
2170
- if (hubKeys) return hubKeys.has(hubCombo(e));
2171
- if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
2172
- // Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
2173
- // keymap and ignores unbound letters.
2174
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))) {
2175
- return true;
2176
- }
2177
- return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
2178
- }
2179
-
2180
- function _hubNavigate(app, url) {
2181
- if (!window.__HUB__?.enabled) return;
2182
- hubPost({ type: 'hub:navigate', app, url });
2183
- }
2184
-
2185
- // Hoisted out of initHubTheme so initHubProject can share it.
2186
- const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
2144
+ const hub = ClaudeHub.connect();
2187
2145
 
2188
- // Every send is addressed to the hub explicitly. With targetOrigin '*' any page that
2189
- // framed this app also received the forwarded keystrokes and navigation intents.
2190
- function hubPost(message) {
2191
- const origin = hubOrigin();
2192
- if (origin) window.parent?.postMessage(message, origin);
2193
- }
2146
+ document.getElementById('helpDocs')?.addEventListener('click', (e) => {
2147
+ e.preventDefault();
2148
+ hub.openExternal(e.currentTarget.href);
2149
+ });
2194
2150
 
2195
2151
  (function initHubTheme() {
2196
2152
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
2197
2153
  const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
2198
- // lastTheme/lastColorTheme are updated synchronously when applying a hub
2199
- // message, so the (async) observer sees no diff and doesn't echo it back.
2200
- let lastTheme = getTheme();
2201
- let lastColorTheme = getColorTheme();
2202
- window.addEventListener('message', (e) => {
2203
- if (e.source !== window.parent || e.origin !== hubOrigin()) return;
2204
- if (e.data?.type !== 'hub:theme') return;
2205
- if (typeof e.data.colorTheme === 'string' && e.data.colorTheme !== getColorTheme()) {
2206
- setColorTheme(e.data.colorTheme);
2207
- lastColorTheme = getColorTheme();
2208
- }
2209
- if (getTheme() !== e.data.theme) {
2210
- window.toggleTheme();
2211
- lastTheme = getTheme();
2212
- }
2154
+ const report = hub.bindTheme({
2155
+ get: () => ({ theme: getTheme(), colorTheme: getColorTheme() }),
2156
+ set: ({ theme, colorTheme }) => {
2157
+ if (colorTheme !== getColorTheme()) setColorTheme(colorTheme);
2158
+ if (theme !== getTheme()) window.toggleTheme();
2159
+ },
2213
2160
  });
2214
- new MutationObserver(() => {
2215
- const t = getTheme();
2216
- const ct = getColorTheme();
2217
- if (t === lastTheme && ct === lastColorTheme) return;
2218
- lastTheme = t;
2219
- lastColorTheme = ct;
2220
- hubPost({ type: 'hub:theme', theme: t, colorTheme: ct });
2221
- }).observe(document.body, {
2161
+ new MutationObserver(report).observe(document.body, {
2222
2162
  attributes: true,
2223
2163
  attributeFilter: ['class', 'data-color-theme'],
2224
2164
  });
2225
2165
  })();
2226
2166
 
2227
- (function initHubKeys() {
2228
- window.addEventListener('message', (e) => {
2229
- if (e.source !== window.parent || e.origin !== hubOrigin()) return;
2230
- if (e.data?.type !== 'hub:keys' || !Array.isArray(e.data.keys)) return;
2231
- hubKeys = new Set(e.data.keys.filter((k) => typeof k === 'string'));
2232
- });
2233
- })();
2167
+ // Under the hub the picker lists the hub's themes, which include the user's own (protocol section 6, rule 5).
2168
+ hub.onThemes((themes) => {
2169
+ buildThemeMenu(themes.map((t) => [t.id, t.label]));
2170
+ syncColorThemeMenu(document.body.dataset.colorTheme);
2171
+ });
2234
2172
 
2235
2173
  // Set synchronously when the hub pushes a project, so the DOMContentLoaded restore below can't
2236
2174
  // let a stale localStorage recent win the race against the hub's choice.
2237
2175
  let hubProjectPath = null;
2176
+ // Shared with the DOMContentLoaded restore so a boot restore and a hub push never apply the same project twice.
2177
+ let lastAppliedProject = null;
2238
2178
 
2239
2179
  (function initHubProject() {
2240
- let lastApplied = null;
2241
- window.addEventListener('message', async (e) => {
2242
- if (e.source !== window.parent || e.origin !== hubOrigin()) return;
2243
- if (e.data?.type !== 'hub:project') return;
2244
- const dirPath = e.data.project;
2245
- if (typeof dirPath !== 'string' || !dirPath) return;
2246
- hubProjectPath = dirPath;
2180
+ async function applyProject(dirPath) {
2247
2181
  // Dedupes against the last applied value, not just an in-flight one: the hub re-posts on
2248
2182
  // every iframe load, so without this each load would PUT and reload twice.
2249
- if (lastApplied === dirPath) return;
2250
- lastApplied = dirPath;
2183
+ if (lastAppliedProject === dirPath) return;
2184
+ lastAppliedProject = dirPath;
2251
2185
  try {
2252
2186
  await putProject(dirPath);
2253
2187
  // The hub does not persist its own scope, so a hub-pushed project must become the recent
@@ -2256,9 +2190,22 @@ let hubProjectPath = null;
2256
2190
  addRecentProject(dirPath);
2257
2191
  await Promise.all([loadProject(), loadData()]);
2258
2192
  } catch (err) {
2259
- lastApplied = null;
2260
- console.warn('hub:project failed:', err.message);
2193
+ lastAppliedProject = null;
2194
+ throw err;
2261
2195
  }
2196
+ }
2197
+
2198
+ hub.subscribe('project.changed', (p) => {
2199
+ const dirPath = typeof p?.project === 'string' && p.project ? p.project : null;
2200
+ if (!dirPath) return;
2201
+ hubProjectPath = dirPath;
2202
+ applyProject(dirPath).catch((err) => console.warn('project.changed failed:', err.message));
2203
+ });
2204
+
2205
+ // A link moves this app off the hub's project, and applyProject records that, so the next
2206
+ // project.changed still applies.
2207
+ hub.handle('project.memory', (p) => {
2208
+ if (p.project) applyProject(p.project).catch(() => showToast('Failed to switch project', 'error'));
2262
2209
  });
2263
2210
  })();
2264
2211
 
@@ -2320,11 +2267,13 @@ document.addEventListener('DOMContentLoaded', async () => {
2320
2267
  // Handle ?project= query param, else restore last-used project from localStorage
2321
2268
  const params = new URLSearchParams(location.search);
2322
2269
  let desiredProject = params.get('project');
2323
- // A hub-pushed project outranks the localStorage recent: initHub() is fire-and-forget at script
2324
- // eval, so hub:project can land before or during this block.
2270
+ // A hub-pushed project outranks the localStorage recent: ClaudeHub.connect() is fire-and-forget at script
2271
+ // eval, so project.changed can land before or during this block.
2325
2272
  await projectInfoPromise;
2326
2273
  if (!desiredProject) desiredProject = hubProjectPath || getRecentProjects()[0] || null;
2327
2274
  if (desiredProject) {
2275
+ // Set before the PUT: the hub's push can land while it is in flight.
2276
+ lastAppliedProject = desiredProject;
2328
2277
  try {
2329
2278
  await putProject(desiredProject);
2330
2279
  } catch {
package/public/index.html CHANGED
@@ -27,6 +27,7 @@
27
27
  <script defer src="/app.js"></script>
28
28
  </head>
29
29
  <body>
30
+ <script src="/vendor/claude-hub-sdk.js"></script>
30
31
  <div class="topbar">
31
32
  <div class="topbar-logo">
32
33
  <div class="logo-mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9"/><path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4"/></svg></div>
@@ -166,6 +167,7 @@
166
167
  <div class="modal modal-help">
167
168
  <div class="modal-header">
168
169
  <h3>Memory Diagnoser</h3>
170
+ <a class="help-docs" id="helpDocs" href="https://nikiforovall.blog/claude-code-memory/" target="_blank" rel="noopener">Docs ↗</a>
169
171
  <button class="modal-close" onclick="toggleHelpModal()">&#10005;</button>
170
172
  </div>
171
173
  <div class="modal-body">
package/public/style.css CHANGED
@@ -964,6 +964,23 @@ body.light .tree-conditional .file-name {
964
964
  margin-bottom: 6px;
965
965
  text-transform: uppercase;
966
966
  }
967
+ .help-docs {
968
+ margin: 0 12px 0 auto;
969
+ padding: 4px 10px;
970
+ border: 1px solid var(--border);
971
+ border-radius: 6px;
972
+ color: var(--text-secondary);
973
+ font-size: 12px;
974
+ text-decoration: none;
975
+ white-space: nowrap;
976
+ transition:
977
+ color 0.15s ease,
978
+ border-color 0.15s ease;
979
+ }
980
+ .help-docs:hover {
981
+ color: var(--accent);
982
+ border-color: var(--accent);
983
+ }
967
984
  /* One shared grid for both halves (key | label | spacer | key | label): each key
968
985
  gutter is sized by its column's widest combo, and left/right rows share row
969
986
  tracks, so paired groups line up — a shorter group just leaves empty rows.
@@ -0,0 +1,50 @@
1
+ // claude-hub-sdk 1.1.0 (sha256 0fed14faa670). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
+ // Claude Code Hub SDK stub: what an app serves when no hub runs it. Under a hub, the hub hands the
3
+ // app its real SDK (client.js) in place of this file. The API must match client.js; a test checks it.
4
+ ((root) => {
5
+ 'use strict';
6
+
7
+ function createClaudeHub(win) {
8
+ let hub = null;
9
+ const off = () => {};
10
+
11
+ function connect({ standalone = {} } = {}) {
12
+ if (hub) return hub;
13
+ const standaloneFn = (action) => {
14
+ const fn = (typeof standalone === 'function' ? standalone() : standalone)[action];
15
+ return typeof fn === 'function' ? fn : null;
16
+ };
17
+ hub = {
18
+ status: 'standalone',
19
+ themes: [],
20
+ inHub: false,
21
+ onStatus: () => off,
22
+ onActive: () => off,
23
+ onThemes: () => off,
24
+ subscribe: () => off,
25
+ bindTheme: () => off,
26
+ handle() {},
27
+ publish() {},
28
+ invoke(action, params = {}) {
29
+ const target = standaloneFn(action)?.(params);
30
+ if (!target) return Promise.resolve({ ok: false, reason: 'unhandled' });
31
+ win.open(target, '_blank', 'noopener');
32
+ return Promise.resolve({ ok: true, handledBy: 'standalone' });
33
+ },
34
+ can: (action) => !!standaloneFn(action),
35
+ forwards: () => false,
36
+ closeGuard() {},
37
+ openExternal(url) {
38
+ win.open(url, '_blank', 'noopener');
39
+ },
40
+ terminalToken: () => Promise.resolve(null),
41
+ };
42
+ return hub;
43
+ }
44
+
45
+ return { connect };
46
+ }
47
+
48
+ if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub };
49
+ else root.ClaudeHub = createClaudeHub(root);
50
+ })(typeof window !== 'undefined' ? window : globalThis);
package/server.js CHANGED
@@ -608,18 +608,10 @@ app.use(net.frameGuard);
608
608
  app.use(net.originGuard);
609
609
 
610
610
  app.use(express.json());
611
+ // Under a hub, the hub hands over its SDK. Alone, public/vendor/claude-hub-sdk.js is a stub.
612
+ if (process.env.HUB_SDK_SERVER) require(process.env.HUB_SDK_SERVER).mount(app);
611
613
  app.use(express.static(path.join(__dirname, 'public')));
612
614
 
613
- app.get('/hub-config', (_req, res) => {
614
- res.json({
615
- name: 'Memory Diagnoser',
616
- icon: 'brain',
617
- description: 'Explore Claude Code memory sources',
618
- enabled: !!process.env.CLAUDE_HUB,
619
- url: process.env.HUB_URL || null,
620
- });
621
- });
622
-
623
615
  app.get('/api/project', (_req, res) => {
624
616
  res.json({ path: currentProjectPath, name: path.basename(currentProjectPath), configDir: CLAUDE_DIR });
625
617
  });