nlm-memory 0.4.2 → 0.5.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 (90) hide show
  1. package/dist/cli/nlm.js +221 -32
  2. package/dist/cli/nlm.js.map +1 -1
  3. package/dist/core/adapters/cursor.d.ts +45 -0
  4. package/dist/core/adapters/cursor.js +397 -0
  5. package/dist/core/adapters/cursor.js.map +1 -0
  6. package/dist/core/adapters/from-source.js +10 -0
  7. package/dist/core/adapters/from-source.js.map +1 -1
  8. package/dist/core/adapters/windsurf.d.ts +44 -0
  9. package/dist/core/adapters/windsurf.js +299 -0
  10. package/dist/core/adapters/windsurf.js.map +1 -0
  11. package/dist/core/hook/claude-settings.d.ts +12 -5
  12. package/dist/core/hook/claude-settings.js +21 -6
  13. package/dist/core/hook/claude-settings.js.map +1 -1
  14. package/dist/core/sources/source-registry.d.ts +1 -1
  15. package/dist/core/sources/source-registry.js +18 -0
  16. package/dist/core/sources/source-registry.js.map +1 -1
  17. package/dist/core/storage/sqlite-session-store.d.ts +2 -0
  18. package/dist/core/storage/sqlite-session-store.js +38 -2
  19. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  20. package/dist/hook/hook-auth.d.ts +13 -0
  21. package/dist/hook/hook-auth.js +19 -0
  22. package/dist/hook/hook-auth.js.map +1 -0
  23. package/dist/hook/prompt-recall-hook.js +7 -1
  24. package/dist/hook/prompt-recall-hook.js.map +1 -1
  25. package/dist/hook/session-start-hook.js +4 -1
  26. package/dist/hook/session-start-hook.js.map +1 -1
  27. package/dist/hook/stop-hook.js +4 -1
  28. package/dist/hook/stop-hook.js.map +1 -1
  29. package/dist/http/app.d.ts +2 -0
  30. package/dist/http/app.js +74 -0
  31. package/dist/http/app.js.map +1 -1
  32. package/dist/install/claude-code.js +1 -1
  33. package/dist/install/claude-code.js.map +1 -1
  34. package/dist/install/cursor.d.ts +25 -0
  35. package/dist/install/cursor.js +43 -0
  36. package/dist/install/cursor.js.map +1 -0
  37. package/dist/install/nlm-dir-perms.d.ts +19 -0
  38. package/dist/install/nlm-dir-perms.js +43 -0
  39. package/dist/install/nlm-dir-perms.js.map +1 -0
  40. package/dist/install/ollama.d.ts +18 -1
  41. package/dist/install/ollama.js +62 -7
  42. package/dist/install/ollama.js.map +1 -1
  43. package/dist/install/setup.d.ts +4 -0
  44. package/dist/install/setup.js +141 -18
  45. package/dist/install/setup.js.map +1 -1
  46. package/dist/install/windsurf.d.ts +25 -0
  47. package/dist/install/windsurf.js +43 -0
  48. package/dist/install/windsurf.js.map +1 -0
  49. package/dist/shared/types.d.ts +4 -0
  50. package/dist/ui/assets/{index-BA6IpU8g.css → index-C8cpwbYJ.css} +1 -1
  51. package/dist/ui/assets/index-CB50QnL-.js +69 -0
  52. package/dist/ui/index.html +2 -2
  53. package/logs/CHANGELOG/CHANGELOG-2026.md +186 -0
  54. package/logs/CHANGELOG/CHANGELOG.md +107 -235
  55. package/migrations/014_sources_cursor.sql +30 -0
  56. package/migrations/015_sources_windsurf.sql +30 -0
  57. package/package.json +1 -1
  58. package/plugin/scripts/prompt-recall-hook.mjs +55 -4
  59. package/plugin/scripts/stop-hook.mjs +57 -6
  60. package/src/cli/nlm.ts +224 -31
  61. package/src/core/adapters/cursor.ts +486 -0
  62. package/src/core/adapters/from-source.ts +10 -0
  63. package/src/core/adapters/windsurf.ts +386 -0
  64. package/src/core/hook/claude-settings.ts +30 -9
  65. package/src/core/sources/source-registry.ts +19 -1
  66. package/src/core/storage/sqlite-session-store.ts +46 -1
  67. package/src/hook/hook-auth.ts +18 -0
  68. package/src/hook/prompt-recall-hook.ts +7 -1
  69. package/src/hook/session-start-hook.ts +4 -1
  70. package/src/hook/stop-hook.ts +4 -1
  71. package/src/http/app.ts +78 -0
  72. package/src/install/claude-code.ts +1 -1
  73. package/src/install/cursor.ts +68 -0
  74. package/src/install/nlm-dir-perms.ts +55 -0
  75. package/src/install/ollama.ts +80 -7
  76. package/src/install/setup.ts +138 -17
  77. package/src/install/windsurf.ts +68 -0
  78. package/src/shared/types.ts +4 -0
  79. package/src/ui/components/SessionDrawer.tsx +97 -34
  80. package/src/ui/pages/River.tsx +90 -44
  81. package/src/ui/pages/Search.tsx +357 -64
  82. package/src/ui/pages/Thread.tsx +267 -56
  83. package/src/ui/styles.css +129 -5
  84. package/tests/integration/getbyids-sqlite.test.ts +40 -0
  85. package/tests/integration/hook-claude-settings.test.ts +14 -1
  86. package/tests/integration/mcp.test.ts +12 -0
  87. package/tests/integration/source-registry.test.ts +5 -3
  88. package/tests/unit/core/adapters/cursor.test.ts +485 -0
  89. package/tests/unit/core/adapters/windsurf.test.ts +416 -0
  90. package/dist/ui/assets/index-B_qIVV0k.js +0 -69
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
  import { Link, useSearchParams } from "react-router-dom";
