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,93 +1,386 @@
1
- import { useMemo, useState } from "react";
2
- import { Link, useSearchParams } from "react-router-dom";
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { useSearchParams } from "react-router-dom";
3
3
  import { useDataset, relativeAge } from "../lib/dataset.js";
4
+ import { SessionDrawer } from "../components/SessionDrawer.js";
5
+
6
+ type MatchedField = "label" | "entity" | "decision" | "open" | "summary";
7
+ type SortMode = "relevance" | "recent";
8
+
9
+ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
10
+
11
+ function score(
12
+ s: { label: string; summary: string; decisions: string[]; open: string[]; entities: string[] },
13
+ tokens: string[],
14
+ phrase: string,
15
+ ): { score: number; matchedField: MatchedField } {
16
+ const label = s.label.toLowerCase();
17
+ const summary = s.summary.toLowerCase();
18
+ const decisionsJoined = s.decisions.join(" ").toLowerCase();
19
+ const openJoined = s.open.join(" ").toLowerCase();
20
+
21
+ const fieldScores: Record<MatchedField, number> = {
22
+ label: 0,
23
+ entity: 0,
24
+ decision: 0,
25
+ open: 0,
26
+ summary: 0,
27
+ };
28
+
29
+ for (const t of tokens) {
30
+ if (label.includes(t)) fieldScores.label += 3;
31
+ for (const e of s.entities) {
32
+ const el = e.toLowerCase();
33
+ if (el === t) { fieldScores.entity += 4; break; }
34
+ else if (el.includes(t)) { fieldScores.entity += 2; break; }
35
+ }
36
+ if (decisionsJoined.includes(t)) fieldScores.decision += 2;
37
+ if (openJoined.includes(t)) fieldScores.open += 2;
38
+ if (summary.includes(t)) fieldScores.summary += 1;
39
+ }
40
+
41
+ if (phrase && (label.includes(phrase) || decisionsJoined.includes(phrase) || openJoined.includes(phrase))) {
42
+ const topField = (Object.keys(fieldScores) as MatchedField[]).reduce((a, b) =>
43
+ fieldScores[a] >= fieldScores[b] ? a : b
44
+ );
45
+ fieldScores[topField] += 5;
46
+ }
47
+
48
+ const total = Object.values(fieldScores).reduce((a, b) => a + b, 0);
49
+
50
+ const priority: MatchedField[] = ["label", "entity", "decision", "open", "summary"];
51
+ let matchedField: MatchedField = "summary";
52
+ let best = -1;
53
+ for (const f of priority) {
54
+ if (fieldScores[f] > best) { best = fieldScores[f]; matchedField = f; }
55
+ }
56
+
57
+ return { score: total, matchedField };
58
+ }
59
+
60
+ function buildSnippet(text: string, tokens: string[], radius = 60): string {
61
+ const lower = text.toLowerCase();
62
+ let best = -1;
63
+ for (const t of tokens) {
64
+ const i = lower.indexOf(t);
65
+ if (i !== -1 && (best === -1 || i < best)) best = i;
66
+ }
67
+ if (best === -1) return text.slice(0, radius * 2);
68
+ const start = Math.max(0, best - radius);
69
+ const end = Math.min(text.length, best + radius);
70
+ return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
71
+ }
72
+
73
+ function highlightTokens(text: string, tokens: string[]): string {
74
+ let escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
75
+ for (const t of tokens) {
76
+ escaped = escaped.replace(
77
+ new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"),
78
+ (m) => `<mark>${m}</mark>`,
79
+ );
80
+ }
81
+ return escaped;
82
+ }
4
83
 
