@ephemushroom/opencode-claude-mem 0.6.2 → 0.7.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.
@@ -3,21 +3,31 @@ export declare const OBSERVATION_TYPE_ICONS: Record<string, string>;
3
3
  export interface MemSidebarView {
4
4
  readonly healthy: boolean;
5
5
  readonly project: string;
6
+ /** `project` when counts come from the Worker database; `all` for the global fallback. */
7
+ readonly scope: 'project' | 'all';
8
+ readonly totalObservations: number | null;
6
9
  readonly observations: number | null;
7
10
  readonly sessions: number | null;
8
11
  readonly summaries: number | null;
9
12
  readonly queueDepth: number | null;
10
13
  readonly isProcessing: boolean;
14
+ /** Start of the minute the ages are measured from; ticks once a minute. */
15
+ readonly asOf: number;
11
16
  readonly recentSummaries: readonly {
12
17
  id: number;
13
18
  request: string;
19
+ at: number | null;
14
20
  }[];
15
21
  readonly recentObservations: readonly {
16
22
  id: number;
17
23
  type: string;
18
24
  title: string;
25
+ at: number | null;
19
26
  }[];
20
27
  }
28
+ export type RecentItemKind = 'summary' | 'observation';
29
+ /** Receives a click on a recent row; ids address the Worker's detail endpoints. */
30
+ export type RecentItemHandler = (kind: RecentItemKind, id: number) => void;
21
31
  export interface ViewNode {
22
32
  readonly kind: 'box' | 'text';
23
33
  readonly props: Record<string, unknown>;
@@ -36,15 +46,30 @@ export interface Theme {
36
46
  export declare function box(props: Record<string, unknown>, children: readonly ViewNode[]): ViewNode;
37
47
  export declare function text(props: Record<string, unknown>, value: string): ViewNode;
38
48
  export declare function decodeEntities(value: string): string;
39
- export declare function truncate(value: string): string;
49
+ /**
50
+ * Drops the current project's name from a title: every row in this sidebar already belongs
51
+ * to that project, so repeating it costs the space the rest of the title needs. Whole-token
52
+ * matches only, so a name inside a longer word survives. Returns the input when nothing else
53
+ * is left.
54
+ */
55
+ export declare function stripProjectName(value: string, project: string): string;
56
+ /** Short age such as `just now`, `12m ago`, `3h ago`, `2d ago`, then a calendar date. */
57
+ export declare function formatAge(at: number | null, now: number): string;
40
58
  export declare function formatCount(value: number | null): string;
41
59
  export declare function summaryLine(view: MemSidebarView): {
42
60
  label: string;
43
61
  tone: 'ok' | 'warn' | 'err';
44
62
  };
45
63
  export declare function toneColor(tone: 'ok' | 'warn' | 'err', theme: Theme): unknown;
46
- export declare function bulletRow(icon: string, iconColor: unknown, label: string, theme: Theme): ViewNode;
47
- export declare function expandedRows(view: MemSidebarView, theme: Theme): ViewNode[];
48
- export declare function buildMemNodes(view: MemSidebarView, theme: Theme, collapsed: boolean, onToggle: () => void): ViewNode[];
64
+ export declare function bulletRow(icon: string, iconColor: unknown, value: string, theme: Theme, labelColor?: unknown): ViewNode;
65
+ export declare function expandedRows(view: MemSidebarView, theme: Theme, onSelect?: RecentItemHandler): ViewNode[];
66
+ export declare function buildMemNodes(view: MemSidebarView, theme: Theme, collapsed: boolean, onToggle: () => void, onSelect?: RecentItemHandler): ViewNode[];
49
67
  export declare function viewKey(view: MemSidebarView): string;
50
68
  export declare function readMemView(project: string, collapsed: boolean): Promise<MemSidebarView>;
69
+ export interface MemDetail {
70
+ readonly title: string;
71
+ readonly message: string;
72
+ }
73
+ /** Dialog text for one observation or session summary; null when the Worker has none. */
74
+ export declare function describeDetail(kind: RecentItemKind, id: number, record: Record<string, unknown> | null): MemDetail | null;
75
+ export declare function readDetail(kind: RecentItemKind, id: number): Promise<MemDetail | null>;
package/dist/tui.js CHANGED
@@ -3,55 +3,16 @@
3
3
  import { existsSync, readFileSync } from "fs";
4
4
  import { homedir } from "os";
5
5
  import { join } from "path";
6
- import { spawn } from "child_process";
7
-
8
- // src/shared.ts
9
- var MAX_OBSERVATION_BYTES = 24 * 1024;
10
- var META_TOOLS = new Set([
11
- "askuserquestion",
12
- "getmcpresource",
13
- "listmcpresourcestool",
14
- "listmcptools",
15
- "mem-get-observations",
16
- "mem-search",
17
- "mem-timeline",
18
- "skill",
19
- "slashcommand",
20
- "todowrite"
21
- ]);
22
- function stripTaggedContent(text) {
23
- let result = "";
24
- let offset = 0;
25
- const stack = [];
26
- for (const match of text.matchAll(/<(\/?)(private|claude-mem-context)\b[^>]*>/gi)) {
27
- if (stack.length === 0) {
28
- result += text.slice(offset, match.index);
29
- }
30
- const name = match[2].toLowerCase();
31
- if (match[1] === "/") {
32
- if (stack.at(-1) === name) {
33
- stack.pop();
34
- }
35
- } else {
36
- stack.push(name);
37
- }
38
- offset = match.index + match[0].length;
39
- }
40
- if (stack.length === 0) {
41
- result += text.slice(offset);
42
- }
43
- return result.trim();
44
- }
45
-
46
- // src/worker-client.ts
47
- var HEALTH_TIMEOUT_MS = 1000;
48
- var WRITE_TIMEOUT_MS = 1e4;
49
- var AUTOSTART_POLL_INTERVAL_MS = 500;
50
- var AUTOSTART_POLL_MAX_ATTEMPTS = 16;
51
6
  var DEFAULT_WORKER_HOST = "127.0.0.1";
52
7
  var DEFAULT_WORKER_PORT = 37777;
53
- var PLATFORM_SOURCE = "opencode";
54
8
  var SETTINGS_FILE = join(homedir(), ".claude-mem", "settings.json");
9
+ var LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
10
+ var PROJECT_COUNTS_SQL = `SELECT
11
+ (SELECT COUNT(*) FROM observations
12
+ WHERE project = ?1 COLLATE NOCASE OR merged_into_project = ?1 COLLATE NOCASE) AS observations,
13
+ (SELECT COUNT(*) FROM session_summaries
14
+ WHERE project = ?1 COLLATE NOCASE OR merged_into_project = ?1 COLLATE NOCASE) AS summaries,
15
+ (SELECT COUNT(*) FROM sdk_sessions WHERE project = ?1 COLLATE NOCASE) AS sessions`;
55
16
  function isRecord(value) {
56
17
  return typeof value === "object" && value !== null && !Array.isArray(value);
57
18
  }
@@ -87,26 +48,43 @@ function parseWorkerEndpoint(env, rawSettings) {
87
48
  const port = readPort(env["CLAUDE_MEM_WORKER_PORT"]) ?? readPort(settings["CLAUDE_MEM_WORKER_PORT"]) ?? DEFAULT_WORKER_PORT;
88
49
  return { host, port };
89
50
  }
90
- function buildSearchParams(options) {
91
- const params = new URLSearchParams;
92
- const entries = [
93
- ["query", options.query ?? ""],
94
- ["limit", options.limit],
95
- ["project", options.project],
96
- ["platformSource", options.platformSource],
97
- ["type", options.type],
98
- ["obs_type", options.obs_type],
99
- ["dateStart", options.dateStart],
100
- ["dateEnd", options.dateEnd],
101
- ["offset", options.offset],
102
- ["orderBy", options.orderBy]
103
- ];
104
- for (const [key, value] of entries) {
105
- if (value !== undefined) {
106
- params.set(key, String(value));
51
+ function resolveDatabasePath(env, rawSettings, home) {
52
+ if (!LOOPBACK_HOSTS.has(parseWorkerEndpoint(env, rawSettings).host)) {
53
+ return null;
54
+ }
55
+ const settings = parseSettings(rawSettings);
56
+ const dir = readString(env["CLAUDE_MEM_DATA_DIR"]) ?? readString(settings["CLAUDE_MEM_DATA_DIR"]) ?? join(home, ".claude-mem");
57
+ return join(dir.replace(/^~(?=$|[\\/])/, home), "claude-mem.db");
58
+ }
59
+ function readCount(value) {
60
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
61
+ }
62
+ async function readProjectCounts(path, project) {
63
+ if (!existsSync(path)) {
64
+ return null;
65
+ }
66
+ try {
67
+ const { Database } = await import("bun:sqlite");
68
+ const db = new Database(path, { readonly: true });
69
+ try {
70
+ const row = db.query(PROJECT_COUNTS_SQL).get(project);
71
+ if (!isRecord(row)) {
72
+ return null;
73
+ }
74
+ const observations = readCount(row["observations"]);
75
+ const summaries = readCount(row["summaries"]);
76
+ const sessions = readCount(row["sessions"]);
77
+ return observations === null || summaries === null || sessions === null ? null : { observations, summaries, sessions };
78
+ } finally {
79
+ db.close();
107
80
  }
81
+ } catch {
82
+ return null;
108
83
  }
109
- return params;
84
+ }
85
+ function readEpoch(item) {
86
+ const at = item["created_at_epoch"];
87
+ return typeof at === "number" && Number.isFinite(at) ? at : null;
110
88
  }
111
89
  function readSettingsRaw() {
112
90
  try {
@@ -119,253 +97,12 @@ function getWorkerBaseUrl() {
119
97
  const endpoint = parseWorkerEndpoint(process.env, readSettingsRaw());
120
98
  return `http://${endpoint.host}:${endpoint.port}`;
121
99
  }
122
- function extractTextContent(value) {
123
- if (typeof value === "string") {
124
- return value.trim() || null;
125
- }
126
- if (!isRecord(value)) {
127
- return null;
128
- }
129
- const { content } = value;
130
- if (typeof content === "string") {
131
- return content.trim() || null;
132
- }
133
- if (Array.isArray(content)) {
134
- const text = content.map((part) => {
135
- if (!isRecord(part) || part["type"] !== "text" || typeof part["text"] !== "string") {
136
- return "";
137
- }
138
- return part["text"];
139
- }).filter(Boolean).join(`
140
- `).trim();
141
- return text || null;
142
- }
143
- const text = JSON.stringify(value, null, 2);
144
- return text === "{}" || text === "null" ? null : text;
145
- }
146
100
 
147
101
  class WorkerClient {
148
102
  static BASE_URL = getWorkerBaseUrl();
149
- static autoStartPromise = null;
150
- static async isHealthy() {
151
- try {
152
- const response = await fetch(`${this.BASE_URL}/api/health`, {
153
- signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
154
- });
155
- return response.ok;
156
- } catch {
157
- return false;
158
- }
159
- }
160
- static async ensureRunning() {
161
- await this.tryAutoStart();
162
- return this.isHealthy();
163
- }
164
- static tryAutoStart() {
165
- if (!this.autoStartPromise) {
166
- this.autoStartPromise = this.runAutoStart().catch(() => "spawn-failed");
167
- }
168
- return this.autoStartPromise;
169
- }
170
- static async runAutoStart() {
171
- if (await this.isHealthy()) {
172
- return "already-running";
173
- }
174
- const bunPath = this.findBun();
175
- if (!bunPath) {
176
- return "no-bun";
177
- }
178
- try {
179
- const child = spawn(bunPath, ["x", "claude-mem", "start"], {
180
- detached: true,
181
- stdio: "ignore",
182
- windowsHide: true,
183
- env: process.env
184
- });
185
- child.on("error", () => {});
186
- child.unref();
187
- } catch {
188
- return "spawn-failed";
189
- }
190
- for (let i = 0;i < AUTOSTART_POLL_MAX_ATTEMPTS; i++) {
191
- await new Promise((resolve) => setTimeout(resolve, AUTOSTART_POLL_INTERVAL_MS));
192
- if (await this.isHealthy()) {
193
- return "started";
194
- }
195
- }
196
- return "timeout";
197
- }
198
- static findBun() {
199
- const bunGlobal = globalThis.Bun;
200
- if (bunGlobal && typeof bunGlobal.which === "function") {
201
- try {
202
- const found = bunGlobal.which("bun");
203
- if (typeof found === "string" && found) {
204
- return found;
205
- }
206
- } catch {}
207
- }
208
- const path = process.env.PATH || "";
209
- const sep = process.platform === "win32" ? ";" : ":";
210
- const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
211
- const slash = process.platform === "win32" ? "\\" : "/";
212
- const dirs = path.split(sep).filter(Boolean);
213
- for (const dir of dirs) {
214
- const trailing = dir.endsWith("/") || dir.endsWith("\\") ? "" : slash;
215
- for (const ext of exts) {
216
- const candidate = `${dir}${trailing}bun${ext}`;
217
- try {
218
- if (existsSync(candidate)) {
219
- return candidate;
220
- }
221
- } catch {}
222
- }
223
- }
224
- return null;
225
- }
226
- static async sessionInit(contentSessionId, project, prompt) {
227
- try {
228
- const response = await fetch(`${this.BASE_URL}/api/sessions/init`, {
229
- method: "POST",
230
- signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
231
- headers: { "Content-Type": "application/json" },
232
- body: JSON.stringify({
233
- contentSessionId,
234
- project,
235
- prompt,
236
- platformSource: PLATFORM_SOURCE
237
- })
238
- });
239
- if (!response.ok) {
240
- return null;
241
- }
242
- const value = await response.json();
243
- if (!isRecord(value) || typeof value.sessionDbId !== "number" || typeof value.promptNumber !== "number" || !Number.isFinite(value.sessionDbId) || !Number.isFinite(value.promptNumber)) {
244
- return null;
245
- }
246
- return {
247
- sessionDbId: value.sessionDbId,
248
- promptNumber: value.promptNumber,
249
- skipped: value.skipped === true,
250
- reason: typeof value.reason === "string" ? value.reason : undefined
251
- };
252
- } catch {
253
- return null;
254
- }
255
- }
256
- static async sendObservation(contentSessionId, toolName, toolInput, toolResponse, cwd, toolUseId) {
257
- try {
258
- const response = await fetch(`${this.BASE_URL}/api/sessions/observations`, {
259
- method: "POST",
260
- signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
261
- headers: { "Content-Type": "application/json" },
262
- body: JSON.stringify({
263
- contentSessionId,
264
- tool_name: toolName,
265
- tool_input: toolInput,
266
- tool_response: toolResponse,
267
- cwd,
268
- platformSource: PLATFORM_SOURCE,
269
- tool_use_id: toolUseId
270
- })
271
- });
272
- return response.ok;
273
- } catch {
274
- return false;
275
- }
276
- }
277
- static async summarize(contentSessionId, lastUserMessage, lastAssistantMessage, metadata = {}) {
278
- try {
279
- const response = await fetch(`${this.BASE_URL}/api/sessions/summarize`, {
280
- method: "POST",
281
- signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
282
- headers: { "Content-Type": "application/json" },
283
- body: JSON.stringify({
284
- contentSessionId,
285
- last_user_message: stripTaggedContent(lastUserMessage),
286
- last_assistant_message: stripTaggedContent(lastAssistantMessage),
287
- platformSource: PLATFORM_SOURCE,
288
- observedModel: metadata.observedModel,
289
- observedBilling: metadata.observedBilling
290
- })
291
- });
292
- return response.ok;
293
- } catch {
294
- return false;
295
- }
296
- }
297
- static async getContext(project) {
298
- try {
299
- const response = await fetch(`${this.BASE_URL}/api/context/inject?project=${encodeURIComponent(project)}`, { signal: AbortSignal.timeout(2000) });
300
- if (!response.ok) {
301
- return null;
302
- }
303
- const contentType = response.headers.get("content-type") || "";
304
- if (contentType.includes("application/json")) {
305
- return extractTextContent(await response.json());
306
- }
307
- const text = await response.text();
308
- return text.trim() || null;
309
- } catch {
310
- return null;
311
- }
312
- }
313
- static async timeline(options) {
314
- try {
315
- const params = new URLSearchParams({ project: options.project });
316
- if (options.anchor !== undefined) {
317
- params.set("anchor", String(options.anchor));
318
- }
319
- if (options.query) {
320
- params.set("query", options.query);
321
- }
322
- if (options.depthBefore !== undefined) {
323
- params.set("depth_before", String(options.depthBefore));
324
- }
325
- if (options.depthAfter !== undefined) {
326
- params.set("depth_after", String(options.depthAfter));
327
- }
328
- const response = await fetch(`${this.BASE_URL}/api/timeline?${params.toString()}`);
329
- if (!response.ok) {
330
- return "";
331
- }
332
- const contentType = response.headers.get("content-type") || "";
333
- if (contentType.includes("application/json")) {
334
- return extractTextContent(await response.json()) || "";
335
- }
336
- const text = await response.text();
337
- return text.trim();
338
- } catch {
339
- return "";
340
- }
341
- }
342
- static async getObservations(ids, project) {
343
- try {
344
- const body = { ids };
345
- if (project) {
346
- body["project"] = project;
347
- }
348
- const response = await fetch(`${this.BASE_URL}/api/observations/batch`, {
349
- method: "POST",
350
- headers: { "Content-Type": "application/json" },
351
- body: JSON.stringify(body)
352
- });
353
- if (!response.ok) {
354
- return "";
355
- }
356
- const contentType = response.headers.get("content-type") || "";
357
- if (contentType.includes("application/json")) {
358
- const payload = await response.json();
359
- if (Array.isArray(payload)) {
360
- return payload.length ? JSON.stringify(payload, null, 2) : "";
361
- }
362
- return extractTextContent(payload) || "";
363
- }
364
- const text = await response.text();
365
- return text.trim();
366
- } catch {
367
- return "";
368
- }
103
+ static DATABASE_PATH = resolveDatabasePath(process.env, readSettingsRaw(), homedir());
104
+ static getProjectCounts(project) {
105
+ return this.DATABASE_PATH ? readProjectCounts(this.DATABASE_PATH, project) : Promise.resolve(null);
369
106
  }
370
107
  static async getStats() {
371
108
  try {
@@ -399,7 +136,7 @@ class WorkerClient {
399
136
  return [];
400
137
  }
401
138
  const request = typeof item["request"] === "string" ? item["request"].trim() : "";
402
- return request ? [{ id: item["id"], request }] : [];
139
+ return request ? [{ id: item["id"], request, at: readEpoch(item) }] : [];
403
140
  });
404
141
  } catch {
405
142
  return [];
@@ -422,34 +159,29 @@ class WorkerClient {
422
159
  }
423
160
  const title = typeof item["title"] === "string" ? item["title"].trim() : "";
424
161
  const type = typeof item["type"] === "string" ? item["type"] : "";
425
- return title ? [{ id: item["id"], type, title }] : [];
162
+ return title ? [{ id: item["id"], type, title, at: readEpoch(item) }] : [];
426
163
  });
427
164
  } catch {
428
165
  return [];
429
166
  }
430
167
  }
431
- static async search(options) {
168
+ static async getDetail(kind, id) {
432
169
  try {
433
- const params = buildSearchParams(options);
434
- const response = await fetch(`${this.BASE_URL}/api/search?${params.toString()}`);
170
+ const path = kind === "observation" ? "observation" : "session";
171
+ const response = await fetch(`${this.BASE_URL}/api/${path}/${id}`);
435
172
  if (!response.ok) {
436
- return "";
437
- }
438
- const contentType = response.headers.get("content-type") || "";
439
- if (contentType.includes("application/json")) {
440
- return extractTextContent(await response.json()) || "";
173
+ return null;
441
174
  }
442
- const text = await response.text();
443
- return text.trim();
175
+ const payload = await response.json();
176
+ return isRecord(payload) ? payload : null;
444
177
  } catch {
445
- return "";
178
+ return null;
446
179
  }
447
180
  }
448
181
  }
449
182
 
450
183
  // src/sidebar-model.ts
451
184
  var POLL_INTERVAL_MS = 5000;
452
- var LABEL_MAX = 40;
453
185
  var RECENT_SUMMARIES = 3;
454
186
  var RECENT_OBSERVATIONS = 3;
455
187
  var OBSERVATION_TYPE_ICONS = {
@@ -478,9 +210,37 @@ var HTML_ENTITIES = {
478
210
  function decodeEntities(value) {
479
211
  return value.replace(/&(?:lt|gt|quot|#39|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
480
212
  }
481
- function truncate(value) {
213
+ function stripProjectName(value, project) {
482
214
  const decoded = decodeEntities(value);
483
- return decoded.length <= LABEL_MAX ? decoded : `${decoded.slice(0, LABEL_MAX - 1)}\u2026`;
215
+ const name = project.trim();
216
+ if (!name) {
217
+ return decoded;
218
+ }
219
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
220
+ const stripped = decoded.replace(new RegExp(`(?<![\\w.@-])(?:[\\w.@-]+/)?${escaped}(?![\\w.-])`, "giu"), " ").replaceAll(/\s{2,}/g, " ").replaceAll(/\s+([\uFF0C\u3002\u3001\uFF1B\uFF1A,.:;])/g, "$1").replace(/^[\s:\uFF1A,\uFF0C\u3001;\uFF1B\u00B7|\u2013\u2014-]+/u, "").trim();
221
+ return stripped || decoded;
222
+ }
223
+ function formatAge(at, now) {
224
+ if (at === null || !Number.isFinite(at)) {
225
+ return "";
226
+ }
227
+ const minutes = Math.floor(Math.max(0, now - at) / 60000);
228
+ if (minutes < 1) {
229
+ return "just now";
230
+ }
231
+ if (minutes < 60) {
232
+ return `${minutes}m ago`;
233
+ }
234
+ const hours = Math.floor(minutes / 60);
235
+ if (hours < 24) {
236
+ return `${hours}h ago`;
237
+ }
238
+ const days = Math.floor(hours / 24);
239
+ if (days < 7) {
240
+ return `${days}d ago`;
241
+ }
242
+ const date = new Date(at);
243
+ return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
484
244
  }
485
245
  function formatCount(value) {
486
246
  if (value === null) {
@@ -503,68 +263,90 @@ function toneColor(tone, theme) {
503
263
  }
504
264
  return tone === "warn" ? theme.warning : theme.textMuted;
505
265
  }
506
- function bulletRow(icon, iconColor, label, theme) {
266
+ function label(color, value) {
267
+ return text({ fg: color, wrapMode: "none", truncate: true }, value);
268
+ }
269
+ function bulletRow(icon, iconColor, value, theme, labelColor = theme.text) {
507
270
  return box({ flexDirection: "row", gap: 1 }, [
508
271
  text({ fg: iconColor, flexShrink: 0 }, icon),
509
- text({ fg: theme.text, wrapMode: "none" }, truncate(label))
272
+ label(labelColor, decodeEntities(value))
273
+ ]);
274
+ }
275
+ function recentRow(icon, iconColor, title, age, theme, onSelect) {
276
+ return box({ flexDirection: "column", ...onSelect ? { onMouseDown: onSelect } : {} }, [
277
+ bulletRow(icon, iconColor, title, theme),
278
+ ...age ? [box({ paddingLeft: 2 }, [label(theme.textMuted, age)])] : []
510
279
  ]);
511
280
  }
512
- function expandedRows(view, theme) {
281
+ function expandedRows(view, theme, onSelect) {
513
282
  if (!view.healthy) {
514
283
  return [bulletRow("\u2022", theme.error, "worker offline", theme)];
515
284
  }
285
+ const counts = `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)} \xB7 ses ${formatCount(view.sessions)}`;
516
286
  const rows = [
517
- bulletRow("\u2022", theme.success, `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)} \xB7 ses ${formatCount(view.sessions)}`, theme)
287
+ bulletRow("\u2022", theme.success, view.scope === "all" ? `all \xB7 ${counts}` : counts, theme)
518
288
  ];
289
+ if (view.scope === "project") {
290
+ rows.push(bulletRow("\u2022", theme.textMuted, `all ${formatCount(view.totalObservations)} obs`, theme, theme.textMuted));
291
+ }
519
292
  if (view.isProcessing || (view.queueDepth ?? 0) > 0) {
520
293
  rows.push(bulletRow("\u2022", theme.warning, `processing \xB7 queue ${view.queueDepth ?? 0}`, theme));
521
294
  }
522
295
  if (view.recentSummaries.length > 0) {
523
296
  rows.push(text({ fg: theme.textMuted }, "Recent sessions"));
524
297
  for (const summary of view.recentSummaries) {
525
- rows.push(bulletRow("\u2022", theme.textMuted, summary.request, theme));
298
+ rows.push(recentRow("\u2022", theme.textMuted, stripProjectName(summary.request, view.project), formatAge(summary.at, view.asOf), theme, onSelect && (() => onSelect("summary", summary.id))));
526
299
  }
527
300
  }
528
301
  if (view.recentObservations.length > 0) {
529
302
  rows.push(text({ fg: theme.textMuted }, "Latest"));
530
303
  for (const observation of view.recentObservations) {
531
304
  const icon = OBSERVATION_TYPE_ICONS[observation.type] ?? "\u25CB";
532
- rows.push(bulletRow(icon, theme.info, observation.title, theme));
305
+ rows.push(recentRow(icon, theme.info, stripProjectName(observation.title, view.project), [formatAge(observation.at, view.asOf), `#${observation.id}`].filter(Boolean).join(" \xB7 "), theme, onSelect && (() => onSelect("observation", observation.id))));
533
306
  }
534
307
  }
308
+ if (onSelect && view.recentSummaries.length + view.recentObservations.length > 0) {
309
+ rows.push(label(theme.textMuted, "Click an item for details"));
310
+ }
535
311
  return rows;
536
312
  }
537
- function buildMemNodes(view, theme, collapsed, onToggle) {
313
+ function buildMemNodes(view, theme, collapsed, onToggle, onSelect) {
538
314
  const summary = summaryLine(view);
539
315
  const header = box({ flexDirection: "row", gap: 1, onMouseDown: onToggle }, [
540
- text({ fg: theme.text }, collapsed ? "\u25B6" : "\u25BC"),
541
- text({ fg: theme.info, attributes: 1 }, "Memory"),
542
- ...collapsed ? [text({ fg: toneColor(summary.tone, theme) }, summary.label)] : []
316
+ text({ fg: theme.text, flexShrink: 0 }, collapsed ? "\u25B6" : "\u25BC"),
317
+ text({ fg: theme.info, attributes: 1, flexShrink: 0 }, "Memory"),
318
+ collapsed ? text({ fg: toneColor(summary.tone, theme) }, summary.label) : label(theme.textMuted, view.project)
543
319
  ]);
544
320
  return [
545
321
  box({ flexDirection: "column", paddingTop: 1 }, [
546
322
  header,
547
- ...collapsed ? [] : [box({ flexDirection: "column", paddingLeft: 2 }, expandedRows(view, theme))]
323
+ ...collapsed ? [] : [box({ flexDirection: "column", paddingLeft: 2 }, expandedRows(view, theme, onSelect))]
548
324
  ])
549
325
  ];
550
326
  }
551
327
  function viewKey(view) {
552
328
  return JSON.stringify(view);
553
329
  }
554
- function readCount(value) {
330
+ function readCount2(value) {
555
331
  return typeof value === "number" && Number.isFinite(value) ? value : null;
556
332
  }
557
333
  async function readMemView(project, collapsed) {
558
- const stats = await WorkerClient.getStats();
334
+ const [stats, counts] = await Promise.all([
335
+ WorkerClient.getStats(),
336
+ WorkerClient.getProjectCounts(project)
337
+ ]);
559
338
  if (!stats) {
560
339
  return {
561
340
  healthy: false,
562
341
  project,
342
+ scope: "all",
343
+ totalObservations: null,
563
344
  observations: null,
564
345
  sessions: null,
565
346
  summaries: null,
566
347
  queueDepth: null,
567
348
  isProcessing: false,
349
+ asOf: 0,
568
350
  recentSummaries: [],
569
351
  recentObservations: []
570
352
  };
@@ -573,14 +355,18 @@ async function readMemView(project, collapsed) {
573
355
  WorkerClient.getRecentSummaries(project, RECENT_SUMMARIES),
574
356
  WorkerClient.getRecentObservations(project, RECENT_OBSERVATIONS)
575
357
  ]);
358
+ const totalObservations = readCount2(stats.database?.observations);
576
359
  return {
577
360
  healthy: true,
578
361
  project,
579
- observations: readCount(stats.database?.observations),
580
- sessions: readCount(stats.database?.sessions),
581
- summaries: readCount(stats.database?.summaries),
582
- queueDepth: readCount(stats.processing?.queueDepth),
362
+ scope: counts ? "project" : "all",
363
+ totalObservations,
364
+ observations: counts ? counts.observations : totalObservations,
365
+ sessions: counts ? counts.sessions : readCount2(stats.database?.sessions),
366
+ summaries: counts ? counts.summaries : readCount2(stats.database?.summaries),
367
+ queueDepth: readCount2(stats.processing?.queueDepth),
583
368
  isProcessing: stats.processing?.isProcessing === true,
369
+ asOf: Math.floor(Date.now() / 60000) * 60000,
584
370
  recentSummaries,
585
371
  recentObservations
586
372
  };