3
3
  import { useDataset, relativeAge } from "../lib/dataset.js";
4
- import type { DatasetSession } from "../lib/dataset.js";
4
+ import type { DatasetSession, Dataset } from "../lib/dataset.js";
5
5
  import { SessionDrawer } from "../components/SessionDrawer.js";
6
6
  import { PromoteOpenButton } from "../components/PromoteOpenButton.js";
7
7
  import { SessionListSkeleton, Skeleton } from "../components/Skeleton.js";
@@ -14,6 +14,8 @@ export function ThreadPage() {
14
14
  const drawerSid = params.get("session");
15
15
 
16
16
  const [sort, setSort] = useState<ThreadSort>(() => readViewSettings().threadSort);
17
+ const [decisionsExpanded, setDecisionsExpanded] = useState(false);
18
+ const [openExpanded, setOpenExpanded] = useState(false);
17
19
 
18
20
  const thread = useMemo(() => {
19
21
  if (!data || !entity) return [];
@@ -54,23 +56,7 @@ export function ThreadPage() {
54
56
  if (!data) return null;
55
57
 
56
58
  if (!entity) {
57
- return (
58
- <div className="page-pad">
59
- <h2 className="page-title">Thread</h2>
60
- <p className="muted">Pick an entity to view its reasoning history.</p>
61
- <ul className="entity-grid">
62
- {data.entities.slice(0, 48).map((e) => (
63
- <li key={e.canonical}>
64
- <Link to={`/thread?entity=${encodeURIComponent(e.canonical)}`} className="card card-lift entity-card">
65
- <span className="dot" style={{ background: data.entity_colors[e.canonical] ?? "#666" }} />
66
- <span className="entity-name">{e.canonical}</span>
67
- <span className="muted small">{e.session_count}</span>
68
- </Link>
69
- </li>
70
- ))}
71
- </ul>
72
- </div>
73
- );
59
+ return <EntityPicker data={data} />;
74
60
  }
75
61
 
76
62
  const decisions = thread.flatMap((s) => s.decisions.map((d) => ({ d, sid: s.id, when: s.started_at })));
@@ -85,31 +71,41 @@ export function ThreadPage() {
85
71
  <h2 className="page-title">{entity}</h2>
86
72
  <span className="muted">{thread.length} session{thread.length === 1 ? "" : "s"}</span>
87
73
  <span className="header-spacer" />
88
- <select className="form-input" value={sort} onChange={(e) => setSort(e.target.value as "recent" | "oldest")}>
89
- <option value="recent">Most recent first</option>
90
- <option value="oldest">Oldest first</option>
91
- </select>
74
+ <div className="filter-group" role="group" aria-label="Sort order">
75
+ {(["recent", "oldest"] as const).map((s) => (
76
+ <button key={s} type="button" className={`chip${sort === s ? " active" : ""}`} onClick={() => setSort(s)}>
77
+ {s === "recent" ? "recent first" : "oldest first"}
78
+ </button>
79
+ ))}
80
+ </div>
92
81
  </div>
93
82
 
94
83
  <div className="thread-grid">
95
84
  <section className="card">
96
85
  <header className="card-head"><h3>Decisions</h3><span className="muted small">{decisions.length}</span></header>
97
86
  <ul className="marker-list">
98
- {decisions.slice(0, 30).map((d, i) => (
87
+ {(decisionsExpanded ? decisions : decisions.slice(0, 30)).map((d, i) => (
99
88
  <li key={i} className="marker-row">
100
89
  <span className="live-tag" data-kind="decision">decision</span>
101
90
  <span className="marker-text">{d.d}</span>
102
91
  <button type="button" className="link-button" onClick={() => openSession(d.sid)}>{relativeAge(d.when)}</button>
103
92
  </li>
104
93
  ))}
105
- {decisions.length === 0 && <li className="muted small">No decisions captured.</li>}
94
+ {decisions.length === 0 && <li className="muted empty-row">No decisions captured yet.</li>}
95
+ {decisions.length > 30 && (
96
+ <li style={{ padding: "8px 14px" }}>
97
+ <button type="button" className="link-button" onClick={() => setDecisionsExpanded((v) => !v)}>
98
+ {decisionsExpanded ? "Show less" : `Showing 30 of ${decisions.length} — show all`}
99
+ </button>
100
+ </li>
101
+ )}
106
102
  </ul>
107
103
  </section>
108
104
 
109
105
  <section className="card">
110
106
  <header className="card-head"><h3>Open questions</h3><span className="muted small">{open.length}</span></header>
111
107
  <ul className="marker-list">
112
- {open.slice(0, 30).map((o, i) => (
108
+ {(openExpanded ? open : open.slice(0, 30)).map((o, i) => (
113
109
  <li key={`${o.id}-${i}`} className="marker-row marker-row-promotable">
114
110
  <span className="live-tag" data-kind="open">open</span>
115
111
  <span className="marker-text">{o.q}</span>
@@ -119,16 +115,35 @@ export function ThreadPage() {
119
115
  </div>
120
116
  </li>
121
117
  ))}
122
- {open.length === 0 && <li className="muted small">No open questions.</li>}
118
+ {open.length === 0 && <li className="muted empty-row">No open questions captured yet.</li>}
119
+ {open.length > 30 && (
120
+ <li style={{ padding: "8px 14px" }}>
121
+ <button type="button" className="link-button" onClick={() => setOpenExpanded((v) => !v)}>
122
+ {openExpanded ? "Show less" : `Showing 30 of ${open.length} — show all`}
123
+ </button>
124
+ </li>
125
+ )}
123
126
  </ul>
124
127
  </section>
125
128
  </div>
126
129
 
127
- <ThreadSessionList thread={thread} onOpenSession={openSession} />
130
+ <ThreadSessionList thread={thread} entity={entity} onOpenSession={openSession} />
128
131
 
129
- {drawerSid && (
130
- <SessionDrawer sessionId={drawerSid} onClose={closeSession} entityColor={entityColor} />
131
- )}
132
+ {drawerSid && (() => {
133
+ const idx = thread.findIndex((s) => s.id === drawerSid);
134
+ const prevId = idx < thread.length - 1 ? thread[idx + 1]!.id : null;
135
+ const nextId = idx > 0 ? thread[idx - 1]!.id : null;
136
+ return (
137
+ <SessionDrawer
138
+ sessionId={drawerSid}
139
+ onClose={closeSession}
140
+ onNavigate={openSession}
141
+ prevSessionId={prevId}
142
+ nextSessionId={nextId}
143
+ entityColor={entityColor}
144
+ />
145
+ );
146
+ })()}
132
147
  </div>
133
148
  );
134
149
  }
@@ -139,20 +154,30 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
139
154
 
140
155
  function ThreadSessionList({
141
156
  thread,
157
+ entity,
142
158
  onOpenSession,
143
159
  }: {
144
160
  thread: DatasetSession[];
161
+ entity: string;
145
162
  onOpenSession: (id: string) => void;
146
163
  }) {
164
+ const [runtimeFilter, setRuntimeFilter] = useState<string>("all");
147
165
  const [query, setQuery] = useState("");
148
166
  const [status, setStatus] = useState<StatusFilter>("all");
149
167
  const [markers, setMarkers] = useState<MarkerFilter>("all");
150
168
  const [pageSize, setPageSize] = useState<number>(25);
151
169
  const [page, setPage] = useState(0);
152
170
 
171
+ const threadRuntimes = useMemo(() => {
172
+ const counts = new Map<string, number>();
173
+ for (const s of thread) counts.set(s.runtime, (counts.get(s.runtime) ?? 0) + 1);
174
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([r]) => r);
175
+ }, [thread]);
176
+
153
177
  const filtered = useMemo(() => {
154
178
  const q = query.toLowerCase().trim();
155
179
  return thread.filter((s) => {
180
+ if (runtimeFilter !== "all" && s.runtime !== runtimeFilter) return false;
156
181
  if (status !== "all" && s.status !== status) return false;
157
182
  if (markers === "decisions" && s.decisions.length === 0) return false;
158
183
  if (markers === "open" && s.open_questions.length === 0) return false;
@@ -164,10 +189,13 @@ function ThreadSessionList({
164
189
  s.open.some((o) => o.toLowerCase().includes(q))
165
190
  );
166
191
  });
167
- }, [thread, query, status, markers]);
192
+ }, [thread, query, status, markers, runtimeFilter]);
168
193
 
169
194
  // Reset page when filter inputs change
170
- useEffect(() => { setPage(0); }, [query, status, markers, pageSize]);
195
+ useEffect(() => { setPage(0); }, [query, status, markers, pageSize, runtimeFilter]);
196
+
197
+ // Reset runtime filter when entity changes
198
+ useEffect(() => { setRuntimeFilter("all"); }, [entity]);
171
199
 
172
200
  const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
173
201
  const currentPage = Math.min(page, pageCount - 1);
@@ -178,36 +206,72 @@ function ThreadSessionList({
178
206
  <>
179
207
  <div className="thread-sessions-head">
180
208
  <h3 className="section-title thread-sessions-title">Sessions</h3>
181
- <input
182
- className="search-input"
183
- placeholder="search label, summary, decisions, open…"
184
- value={query}
185
- onChange={(e) => setQuery(e.target.value)}
186
- />
209
+ <div className="search-wrap">
210
+ <input
211
+ className="search-input"
212
+ placeholder="search label, summary, decisions, open…"
213
+ value={query}
214
+ onChange={(e) => setQuery(e.target.value)}
215
+ onKeyDown={(e) => { if (e.key === "Escape") setQuery(""); }}
216
+ />
217
+ {query && (
218
+ <button type="button" className="search-clear" onClick={() => setQuery("")} aria-label="Clear search">
219
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
220
+ </button>
221
+ )}
222
+ </div>
187
223
  </div>
188
224
 
189
225
  <div className="thread-filters">
190
- <div className="filter-group" role="group" aria-label="Status filter">
191
- {(["all", "active", "idle", "closed", "superseded"] as const).map((s) => (
226
+ {threadRuntimes.length > 1 && (
227
+ <div className="filter-group" role="group" aria-label="Agent filter">
192
228
  <button
193
- key={s}
194
229
  type="button"
195
- className={`chip${status === s ? " active" : ""}`}
196
- data-status={s === "all" ? undefined : s}
197
- onClick={() => setStatus(s)}
198
- >{s}</button>
199
- ))}
230
+ className={`chip${runtimeFilter === "all" ? " active" : ""}`}
231
+ onClick={() => setRuntimeFilter("all")}
232
+ >all</button>
233
+ {threadRuntimes.map((r) => {
234
+ const count = thread.filter((s) => s.runtime === r).length;
235
+ return (
236
+ <button
237
+ key={r}
238
+ type="button"
239
+ className={`chip${runtimeFilter === r ? " active" : ""}`}
240
+ onClick={() => setRuntimeFilter(r)}
241
+ >{r} · {count}</button>
242
+ );
243
+ })}
244
+ </div>
245
+ )}
246
+ <div className="filter-group" role="group" aria-label="Status filter">
247
+ {(["all", "active", "idle", "closed", "superseded"] as const).map((s) => {
248
+ const count = s === "all" ? thread.length : thread.filter((x) => x.status === s).length;
249
+ return (
250
+ <button
251
+ key={s}
252
+ type="button"
253
+ className={`chip${status === s ? " active" : ""}`}
254
+ data-status={s === "all" ? undefined : s}
255
+ onClick={() => setStatus(s)}
256
+ >{s} · {count}</button>
257
+ );
258
+ })}
200
259
  </div>
201
260
  <div className="filter-group" role="group" aria-label="Marker filter">
202
- {(["all", "decisions", "open"] as const).map((m) => (
203
- <button
204
- key={m}
205
- type="button"
206
- className={`chip${markers === m ? " active" : ""}`}
207
- data-marker={m === "all" ? undefined : m}
208
- onClick={() => setMarkers(m)}
209
- >{m === "all" ? "all markers" : m}</button>
210
- ))}
261
+ {(["all", "decisions", "open"] as const).map((m) => {
262
+ const count = m === "decisions"
263
+ ? thread.filter((s) => s.decisions.length > 0).length
264
+ : thread.filter((s) => s.open_questions.length > 0).length;
265
+ return (
266
+ <button
267
+ key={m}
268
+ type="button"
269
+ className={`chip${markers === m ? " active" : ""}`}
270
+ data-marker={m === "all" ? undefined : m}
271
+ onClick={() => setMarkers(m)}
272
+ >{m === "all" ? "all" : `${m} · ${count}`}</button>
273
+ );
274
+ })}
211
275
  </div>
212
276
  <span className="header-spacer" />
213
277
  <span className="muted small">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
@@ -225,7 +289,7 @@ function ThreadSessionList({
225
289
  </li>
226
290
  ))}
227
291
  {slice.length === 0 && (
228
- <li className="muted small empty-row">
292
+ <li className="muted empty-row">
229
293
  {thread.length === 0 ? "No sessions yet." : "No sessions match the current filters."}
230
294
  </li>
231
295
  )}
@@ -260,3 +324,150 @@ function ThreadSessionList({
260
324
  );
261
325
  }
262
326
 
327
+ type EntitySort = "most-active" | "least-active" | "a-z" | "z-a";
328
+ const ENTITY_PAGE_SIZE_OPTIONS = [24, 48, 96] as const;
329
+
330
+ function EntityPicker({ data }: { data: Dataset }) {
331
+ const [runtimeFilter, setRuntimeFilter] = useState<string>("all");
332
+ const [entitySearch, setEntitySearch] = useState("");
333
+ const [sort, setSort] = useState<EntitySort>("most-active");
334
+ const [pageSize, setPageSize] = useState<number>(48);
335
+ const [page, setPage] = useState(0);
336
+
337
+ const entityRuntimeMap = useMemo(() => {
338
+ const m = new Map<string, Set<string>>();
339
+ for (const s of data.sessions) {
340
+ for (const e of s.entities) {
341
+ let set = m.get(e);
342
+ if (!set) { set = new Set(); m.set(e, set); }
343
+ set.add(s.runtime);
344
+ }
345
+ }
346
+ return m;
347
+ }, [data.sessions]);
348
+
349
+ const sortedRuntimes = useMemo(() => {
350
+ return [...data.runtimes].sort((a, b) => b.sessions_total - a.sessions_total);
351
+ }, [data.runtimes]);
352
+
353
+ const filtered = useMemo(() => {
354
+ const q = entitySearch.toLowerCase().trim();
355
+ const matches = q
356
+ ? data.entities.filter((e) => e.canonical.toLowerCase().includes(q))
357
+ : [...data.entities];
358
+ const result = runtimeFilter === "all"
359
+ ? matches
360
+ : matches.filter((e) => entityRuntimeMap.get(e.canonical)?.has(runtimeFilter) ?? false);
361
+ result.sort((a, b) => {
362
+ if (sort === "most-active") return b.session_count - a.session_count;
363
+ if (sort === "least-active") return a.session_count - b.session_count;
364
+ if (sort === "a-z") return a.canonical.localeCompare(b.canonical);
365
+ return b.canonical.localeCompare(a.canonical);
366
+ });
367
+ return result;
368
+ }, [data.entities, entitySearch, sort, runtimeFilter, entityRuntimeMap]);
369
+
370
+ useEffect(() => { setPage(0); }, [entitySearch, sort, pageSize, runtimeFilter]);
371
+
372
+ const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
373
+ const currentPage = Math.min(page, pageCount - 1);
374
+ const start = currentPage * pageSize;
375
+ const slice = filtered.slice(start, start + pageSize);
376
+
377
+ return (
378
+ <div className="page-pad">
379
+ <h2 className="page-title">Thread</h2>
380
+ <p className="muted">Pick an entity to view its reasoning history.</p>
381
+ <div className="thread-sessions-head" style={{ marginTop: 16 }}>
382
+ <div className="search-wrap" style={{ maxWidth: 320 }}>
383
+ <input
384
+ className="search-input"
385
+ placeholder="search entities…"
386
+ value={entitySearch}
387
+ onChange={(e) => setEntitySearch(e.target.value)}
388
+ onKeyDown={(e) => { if (e.key === "Escape") setEntitySearch(""); }}
389
+ />
390
+ {entitySearch && (
391
+ <button type="button" className="search-clear" onClick={() => setEntitySearch("")} aria-label="Clear search">
392
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
393
+ </button>
394
+ )}
395
+ </div>
396
+ <span className="header-spacer" />
397
+ <span className="muted small">
398
+ {entitySearch
399
+ ? `${filtered.length} of ${data.entities.length} entities`
400
+ : `${data.entities.length} entities`}
401
+ </span>
402
+ </div>
403
+ <div className="thread-filters">
404
+ <div className="filter-group" role="group" aria-label="Agent filter">
405
+ <button
406
+ type="button"
407
+ className={`chip${runtimeFilter === "all" ? " active" : ""}`}
408
+ onClick={() => setRuntimeFilter("all")}
409
+ >all</button>
410
+ {sortedRuntimes.map((r) => (
411
+ <button
412
+ key={r.name}
413
+ type="button"
414
+ className={`chip${runtimeFilter === r.name ? " active" : ""}`}
415
+ onClick={() => setRuntimeFilter(r.name)}
416
+ >{r.name} · {r.sessions_total}</button>
417
+ ))}
418
+ </div>
419
+ <div className="filter-group" role="group" aria-label="Sort order">
420
+ {(["most-active", "least-active", "a-z", "z-a"] as const).map((s) => (
421
+ <button
422
+ key={s}
423
+ type="button"
424
+ className={`chip${sort === s ? " active" : ""}`}
425
+ onClick={() => setSort(s)}
426
+ >
427
+ {s === "most-active" ? "most active" : s === "least-active" ? "least active" : s}
428
+ </button>
429
+ ))}
430
+ </div>
431
+ </div>
432
+ <ul className="entity-grid">
433
+ {slice.map((e) => (
434
+ <li key={e.canonical}>
435
+ <Link to={`/thread?entity=${encodeURIComponent(e.canonical)}`} className="card card-lift entity-card">
436
+ <span className="dot" style={{ background: data.entity_colors[e.canonical] ?? "#666" }} />
437
+ <span className="entity-name">{e.canonical}</span>
438
+ <span className="muted small">{e.session_count}</span>
439
+ </Link>
440
+ </li>
441
+ ))}
442
+ {slice.length === 0 && (
443
+ <li style={{ gridColumn: "1 / -1" }} className="muted empty-row">No entities match.</li>
444
+ )}
445
+ </ul>
446
+ {filtered.length > pageSize && (
447
+ <div className="pagination">
448
+ <div className="page-size">
449
+ <label className="form-label">Per page</label>
450
+ <select
451
+ className="form-input form-input-inline"
452
+ value={pageSize}
453
+ onChange={(e) => setPageSize(Number.parseInt(e.target.value, 10))}
454
+ >
455
+ {ENTITY_PAGE_SIZE_OPTIONS.map((n) => <option key={n} value={n}>{n}</option>)}
456
+ </select>
457
+ </div>
458
+ <span className="header-spacer" />
459
+ <span className="muted small">
460
+ {start + 1}–{Math.min(start + pageSize, filtered.length)} of {filtered.length}
461
+ </span>
462
+ <div className="page-nav">
463
+ <button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage(0)}>« first</button>
464
+ <button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>‹ prev</button>
465
+ <span className="page-indicator mono">{currentPage + 1} / {pageCount}</span>
466
+ <button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}>next ›</button>
467
+ <button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage(pageCount - 1)}>last »</button>
468
+ </div>
469
+ </div>
470
+ )}
471
+ </div>
472
+ );
473
+ }
package/src/ui/styles.css CHANGED
@@ -1314,7 +1314,7 @@ a.session-label:hover { color: var(--accent); }
1314
1314
  --river-label-w: 200px;
1315
1315
  --river-row-gap: 3px;
1316
1316
  --river-cell-gap: 2px;
1317
- --river-cell-h: 16px;
1317
+ --river-cell-h: 20px;
1318
1318
  padding: 12px;
1319
1319
  width: 100%;
1320
1320
  display: flex;
@@ -1461,7 +1461,7 @@ a.session-label:hover { color: var(--accent); }
1461
1461
  }
1462
1462
 
1463
1463
  .river-cell {
1464
- transition: transform 0.08s ease, outline-color var(--ease);
1464
+ transition: outline-color var(--ease);
1465
1465
  outline: 1px solid transparent;
1466
1466
  outline-offset: 1px;
1467
1467
  position: relative;
@@ -1469,7 +1469,6 @@ a.session-label:hover { color: var(--accent); }
1469
1469
  }
1470
1470
 
1471
1471
  .river-cell:hover {
1472
- transform: scale(1.3);
1473
1472
  outline-color: rgba(255, 255, 255, 0.7);
1474
1473
  z-index: 1;
1475
1474
  }
@@ -1626,6 +1625,55 @@ a.session-label:hover { color: var(--accent); }
1626
1625
  gap: 6px;
1627
1626
  }
1628
1627
 
1628
+ /* ── Supersedence banners (SessionDrawer) ───────────────────────────────── */
1629
+ .supersedence-banner {
1630
+ display: flex;
1631
+ align-items: baseline;
1632
+ flex-wrap: wrap;
1633
+ gap: 6px;
1634
+ padding: 8px 12px;
1635
+ border-radius: var(--r-sm);
1636
+ margin-bottom: 10px;
1637
+ font-size: var(--text-sm);
1638
+ }
1639
+ .supersedence-banner--superseded {
1640
+ background: rgba(255, 107, 53, 0.08);
1641
+ border: 1px solid rgba(255, 107, 53, 0.25);
1642
+ color: var(--danger);
1643
+ }
1644
+ .supersedence-banner--supersedes {
1645
+ background: rgba(168, 168, 168, 0.06);
1646
+ border: 1px solid var(--border-2);
1647
+ color: var(--text-2);
1648
+ }
1649
+ .supersedence-label {
1650
+ font-weight: 500;
1651
+ white-space: nowrap;
1652
+ }
1653
+ .supersedence-ids {
1654
+ display: flex;
1655
+ flex-wrap: wrap;
1656
+ gap: 4px;
1657
+ }
1658
+ .supersedence-id {
1659
+ color: inherit;
1660
+ opacity: 0.8;
1661
+ }
1662
+ .supersedence-link {
1663
+ background: none;
1664
+ border: none;
1665
+ padding: 0;
1666
+ cursor: pointer;
1667
+ font-family: var(--font-mono);
1668
+ font-size: var(--text-xs);
1669
+ color: inherit;
1670
+ text-decoration: underline;
1671
+ text-underline-offset: 2px;
1672
+ }
1673
+ .supersedence-link:hover {
1674
+ opacity: 0.75;
1675
+ }
1676
+
1629
1677
  /* ── Card head with stacked filters ──────────────────────────────────── */
1630
1678
  .card-head.card-head-stack {
1631
1679
  flex-direction: column;
@@ -1678,8 +1726,6 @@ a.session-label:hover { color: var(--accent); }
1678
1726
 
1679
1727
  .thread-filters .filter-group { flex-wrap: wrap; }
1680
1728
 
1681
- .empty-row { padding: 16px 14px; }
1682
-
1683
1729
  .pagination {
1684
1730
  display: flex;
1685
1731
  align-items: center;
@@ -1764,3 +1810,81 @@ a.session-label:hover { color: var(--accent); }
1764
1810
  .pagination.pagination-compact .page-indicator { min-width: 40px; padding: 0 4px; font-size: var(--text-xs); }
1765
1811
  .pagination.pagination-compact .chip { padding: 1px 6px; font-size: var(--text-xs); }
1766
1812
  .pagination.pagination-compact .form-label { font-size: 9px; }
1813
+
1814
+ /* ── River date label month boundary ───────────────────────────────────── */
1815
+ .river-date-cell--month-start {
1816
+ border-left: 1px solid var(--border-2);
1817
+ padding-left: 3px;
1818
+ }
1819
+
1820
+ /* ── River legend ────────────────────────────────────────────────────────── */
1821
+ .river-legend { display: flex; align-items: center; gap: 3px; }
1822
+ .river-legend-cell { width: 12px; height: 12px; border-radius: 2px; background: var(--surface-2); }
1823
+ .river-legend-cell.tier-1 { background: rgba(232,255,110,0.20); }
1824
+ .river-legend-cell.tier-2 { background: rgba(232,255,110,0.40); }
1825
+ .river-legend-cell.tier-3 { background: rgba(232,255,110,0.65); }
1826
+ .river-legend-cell.tier-4 { background: var(--accent); }
1827
+
1828
+ /* ── Empty row ───────────────────────────────────────────────────────────── */
1829
+ .empty-row { color: var(--text-2); padding: 16px 0; font-size: var(--text-sm); }
1830
+
1831
+ /* ── Session meta 2-line wrap ───────────────────────────────────────────── */
1832
+ .session-meta {
1833
+ display: -webkit-box;
1834
+ -webkit-line-clamp: 2;
1835
+ line-clamp: 2;
1836
+ -webkit-box-orient: vertical;
1837
+ overflow: hidden;
1838
+ white-space: normal;
1839
+ }
1840
+
1841
+ /* ── Search clear button ─────────────────────────────────────────────────── */
1842
+ .search-wrap { position: relative; display: flex; align-items: center; }
1843
+ .search-wrap .search-input { padding-right: 28px; width: 100%; }
1844
+ .search-clear { position: absolute; right: 6px; background: none; border: none; color: var(--text-3); cursor: pointer; display: flex; align-items: center; padding: 0; }
1845
+ .search-clear:hover { color: var(--text-1); }
1846
+
1847
+ /* ── Drawer nav buttons ──────────────────────────────────────────────────── */
1848
+ .drawer-nav {
1849
+ display: flex;
1850
+ gap: 4px;
1851
+ align-items: center;
1852
+ flex-shrink: 0;
1853
+ }
1854
+ .drawer-nav-btn {
1855
+ background: none;
1856
+ border: 1px solid var(--border-2);
1857
+ color: var(--text-3);
1858
+ cursor: pointer;
1859
+ display: flex;
1860
+ align-items: center;
1861
+ justify-content: center;
1862
+ padding: 3px;
1863
+ border-radius: var(--r-sm);
1864
+ transition: color var(--ease), border-color var(--ease), background var(--ease);
1865
+ }
1866
+ .drawer-nav-btn:hover:not(:disabled) { color: var(--text-1); border-color: var(--border-3); background: var(--surface-2); }
1867
+ .drawer-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; }
1868
+
1869
+ /* ── Dot pulse animation ─────────────────────────────────────────────────── */
1870
+ .dot-pulse {
1871
+ animation: dot-pulse 2s ease-in-out infinite;
1872
+ }
1873
+ @keyframes dot-pulse {
1874
+ 0%, 100% { opacity: 1; transform: scale(1); }
1875
+ 50% { opacity: 0.6; transform: scale(1.35); }
1876
+ }
1877
+
1878
+ /* ── Search page additions ───────────────────────────────────────────────── */
1879
+ .search-hint { margin: 8px 0 12px; }
1880
+
1881
+ .search-header { position: sticky; top: 0; z-index: 10; background: var(--surface-0); padding-bottom: 12px; border-bottom: 1px solid var(--border-1); margin-bottom: 16px; }
1882
+
1883
+ .session-row.is-active { background: var(--surface-2); border-left: 2px solid var(--accent); padding-left: 12px; }
1884
+
1885
+ .live-tag[data-kind="summary"] { background: var(--surface-2); color: var(--text-2); }
1886
+ .live-tag[data-kind="label"] { background: var(--surface-2); color: var(--text-1); }
1887
+ .live-tag[data-kind="entity"] { background: var(--accent-glow); border-color: rgba(232,255,110,0.25); color: var(--accent); }
1888
+
1889
+ .match-snippet { color: var(--text-2); font-size: var(--text-xs); line-height: var(--lh-base); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 4px; }
1890
+ .match-snippet mark { background: rgba(232,255,110,0.15); color: var(--accent); padding: 0 2px; border-radius: 2px; }
@@ -58,3 +58,43 @@ describe("SqliteSessionStore.getByIds", () => {
58
58
  expect(s1?.body).toBe("");
59
59
  });
60
60
  });
61
+
62
+ describe("SqliteSessionStore.getById — supersedence edges", () => {
63
+ let tmp: string;
64
+ let store: SqliteSessionStore;
65
+
66
+ beforeEach(() => {
67
+ tmp = mkdtempSync(join(tmpdir(), "nlm-getbyid-edges-"));
68
+ store = new SqliteSessionStore({
69
+ dbPath: join(tmp, "canonical.sqlite"),
70
+ migrationsDir: MIGRATIONS_DIR,
71
+ });
72
+ store.insertSessionForTest(makeSession({ id: "old", label: "old session" }));
73
+ store.insertSessionForTest(makeSession({ id: "new", label: "new session" }));
74
+ store.insertEdgeForTest("new", "old", "supersedes");
75
+ });
76
+
77
+ afterEach(() => {
78
+ store.close();
79
+ rmSync(tmp, { recursive: true, force: true });
80
+ });
81
+
82
+ it("superseding session reports what it supersedes", async () => {
83
+ const s = await store.getById("new");
84
+ expect(s?.supersedes).toEqual(["old"]);
85
+ expect(s?.supersededBy).toBeNull();
86
+ });
87
+
88
+ it("superseded session reports what superseded it", async () => {
89
+ const s = await store.getById("old");
90
+ expect(s?.supersededBy).toBe("new");
91
+ expect(s?.supersedes).toEqual([]);
92
+ });
93
+
94
+ it("session with no edges has null supersededBy and empty supersedes", async () => {
95
+ store.insertSessionForTest(makeSession({ id: "unrelated", label: "standalone" }));
96
+ const s = await store.getById("unrelated");
97
+ expect(s?.supersededBy).toBeNull();
98
+ expect(s?.supersedes).toEqual([]);
99
+ });
100
+ });