5
84
  export function SearchPage() {
6
85
  const { data, loading, error } = useDataset();
7
86
  const [params, setParams] = useSearchParams();
8
- const q = params.get("q") ?? "";
9
- const entity = params.get("entity") ?? "";
10
87
 
88
+ const q = params.get("q") ?? "";
11
89
  const [input, setInput] = useState(q);
12
90
 
13
- const tokens = useMemo(() => {
14
- const norm = q.toLowerCase().split(/\s+/).filter(Boolean);
15
- return norm;
16
- }, [q]);
91
+ const [entityFilter, setEntityFilter] = useState(params.get("entity") ?? "");
92
+ const [runtimeFilter, setRuntimeFilter] = useState<string>("all");
93
+ const [statusFilter, setStatusFilter] = useState<string>("all");
94
+ const [sortMode, setSortMode] = useState<SortMode>("recent");
95
+
96
+ const [pageSize, setPageSize] = useState<number>(25);
97
+ const [page, setPage] = useState(0);
98
+
99
+ const [drawerSid, setDrawerSid] = useState<string | null>(params.get("session"));
100
+
101
+ const openSession = (id: string) => {
102
+ const next = new URLSearchParams(params);
103
+ next.set("session", id);
104
+ setParams(next);
105
+ setDrawerSid(id);
106
+ };
107
+
108
+ const closeSession = () => {
109
+ const next = new URLSearchParams(params);
110
+ next.delete("session");
111
+ setParams(next);
112
+ setDrawerSid(null);
113
+ };
114
+
115
+ useEffect(() => {
116
+ const t = setTimeout(() => {
117
+ const next = new URLSearchParams(params);
118
+ if (input) next.set("q", input); else next.delete("q");
119
+ setParams(next);
120
+ }, 200);
121
+ return () => clearTimeout(t);
122
+ }, [input]);
123
+
124
+ useEffect(() => {
125
+ const next = new URLSearchParams(params);
126
+ if (entityFilter) next.set("entity", entityFilter); else next.delete("entity");
127
+ setParams(next);
128
+ }, [entityFilter]);
129
+
130
+ const tokens = useMemo(() => q.toLowerCase().split(/\s+/).filter(Boolean), [q]);
131
+ const phrase = tokens.join(" ");
17
132
 
18
133
  const results = useMemo(() => {
19
134
  if (!data) return [];
20
- const sessions = entity
21
- ? data.sessions.filter((s) => s.entities.includes(entity))
22
- : data.sessions;
23
- if (tokens.length === 0) return sessions.slice(-50).reverse();
24
- const scored = sessions
25
- .map((s) => ({ session: s, score: score(s, tokens) }))
26
- .filter((x) => x.score > 0)
27
- .sort((a, b) => b.score - a.score)
28
- .slice(0, 100);
29
- return scored.map((x) => x.session);
30
- }, [data, tokens, entity]);
31
-
32
- const onSubmit = (e: React.FormEvent) => {
33
- e.preventDefault();
34
- const next = new URLSearchParams(params);
35
- if (input) next.set("q", input);
36
- else next.delete("q");
135
+
136
+ const filtered = data.sessions.filter((s) => {
137
+ if (entityFilter && !s.entities.includes(entityFilter)) return false;
138
+ if (runtimeFilter !== "all" && s.runtime !== runtimeFilter) return false;
139
+ if (statusFilter !== "all" && s.status !== statusFilter) return false;
140
+ return true;
141
+ });
142
+
143
+ if (tokens.length === 0) {
144
+ const sorted = [...filtered].sort((a, b) => (b.started_at ?? "").localeCompare(a.started_at ?? ""));
145
+ return sorted.map((session) => ({ session, score: 0, matchedField: "summary" as MatchedField }));
146
+ }
147
+
148
+ const scored = filtered
149
+ .map((s) => {
150
+ const { score: sc, matchedField } = score(s, tokens, phrase);
151
+ return { session: s, score: sc, matchedField };
152
+ })
153
+ .filter((x) => x.score > 0);
154
+
155
+ if (sortMode === "recent") {
156
+ scored.sort((a, b) => (b.session.started_at ?? "").localeCompare(a.session.started_at ?? ""));
157
+ } else {
158
+ scored.sort((a, b) => b.score - a.score);
159
+ }
160
+
161
+ return scored;
162
+ }, [data, tokens, phrase, entityFilter, runtimeFilter, statusFilter, sortMode]);
163
+
164
+ useEffect(() => { setPage(0); }, [results, runtimeFilter, statusFilter, entityFilter]);
165
+
166
+ const pageCount = Math.max(1, Math.ceil(results.length / pageSize));
167
+ const currentPage = Math.min(page, pageCount - 1);
168
+ const start = currentPage * pageSize;
169
+ const slice = results.slice(start, start + pageSize);
170
+
171
+ const availableRuntimes = useMemo(() => {
172
+ if (!data) return [];
173
+ const counts = new Map<string, number>();
174
+ for (const s of data.sessions) counts.set(s.runtime, (counts.get(s.runtime) ?? 0) + 1);
175
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([r]) => r);
176
+ }, [data]);
177
+
178
+ const topEntities = useMemo(() => {
179
+ const counts = new Map<string, number>();
180
+ for (const { session: s } of results) {
181
+ for (const e of s.entities) counts.set(e, (counts.get(e) ?? 0) + 1);
182
+ }
183
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([e]) => e);
184
+ }, [results]);
185
+
186
+ const anyFilterActive = q !== "" || entityFilter !== "" || runtimeFilter !== "all" || statusFilter !== "all";
187
+
188
+ const clearAllFilters = () => {
189
+ setInput("");
190
+ setEntityFilter("");
191
+ setRuntimeFilter("all");
192
+ setStatusFilter("all");
193
+ setSortMode("recent");
194
+ const next = new URLSearchParams();
195
+ if (drawerSid) next.set("session", drawerSid);
37
196
  setParams(next);
38
197
  };
