nlm-memory 0.4.1 → 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 +68 -10
  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 +86 -10
  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,9 +1,3 @@
1
- /**
2
- * SessionDrawer — right-side detail panel for a single session.
3
- * Fetches /api/session/:id on open. Shared between Thread (entity timeline)
4
- * and Pulse (Recent sessions) — and ready for any future caller.
5
- */
6
-
7
1
  import { useEffect, useState } from "react";
8
2
  import { Link } from "react-router-dom";
9
3
  import { SessionDrawerSkeleton } from "./Skeleton.js";
@@ -21,16 +15,20 @@ interface SessionDetail {
21
15
  entities: string[];
22
16
  decisions: string[];
23
17
  open: string[];
18
+ supersededBy: string | null;
19
+ supersedes: string[];
24
20
  }
25
21
 
26
22
  interface SessionDrawerProps {
27
23
  sessionId: string;
28
24
  onClose: () => void;
29
- /** Optional dot color (Thread passes the entity color). */
30
25
  entityColor?: string;
26
+ onNavigate?: (id: string) => void;
27
+ prevSessionId?: string | null;
28
+ nextSessionId?: string | null;
31
29
  }
32
30
 
33
- export function SessionDrawer({ sessionId, onClose, entityColor }: SessionDrawerProps) {
31
+ export function SessionDrawer({ sessionId, onClose, entityColor, onNavigate, prevSessionId, nextSessionId }: SessionDrawerProps) {
34
32
  const [session, setSession] = useState<SessionDetail | null>(null);
35
33
  const [error, setError] = useState<string | null>(null);
36
34
 
@@ -54,16 +52,22 @@ export function SessionDrawer({ sessionId, onClose, entityColor }: SessionDrawer
54
52
  entities: Array.isArray(raw["entities"]) ? (raw["entities"] as string[]) : [],
55
53
  decisions: Array.isArray(raw["decisions"]) ? (raw["decisions"] as string[]) : [],
56
54
  open: Array.isArray(raw["open"]) ? (raw["open"] as string[]) : [],
55
+ supersededBy: typeof raw["supersededBy"] === "string" ? (raw["supersededBy"] as string) : null,
56
+ supersedes: Array.isArray(raw["supersedes"]) ? (raw["supersedes"] as string[]) : [],
57
57
  });
58
58
  })
59
59
  .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
60
60
  }, [sessionId]);
61
61
 
62
62
  useEffect(() => {
63
- const onEsc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
64
- window.addEventListener("keydown", onEsc);
65
- return () => window.removeEventListener("keydown", onEsc);
66
- }, [onClose]);
63
+ const handler = (e: KeyboardEvent) => {
64
+ if (e.key === "Escape") { onClose(); return; }
65
+ if (e.key === "ArrowLeft" && prevSessionId != null && onNavigate) onNavigate(prevSessionId);
66
+ if (e.key === "ArrowRight" && nextSessionId != null && onNavigate) onNavigate(nextSessionId);
67
+ };
68
+ window.addEventListener("keydown", handler);
69
+ return () => window.removeEventListener("keydown", handler);
70
+ }, [onClose, onNavigate, prevSessionId, nextSessionId]);
67
71
 
