memstack-skill-loader 4.4.0__tar.gz → 4.5.0__tar.gz

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 (40) hide show
  1. {memstack_skill_loader-4.4.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.5.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/dashboard.html +205 -7
  5. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/dashboard.py +129 -0
  6. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/license.py +326 -49
  7. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  8. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +1 -0
  9. memstack_skill_loader-4.5.0/tests/test_license_grace.py +604 -0
  10. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/tests/test_pro_skills_update.py +281 -281
  11. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/tests/test_skill_drift.py +36 -36
  12. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/MANIFEST.in +0 -0
  13. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/README.md +0 -0
  14. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/setup.cfg +0 -0
  15. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/__main__.py +0 -0
  16. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/agent_runner.py +0 -0
  17. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/categories.py +0 -0
  18. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/compression.py +0 -0
  19. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/config.py +0 -0
  20. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/indexer.py +0 -0
  21. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/memory_db.py +0 -0
  22. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  23. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  24. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  25. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  26. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  27. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  28. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  29. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  30. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/search.py +0 -0
  31. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/server.py +0 -0
  32. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/skill_config.py +0 -0
  33. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/stats.py +0 -0
  34. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  35. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader/version_check.py +0 -0
  36. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  37. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  38. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  39. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  40. {memstack_skill_loader-4.4.0 → memstack_skill_loader-4.5.0}/tests/test_pro_compressor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.4.0
3
+ Version: 4.5.0
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.4.0"
7
+ version = "4.5.0"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.4.0"
3
+ __version__ = "4.5.0"
@@ -546,6 +546,39 @@
546
546
  .diary-pagination .diary-pg-btn { font-size: 0.78rem; color: #58a6ff; cursor: pointer; background: none; border: 1px solid #30363d; border-radius: 4px; padding: 0.25rem 0.7rem; }
547
547
  .diary-pagination .diary-pg-btn:hover { border-color: #58a6ff; }
548
548
 
549
+ /* ── Memory Browser ── */
550
+ .mb-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.9rem; flex-wrap: wrap; }
551
+ .mb-select, .mb-search {
552
+ background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
553
+ padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 0.82rem;
554
+ }
555
+ .mb-search { flex: 1; min-width: 200px; }
556
+ .mb-search:focus, .mb-select:focus { outline: none; border-color: #58a6ff; }
557
+ .mb-count { font-size: 0.75rem; color: #484f58; white-space: nowrap; }
558
+ .mb-entry { cursor: pointer; transition: border-color 0.15s; }
559
+ .mb-entry:hover { border-color: #58a6ff; }
560
+ .mb-badges { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; }
561
+ .mb-badge {
562
+ font-size: 0.66rem; padding: 0.1rem 0.45rem; border-radius: 10px;
563
+ border: 1px solid #30363d; color: #8b949e; white-space: nowrap;
564
+ }
565
+ .mb-badge.project { color: #58a6ff; border-color: #1f4068; }
566
+ .mb-badge.agent { color: #d29922; border-color: #5a4413; }
567
+ .mb-badge.direct { color: #3fb950; border-color: #1b4721; }
568
+ .mb-detail-head { display: flex; justify-content: space-between; align-items: center; gap: 0.6rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
569
+ .mb-back {
570
+ background: #21262d; border: 1px solid #30363d; color: #c9d1d9;
571
+ padding: 0.3rem 0.7rem; border-radius: 6px; cursor: pointer; font-size: 0.78rem;
572
+ }
573
+ .mb-back:hover { border-color: #58a6ff; }
574
+ .mb-detail-actions { display: flex; gap: 0.5rem; align-items: center; }
575
+ .mb-detail-title { color: #e6edf3; font-size: 1rem; margin-bottom: 0.6rem; }
576
+ .mb-detail-content {
577
+ background: #0d1117; border: 1px solid #21262d; border-radius: 6px;
578
+ padding: 1rem; font-size: 0.8rem; color: #c9d1d9; line-height: 1.5;
579
+ white-space: pre-wrap; word-break: break-word; max-height: 65vh; overflow-y: auto;
580
+ }
581
+
549
582
  /* ── Projects ── */
550
583
  .projects-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
551
584
  .project-card {
@@ -1656,13 +1689,18 @@
1656
1689
 
1657
1690
  <!-- ═══════ Memory Browser ═══════ -->
1658
1691
  <div id="page-memory-browser" class="page">
1659
- <div class="placeholder-page">
1660
- <div class="placeholder-card">
1661
- <div class="icon">&#128218;</div>
1662
- <h3>Memory Browser</h3>
1663
- <div class="version-badge">Coming in v4.1</div>
1664
- <p>Search across sessions, lessons, and facts from your development history. Memory capture pipelines are being refined for the next release.</p>
1692
+ <div class="page-header">
1693
+ <h2>Memory Browser</h2>
1694
+ <p class="meta">Search session diaries across all projects (agent runs and direct sessions).</p>
1695
+ </div>
1696
+ <div class="panel">
1697
+ <div class="mb-controls">
1698
+ <select id="mb-project" class="mb-select" aria-label="Project filter"></select>
1699
+ <input id="mb-search" class="mb-search" type="text" placeholder="Search title and content..." autocomplete="off">
1700
+ <span id="mb-count" class="mb-count"></span>
1665
1701
  </div>
1702
+ <div id="mb-list" class="diary-list"></div>
1703
+ <div id="mb-detail" class="mb-detail" style="display:none;"></div>
1666
1704
  </div>
1667
1705
  </div>
1668
1706
 
@@ -2194,6 +2232,7 @@ document.querySelectorAll('.nav-item').forEach(item => {
2194
2232
  if (page === 'overview' && !projectsLoaded) loadProjects();
2195
2233
  if (page === 'overview') { updateWelcomeGreeting(); loadDiary(); }
2196
2234
  if (page === 'agent-monitor') loadAgentMonitor();
2235
+ if (page === 'memory-browser') loadMemoryBrowser();
2197
2236
  if (page === 'burn-report') loadBurnReport();
2198
2237
  if (page === 'settings') loadSettings();
2199
2238
  if (page !== 'agent-monitor') stopAgentRefresh();
@@ -2922,7 +2961,166 @@ async function loadProjects() {
2922
2961
  }
2923
2962
  }
2924
2963
 
2925
- /* Memory Browser — placeholder, full implementation coming in v4.1 */
2964
+ /* ════════════════════════════════════════════
2965
+ Memory Browser
2966
+ ════════════════════════════════════════════ */
2967
+ let _mbEntries = [];
2968
+ let _mbLoaded = false;
2969
+
2970
+ // Mask secrets/PII so they are never rendered in plain sight.
2971
+ function maskSecrets(text) {
2972
+ if (!text) return '';
2973
+ return text
2974
+ .replace(/MSPRO-[A-Z0-9-]{4,}/gi, 'MSPRO-****')
2975
+ .replace(/sk-[A-Za-z0-9-]{8,}/g, 'sk-****')
2976
+ .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, function (m) {
2977
+ const at = m.indexOf('@');
2978
+ return m[0] + '****' + m.slice(at);
2979
+ });
2980
+ }
2981
+
2982
+ // Clean seam: a future semantic search can replace this body without touching the UI.
2983
+ function searchEntries(entries, query) {
2984
+ const q = (query || '').trim().toLowerCase();
2985
+ if (!q) return entries;
2986
+ const terms = q.split(/\s+/);
2987
+ return entries.filter(function (e) {
2988
+ const hay = ((e.title || '') + '\n' + (e.content || '')).toLowerCase();
2989
+ return terms.every(function (t) { return hay.includes(t); });
2990
+ });
2991
+ }
2992
+
2993
+ function _mbCleanBody(raw, title) {
2994
+ const titleLower = (title || '').toLowerCase().trim();
2995
+ return (raw || '').split('\n')
2996
+ .map(function (l) { return l.replace(/^#{1,6}\s+/, ''); })
2997
+ .filter(function (l, i) {
2998
+ const t = l.trim();
2999
+ if (!t) return true;
3000
+ if (t.startsWith('Working directory:')) return false;
3001
+ if (i === 0 && (t.toLowerCase() === titleLower || /^\d{4}-/.test(t))) return false;
3002
+ return true;
3003
+ })
3004
+ .join('\n').replace(/\n{3,}/g, '\n\n').trim();
3005
+ }
3006
+
3007
+ function _mbCleanTitle(e) {
3008
+ return (e.title || e.filename || '').replace(/^\d{4}-\d{2}-\d{2}[\s\-_]*/, '').trim() || e.filename || 'Untitled';
3009
+ }
3010
+
3011
+ function _mbFormatDate(raw) {
3012
+ if (!raw) return '';
3013
+ const d = new Date(raw + 'T00:00:00');
3014
+ return isNaN(d.getTime()) ? raw : d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
3015
+ }
3016
+
3017
+ function _mbSourceClass(e) { return e.source_type === 'agent' ? 'agent' : 'direct'; }
3018
+
3019
+ function renderMemoryList() {
3020
+ const listEl = document.getElementById('mb-list');
3021
+ const detailEl = document.getElementById('mb-detail');
3022
+ const countEl = document.getElementById('mb-count');
3023
+ detailEl.style.display = 'none';
3024
+ listEl.style.display = 'flex';
3025
+ const query = document.getElementById('mb-search').value;
3026
+ const filtered = searchEntries(_mbEntries, query);
3027
+ countEl.textContent = filtered.length + ' of ' + _mbEntries.length + ' entries';
3028
+ if (!_mbEntries.length) {
3029
+ listEl.innerHTML = '<span class="empty-state">No diary entries found.</span>';
3030
+ return;
3031
+ }
3032
+ if (!filtered.length) {
3033
+ listEl.innerHTML = '<span class="empty-state">No entries match your search.</span>';
3034
+ return;
3035
+ }
3036
+ listEl.innerHTML = filtered.map(function (e, i) {
3037
+ const body = _mbCleanBody(e.content, e.title);
3038
+ const snippet = escapeHtml(maskSecrets(body.substring(0, 220)));
3039
+ const more = body.length > 220 ? '...' : '';
3040
+ return '<div class="diary-entry mb-entry" data-idx="' + i + '">'
3041
+ + '<div class="diary-header">'
3042
+ + '<span class="diary-title">' + escapeHtml(_mbCleanTitle(e)) + '</span>'
3043
+ + '<span class="diary-date">' + _mbFormatDate(e.date) + '</span>'
3044
+ + '</div>'
3045
+ + '<div class="mb-badges">'
3046
+ + '<span class="mb-badge project">' + escapeHtml(e.project || 'Unknown') + '</span>'
3047
+ + '<span class="mb-badge ' + _mbSourceClass(e) + '">' + _mbSourceClass(e) + '</span>'
3048
+ + '</div>'
3049
+ + '<div class="diary-body" style="margin-top:0.4rem">' + snippet + more + '</div>'
3050
+ + '</div>';
3051
+ }).join('');
3052
+ listEl.querySelectorAll('.mb-entry').forEach(function (el) {
3053
+ el.addEventListener('click', function () {
3054
+ showMemoryDetail(filtered[parseInt(el.dataset.idx, 10)]);
3055
+ });
3056
+ });
3057
+ }
3058
+
3059
+ function showMemoryDetail(e) {
3060
+ if (!e) return;
3061
+ const listEl = document.getElementById('mb-list');
3062
+ const detailEl = document.getElementById('mb-detail');
3063
+ listEl.style.display = 'none';
3064
+ detailEl.style.display = 'block';
3065
+ const body = maskSecrets(_mbCleanBody(e.content, e.title));
3066
+ detailEl.innerHTML = ''
3067
+ + '<div class="mb-detail-head">'
3068
+ + '<div class="mb-detail-actions">'
3069
+ + '<button class="mb-back" id="mb-back-btn">&larr; Back to list</button>'
3070
+ + '<button class="mb-back" id="mb-copy-btn" title="Copy to clipboard">Copy</button>'
3071
+ + '</div>'
3072
+ + '<div class="mb-badges">'
3073
+ + '<span class="mb-badge project">' + escapeHtml(e.project || 'Unknown') + '</span>'
3074
+ + '<span class="mb-badge ' + _mbSourceClass(e) + '">' + _mbSourceClass(e) + '</span>'
3075
+ + '<span class="diary-date">' + _mbFormatDate(e.date) + '</span>'
3076
+ + '</div></div>'
3077
+ + '<h3 class="mb-detail-title">' + escapeHtml(_mbCleanTitle(e)) + '</h3>'
3078
+ + '<div class="mb-detail-content">' + escapeHtml(body) + '</div>';
3079
+ document.getElementById('mb-back-btn').addEventListener('click', renderMemoryList);
3080
+ document.getElementById('mb-copy-btn').addEventListener('click', function() {
3081
+ const btn = this;
3082
+ // Copy the masked body (what is displayed), never the raw e.content
3083
+ navigator.clipboard.writeText(body).then(function() {
3084
+ btn.textContent = 'Copied!';
3085
+ btn.style.color = '#3fb950';
3086
+ btn.style.borderColor = '#3fb950';
3087
+ setTimeout(function() { btn.textContent = 'Copy'; btn.style.color = ''; btn.style.borderColor = ''; }, 2000);
3088
+ }).catch(function() { /* fallback: ignore */ });
3089
+ });
3090
+ }
3091
+
3092
+ async function fetchMemoryEntries(project) {
3093
+ const listEl = document.getElementById('mb-list');
3094
+ listEl.innerHTML = '<span class="empty-state">Loading...</span>';
3095
+ try {
3096
+ const res = await fetch('/api/memory-browser/entries?project=' + encodeURIComponent(project), {headers: AUTH_GET});
3097
+ _mbEntries = await res.json();
3098
+ } catch (err) {
3099
+ _mbEntries = [];
3100
+ }
3101
+ renderMemoryList();
3102
+ }
3103
+
3104
+ async function loadMemoryBrowser() {
3105
+ if (_mbLoaded) return;
3106
+ _mbLoaded = true;
3107
+ const sel = document.getElementById('mb-project');
3108
+ const searchEl = document.getElementById('mb-search');
3109
+ searchEl.addEventListener('input', renderMemoryList);
3110
+ sel.addEventListener('change', function () { fetchMemoryEntries(sel.value); });
3111
+ try {
3112
+ const res = await fetch('/api/memory-browser/projects', {headers: AUTH_GET});
3113
+ const projects = await res.json();
3114
+ const total = projects.reduce(function (s, p) { return s + p.count; }, 0);
3115
+ sel.innerHTML = '<option value="__all__">All projects (' + total + ')</option>'
3116
+ + projects.map(function (p) {
3117
+ return '<option value="' + escapeHtml(p.project) + '">' + escapeHtml(p.project) + ' (' + p.count + ')</option>';
3118
+ }).join('');
3119
+ } catch (err) {
3120
+ sel.innerHTML = '<option value="__all__">All projects</option>';
3121
+ }
3122
+ await fetchMemoryEntries('__all__');
3123
+ }
2926
3124
 
2927
3125
 
2928
3126
  /* ════════════════════════════════════════════
@@ -427,6 +427,118 @@ def _get_diary_entries() -> list[dict]:
427
427
  return entries
428
428
 
429
429
 
430
+ # ── Memory Browser (project-aware diary reader) ──────────────────────────────
431
+ # Reuses the markdown diary record (agent autosaves in ~/.memstack/diary + each
432
+ # project's memory/sessions) but, unlike _get_diary_entries above, resolves
433
+ # projects EXPLICITLY instead of inheriting the launch cwd.
434
+
435
+ _MB_WORKDIR_RE = re.compile(r"(?:Working directory|Project)\s*[:=]\s*(.+)", re.IGNORECASE)
436
+ _MB_PROJECTS_PATH_RE = re.compile(r"([A-Za-z]:[\\/]+Projects[\\/]+[A-Za-z0-9_.+\- ]+)")
437
+
438
+
439
+ def _mb_basename(path_str: str) -> str | None:
440
+ """Last non-empty path segment of a filesystem path."""
441
+ path_str = (path_str or "").strip().strip('"').strip()
442
+ if not path_str:
443
+ return None
444
+ parts = [p for p in re.split(r"[\\/]+", path_str.rstrip("\\/")) if p]
445
+ return parts[-1] if parts else None
446
+
447
+
448
+ def _mb_extract_workdir(content: str) -> str | None:
449
+ """Best-effort absolute working dir for an agent diary (no structured field exists)."""
450
+ head = content[:1500]
451
+ m = _MB_WORKDIR_RE.search(head)
452
+ if m:
453
+ cand = m.group(1).strip().strip('"').strip()
454
+ if cand and re.match(r"^[A-Za-z]:[\\/]", cand):
455
+ return cand
456
+ m2 = _MB_PROJECTS_PATH_RE.search(head)
457
+ if m2:
458
+ return m2.group(1).strip()
459
+ return None
460
+
461
+
462
+ def _mb_read_md_dir(diary_dir: Path, source_type: str, project_override: str | None = None) -> list[dict]:
463
+ """Read *.md from one directory into browser entries."""
464
+ out = []
465
+ if not diary_dir.exists():
466
+ return out
467
+ for f in diary_dir.glob("*.md"):
468
+ try:
469
+ content = f.read_text(encoding="utf-8", errors="replace")
470
+ except Exception:
471
+ continue
472
+ name = f.stem
473
+ date_part = name[:10] if len(name) >= 10 else name
474
+ first_line = content.split("\n", 1)[0].strip("#").strip()
475
+ if project_override is not None:
476
+ project = project_override
477
+ else:
478
+ wd = _mb_extract_workdir(content)
479
+ project = (_mb_basename(wd) if wd else None) or "Agent Runs"
480
+ try:
481
+ mtime = f.stat().st_mtime
482
+ except OSError:
483
+ mtime = 0.0
484
+ out.append({
485
+ "date": date_part,
486
+ "filename": f.name,
487
+ "title": first_line or name,
488
+ "project": project,
489
+ "source_type": source_type,
490
+ "content": content,
491
+ "mtime": mtime,
492
+ })
493
+ return out
494
+
495
+
496
+ def _resolve_diary_project_roots() -> dict[str, Path]:
497
+ """Resolve project roots that hold a memory/sessions dir, WITHOUT inheriting cwd.
498
+
499
+ Candidates: recent project dirs (Store B telemetry), working dirs harvested from
500
+ agent diaries, and the launch cwd. Only roots with memory/sessions are kept.
501
+ """
502
+ candidates: list[str] = []
503
+ try:
504
+ candidates.extend(memory_db.get_recent_project_dirs(limit=25))
505
+ except Exception:
506
+ pass
507
+ candidates.append(os.getcwd())
508
+ agent_dir = Path.home() / ".memstack" / "diary"
509
+ if agent_dir.exists():
510
+ for f in agent_dir.glob("*.md"):
511
+ try:
512
+ wd = _mb_extract_workdir(f.read_text(encoding="utf-8", errors="replace"))
513
+ except Exception:
514
+ wd = None
515
+ if wd:
516
+ candidates.append(wd)
517
+ roots: dict[str, Path] = {}
518
+ for c in candidates:
519
+ if not c:
520
+ continue
521
+ try:
522
+ root = Path(c)
523
+ except (TypeError, ValueError):
524
+ continue
525
+ if (root / "memory" / "sessions").is_dir():
526
+ roots[root.name] = root
527
+ return roots
528
+
529
+
530
+ def _collect_browser_entries(project: str | None = None) -> list[dict]:
531
+ """All diary entries across agent autosaves + every resolved project's sessions."""
532
+ entries: list[dict] = []
533
+ entries.extend(_mb_read_md_dir(Path.home() / ".memstack" / "diary", "agent"))
534
+ for proj_name, root in _resolve_diary_project_roots().items():
535
+ entries.extend(_mb_read_md_dir(root / "memory" / "sessions", "direct", project_override=proj_name))
536
+ entries.sort(key=lambda e: e["mtime"], reverse=True)
537
+ if project and project != "__all__":
538
+ entries = [e for e in entries if e["project"] == project]
539
+ return entries
540
+
541
+
430
542
  class _DashboardServer(HTTPServer):
431
543
  allow_reuse_address = False
432
544
 
@@ -486,6 +598,23 @@ class _Handler(BaseHTTPRequestHandler):
486
598
  elif self.path == "/api/diary":
487
599
  body = json.dumps(_get_diary_entries()).encode()
488
600
  self._respond(200, "application/json", body)
601
+ elif self.path.startswith("/api/memory-browser/projects"):
602
+ entries = _collect_browser_entries()
603
+ counts: dict[str, int] = {}
604
+ for e in entries:
605
+ counts[e["project"]] = counts.get(e["project"], 0) + 1
606
+ data = sorted(
607
+ ({"project": p, "count": n} for p, n in counts.items()),
608
+ key=lambda d: (-d["count"], d["project"].lower()),
609
+ )
610
+ body = json.dumps(data).encode()
611
+ self._respond(200, "application/json", body)
612
+ elif self.path.startswith("/api/memory-browser/entries"):
613
+ parsed = urlparse(self.path)
614
+ qs = parse_qs(parsed.query)
615
+ project = qs.get("project", ["__all__"])[0]
616
+ body = json.dumps(_collect_browser_entries(project)).encode()
617
+ self._respond(200, "application/json", body)
489
618
  elif self.path == "/api/projects":
490
619
  body = json.dumps(get_project_details()).encode()
491
620
  self._respond(200, "application/json", body)