39
198
 
199
+ const idx = slice.findIndex((r) => r.session.id === drawerSid);
200
+ const prevId = idx < slice.length - 1 ? slice[idx + 1]!.session.id : null;
201
+ const nextId = idx > 0 ? slice[idx - 1]!.session.id : null;
202
+
40
203
  return (
41
204
  <div className="page-pad">
42
- <form onSubmit={onSubmit} className="search-bar">
43
- <input
44
- className="search-input search-big"
45
- placeholder="search sessions, decisions, open questions…"
46
- value={input}
47
- onChange={(e) => setInput(e.target.value)}
48
- autoFocus
49
- />
50
- {entity && (
51
- <span className="chip-inline">
52
- entity: {entity}
53
- <button type="button" className="chip-x" onClick={() => { const n = new URLSearchParams(params); n.delete("entity"); setParams(n); }}>×</button>
54
- </span>
55
- )}
56
- </form>
205
+ <div className="search-header">
206
+ <form onSubmit={(e) => e.preventDefault()} className="search-bar">
207
+ <div className="search-wrap" style={{ flex: 1 }}>
208
+ <input
209
+ className="search-input search-big"
210
+ placeholder="search sessions, decisions, open questions…"
211
+ value={input}
212
+ onChange={(e) => setInput(e.target.value)}
213
+ autoFocus
214
+ />
215
+ {input.length > 0 && (
216
+ <button type="button" className="search-clear" onClick={() => setInput("")} aria-label="Clear search">
217
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
218
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
219
+ </svg>
220
+ </button>
221
+ )}
222
+ </div>
223
+ </form>
224
+
225
+ <div className="thread-filters">
226
+ {availableRuntimes.length > 1 && (
227
+ <div className="filter-group" role="group" aria-label="Agent filter">
228
+ <button type="button" className={`chip${runtimeFilter === "all" ? " active" : ""}`} onClick={() => setRuntimeFilter("all")}>all</button>
229
+ {availableRuntimes.map((r) => {
230
+ const count = results.filter((x) => x.session.runtime === r).length;
231
+ return (
232
+ <button key={r} type="button" className={`chip${runtimeFilter === r ? " active" : ""}`} onClick={() => setRuntimeFilter(r)}>
233
+ {r} · {count}
234
+ </button>
235
+ );
236
+ })}
237
+ </div>
238
+ )}
239
+
240
+ <div className="filter-group" role="group" aria-label="Status filter">
241
+ {(["all", "active", "idle", "closed", "superseded"] as const).map((s) => {
242
+ const count = s === "all" ? results.length : results.filter((x) => x.session.status === s).length;
243
+ return (
244
+ <button
245
+ key={s}
246
+ type="button"
247
+ className={`chip${statusFilter === s ? " active" : ""}`}
248
+ data-status={s === "all" ? undefined : s}
249
+ onClick={() => setStatusFilter(s)}
250
+ >{s} · {count}</button>
251
+ );
252
+ })}
253
+ </div>
254
+
255
+ {q !== "" && (
256
+ <div className="filter-group" role="group" aria-label="Sort order">
257
+ {(["relevance", "recent"] as const).map((m) => (
258
+ <button key={m} type="button" className={`chip${sortMode === m ? " active" : ""}`} onClick={() => setSortMode(m)}>{m}</button>
259
+ ))}
260
+ </div>
261
+ )}
262
+
263
+ {topEntities.length > 0 && (
264
+ <div className="filter-group" role="group" aria-label="Entity filter" style={{ flexWrap: "wrap" }}>
265
+ <button type="button" className={`chip${entityFilter === "" ? " active" : ""}`} onClick={() => setEntityFilter("")}>all entities</button>
266
+ {topEntities.slice(0, 12).map((e) => (
267
+ <button key={e} type="button" className={`chip${entityFilter === e ? " active" : ""}`} onClick={() => setEntityFilter(entityFilter === e ? "" : e)}>{e}</button>
268
+ ))}
269
+ {topEntities.length > 12 && (
270
+ <select
271
+ className="form-input form-input-inline"
272
+ value={entityFilter}
273
+ onChange={(e) => setEntityFilter(e.target.value)}
274
+ >
275
+ <option value="">more entities…</option>
276
+ {topEntities.slice(12).map((e) => (
277
+ <option key={e} value={e}>{e}</option>
278
+ ))}
279
+ </select>
280
+ )}
281
+ </div>
282
+ )}
283
+ </div>
284
+ </div>
57
285
 
58
286
  {loading && !data && <div className="muted">Loading…</div>}
59
287
  {error && <div className="muted error">{error}</div>}
60
288
 
289
+ {!anyFilterActive && <p className="muted small search-hint">Showing recent sessions. Type to search across labels, decisions, open questions, and summaries.</p>}
290
+
61
291
  <div className="muted small search-meta">
62
292
  {results.length} result{results.length === 1 ? "" : "s"}
293
+ {anyFilterActive && (
294
+ <button type="button" className="link-button" style={{ marginLeft: 8 }} onClick={clearAllFilters}>
295
+ clear filters
296
+ </button>
297
+ )}
63
298
  </div>
64
299
 
65
- <ul className="session-list compact">
66
- {results.map((s) => (
67
- <li key={s.id} className="session-row">
68
- <span className={`chip-inline status-${s.status}`}>{s.status}</span>
69
- <Link to={`/thread?entity=${encodeURIComponent(s.entities[0] ?? "")}`} className="session-label">
70
- {s.label}
71
- </Link>
72
- <span className="session-meta">{relativeAge(s.started_at)} · {s.entities.slice(0, 4).join(", ")}</span>
300
+ <ul className="session-list">
301
+ {slice.map(({ session: s, matchedField }) => {
302
+ let snippetText = s.summary;
303
+ if (tokens.length > 0) {
304
+ if (matchedField === "decision") {
305
+ snippetText = s.decisions.find((d) => tokens.some((t) => d.toLowerCase().includes(t))) ?? s.summary;
306
+ } else if (matchedField === "open") {
307
+ snippetText = s.open.find((o) => tokens.some((t) => o.toLowerCase().includes(t))) ?? s.summary;
308
+ }
309
+ }
310
+
311
+ return (
312
+ <li
313
+ key={s.id}
314
+ className={`session-row session-row-detail clickable${drawerSid === s.id ? " is-active" : ""}`}
315
+ onClick={() => openSession(s.id)}
316
+ >
317
+ <span className={`chip-inline status-${s.status}`}>{s.status}</span>
318
+ <div className="session-row-main">
319
+ <span className="session-label">{s.label}</span>
320
+ <span className="session-meta">
321
+ {s.entities.slice(0, 4).map((e) => (
322
+ <span key={e} className="chip-inline" style={{ marginRight: 4 }}>{e}</span>
323
+ ))}
324
+ </span>
325
+ {tokens.length > 0 && (
326
+ <>
327
+ <span className="live-tag" data-kind={matchedField}>{matchedField}</span>
328
+ <div
329
+ className="match-snippet"
330
+ dangerouslySetInnerHTML={{ __html: highlightTokens(buildSnippet(snippetText, tokens), tokens) }}
331
+ />
332
+ </>
333
+ )}
334
+ </div>
335
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4 }}>
336
+ <span className="muted small mono">{relativeAge(s.started_at)}</span>
337
+ <span className="chip-inline">{s.runtime}</span>
338
+ </div>
339
+ </li>
340
+ );
341
+ })}
342
+ {slice.length === 0 && data && (
343
+ <li className="muted empty-row">
344
+ {results.length === 0 ? "No sessions match the current filters." : "No sessions on this page."}
73
345
  </li>
74
- ))}
346
+ )}
75
347
  </ul>
