@ephemushroom/opencode-claude-mem 0.6.2 → 0.7.1

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.
package/dist/tui.js CHANGED
@@ -1,57 +1,88 @@
1
1
  // @bun
2
- // src/worker-client.ts
3
- import { existsSync, readFileSync } from "fs";
2
+ // src/capture-status.ts
3
+ import { createHash, randomUUID } from "crypto";
4
+ import { join, resolve } from "path";
5
+ import {
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ writeFileSync
14
+ } from "fs";
4
15
  import { homedir } from "os";
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);
16
+ function cacheDirectory(env = process.env, home = homedir()) {
17
+ return join(env["XDG_CACHE_HOME"]?.trim() || join(home, ".cache"), "opencode-claude-mem");
18
+ }
19
+ function statusKey(directory) {
20
+ let resolved = resolve(directory);
21
+ try {
22
+ resolved = realpathSync(directory);
23
+ } catch {}
24
+ return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
25
+ }
26
+ function isAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (error) {
31
+ return error.code === "EPERM";
32
+ }
33
+ }
34
+ function parseStatus(file) {
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
37
+ if (typeof parsed !== "object" || parsed === null) {
38
+ return null;
29
39
  }
30
- const name = match[2].toLowerCase();
31
- if (match[1] === "/") {
32
- if (stack.at(-1) === name) {
33
- stack.pop();
34
- }
40
+ const { state, message, pid } = parsed;
41
+ return (state === "ok" || state === "problem") && typeof message === "string" && typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? { state, message, pid } : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+ function readCaptureStatus(directory, dir = cacheDirectory()) {
47
+ const folder = join(dir, "status");
48
+ const prefix = `${statusKey(directory)}.`;
49
+ let names = [];
50
+ try {
51
+ names = readdirSync(folder).filter((name) => name.startsWith(prefix) && name.endsWith(".json"));
52
+ } catch {
53
+ return null;
54
+ }
55
+ let best = null;
56
+ for (const name of names) {
57
+ const file = join(folder, name);
58
+ const parsed = parseStatus(file);
59
+ if (!parsed || !isAlive(parsed.pid)) {
60
+ rmSync(file, { force: true });
35
61
  } else {
36
- stack.push(name);
62
+ const own = parsed.pid === process.pid;
63
+ const at = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0;
64
+ if (!best || own && !best.own || own === best.own && at > best.at) {
65
+ best = { status: { state: parsed.state, message: parsed.message }, own, at };
66
+ }
37
67
  }
38
- offset = match.index + match[0].length;
39
- }
40
- if (stack.length === 0) {
41
- result += text.slice(offset);
42
68
  }
43
- return result.trim();
69
+ return best?.status ?? null;
44
70
  }
45
71
 
46
72
  // 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;
73
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
74
+ import { join as join2, resolve as resolve2, win32 } from "path";
75
+ import { createHash as createHash2 } from "crypto";
76
+ import { homedir as homedir2 } from "os";
51
77
  var DEFAULT_WORKER_HOST = "127.0.0.1";
52
- var DEFAULT_WORKER_PORT = 37777;
53
- var PLATFORM_SOURCE = "opencode";
54
- var SETTINGS_FILE = join(homedir(), ".claude-mem", "settings.json");
78
+ var WORKER_PORT_BASE = 37700;
79
+ var TOKEN_HEADER = "x-claude-mem-token";
80
+ var PROJECT_COUNTS_SQL = `SELECT
81
+ (SELECT COUNT(*) FROM observations
82
+ WHERE project = ?1 COLLATE NOCASE OR merged_into_project = ?1 COLLATE NOCASE) AS observations,
83
+ (SELECT COUNT(*) FROM session_summaries
84
+ WHERE project = ?1 COLLATE NOCASE OR merged_into_project = ?1 COLLATE NOCASE) AS summaries,
85
+ (SELECT COUNT(*) FROM sdk_sessions WHERE project = ?1 COLLATE NOCASE) AS sessions`;
55
86
  function isRecord(value) {
56
87
  return typeof value === "object" && value !== null && !Array.isArray(value);
57
88
  }
@@ -75,303 +106,134 @@ function parseSettings(rawSettings) {
75
106
  return {};
76
107
  }
77
108
  try {
78
- const parsed = JSON.parse(rawSettings);
79
- return isRecord(parsed) ? parsed : {};
109
+ const parsed = JSON.parse(rawSettings.replace(/^\uFEFF/, ""));
110
+ if (!isRecord(parsed)) {
111
+ return {};
112
+ }
113
+ const nested = parsed["env"];
114
+ const source = isRecord(nested) && Object.keys(nested).some((key) => key.startsWith("CLAUDE_MEM_")) ? nested : parsed;
115
+ return Object.fromEntries(Object.entries(source).map(([key, value]) => [
116
+ key,
117
+ typeof value === "number" || typeof value === "boolean" ? String(value) : value
118
+ ]));
80
119
  } catch {
81
120
  return {};
82
121
  }
83
122
  }
84
- function parseWorkerEndpoint(env, rawSettings) {
123
+ function dataDirectory(env, home) {
124
+ const dir = readString(env["CLAUDE_MEM_DATA_DIR"]) ?? join2(home, ".claude-mem");
125
+ return dir.replace(/^~(?=$|[\\/])/, home);
126
+ }
127
+ function defaultWorkerPort(dataDir, platform = process.platform, uid = typeof process.getuid === "function" ? process.getuid() : null) {
128
+ if (uid !== null) {
129
+ return WORKER_PORT_BASE + (uid % 100 + 100) % 100;
130
+ }
131
+ const normalized = platform === "win32" ? win32.resolve(dataDir).toLowerCase() : resolve2(dataDir);
132
+ const digest = createHash2("sha256").update(normalized, "utf8").digest();
133
+ return WORKER_PORT_BASE + digest.readUInt32BE(0) % 100;
134
+ }
135
+ function parseWorkerEndpoint(env, rawSettings, fallbackPort = defaultWorkerPort(dataDirectory(env, homedir2()))) {
85
136
  const settings = parseSettings(rawSettings);
86
- const host = readString(env["CLAUDE_MEM_WORKER_HOST"]) ?? readString(settings["CLAUDE_MEM_WORKER_HOST"]) ?? DEFAULT_WORKER_HOST;
87
- const port = readPort(env["CLAUDE_MEM_WORKER_PORT"]) ?? readPort(settings["CLAUDE_MEM_WORKER_PORT"]) ?? DEFAULT_WORKER_PORT;
137
+ const configured = readString(env["CLAUDE_MEM_WORKER_HOST"]) ?? readString(settings["CLAUDE_MEM_WORKER_HOST"]) ?? DEFAULT_WORKER_HOST;
138
+ const host = configured === "localhost" ? DEFAULT_WORKER_HOST : configured;
139
+ const port = readPort(env["CLAUDE_MEM_WORKER_PORT"]) ?? readPort(settings["CLAUDE_MEM_WORKER_PORT"]) ?? fallbackPort;
88
140
  return { host, port };
89
141
  }
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));
107
- }
108
- }
109
- return params;
142
+ function isLoopbackHost(host) {
143
+ const name = host.toLowerCase().replace(/^\[|\]$/g, "");
144
+ return name === "localhost" || name.endsWith(".localhost") || name === "::1" || /^127(?:\.\d{1,3}){1,3}$/.test(name);
110
145
  }
