@guckdev/core 0.1.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 (62) hide show
  1. package/dist/config.d.ts +19 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +178 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/index.d.ts +7 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +7 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/redact.d.ts +3 -0
  10. package/dist/redact.d.ts.map +1 -0
  11. package/dist/redact.js +68 -0
  12. package/dist/redact.js.map +1 -0
  13. package/dist/schema.d.ts +113 -0
  14. package/dist/schema.d.ts.map +1 -0
  15. package/dist/schema.js +2 -0
  16. package/dist/schema.js.map +1 -0
  17. package/dist/store/backends/cloudwatch.d.ts +4 -0
  18. package/dist/store/backends/cloudwatch.d.ts.map +1 -0
  19. package/dist/store/backends/cloudwatch.js +302 -0
  20. package/dist/store/backends/cloudwatch.js.map +1 -0
  21. package/dist/store/backends/k8s.d.ts +4 -0
  22. package/dist/store/backends/k8s.d.ts.map +1 -0
  23. package/dist/store/backends/k8s.js +307 -0
  24. package/dist/store/backends/k8s.js.map +1 -0
  25. package/dist/store/backends/local.d.ts +10 -0
  26. package/dist/store/backends/local.d.ts.map +1 -0
  27. package/dist/store/backends/local.js +31 -0
  28. package/dist/store/backends/local.js.map +1 -0
  29. package/dist/store/backends/types.d.ts +25 -0
  30. package/dist/store/backends/types.d.ts.map +1 -0
  31. package/dist/store/backends/types.js +2 -0
  32. package/dist/store/backends/types.js.map +1 -0
  33. package/dist/store/file-store.d.ts +21 -0
  34. package/dist/store/file-store.d.ts.map +1 -0
  35. package/dist/store/file-store.js +169 -0
  36. package/dist/store/file-store.js.map +1 -0
  37. package/dist/store/filters.d.ts +4 -0
  38. package/dist/store/filters.d.ts.map +1 -0
  39. package/dist/store/filters.js +73 -0
  40. package/dist/store/filters.js.map +1 -0
  41. package/dist/store/read-store.d.ts +35 -0
  42. package/dist/store/read-store.d.ts.map +1 -0
  43. package/dist/store/read-store.js +256 -0
  44. package/dist/store/read-store.js.map +1 -0
  45. package/dist/store/time.d.ts +4 -0
  46. package/dist/store/time.d.ts.map +1 -0
  47. package/dist/store/time.js +47 -0
  48. package/dist/store/time.js.map +1 -0
  49. package/package.json +38 -0
  50. package/src/config.ts +210 -0
  51. package/src/index.ts +6 -0
  52. package/src/redact.ts +83 -0
  53. package/src/schema.ts +130 -0
  54. package/src/store/backends/cloudwatch.ts +373 -0
  55. package/src/store/backends/k8s.ts +400 -0
  56. package/src/store/backends/local.ts +47 -0
  57. package/src/store/backends/types.ts +18 -0
  58. package/src/store/file-store.ts +217 -0
  59. package/src/store/filters.ts +83 -0
  60. package/src/store/read-store.ts +340 -0
  61. package/src/store/time.ts +54 -0
  62. package/tsconfig.json +19 -0