348
+
349
+ {results.length > 0 && (
350
+ <div className="pagination">
351
+ <div className="page-size">
352
+ <label className="form-label">Per page</label>
353
+ <select
354
+ className="form-input form-input-inline"
355
+ value={pageSize}
356
+ onChange={(e) => setPageSize(Number.parseInt(e.target.value, 10))}
357
+ >
358
+ {PAGE_SIZE_OPTIONS.map((n) => <option key={n} value={n}>{n}</option>)}
359
+ </select>
360
+ </div>
361
+ <span className="header-spacer" />
362
+ <span className="muted small">
363
+ {start + 1}&ndash;{Math.min(start + pageSize, results.length)} of {results.length}
364
+ </span>
365
+ <div className="page-nav">
366
+ <button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage(0)}>&laquo; first</button>
367
+ <button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>&lsaquo; prev</button>
368
+ <span className="page-indicator mono">{currentPage + 1} / {pageCount}</span>
369
+ <button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}>next &rsaquo;</button>
370
+ <button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage(pageCount - 1)}>last &raquo;</button>
371
+ </div>
372
+ </div>
373
+ )}
374
+
375
+ {drawerSid && (
376
+ <SessionDrawer
377
+ sessionId={drawerSid}
378
+ onClose={closeSession}
379
+ onNavigate={openSession}
380
+ prevSessionId={prevId}
381
+ nextSessionId={nextId}
382
+ />
383
+ )}
76
384
  </div>
77
385
  );
78
386
  }
79
-
80
- function score(s: { label: string; summary: string; decisions: string[]; open: string[]; entities: string[] }, tokens: string[]): number {
81
- let total = 0;
82
- const label = s.label.toLowerCase();
83
- const summary = s.summary.toLowerCase();
84
- const decisions = s.decisions.join(" ").toLowerCase();
85
- const open = s.open.join(" ").toLowerCase();
86
- for (const t of tokens) {
87
- if (label.includes(t)) total += 3;
88
- if (decisions.includes(t)) total += 2;
89
- if (open.includes(t)) total += 2;
90
- if (summary.includes(t)) total += 1;
91
- }
92
- return total;
93
- }