111
- function readSettingsRaw() {
146
+ function workerUrl(endpoint) {
147
+ const host = endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host;
148
+ return `http://${host}:${endpoint.port}`;
149
+ }
150
+ function tokenHeaders(baseUrl, rawSettings) {
151
+ const token = readString(parseSettings(rawSettings)["CLAUDE_MEM_LOCAL_API_TOKEN"]);
112
152
  try {
113
- return readFileSync(SETTINGS_FILE, "utf8");
153
+ return token && isLoopbackHost(new URL(baseUrl).hostname) ? { [TOKEN_HEADER]: token } : {};
114
154
  } catch {
115
- return null;
155
+ return {};
116
156
  }
117
157
  }
118
- function getWorkerBaseUrl() {
119
- const endpoint = parseWorkerEndpoint(process.env, readSettingsRaw());
120
- return `http://${endpoint.host}:${endpoint.port}`;
121
- }
122
- function extractTextContent(value) {
123
- if (typeof value === "string") {
124
- return value.trim() || null;
125
- }
126
- if (!isRecord(value)) {
158
+ function resolveDatabasePath(env, rawSettings, home) {
159
+ if (!isLoopbackHost(parseWorkerEndpoint(env, rawSettings).host)) {
127
160
  return null;
128
161
  }
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;
162
+ const settings = parseSettings(rawSettings);
163
+ const dir = readString(env["CLAUDE_MEM_DATA_DIR"]) ?? readString(settings["CLAUDE_MEM_DATA_DIR"]) ?? join2(home, ".claude-mem");
164
+ return join2(dir.replace(/^~(?=$|[\\/])/, home), "claude-mem.db");
145
165
  }
146
-
147
- class WorkerClient {
148
- 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
- }
166
+ function readCount(value) {
167
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
168
+ }
169
+ async function readProjectCounts(path, project) {
170
+ if (!existsSync(path)) {
224
171
  return null;
225
172
  }
226
- static async sessionInit(contentSessionId, project, prompt) {
173
+ try {
174
+ const { Database } = await import("bun:sqlite");
175
+ const db = new Database(path, { readonly: true });
227
176
  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) {
177
+ const row = db.query(PROJECT_COUNTS_SQL).get(project);
178
+ if (!isRecord(row)) {
240
179
  return null;
241
180
  }
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;
181
+ const observations = readCount(row["observations"]);
182
+ const summaries = readCount(row["summaries"]);
183
+ const sessions = readCount(row["sessions"]);
184
+ return observations === null || summaries === null || sessions === null ? null : { observations, summaries, sessions };
185
+ } finally {
186
+ db.close();
295
187
  }
188
+ } catch {
189
+ return null;
296
190
  }
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
- }
191
+ }
192
+ function readEpoch(item) {
193
+ const at = item["created_at_epoch"];
194
+ return typeof at === "number" && Number.isFinite(at) ? at : null;
195
+ }
196
+ function readSettingsRaw() {
197
+ try {
198
+ return readFileSync2(join2(dataDirectory(process.env, homedir2()), "settings.json"), "utf8");
199
+ } catch {
200
+ return null;
312
201
  }
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 "";
202
+ }
203
+ function readWorkerSetting(key) {
204
+ return readString(process.env[key]) ?? readString(parseSettings(readSettingsRaw())[key]);
205
+ }
206
+ function getWorkerEndpoint() {
207
+ return parseWorkerEndpoint(process.env, readSettingsRaw());
208
+ }
209
+ function getWorkerBaseUrl() {
210
+ return workerUrl(getWorkerEndpoint());
211
+ }
212
+ function workerHeaders(baseUrl = getWorkerBaseUrl()) {
213
+ return tokenHeaders(baseUrl, readSettingsRaw());
214
+ }
215
+
216
+ class WorkerClient {
217
+ static BASE_URL = getWorkerBaseUrl();
218
+ static SUPPORTED = !readWorkerSetting("CLAUDE_MEM_WORKER_URL");
219
+ static DATABASE_PATH = resolveDatabasePath(process.env, readSettingsRaw(), homedir2());
220
+ static get(path) {
221
+ if (!this.SUPPORTED) {
222
+ return Promise.reject(new Error("CLAUDE_MEM_WORKER_URL is not supported"));
340
223
  }
224
+ return fetch(`${this.BASE_URL}${path}`, {
225
+ headers: workerHeaders(this.BASE_URL),
226
+ redirect: "error"
227
+ });
341
228
  }
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
- }
229
+ static getProjectCounts(project) {
230
+ return this.DATABASE_PATH && this.SUPPORTED ? readProjectCounts(this.DATABASE_PATH, project) : Promise.resolve(null);
369
231
  }
370
232
  static async getStats() {
371
233
  try {
372
234
  const [statsRes, processingRes] = await Promise.all([
373
- fetch(`${this.BASE_URL}/api/stats`),
374
- fetch(`${this.BASE_URL}/api/processing-status`)
235
+ this.get(`/api/stats`),
236
+ this.get(`/api/processing-status`)
375
237
  ]);
376
238
  if (!statsRes.ok) {
377
239
  return null;
@@ -386,7 +248,7 @@ class WorkerClient {
386
248
  static async getRecentSummaries(project, limit) {
387
249
  try {
388
250
  const params = new URLSearchParams({ project, limit: String(limit) });
389
- const response = await fetch(`${this.BASE_URL}/api/summaries?${params.toString()}`);
251
+ const response = await this.get(`/api/summaries?${params.toString()}`);
390
252
  if (!response.ok) {
391
253
  return [];
392
254
  }
@@ -399,7 +261,7 @@ class WorkerClient {
399
261
  return [];
400
262
  }
401
263
  const request = typeof item["request"] === "string" ? item["request"].trim() : "";
402
- return request ? [{ id: item["id"], request }] : [];
264
+ return request ? [{ id: item["id"], request, at: readEpoch(item) }] : [];
403
265
  });
404
266
  } catch {
405
267
  return [];
@@ -408,7 +270,7 @@ class WorkerClient {
408
270
  static async getRecentObservations(project, limit) {
409
271
  try {
410
272
  const params = new URLSearchParams({ project, limit: String(limit) });
411
- const response = await fetch(`${this.BASE_URL}/api/observations?${params.toString()}`);
273
+ const response = await this.get(`/api/observations?${params.toString()}`);
412
274
  if (!response.ok) {
413
275
  return [];
414
276
  }
@@ -422,34 +284,29 @@ class WorkerClient {
422
284
  }
423
285
  const title = typeof item["title"] === "string" ? item["title"].trim() : "";
424
286
  const type = typeof item["type"] === "string" ? item["type"] : "";
425
- return title ? [{ id: item["id"], type, title }] : [];
287
+ return title ? [{ id: item["id"], type, title, at: readEpoch(item) }] : [];
426
288
  });
427
289
  } catch {
428
290
  return [];
429
291
  }
430
292
  }
431
- static async search(options) {
293
+ static async getDetail(kind, id) {
432
294
  try {
433
- const params = buildSearchParams(options);
434
- const response = await fetch(`${this.BASE_URL}/api/search?${params.toString()}`);
295
+ const path = kind === "observation" ? "observation" : "session";
296
+ const response = await this.get(`/api/${path}/${id}`);
435
297
  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()) || "";
298
+ return null;
441
299
  }
442
- const text = await response.text();
443
- return text.trim();
300
+ const payload = await response.json();
301
+ return isRecord(payload) ? payload : null;
444
302
  } catch {
445
- return "";
303
+ return null;
446
304
  }
447
305
  }
448
306
  }
449
307
 
450
308
  // src/sidebar-model.ts
451
309
  var POLL_INTERVAL_MS = 5000;
452
- var LABEL_MAX = 40;
453
310
  var RECENT_SUMMARIES = 3;
454
311
  var RECENT_OBSERVATIONS = 3;
455
312
  var OBSERVATION_TYPE_ICONS = {
@@ -478,9 +335,37 @@ var HTML_ENTITIES = {
478
335
  function decodeEntities(value) {
479
336
  return value.replace(/&(?:lt|gt|quot|#39|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
480
337
  }
481
- function truncate(value) {
338
+ function stripProjectName(value, project) {
482
339
  const decoded = decodeEntities(value);
483
- return decoded.length <= LABEL_MAX ? decoded : `${decoded.slice(0, LABEL_MAX - 1)}\u2026`;
340
+ const name = project.trim();
341
+ if (!name) {
342
+ return decoded;
343
+ }
344
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
345
+ 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();
346
+ return stripped || decoded;
347
+ }
348
+ function formatAge(at, now) {
349
+ if (at === null || !Number.isFinite(at)) {
350
+ return "";
351
+ }
352
+ const minutes = Math.floor(Math.max(0, now - at) / 60000);
353
+ if (minutes < 1) {
354
+ return "just now";
355
+ }
356
+ if (minutes < 60) {
357
+ return `${minutes}m ago`;
358
+ }
359
+ const hours = Math.floor(minutes / 60);
360
+ if (hours < 24) {
361
+ return `${hours}h ago`;
362
+ }
363
+ const days = Math.floor(hours / 24);
364
+ if (days < 7) {
365
+ return `${days}d ago`;
366
+ }
367
+ const date = new Date(at);
368
+ return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
484
369
  }
485
370
  function formatCount(value) {
486
371
  if (value === null) {
@@ -492,6 +377,9 @@ function summaryLine(view) {
492
377
  if (!view.healthy) {
493
378
  return { label: "(offline)", tone: "err" };
494
379
  }
380
+ if (view.capture?.state === "problem") {
381
+ return { label: "(capture off)", tone: "err" };
382
+ }
495
383
  if (view.isProcessing || (view.queueDepth ?? 0) > 0) {
496
384
  return { label: `(queue ${view.queueDepth ?? 0})`, tone: "warn" };
497
385
  }
@@ -503,68 +391,102 @@ function toneColor(tone, theme) {
503
391
  }
504
392
  return tone === "warn" ? theme.warning : theme.textMuted;
505
393
  }
506
- function bulletRow(icon, iconColor, label, theme) {
394
+ function label(color, value) {
395
+ return text({ fg: color, wrapMode: "none", truncate: true }, value);
396
+ }
397
+ function bulletRow(icon, iconColor, value, theme, labelColor = theme.text) {
507
398
  return box({ flexDirection: "row", gap: 1 }, [
508
399
  text({ fg: iconColor, flexShrink: 0 }, icon),
509
- text({ fg: theme.text, wrapMode: "none" }, truncate(label))
400
+ label(labelColor, decodeEntities(value))
510
401
  ]);
511
402
  }
512
- function expandedRows(view, theme) {
403
+ function recentRow(icon, iconColor, title, age, theme, onSelect) {
404
+ return box({ flexDirection: "column", ...onSelect ? { onMouseDown: onSelect } : {} }, [
405
+ bulletRow(icon, iconColor, title, theme),
406
+ ...age ? [box({ paddingLeft: 2 }, [label(theme.textMuted, age)])] : []
407
+ ]);
408
+ }
409
+ function expandedRows(view, theme, onSelect) {
410
+ const notice = view.capture?.message ? [
411
+ box({ flexDirection: "row", gap: 1 }, [
412
+ text({ fg: theme.warning, flexShrink: 0 }, "!"),
413
+ text({
414
+ fg: view.capture.state === "problem" ? theme.error : theme.warning,
415
+ wrapMode: "word"
416
+ }, view.capture.message)
417
+ ])
418
+ ] : [];
513
419
  if (!view.healthy) {
514
- return [bulletRow("\u2022", theme.error, "worker offline", theme)];
420
+ return [bulletRow("\u2022", theme.error, "worker offline", theme), ...notice];
515
421
  }
422
+ const counts = `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)} \xB7 ses ${formatCount(view.sessions)}`;
516
423
  const rows = [
517
- bulletRow("\u2022", theme.success, `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)} \xB7 ses ${formatCount(view.sessions)}`, theme)
424
+ ...notice,
425
+ bulletRow("\u2022", theme.success, view.scope === "all" ? `all \xB7 ${counts}` : counts, theme)
518
426
  ];
427
+ if (view.scope === "project") {
428
+ rows.push(bulletRow("\u2022", theme.textMuted, `all ${formatCount(view.totalObservations)} obs`, theme, theme.textMuted));
429
+ }
519
430
  if (view.isProcessing || (view.queueDepth ?? 0) > 0) {
520
431
  rows.push(bulletRow("\u2022", theme.warning, `processing \xB7 queue ${view.queueDepth ?? 0}`, theme));
521
432
  }
522
433
  if (view.recentSummaries.length > 0) {
523
434
  rows.push(text({ fg: theme.textMuted }, "Recent sessions"));
524
435
  for (const summary of view.recentSummaries) {
525
- rows.push(bulletRow("\u2022", theme.textMuted, summary.request, theme));
436
+ rows.push(recentRow("\u2022", theme.textMuted, stripProjectName(summary.request, view.project), formatAge(summary.at, view.asOf), theme, onSelect && (() => onSelect("summary", summary.id))));
526
437
  }
527
438
  }
528
439
  if (view.recentObservations.length > 0) {
529
440
  rows.push(text({ fg: theme.textMuted }, "Latest"));
530
441
  for (const observation of view.recentObservations) {
531
442
  const icon = OBSERVATION_TYPE_ICONS[observation.type] ?? "\u25CB";
532
- rows.push(bulletRow(icon, theme.info, observation.title, theme));
443
+ 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
444
  }
534
445
  }
446
+ if (onSelect && view.recentSummaries.length + view.recentObservations.length > 0) {
447
+ rows.push(label(theme.textMuted, "Click an item for details"));
448
+ }
535
449
  return rows;
536
450
  }
537
- function buildMemNodes(view, theme, collapsed, onToggle) {
451
+ function buildMemNodes(view, theme, collapsed, onToggle, onSelect) {
538
452
  const summary = summaryLine(view);
539
453
  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)] : []
454
+ text({ fg: theme.text, flexShrink: 0 }, collapsed ? "\u25B6" : "\u25BC"),
455
+ text({ fg: theme.info, attributes: 1, flexShrink: 0 }, "Memory"),
456
+ collapsed ? text({ fg: toneColor(summary.tone, theme) }, summary.label) : label(theme.textMuted, view.project)
543
457
  ]);
544
458
  return [
545
459
  box({ flexDirection: "column", paddingTop: 1 }, [
546
460
  header,
547
- ...collapsed ? [] : [box({ flexDirection: "column", paddingLeft: 2 }, expandedRows(view, theme))]
461
+ ...collapsed ? [] : [box({ flexDirection: "column", paddingLeft: 2 }, expandedRows(view, theme, onSelect))]
548
462
  ])
549
463
  ];
550
464
  }
551
465
  function viewKey(view) {
552
466
  return JSON.stringify(view);
553
467
  }
554
- function readCount(value) {
468
+ function readCount2(value) {
555
469
  return typeof value === "number" && Number.isFinite(value) ? value : null;
556
470
  }
557
- async function readMemView(project, collapsed) {
558
- const stats = await WorkerClient.getStats();
471
+ async function readMemView(project, collapsed, directory = "") {
472
+ const [stats, counts] = await Promise.all([
473
+ WorkerClient.getStats(),
474
+ WorkerClient.getProjectCounts(project)
475
+ ]);
476
+ const capture = directory ? readCaptureStatus(directory) : null;
559
477
  if (!stats) {
560
478
  return {
561
479
  healthy: false,
480
+ capture,
562
481
  project,
482
+ scope: "all",
483
+ totalObservations: null,
563
484
  observations: null,
564
485
  sessions: null,
565
486
  summaries: null,
566
487
  queueDepth: null,
567
488
  isProcessing: false,
489
+ asOf: 0,
568
490
  recentSummaries: [],
569
491
  recentObservations: []
570
492
  };
@@ -573,14 +495,19 @@ async function readMemView(project, collapsed) {
573
495
  WorkerClient.getRecentSummaries(project, RECENT_SUMMARIES),
574
496
  WorkerClient.getRecentObservations(project, RECENT_OBSERVATIONS)
575
497
  ]);
498
+ const totalObservations = readCount2(stats.database?.observations);
576
499
  return {
577
500
  healthy: true,
501
+ capture,
578
502
  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),
503
+ scope: counts ? "project" : "all",
504
+ totalObservations,
505
+ observations: counts ? counts.observations : totalObservations,
506
+ sessions: counts ? counts.sessions : readCount2(stats.database?.sessions),
507
+ summaries: counts ? counts.summaries : readCount2(stats.database?.summaries),
508
+ queueDepth: readCount2(stats.processing?.queueDepth),
583
509
  isProcessing: stats.processing?.isProcessing === true,
510
+ asOf: Math.floor(Date.now() / 60000) * 60000,
584
511
  recentSummaries,
585
512
  recentObservations
586
513
  };
@@ -642,8 +569,9 @@ var tuiModule = {
642
569
  }
643
570
  ];
644
571
  };
645
- const project = projectNameFromDirectory(String(api.state?.path?.directory ?? ""));
646
- const initialView = await readMemView(project, true);
572
+ const directory = String(api.state?.path?.directory ?? "");
573
+ const project = projectNameFromDirectory(directory);
574
+ const initialView = await readMemView(project, true, directory);
647
575
  const [collapsed, setCollapsed] = signal(true);
648
576
  const [view, setView] = signal(initialView);
649
577
  let currentKey = viewKey(initialView);
@@ -651,7 +579,7 @@ var tuiModule = {
651
579
  let inFlight = false;
652
580
  let timer = null;
653
581
  const refresh = async () => {
654
- const nextView = await readMemView(project, collapsed());
582
+ const nextView = await readMemView(project, collapsed(), directory);
655
583
  const nextKey = viewKey(nextView);
656
584
  if (nextKey !== currentKey) {
657
585
  currentKey = nextKey;