@@ -0,0 +1,400 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import {
4
+ GuckEvent,
5
+ GuckSearchParams,
6
+ GuckSessionsParams,
7
+ GuckStatsParams,
8
+ GuckK8sReadBackendConfig,
9
+ } from "../../schema.js";
10
+ import { eventMatches, normalizeLevel } from "../filters.js";
11
+ import { normalizeTimestamp, parseTimeInput } from "../time.js";
12
+ import { ReadBackend, SearchResult, SessionsResult, StatsResult } from "./types.js";
13
+
14
+ type K8sClient = {
15
+ listNamespacedPod: (
16
+ namespace: string,
17
+ pretty?: string,
18
+ allowWatchBookmarks?: boolean,
19
+ _continue?: string,
20
+ fieldSelector?: string,
21
+ labelSelector?: string,
22
+ ) => Promise<{ body: { items: Array<{ metadata?: { name?: string } }> } }>;
23
+ readNamespacedPodLog: (
24
+ name: string,
25
+ namespace: string,
26
+ container?: string,
27
+ follow?: boolean,
28
+ pretty?: string,
29
+ previous?: boolean,
30
+ sinceSeconds?: number,
31
+ sinceTime?: string,
32
+ timestamps?: boolean,
33
+ tailLines?: number,
34
+ limitBytes?: number,
35
+ ) => Promise<{ body: string }>;
36
+ };
37
+
38
+ const inferLevel = (message?: string): "fatal" | "error" | "warn" | "info" | "debug" | "trace" => {
39
+ const text = message?.toLowerCase() ?? "";
40
+ if (text.includes("fatal")) {
41
+ return "fatal";
42
+ }
43
+ if (text.includes("error")) {
44
+ return "error";
45
+ }
46
+ if (text.includes("warn")) {
47
+ return "warn";
48
+ }
49
+ if (text.includes("debug")) {
50
+ return "debug";
51
+ }
52
+ if (text.includes("trace")) {
53
+ return "trace";
54
+ }
55
+ return "info";
56
+ };
57
+
58
+ const deriveService = (selector: string): string | undefined => {
59
+ const first = selector.split(",")[0]?.trim();
60
+ if (!first) {
61
+ return undefined;
62
+ }
63
+ const parts = first.split("=");
64
+ if (parts.length === 2 && parts[1]) {
65
+ return parts[1].trim();
66
+ }
67
+ return undefined;
68
+ };
69
+
70
+ const isGuckLikeObject = (value: Record<string, unknown>): boolean => {
71
+ const keys = [
72
+ "id",
73
+ "ts",
74
+ "level",
75
+ "type",
76
+ "service",
77
+ "run_id",
78
+ "session_id",
79
+ "message",
80
+ "data",
81
+ "tags",
82
+ "trace_id",
83
+ "span_id",
84
+ "source",
85
+ ];
86
+ return keys.some((key) => key in value);
87
+ };
88
+
89
+ const extractTags = (value: unknown): Record<string, string> | undefined => {
90
+ if (!value || typeof value !== "object") {
91
+ return undefined;
92
+ }
93
+ const record: Record<string, string> = {};
94
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
95
+ if (typeof entry === "string") {
96
+ record[key] = entry;
97
+ }
98
+ }
99
+ return Object.keys(record).length > 0 ? record : undefined;
100
+ };
101
+
102
+ const extractData = (
103
+ value: Record<string, unknown>,
104
+ fallback: Record<string, unknown>,
105
+ ): Record<string, unknown> | undefined => {
106
+ if (value.data && typeof value.data === "object" && value.data !== null) {
107
+ return value.data as Record<string, unknown>;
108
+ }
109
+ const knownKeys = new Set([
110
+ "id",
111
+ "ts",
112
+ "level",
113
+ "type",
114
+ "service",
115
+ "run_id",
116
+ "session_id",
117
+ "message",
118
+ "data",
119
+ "tags",
120
+ "trace_id",
121
+ "span_id",
122
+ "source",
123
+ ]);
124
+ const extra: Record<string, unknown> = {};
125
+ for (const [key, entry] of Object.entries(value)) {
126
+ if (!knownKeys.has(key)) {
127
+ extra[key] = entry;
128
+ }
129
+ }
130
+ if (Object.keys(extra).length === 0) {
131
+ return fallback;
132
+ }
133
+ return extra;
134
+ };
135
+
136
+ const normalizeSource = (
137
+ source: unknown,
138
+ backendId?: string,
139
+ ): { kind: "mcp"; backend: "k8s"; backend_id?: string } & Record<string, unknown> => {
140
+ const base =
141
+ source && typeof source === "object" ? (source as Record<string, unknown>) : {};
142
+ return {
143
+ ...base,
144
+ kind: "mcp",
145
+ backend: "k8s",
146
+ backend_id: backendId ?? (base.backend_id as string | undefined),
147
+ };
148
+ };
149
+
150
+ const toEvent = (
151
+ config: GuckK8sReadBackendConfig,
152
+ message: string,
153
+ ts: string,
154
+ pod: string,
155
+ container?: string,
156
+ ): GuckEvent => {
157
+ const trimmed = message.trim();
158
+ const service = config.service ?? deriveService(config.selector) ?? "k8s";
159
+ const runId = pod;
160
+ const fallbackData: Record<string, unknown> = { pod, container, raw_message: message };
161
+ const fallback: GuckEvent = {
162
+ id: randomUUID(),
163
+ ts,
164
+ level: inferLevel(message),
165
+ type: "log",
166
+ service,
167
+ run_id: runId,
168
+ message,
169
+ data: fallbackData,
170
+ source: normalizeSource(undefined, config.id),
171
+ };
172
+
173
+ if (!trimmed.startsWith("{")) {
174
+ return fallback;
175
+ }
176
+
177
+ try {
178
+ const parsed = JSON.parse(trimmed);
179
+ if (!parsed || typeof parsed !== "object") {
180
+ return fallback;
181
+ }
182
+ const record = parsed as Record<string, unknown>;
183
+ if (!isGuckLikeObject(record)) {
184
+ return fallback;
185
+ }
186
+
187
+ const level = normalizeLevel(record.level as string | undefined) ?? inferLevel(message);
188
+ const eventMessage = typeof record.message === "string" ? record.message : message;
189
+ const source = normalizeSource(record.source, config.id);
190
+ return {
191
+ id: typeof record.id === "string" ? record.id : fallback.id,
192
+ ts: typeof record.ts === "string" ? record.ts : ts,
193
+ level,
194
+ type: typeof record.type === "string" ? record.type : "log",
195
+ service: typeof record.service === "string" ? record.service : service,
196
+ run_id: typeof record.run_id === "string" ? record.run_id : runId,
197
+ session_id: typeof record.session_id === "string" ? record.session_id : undefined,
198
+ message: eventMessage,
199
+ data: extractData(record, fallbackData),
200
+ tags: extractTags(record.tags),
201
+ trace_id: typeof record.trace_id === "string" ? record.trace_id : undefined,
202
+ span_id: typeof record.span_id === "string" ? record.span_id : undefined,
203
+ source,
204
+ };
205
+ } catch {
206
+ return fallback;
207
+ }
208
+ };
209
+
210
+ const requireModule = createRequire(import.meta.url);
211
+
212
+ const loadClient = (context?: string): K8sClient => {
213
+ let module: {
214
+ KubeConfig: new () => {
215
+ loadFromDefault: () => void;
216
+ setCurrentContext: (ctx: string) => void;
217
+ makeApiClient: (api: unknown) => unknown;
218
+ };
219
+ CoreV1Api: new () => unknown;
220
+ };
221
+ try {
222
+ module = requireModule("@kubernetes/client-node");
223
+ } catch {
224
+ throw new Error(
225
+ "Kubernetes backend requires @kubernetes/client-node. Install it to enable this backend.",
226
+ );
227
+ }
228
+
229
+ const { KubeConfig, CoreV1Api } = module;
230
+ const kc = new KubeConfig();
231
+ kc.loadFromDefault();
232
+ if (context) {
233
+ kc.setCurrentContext(context);
234
+ }
235
+ const client = kc.makeApiClient(CoreV1Api) as unknown as K8sClient;
236
+ return client;
237
+ };
238
+
239
+ const parseLogLine = (line: string): { ts: string; message: string } => {
240
+ const trimmed = line.trim();
241
+ if (!trimmed) {
242
+ return { ts: new Date().toISOString(), message: "" };
243
+ }
244
+ const space = trimmed.indexOf(" ");
245
+ if (space > 0) {
246
+ const possibleTs = trimmed.slice(0, space);
247
+ const parsed = Date.parse(possibleTs);
248
+ if (!Number.isNaN(parsed)) {
249
+ return { ts: new Date(parsed).toISOString(), message: trimmed.slice(space + 1) };
250
+ }
251
+ }
252
+ return { ts: new Date().toISOString(), message: trimmed };
253
+ };
254
+
255
+ export const createK8sBackend = (config: GuckK8sReadBackendConfig): ReadBackend => {
256
+ let client: K8sClient | null = null;
257
+ const getClient = (): K8sClient => {
258
+ if (!client) {
259
+ client = loadClient(config.context);
260
+ }
261
+ return client;
262
+ };
263
+
264
+ const fetchEvents = async (params: GuckSearchParams): Promise<SearchResult> => {
265
+ const client = getClient();
266
+ const sinceMs = params.since ? parseTimeInput(params.since) : undefined;
267
+ const untilMs = params.until ? parseTimeInput(params.until) : undefined;
268
+ const sinceSeconds =
269
+ sinceMs && sinceMs < Date.now()
270
+ ? Math.max(1, Math.floor((Date.now() - sinceMs) / 1000))
271
+ : undefined;
272
+ const limit = params.limit ?? 200;
273
+ const events: GuckEvent[] = [];
274
+ let truncated = false;
275
+
276
+ const pods = await client.listNamespacedPod(
277
+ config.namespace,
278
+ undefined,
279
+ undefined,
280
+ undefined,
281
+ undefined,
282
+ config.selector,
283
+ );
284
+
285
+ for (const pod of pods.body.items) {
286
+ const podName = pod.metadata?.name;
287
+ if (!podName) {
288
+ continue;
289
+ }
290
+ const response = await client.readNamespacedPodLog(
291
+ podName,
292
+ config.namespace,
293
+ config.container,
294
+ false,
295
+ undefined,
296
+ undefined,
297
+ sinceSeconds,
298
+ undefined,
299
+ true,
300
+ );
301
+ const lines = response.body.split(/\r?\n/);
302
+ for (const line of lines) {
303
+ if (!line.trim()) {
304
+ continue;
305
+ }
306
+ const { ts, message } = parseLogLine(line);
307
+ const event = toEvent(config, message, ts, podName, config.container);
308
+ if (!eventMatches(event, params, sinceMs, untilMs)) {
309
+ continue;
310
+ }
311
+ events.push(event);
312
+ if (events.length >= limit) {
313
+ truncated = true;
314
+ break;
315
+ }
316
+ }
317
+ if (truncated) {
318
+ break;
319
+ }
320
+ }
321
+
322
+ return { events, truncated };
323
+ };
324
+
325
+ const stats = async (params: GuckStatsParams): Promise<StatsResult> => {
326
+ const searchParams: GuckSearchParams = {
327
+ service: params.service,
328
+ session_id: params.session_id,
329
+ since: params.since,
330
+ until: params.until,
331
+ limit: params.limit ?? 1000,
332
+ };
333
+ const result = await fetchEvents(searchParams);
334
+ const buckets = new Map<string, number>();
335
+ for (const event of result.events) {
336
+ let key = "unknown";
337
+ if (params.group_by === "type") {
338
+ key = event.type;
339
+ } else if (params.group_by === "level") {
340
+ key = event.level;
341
+ } else if (params.group_by === "stage") {
342
+ const stage = (event.data as Record<string, unknown> | undefined)?.stage;
343
+ key = typeof stage === "string" ? stage : "unknown";
344
+ }
345
+ buckets.set(key, (buckets.get(key) ?? 0) + 1);
346
+ }
347
+ const limit = params.limit ?? 200;
348
+ const sorted = [...buckets.entries()]
349
+ .sort((a, b) => b[1] - a[1])
350
+ .slice(0, limit)
351
+ .map(([key, count]) => ({ key, count }));
352
+ return { buckets: sorted };
353
+ };
354
+
355
+ const sessions = async (params: GuckSessionsParams): Promise<SessionsResult> => {
356
+ const searchParams: GuckSearchParams = {
357
+ service: params.service,
358
+ since: params.since,
359
+ limit: params.limit ?? 1000,
360
+ };
361
+ const result = await fetchEvents(searchParams);
362
+ const sessions = new Map<
363
+ string,
364
+ { session_id: string; last_ts: string; event_count: number; error_count: number }
365
+ >();
366
+ for (const event of result.events) {
367
+ if (!event.session_id) {
368
+ continue;
369
+ }
370
+ const existing = sessions.get(event.session_id) ?? {
371
+ session_id: event.session_id,
372
+ last_ts: event.ts,
373
+ event_count: 0,
374
+ error_count: 0,
375
+ };
376
+ existing.event_count += 1;
377
+ if (event.level === "error" || event.level === "fatal") {
378
+ existing.error_count += 1;
379
+ }
380
+ const existingTs = normalizeTimestamp(existing.last_ts) ?? 0;
381
+ const eventTs = normalizeTimestamp(event.ts) ?? 0;
382
+ if (eventTs > existingTs) {
383
+ existing.last_ts = event.ts;
384
+ }
385
+ sessions.set(event.session_id, existing);
386
+ }
387
+
388
+ const limit = params.limit ?? 200;
389
+ const sorted = [...sessions.values()]
390
+ .sort((a, b) => (normalizeTimestamp(b.last_ts) ?? 0) - (normalizeTimestamp(a.last_ts) ?? 0))
391
+ .slice(0, limit);
392
+ return { sessions: sorted };
393
+ };
394
+
395
+ return {
396
+ search: fetchEvents,
397
+ stats,
398
+ sessions,
399
+ };
400
+ };
@@ -0,0 +1,47 @@
1
+ import {
2
+ GuckConfig,
3
+ GuckEvent,
4
+ GuckSearchParams,
5
+ GuckSessionsParams,
6
+ GuckStatsParams,
7
+ } from "../../schema.js";
8
+ import { listSessions, searchEvents, statsEvents } from "../file-store.js";
9
+ import { ReadBackend, SearchResult, SessionsResult, StatsResult } from "./types.js";
10
+
11
+ type LocalBackendOptions = {
12
+ storeDir: string;
13
+ config: GuckConfig;
14
+ backendId?: string;
15
+ };
16
+
17
+ const tagLocalSource = (event: GuckEvent, backendId?: string): GuckEvent => {
18
+ const source = event.source ?? { kind: "mcp" as const };
19
+ return {
20
+ ...event,
21
+ source: {
22
+ ...source,
23
+ backend: "local",
24
+ backend_id: backendId ?? source.backend_id,
25
+ },
26
+ };
27
+ };
28
+
29
+ export const createLocalBackend = (options: LocalBackendOptions): ReadBackend => {
30
+ const { storeDir, config, backendId } = options;
31
+
32
+ return {
33
+ search: async (params: GuckSearchParams): Promise<SearchResult> => {
34
+ const result = await searchEvents(storeDir, config, params);
35
+ return {
36
+ ...result,
37
+ events: result.events.map((event) => tagLocalSource(event, backendId)),
38
+ };
39
+ },
40
+ stats: async (params: GuckStatsParams): Promise<StatsResult> => {
41
+ return statsEvents(storeDir, config, params);
42
+ },
43
+ sessions: async (params: GuckSessionsParams): Promise<SessionsResult> => {
44
+ return listSessions(storeDir, config, params);
45
+ },
46
+ };
47
+ };
@@ -0,0 +1,18 @@
1
+ import {
2
+ GuckEvent,
3
+ GuckSearchParams,
4
+ GuckStatsParams,
5
+ GuckSessionsParams,
6
+ } from "../../schema.js";
7
+
8
+ export type SearchResult = { events: GuckEvent[]; truncated: boolean };
9
+ export type StatsResult = { buckets: Array<{ key: string; count: number }> };
10
+ export type SessionsResult = {
11
+ sessions: Array<{ session_id: string; last_ts: string; event_count: number; error_count: number }>;
12
+ };
13
+
14
+ export type ReadBackend = {
15
+ search: (params: GuckSearchParams) => Promise<SearchResult>;
16
+ stats: (params: GuckStatsParams) => Promise<StatsResult>;
17
+ sessions: (params: GuckSessionsParams) => Promise<SessionsResult>;
18
+ };
@@ -0,0 +1,217 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import {
5
+ GuckConfig,
6
+ GuckEvent,
7
+ GuckSearchParams,
8
+ GuckSessionsParams,
9
+ GuckStatsParams,
10
+ } from "../schema.js";
11
+ import { parseTimeInput, normalizeTimestamp, formatDateSegment } from "./time.js";
12
+ import { eventMatches } from "./filters.js";
13
+
14
+ const ensureDir = async (dir: string): Promise<void> => {
15
+ await fs.promises.mkdir(dir, { recursive: true });
16
+ };
17
+
18
+ const getEventTimestamp = (event: GuckEvent): number | undefined => {
19
+ return normalizeTimestamp(event.ts);
20
+ };
21
+
22
+ const collectFiles = async (root: string, service?: string): Promise<string[]> => {
23
+ const storeRoot = service ? path.join(root, service) : root;
24
+ const result: string[] = [];
25
+ if (!fs.existsSync(storeRoot)) {
26
+ return result;
27
+ }
28
+
29
+ const stack: string[] = [storeRoot];
30
+ while (stack.length > 0) {
31
+ const current = stack.pop();
32
+ if (!current) {
33
+ continue;
34
+ }
35
+ const entries = await fs.promises.readdir(current, { withFileTypes: true });
36
+ for (const entry of entries) {
37
+ const fullPath = path.join(current, entry.name);
38
+ if (entry.isDirectory()) {
39
+ stack.push(fullPath);
40
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
41
+ result.push(fullPath);
42
+ }
43
+ }
44
+ }
45
+
46
+ return result;
47
+ };
48
+
49
+ const readJsonLines = async (
50
+ filePath: string,
51
+ onEvent: (event: GuckEvent) => boolean | void,
52
+ ): Promise<void> => {
53
+ const stream = fs.createReadStream(filePath, { encoding: "utf8" });
54
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
55
+ for await (const line of rl) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed) {
58
+ continue;
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(trimmed) as GuckEvent;
62
+ const shouldContinue = onEvent(parsed);
63
+ if (shouldContinue === false) {
64
+ rl.close();
65
+ break;
66
+ }
67
+ } catch {
68
+ // ignore malformed line
69
+ }
70
+ }
71
+ };
72
+
73
+ export const appendEvent = async (
74
+ storeDir: string,
75
+ event: GuckEvent,
76
+ ): Promise<string> => {
77
+ const dateSegment = formatDateSegment(new Date(event.ts));
78
+ const fileDir = path.join(storeDir, event.service, dateSegment);
79
+ await ensureDir(fileDir);
80
+ const filePath = path.join(fileDir, `${event.run_id}.jsonl`);
81
+ await fs.promises.appendFile(filePath, `${JSON.stringify(event)}\n`, "utf8");
82
+ return filePath;
83
+ };
84
+
85
+ export const searchEvents = async (
86
+ storeDir: string,
87
+ config: GuckConfig,
88
+ params: GuckSearchParams,
89
+ ): Promise<{ events: GuckEvent[]; truncated: boolean }> => {
90
+ const limit = params.limit ?? config.mcp.max_results;
91
+ const sinceMs = params.since ? parseTimeInput(params.since) : undefined;
92
+ const untilMs = params.until ? parseTimeInput(params.until) : undefined;
93
+ const events: GuckEvent[] = [];
94
+ let truncated = false;
95
+
96
+ const files = await collectFiles(storeDir, params.service);
97
+ for (const filePath of files) {
98
+ await readJsonLines(filePath, (event) => {
99
+ if (!eventMatches(event, params, sinceMs, untilMs)) {
100
+ return true;
101
+ }
102
+ events.push(event);
103
+ if (events.length >= limit) {
104
+ truncated = true;
105
+ return false;
106
+ }
107
+ return true;
108
+ });
109
+ if (truncated) {
110
+ break;
111
+ }
112
+ }
113
+
114
+ return { events, truncated };
115
+ };
116
+
117
+ export const statsEvents = async (
118
+ storeDir: string,
119
+ config: GuckConfig,
120
+ params: GuckStatsParams,
121
+ ): Promise<{ buckets: Array<{ key: string; count: number }> }> => {
122
+ const limit = params.limit ?? config.mcp.max_results;
123
+ const sinceMs = params.since ? parseTimeInput(params.since) : undefined;
124
+ const untilMs = params.until ? parseTimeInput(params.until) : undefined;
125
+ const buckets = new Map<string, number>();
126
+
127
+ const files = await collectFiles(storeDir, params.service);
128
+ for (const filePath of files) {
129
+ await readJsonLines(filePath, (event) => {
130
+ const match = eventMatches(
131
+ event,
132
+ {
133
+ service: params.service,
134
+ session_id: params.session_id,
135
+ types: undefined,
136
+ levels: undefined,
137
+ },
138
+ sinceMs,
139
+ untilMs,
140
+ );
141
+ if (!match) {
142
+ return true;
143
+ }
144
+ let key = "unknown";
145
+ if (params.group_by === "type") {
146
+ key = event.type;
147
+ } else if (params.group_by === "level") {
148
+ key = event.level;
149
+ } else if (params.group_by === "stage") {
150
+ const stage = (event.data as Record<string, unknown> | undefined)?.stage;
151
+ key = typeof stage === "string" ? stage : "unknown";
152
+ }
153
+ buckets.set(key, (buckets.get(key) ?? 0) + 1);
154
+ return true;
155
+ });
156
+ }
157
+
158
+ const sorted = [...buckets.entries()]
159
+ .sort((a, b) => b[1] - a[1])
160
+ .slice(0, limit)
161
+ .map(([key, count]) => ({ key, count }));
162
+
163
+ return { buckets: sorted };
164
+ };
165
+
166
+ export const listSessions = async (
167
+ storeDir: string,
168
+ config: GuckConfig,
169
+ params: GuckSessionsParams,
170
+ ): Promise<{
171
+ sessions: Array<{ session_id: string; last_ts: string; event_count: number; error_count: number }>;
172
+ }> => {
173
+ const limit = params.limit ?? config.mcp.max_results;
174
+ const sinceMs = params.since ? parseTimeInput(params.since) : undefined;
175
+ const sessions = new Map<
176
+ string,
177
+ { session_id: string; last_ts: string; event_count: number; error_count: number }
178
+ >();
179
+
180
+ const files = await collectFiles(storeDir, params.service);
181
+ for (const filePath of files) {
182
+ await readJsonLines(filePath, (event) => {
183
+ if (params.service && event.service !== params.service) {
184
+ return true;
185
+ }
186
+ if (!event.session_id) {
187
+ return true;
188
+ }
189
+ const ts = getEventTimestamp(event);
190
+ if (sinceMs !== undefined && ts !== undefined && ts < sinceMs) {
191
+ return true;
192
+ }
193
+ const existing = sessions.get(event.session_id) ?? {
194
+ session_id: event.session_id,
195
+ last_ts: event.ts,
196
+ event_count: 0,
197
+ error_count: 0,
198
+ };
199
+ existing.event_count += 1;
200
+ if (event.level === "error" || event.level === "fatal") {
201
+ existing.error_count += 1;
202
+ }
203
+ const existingTs = normalizeTimestamp(existing.last_ts) ?? 0;
204
+ if (ts !== undefined && ts > existingTs) {
205
+ existing.last_ts = event.ts;
206
+ }
207
+ sessions.set(event.session_id, existing);
208
+ return true;
209
+ });
210
+ }
211
+
212
+ const sorted = [...sessions.values()]
213
+ .sort((a, b) => (normalizeTimestamp(b.last_ts) ?? 0) - (normalizeTimestamp(a.last_ts) ?? 0))
214
+ .slice(0, limit);
215
+
216
+ return { sessions: sorted };
217
+ };