@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.
- package/README.md +127 -594
- package/dist/cli.js +217 -342
- package/dist/sidebar-model.d.ts +29 -4
- package/dist/tui.js +127 -341
- package/dist/worker-client.d.ts +24 -89
- package/package.json +8 -25
- package/dist/file-context.d.ts +0 -17
- package/dist/index.d.ts +0 -20
- package/dist/index.js +0 -13844
- package/dist/memory-options.d.ts +0 -12
- package/dist/memory-session.d.ts +0 -22
- package/dist/memory-turn.d.ts +0 -10
- package/dist/memory-worker.d.ts +0 -21
- package/dist/package-server.d.ts +0 -7
- package/dist/package-server.js +0 -10
- package/dist/shared.d.ts +0 -14
- package/dist/tui-registration.d.ts +0 -16
- package/dist/v2.d.ts +0 -22
- package/dist/v2.js +0 -5133
package/dist/sidebar-model.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
static
|
|
151
|
-
|
|
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
|
|
168
|
+
static async getDetail(kind, id) {
|
|
432
169
|
try {
|
|
433
|
-
const
|
|
434
|
-
const response = await fetch(`${this.BASE_URL}/api
|
|
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
|
|
443
|
-
return
|
|
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
|
|
213
|
+
function stripProjectName(value, project) {
|
|
482
214
|
const decoded = decodeEntities(value);
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
};
|