68
72
  return (
69
73
  <>
@@ -72,32 +76,75 @@ export function SessionDrawer({ sessionId, onClose, entityColor }: SessionDrawer
72
76
  <header className="drawer-head">
73
77
  {entityColor && <span className="dot" style={{ background: entityColor }} />}
74
78
  <h3 className="drawer-title">{session?.label ?? sessionId}</h3>
79
+ {(prevSessionId != null || nextSessionId != null) && onNavigate && (
80
+ <div className="drawer-nav">
81
+ <button
82
+ type="button"
83
+ className="drawer-nav-btn"
84
+ disabled={prevSessionId == null}
85
+ onClick={() => prevSessionId && onNavigate(prevSessionId)}
86
+ aria-label="Previous session"
87
+ >
88
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
89
+ </button>
90
+ <button
91
+ type="button"
92
+ className="drawer-nav-btn"
93
+ disabled={nextSessionId == null}
94
+ onClick={() => nextSessionId && onNavigate(nextSessionId)}
95
+ aria-label="Next session"
96
+ >
97
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18l6-6-6-6"/></svg>
98
+ </button>
99
+ </div>
100
+ )}
75
101
  <button type="button" className="drawer-close" onClick={onClose} aria-label="Close">×</button>
76
102
  </header>
77
103
  {error && <div className="muted error drawer-body">{error}</div>}
78
104
  {!session && !error && <SessionDrawerSkeleton />}
79
105
  {session && (
80
106
  <div className="drawer-body">
81
- <dl className="kv-list">
82
- <dt className="kv-label">Status</dt>
83
- <dd className="kv-value"><span className={`chip-inline status-${session.status}`}>{session.status}</span></dd>
84
- <dt className="kv-label">Started</dt>
85
- <dd className="kv-value mono small">{session.startedAt ?? "—"}</dd>
86
- <dt className="kv-label">Duration</dt>
87
- <dd className="kv-value">{session.durationMin ?? "—"} min</dd>
88
- <dt className="kv-label">Runtime</dt>
89
- <dd className="kv-value mono small">{session.runtime}</dd>
90
- <dt className="kv-label">Session ID</dt>
91
- <dd className="kv-value mono small">{session.id}</dd>
92
- </dl>
93
- {session.entities.length > 0 && (
94
- <>
95
- <h4 className="drawer-section">Entities</h4>
96
- <div className="entity-chips">
97
- {session.entities.map((e) => (
98
- <Link key={e} to={`/thread?entity=${encodeURIComponent(e)}`} className="chip" onClick={onClose}>{e}</Link>
107
+ {session.supersededBy && (
108
+ <div className="supersedence-banner supersedence-banner--superseded">
109
+ <span className="supersedence-label">Superseded by</span>
110
+ {onNavigate ? (
111
+ <button
112
+ type="button"
113
+ className="supersedence-link"
114
+ onClick={() => onNavigate(session.supersededBy!)}
115
+ >
116
+ {session.supersededBy}
117
+ </button>
118
+ ) : (
119
+ <span className="supersedence-id mono small">{session.supersededBy}</span>
120
+ )}
121
+ </div>
122
+ )}
123
+ {session.supersedes.length > 0 && (
124
+ <div className="supersedence-banner supersedence-banner--supersedes">
125
+ <span className="supersedence-label">Supersedes</span>
126
+ <span className="supersedence-ids">
127
+ {session.supersedes.map((sid) => (
128
+ onNavigate ? (
129
+ <button
130
+ key={sid}
131
+ type="button"
132
+ className="supersedence-link"
133
+ onClick={() => onNavigate(sid)}
134
+ >
135
+ {sid}
136
+ </button>
137
+ ) : (
138
+ <span key={sid} className="supersedence-id mono small">{sid}</span>
139
+ )
99
140
  ))}
100
- </div>
141
+ </span>
142
+ </div>
143
+ )}
144
+ {session.summary && (
145
+ <>
146
+ <h4 className="drawer-section">Summary</h4>
147
+ <p className="drawer-paragraph">{session.summary}</p>
101
148
  </>
102
149
  )}
103
150
  {session.decisions.length > 0 && (
@@ -116,12 +163,28 @@ export function SessionDrawer({ sessionId, onClose, entityColor }: SessionDrawer
116
163
  </ul>
117
164
  </>
118
165
  )}
119
- {session.summary && (
166
+ {session.entities.length > 0 && (
120
167
  <>
121
- <h4 className="drawer-section">Summary</h4>
122
- <p className="drawer-paragraph">{session.summary}</p>
168
+ <h4 className="drawer-section">Entities</h4>
169
+ <div className="entity-chips">
170
+ {session.entities.map((e) => (
171
+ <Link key={e} to={`/thread?entity=${encodeURIComponent(e)}`} className="chip" onClick={onClose}>{e}</Link>
172
+ ))}
173
+ </div>
123
174
  </>
124
175
  )}
176
+ <dl className="kv-list">
177
+ <dt className="kv-label">Status</dt>
178
+ <dd className="kv-value"><span className={`chip-inline status-${session.status}`}>{session.status}</span></dd>
179
+ <dt className="kv-label">Started</dt>
180
+ <dd className="kv-value mono small">{session.startedAt ?? "—"}</dd>
181
+ <dt className="kv-label">Duration</dt>
182
+ <dd className="kv-value">{session.durationMin ?? "—"} min</dd>
183
+ <dt className="kv-label">Runtime</dt>
184
+ <dd className="kv-value mono small">{session.runtime}</dd>
185
+ <dt className="kv-label">Session ID</dt>
186
+ <dd className="kv-value mono small">{session.id}</dd>
187
+ </dl>
125
188
  {session.body && (
126
189
  <>
127
190
  <h4 className="drawer-section">Transcript excerpt</h4>
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
3
3
  import { useDataset, relativeAge } from "../lib/dataset.js";
4
4
  import type { DatasetSession } from "../lib/dataset.js";
5
5
  import { SessionDrawer } from "../components/SessionDrawer.js";
6
+ import { Skeleton } from "../components/Skeleton.js";
6
7
  import { readViewSettings } from "../lib/view-settings.js";
7
8
 
8
9
  type Span = "7d" | "30d" | "90d" | "all";
@@ -68,7 +69,12 @@ export function RiverPage() {
68
69
  }))
69
70
  .sort((a, b) => b.total - a.total)
70
71
  .slice(0, 24);
71
- return { dates, laneRows, total: filtered.length };
72
+ const recentEntities = new Set<string>(
73
+ filtered
74
+ .filter(s => s.started_at && (now - Date.parse(s.started_at)) < 86_400_000)
75
+ .flatMap(s => s.entities)
76
+ );
77
+ return { dates, laneRows, total: filtered.length, recentEntities };
72
78
  }, [data, span]);
73
79
 
74
80
  const onCellClick = (entity: string, date: string) => {
@@ -135,7 +141,19 @@ export function RiverPage() {
135
141
  setDragRange(null);
136
142
  };
137
143
 
138
- if (loading && !data) return <div className="page-pad"><div className="muted">Loading dataset…</div></div>;
144
+ if (loading && !data) return (
145
+ <div className="page-pad">
146
+ <div className="river-toolbar"><Skeleton h={22} w={80} /></div>
147
+ <div className="card" style={{ padding: 12 }}>
148
+ {Array.from({ length: 8 }).map((_, i) => (
149
+ <div key={i} className="river-row" style={{ marginBottom: 3 }}>
150
+ <Skeleton h={14} w={160} />
151
+ <Skeleton h={20} />
152
+ </div>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ );
139
157
  if (error && !data) return <div className="page-pad"><div className="muted error">{error}</div></div>;
140
158
  if (!data || !view) return null;
141
159
 
@@ -153,6 +171,13 @@ export function RiverPage() {
153
171
  onClick={() => setSpan(s)}
154
172
  >{s}</button>
155
173
  ))}
174
+ <div className="river-legend" aria-label="Activity scale">
175
+ <span className="muted small">less</span>
176
+ {([0, 1, 2, 3, 4] as const).map((t) => (
177
+ <span key={t} className={`river-legend-cell tier-${t}`} />
178
+ ))}
179
+ <span className="muted small">more</span>
180
+ </div>
156
181
  </div>
157
182
 
158
183
  <div
@@ -173,40 +198,52 @@ export function RiverPage() {
173
198
  <div className="river-row river-row-dates">
174
199
  <div className="river-lane-label river-lane-label--header" aria-hidden="true" />
175
200
  <div className="river-cells">
176
- {view.dates.map((d) => (
177
- <div key={d} className="river-date-cell" title={d}>{d.slice(5)}</div>
178
- ))}
179
- </div>
180
- </div>
181
- {view.laneRows.map(({ entity, perDate, total }) => (
182
- <div key={entity} className="river-row">
183
- <button
184
- type="button"
185
- className="river-lane-label"
186
- onClick={() => navigate(`/thread?entity=${encodeURIComponent(entity)}`)}
187
- >
188
- <span className="dot" style={{ background: data.entity_colors[entity] ?? "#666" }} />
189
- <span className="river-lane-name">{entity}</span>
190
- <span className="muted small">{total}</span>
191
- </button>
192
- <div className="river-cells">
193
- {view.dates.map((d) => {
194
- const v = perDate.get(d) ?? 0;
201
+ {(() => {
202
+ const len = view.dates.length;
203
+ const stride = len <= 60 ? 1 : len <= 120 ? 2 : len <= 250 ? 7 : 14;
204
+ return view.dates.map((d, index) => {
205
+ const isMonthStart = d.slice(8, 10) === "01";
206
+ const cls = ["river-date-cell", isMonthStart ? "river-date-cell--month-start" : ""].filter(Boolean).join(" ");
195
207
  return (
196
- <div
197
- key={d}
198
- className={`river-cell tier-${tier(v)}`}
199
- onMouseEnter={(e) => setHover({ entity, date: d, count: v, x: e.clientX, y: e.clientY })}
200
- onMouseMove={(e) => setHover({ entity, date: d, count: v, x: e.clientX, y: e.clientY })}
201
- onMouseLeave={() => setHover(null)}
202
- onClick={() => v > 0 && onCellClick(entity, d)}
203
- style={v > 0 ? { cursor: "pointer" } : {}}
204
- />
208
+ <div key={d} className={cls} title={d}>
209
+ {index % stride === 0 ? d.slice(5) : ""}
210
+ </div>
205
211
  );
206
- })}
207
- </div>
212
+ });
213
+ })()}
208
214
  </div>
209
- ))}
215
+ </div>
216
+ {view.laneRows.map(({ entity, perDate, total }) => {
217
+ const isRecent = view.recentEntities.has(entity);
218
+ return (
219
+ <div key={entity} className="river-row">
220
+ <button
221
+ type="button"
222
+ className="river-lane-label"
223
+ onClick={() => navigate(`/thread?entity=${encodeURIComponent(entity)}`)}
224
+ >
225
+ <span className={`dot${isRecent ? " dot-pulse" : ""}`} style={{ background: data.entity_colors[entity] ?? "#666" }} />
226
+ <span className="river-lane-name">{entity}</span>
227
+ <span className="muted small">{total}</span>
228
+ </button>
229
+ <div className="river-cells">
230
+ {view.dates.map((d) => {
231
+ const v = perDate.get(d) ?? 0;
232
+ return (
233
+ <div
234
+ key={d}
235
+ className={`river-cell tier-${tier(v)}`}
236
+ onMouseEnter={(e) => setHover({ entity, date: d, count: v, x: e.clientX, y: e.clientY })}
237
+ onMouseLeave={() => setHover(null)}
238
+ onClick={() => v > 0 && onCellClick(entity, d)}
239
+ style={v > 0 ? { cursor: "pointer" } : {}}
240
+ />
241
+ );
242
+ })}
243
+ </div>
244
+ </div>
245
+ );
246
+ })}
210
247
  </div>
211
248
 
212
249
  {view.laneRows.length === 0 && <div className="muted">No entities in this window.</div>}
@@ -229,17 +266,26 @@ export function RiverPage() {
229
266
  />
230
267
  )}
231
268
 
232
- {sessionId && (
233
- <SessionDrawer
234
- sessionId={sessionId}
235
- onClose={() => setSessionId(null)}
236
- entityColor={(() => {
237
- const s = data.sessions.find((x) => x.id === sessionId);
238
- const e = s?.entities[0];
239
- return e ? data.entity_colors[e] : undefined;
240
- })()}
241
- />
242
- )}
269
+ {sessionId && (() => {
270
+ const siblingList = cellDrawer
271
+ ? cellDrawer.sessions
272
+ : [...data.sessions].filter((s) => s.started_at !== null).sort((a, b) => (b.started_at ?? "").localeCompare(a.started_at ?? ""));
273
+ const idx = siblingList.findIndex((s) => s.id === sessionId);
274
+ const prevId = idx < siblingList.length - 1 ? siblingList[idx + 1]!.id : null;
275
+ const nextId = idx > 0 ? siblingList[idx - 1]!.id : null;
276
+ const s = data.sessions.find((x) => x.id === sessionId);
277
+ const e = s?.entities[0];
278
+ return (
279
+ <SessionDrawer
280
+ sessionId={sessionId}
281
+ onClose={() => setSessionId(null)}
282
+ onNavigate={setSessionId}
283
+ prevSessionId={prevId}
284
+ nextSessionId={nextId}
285
+ entityColor={e ? data.entity_colors[e] : undefined}
286
+ />
287
+ );
288
+ })()}
243
289
  </div>
244
290
  );
245
291
  }