@blogic-cz/agent-tools 0.15.2 → 0.15.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -25,6 +25,7 @@ import {
25
25
  prCloseCommand,
26
26
  prEditCommand,
27
27
  prMergeCommand,
28
+ prReadyCommand,
28
29
  prThreadsCommand,
29
30
  prCommentsCommand,
30
31
  prIssueCommentsCommand,
@@ -81,6 +82,7 @@ const prCommand = Command.make("pr", {}).pipe(
81
82
  prCloseCommand,
82
83
  prEditCommand,
83
84
  prMergeCommand,
85
+ prReadyCommand,
84
86
  prWaitMergeableCommand,
85
87
  prThreadsCommand,
86
88
  prCommentsCommand,
@@ -38,6 +38,7 @@ import {
38
38
  fetchFailedChecks,
39
39
  listPRs,
40
40
  mergePR,
41
+ readyPR,
41
42
  rerunChecks,
42
43
  triggerChecks,
43
44
  watchPRs,
@@ -644,6 +645,30 @@ export const prMergeCommand = Command.make(
644
645
  ),
645
646
  ).pipe(Command.withDescription("Merge a PR (dry-run by default, use --confirm to execute)"));
646
647
 
648
+ export const prReadyCommand = Command.make(
649
+ "ready",
650
+ {
651
+ format: formatOption,
652
+ pr: Flag.integer("pr").pipe(
653
+ Flag.withDescription("PR number (default: current branch PR)"),
654
+ Flag.optional,
655
+ ),
656
+ repo: repoOption,
657
+ },
658
+ ({ format, pr, repo }) =>
659
+ withRepo(
660
+ repo,
661
+ Effect.gen(function* () {
662
+ const result = yield* readyPR({ pr: Option.getOrNull(pr) });
663
+ yield* logFormatted(result, format);
664
+ }),
665
+ ),
666
+ ).pipe(
667
+ Command.withDescription(
668
+ "Mark a draft PR as ready for review (no-op if it's already ready for review)",
669
+ ),
670
+ );
671
+
647
672
  export const prChecksCommand = Command.make(
648
673
  "checks",
649
674
  {
@@ -972,6 +972,23 @@ export const closePR = Effect.fn("pr.closePR")(function* (opts: {
972
972
  return yield* viewPR(opts.pr);
973
973
  });
974
974
 
975
+ export const readyPR = Effect.fn("pr.readyPR")(function* (opts: { pr: number | null }) {
976
+ const gh = yield* GitHubService;
977
+
978
+ const info = yield* viewPR(opts.pr);
979
+
980
+ // Idempotent: a PR that's already ready needs no mutation, and `gh pr ready` on a
981
+ // non-draft PR is a no-op that would just add a pointless call.
982
+ if (!info.isDraft) {
983
+ return { ...info, wasAlreadyReady: true };
984
+ }
985
+
986
+ yield* gh.runGh(["pr", "ready", String(info.number)]);
987
+
988
+ const updated = yield* viewPR(info.number);
989
+ return { ...updated, wasAlreadyReady: false };
990
+ });
991
+
975
992
  export const editPR = Effect.fn("pr.editPR")(function* (opts: {
976
993
  pr: number;
977
994
  title: string | null;
@@ -13,6 +13,7 @@ export {
13
13
  prLastHumanReviewerCommand,
14
14
  prListCommand,
15
15
  prMergeCommand,
16
+ prReadyCommand,
16
17
  prReplyCommand,
17
18
  prRerunChecksCommand,
18
19
  prReplyAndResolveCommand,
@@ -67,6 +67,31 @@ async function discoverDatasources(url: string, token?: string): Promise<Grafana
67
67
  return (await response.json()) as GrafanaDatasource[];
68
68
  }
69
69
 
70
+ function findTempoUid(datasources: GrafanaDatasource[]): string | undefined {
71
+ return (
72
+ datasources.find((datasource) => datasource.uid === DEFAULT_TEMPO_UID)?.uid ??
73
+ datasources.find((datasource) => datasource.type === "tempo")?.uid
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Trace commands need a Tempo datasource. Fails with an explicit message instead of
79
+ * building a request against an undefined datasource UID.
80
+ */
81
+ export function requireTempoUid(
82
+ config: ObservabilityEnvConfig,
83
+ ): Effect.Effect<string, ObservabilityToolError> {
84
+ return config.tempoUid
85
+ ? Effect.succeed(config.tempoUid)
86
+ : Effect.fail(
87
+ new ObservabilityToolError({
88
+ cause: new Error(
89
+ "No Tempo datasource found on this Grafana instance - trace commands are unavailable (metrics query still works)",
90
+ ),
91
+ }),
92
+ );
93
+ }
94
+
70
95
  async function resolveFromProfile(
71
96
  profile: ObservabilityConfig | undefined,
72
97
  env: string,
@@ -79,13 +104,9 @@ async function resolveFromProfile(
79
104
  const token = resolveToken(environment.tokenEnvVar);
80
105
  const datasources = await discoverDatasources(environment.url, token);
81
106
 
82
- const tempoUid =
83
- datasources.find((datasource) => datasource.uid === DEFAULT_TEMPO_UID)?.uid ??
84
- datasources.find((datasource) => datasource.type === "tempo")?.uid;
85
-
86
- if (!tempoUid) {
87
- throw new Error(`No Tempo datasource found in observability.${env} config`);
88
- }
107
+ // Tempo is optional: metrics/logs work on Grafana instances without it (e.g. Percona PMM).
108
+ // Trace commands fail later with an explicit message via requireTempoUid().
109
+ const tempoUid = findTempoUid(datasources);
89
110
 
90
111
  return {
91
112
  url: environment.url,
@@ -146,17 +167,10 @@ export const resolveConfig = (env: string, profile: Option.Option<string>) =>
146
167
  const resolved = resolveFromEnv(env);
147
168
 
148
169
  const datasources = await discoverDatasources(resolved.url, resolved.token);
149
- const tempoUid =
150
- datasources.find((datasource) => datasource.uid === DEFAULT_TEMPO_UID)?.uid ??
151
- datasources.find((datasource) => datasource.type === "tempo")?.uid;
152
-
153
- if (!tempoUid) {
154
- throw new Error(`No Tempo datasource found for environment '${env}'`);
155
- }
156
170
 
157
171
  return {
158
172
  ...resolved,
159
- tempoUid,
173
+ tempoUid: findTempoUid(datasources),
160
174
  } satisfies ObservabilityEnvConfig;
161
175
  },
162
176
  catch: (cause) => new ObservabilityToolError({ cause }),
@@ -11,6 +11,7 @@ import {
11
11
  observabilityDsQuery,
12
12
  observabilityFetch,
13
13
  profileOption,
14
+ requireTempoUid,
14
15
  resolveConfig,
15
16
  } from "./shared";
16
17
  import { extractLogsFromDsQuery } from "./logs";
@@ -232,15 +233,18 @@ function searchTempoBySpanId(
232
233
  spanId: string,
233
234
  window: SearchWindow,
234
235
  ): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
235
- const now = Math.floor(Date.now() / 1000);
236
- const startEpoch = relativeToEpoch(window.start, now);
237
- const endEpoch = relativeToEpoch(window.end, now);
238
- const traceql = encodeURIComponent(`{ span:id = "${spanId}" }`);
239
- const searchUrl =
240
- `/api/datasources/proxy/uid/${config.tempoUid}/api/search` +
241
- `?q=${traceql}&start=${startEpoch}&end=${endEpoch}&limit=5`;
242
-
243
- return observabilityFetch<TempoSearchResponse>(config, searchUrl);
236
+ return Effect.gen(function* () {
237
+ const tempoUid = yield* requireTempoUid(config);
238
+ const now = Math.floor(Date.now() / 1000);
239
+ const startEpoch = relativeToEpoch(window.start, now);
240
+ const endEpoch = relativeToEpoch(window.end, now);
241
+ const traceql = encodeURIComponent(`{ span:id = "${spanId}" }`);
242
+ const searchUrl =
243
+ `/api/datasources/proxy/uid/${tempoUid}/api/search` +
244
+ `?q=${traceql}&start=${startEpoch}&end=${endEpoch}&limit=5`;
245
+
246
+ return yield* observabilityFetch<TempoSearchResponse>(config, searchUrl);
247
+ });
244
248
  }
245
249
 
246
250
  function fetchFullTrace(
@@ -248,9 +252,10 @@ function fetchFullTrace(
248
252
  traceId: string,
249
253
  ): Effect.Effect<FlattenedSpan[], ObservabilityToolError> {
250
254
  return Effect.gen(function* () {
255
+ const tempoUid = yield* requireTempoUid(config);
251
256
  const raw = yield* observabilityFetch<TempoTraceResponse>(
252
257
  config,
253
- `/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId}`,
258
+ `/api/datasources/proxy/uid/${tempoUid}/api/traces/${traceId}`,
254
259
  );
255
260
  return flattenTrace(raw);
256
261
  });
@@ -382,7 +387,7 @@ function handleTraceGet(
382
387
  data: {
383
388
  environment: env,
384
389
  grafanaUrl: config.url,
385
- tempoDatasourceUid: config.tempoUid,
390
+ tempoDatasourceUid: config.tempoUid ?? null,
386
391
  input: parsed,
387
392
  resolution,
388
393
  summary: summarizeTrace(resolution.resolvedTraceId, spans),
@@ -5,7 +5,8 @@ export type ObservabilityEnvConfig = {
5
5
  token?: string;
6
6
  prometheusUid: string;
7
7
  lokiUid: string;
8
- tempoUid: string;
8
+ /** Undefined when the Grafana instance has no Tempo datasource (e.g. Percona PMM). */
9
+ tempoUid?: string;
9
10
  };
10
11
 
11
12
  export type LogLine = {
@@ -13,11 +13,18 @@ import { Console, Effect, Layer, Result } from "effect";
13
13
 
14
14
  import type { MessageSummary, SessionResult, SessionSource } from "./types";
15
15
 
16
+ import { ALL_SESSION_SOURCES } from "./types";
17
+
16
18
  import { makeSchemaCommand, formatOption, formatOutput, VERSION } from "#shared";
17
19
  import { AuditServiceLayer, withAudit } from "#shared/audit";
18
20
  import { ResolvedPaths, ResolvedPathsLayer } from "./config";
19
21
  import { SessionStorageNotFoundError } from "./errors";
20
22
  import { formatDate, SessionService, SessionServiceLayer, truncate } from "./service";
23
+ import {
24
+ projectSessionFilter,
25
+ sessionSummariesFromMessages,
26
+ sortSessionSummaries,
27
+ } from "./summaries";
21
28
 
22
29
  const AppLayer = SessionServiceLayer.pipe(Layer.provideMerge(ResolvedPathsLayer));
23
30
 
@@ -31,19 +38,8 @@ const filterBySource = (summaries: MessageSummary[], source: string): MessageSum
31
38
  return summaries.filter((s) => s.source === (source as SessionSource));
32
39
  };
33
40
 
34
- const latestSessionSummaries = (summaries: MessageSummary[]): MessageSummary[] => {
35
- const bySession = new Map<string, MessageSummary>();
36
-
37
- for (const summary of summaries) {
38
- const key = `${summary.source}:${summary.sessionID}`;
39
- const previous = bySession.get(key);
40
- if (previous === undefined || summary.created > previous.created) {
41
- bySession.set(key, summary);
42
- }
43
- }
44
-
45
- return [...bySession.values()].toSorted((left, right) => right.created - left.created);
46
- };
41
+ const sourceSet = (source: string): ReadonlySet<SessionSource> =>
42
+ source === "all" ? ALL_SESSION_SOURCES : new Set([source as SessionSource]);
47
43
 
48
44
  const buildScopeLabel = (searchAll: boolean, currentDir: string) => {
49
45
  if (searchAll) {
@@ -97,9 +93,19 @@ const listCommand = Command.make(
97
93
  const scope = buildScopeLabel(all, currentDir);
98
94
 
99
95
  const result = yield* Effect.gen(function* () {
100
- const sessionFilter = all ? null : yield* sessionService.getSessionsForProject(currentDir);
96
+ const sources = sourceSet(source);
97
+ const projectSessions = new Map<SessionSource, Set<string>>();
98
+ if (!all) {
99
+ for (const sessionSource of sources) {
100
+ const matching = yield* sessionService.getSessionsForProject(
101
+ currentDir,
102
+ new Set([sessionSource]),
103
+ );
104
+ projectSessions.set(sessionSource, matching);
105
+ }
106
+ }
101
107
 
102
- if (sessionFilter !== null && sessionFilter.size === 0) {
108
+ if (!all && [...projectSessions.values()].every((sessions) => sessions.size === 0)) {
103
109
  return {
104
110
  success: false,
105
111
  error: "No sessions found for current project",
@@ -113,10 +119,28 @@ const listCommand = Command.make(
113
119
  } satisfies SessionResult;
114
120
  }
115
121
 
116
- const allSummaries = yield* sessionService.getMessageSummaries(sessionFilter);
117
- const summaries = latestSessionSummaries(filterBySource(allSummaries, source));
122
+ const nonPiSources = [...sources].filter((item) => item !== "pi");
123
+ const [messagesBySource, piSummaries] = yield* Effect.all([
124
+ Effect.all(
125
+ nonPiSources.map((sessionSource) =>
126
+ sessionService.getMessageSummaries(
127
+ projectSessionFilter(projectSessions, sessionSource, all),
128
+ new Set([sessionSource]),
129
+ ),
130
+ ),
131
+ ),
132
+ sources.has("pi")
133
+ ? sessionService.getPiSessionSummaries(projectSessionFilter(projectSessions, "pi", all))
134
+ : Effect.succeed([]),
135
+ ]);
136
+ const messages = messagesBySource.flat();
137
+ const summaries = sortSessionSummaries([
138
+ ...sessionSummariesFromMessages(messages),
139
+ ...piSummaries,
140
+ ]);
118
141
  const results = summaries.slice(0, limit).map((summary) => ({
119
- created: formatDate(summary.created),
142
+ createdAt: formatDate(summary.createdAt),
143
+ updatedAt: formatDate(summary.updatedAt),
120
144
  sessionID: summary.sessionID,
121
145
  title: summary.title,
122
146
  source: summary.source,
@@ -1,9 +1,17 @@
1
1
  import { Effect } from "effect";
2
+ // node:fs/promises, not Bun.file/Bun.Glob: vitest runs under Node, where the Bun globals do not
3
+ // exist, and every function below is reached by the suite. See AGENTS.md.
4
+ import { open, readFile, readdir, stat } from "node:fs/promises";
2
5
 
3
- import type { MessageSummary } from "./types";
6
+ import type { MessageSummary, SessionSummary } from "./types";
4
7
 
5
8
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
6
9
 
10
+ export const PI_HEADER_MAX_BYTES = 64 * 1024;
11
+ export const PI_SUMMARY_HEAD_MAX_BYTES = 256 * 1024;
12
+ export const PI_SUMMARY_TAIL_MAX_BYTES = 256 * 1024;
13
+ const PI_READ_CONCURRENCY = 16;
14
+
7
15
  export type PiContentBlock =
8
16
  | { type: "text"; text: string }
9
17
  | { type: string; [key: string]: unknown };
@@ -16,18 +24,33 @@ export type PiRecord =
16
24
  message: { role: string; content: string | ReadonlyArray<PiContentBlock> };
17
25
  };
18
26
 
27
+ export type PiSessionMetadata = SessionSummary & {
28
+ cwd: string | null;
29
+ bytesRead: number;
30
+ parsedLines: number;
31
+ };
32
+
19
33
  const isRecord = (value: unknown): value is Record<string, unknown> =>
20
34
  typeof value === "object" && value !== null;
21
35
 
22
- export const parsePiLine = (line: string): PiRecord | null => {
23
- let parsed: unknown;
36
+ const parseJsonRecord = (line: string): Record<string, unknown> | null => {
24
37
  try {
25
- parsed = JSON.parse(line);
38
+ const parsed: unknown = JSON.parse(line);
39
+ return isRecord(parsed) ? parsed : null;
26
40
  } catch {
27
41
  return null;
28
42
  }
43
+ };
29
44
 
30
- if (!isRecord(parsed) || typeof parsed.type !== "string") {
45
+ const timestampValue = (value: unknown): number | null => {
46
+ if (typeof value !== "string") return null;
47
+ const timestamp = new Date(value).getTime();
48
+ return Number.isFinite(timestamp) ? timestamp : null;
49
+ };
50
+
51
+ export const parsePiLine = (line: string): PiRecord | null => {
52
+ const parsed = parseJsonRecord(line);
53
+ if (parsed === null || typeof parsed.type !== "string") {
31
54
  return null;
32
55
  }
33
56
 
@@ -89,25 +112,106 @@ export const getPiSessionId = (filePath: string): string => {
89
112
  };
90
113
 
91
114
  const walkSessionFiles = async (basePath: string): Promise<string[]> => {
92
- const { Glob } = await import("bun");
93
- const glob = new Glob("*/*.jsonl");
94
- return Array.fromAsync(glob.scan({ cwd: basePath, absolute: true }));
115
+ const directories = await readdir(basePath, { withFileTypes: true });
116
+ const files = await Promise.all(
117
+ directories
118
+ .filter((entry) => entry.isDirectory())
119
+ .map(async (directory) => {
120
+ const directoryPath = `${basePath}/${directory.name}`;
121
+ return (await readdir(directoryPath, { withFileTypes: true }))
122
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
123
+ .map((entry) => `${directoryPath}/${entry.name}`);
124
+ }),
125
+ );
126
+ return files.flat();
95
127
  };
96
128
 
97
- const readSessionCwd = async (sessionFile: string): Promise<string | null> => {
129
+ const readSlice = async (filePath: string, start: number, end: number): Promise<string> => {
130
+ const length = Math.max(0, end - start);
131
+ if (length === 0) return "";
132
+ const handle = await open(filePath, "r");
98
133
  try {
99
- const text = await Bun.file(sessionFile).text();
100
- const firstLine = text.split("\n")[0] ?? "";
101
- const record = parsePiLine(firstLine);
102
- if (record !== null && record.type === "session") {
103
- return record.cwd ?? null;
104
- }
105
- return null;
106
- } catch {
107
- return null;
134
+ const buffer = Buffer.allocUnsafe(length);
135
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
136
+ return buffer.subarray(0, bytesRead).toString("utf8");
137
+ } finally {
138
+ await handle.close();
108
139
  }
109
140
  };
110
141
 
142
+ const completeLines = (text: string, startsAtBeginning: boolean, endsAtEnd: boolean): string[] => {
143
+ const lines = text.split(/\r?\n/u);
144
+ if (!startsAtBeginning) lines.shift();
145
+ if (!endsAtEnd && !text.endsWith("\n")) lines.pop();
146
+ return lines.map((line) => line.trim()).filter((line) => line.length > 0);
147
+ };
148
+
149
+ const readPiSessionHeader = async (sessionFile: string) => {
150
+ const { size } = await stat(sessionFile);
151
+ const end = Math.min(size, PI_HEADER_MAX_BYTES);
152
+ const text = await readSlice(sessionFile, 0, end);
153
+ const line = completeLines(text, true, end === size)[0] ?? "";
154
+ const record = parsePiLine(line);
155
+ return record?.type === "session" ? record : null;
156
+ };
157
+
158
+ export const readPiSessionMetadata = async (sessionFile: string): Promise<PiSessionMetadata> => {
159
+ const file = await stat(sessionFile);
160
+ const { size } = file;
161
+ const fullReadLimit = PI_SUMMARY_HEAD_MAX_BYTES + PI_SUMMARY_TAIL_MAX_BYTES;
162
+ const readWhole = size <= fullReadLimit;
163
+ const headEnd = readWhole ? size : PI_SUMMARY_HEAD_MAX_BYTES;
164
+ const tailStart = readWhole ? 0 : Math.max(headEnd, size - PI_SUMMARY_TAIL_MAX_BYTES);
165
+ const tailReadStart = readWhole ? 0 : Math.max(0, tailStart - 1);
166
+ const head = await readSlice(sessionFile, 0, headEnd);
167
+ const tail = readWhole ? head : await readSlice(sessionFile, tailReadStart, size);
168
+ const headLines = completeLines(head, true, headEnd === size);
169
+ const tailLines = readWhole ? [] : completeLines(tail, tailReadStart === 0, true);
170
+ const allLines = [...headLines, ...tailLines];
171
+ const header = parsePiLine(headLines[0] ?? "");
172
+ const records = headLines
173
+ .map(parsePiLine)
174
+ .filter((record): record is PiRecord => record !== null);
175
+ const activityTimestamps = allLines
176
+ .map(parseJsonRecord)
177
+ .filter((record): record is Record<string, unknown> => record !== null)
178
+ .filter((record) => record.type !== "session")
179
+ .map((record) => timestampValue(record.timestamp))
180
+ .filter((timestamp): timestamp is number => timestamp !== null);
181
+ const headerTimestamp = header?.type === "session" ? timestampValue(header.timestamp) : null;
182
+ const createdAt = headerTimestamp ?? activityTimestamps[0] ?? file.mtimeMs;
183
+ const updatedAt = activityTimestamps.length > 0 ? Math.max(...activityTimestamps) : file.mtimeMs;
184
+
185
+ return {
186
+ sessionID: getPiSessionId(sessionFile),
187
+ title: extractPiTitle(records),
188
+ createdAt,
189
+ updatedAt,
190
+ source: "pi",
191
+ cwd: header?.type === "session" ? (header.cwd ?? null) : null,
192
+ bytesRead: headEnd + (readWhole ? 0 : size - tailReadStart),
193
+ parsedLines: allLines.length,
194
+ };
195
+ };
196
+
197
+ const mapConcurrent = async <T, R>(
198
+ values: ReadonlyArray<T>,
199
+ transform: (value: T) => Promise<R | null>,
200
+ ): Promise<R[]> => {
201
+ const results: Array<R | null> = Array.from({ length: values.length }, () => null);
202
+ let next = 0;
203
+ const workers = Array.from({ length: Math.min(PI_READ_CONCURRENCY, values.length) }, async () => {
204
+ while (next < values.length) {
205
+ const index = next;
206
+ next += 1;
207
+ // eslint-disable-next-line no-await-in-loop -- worker loop intentionally caps file-read concurrency
208
+ results[index] = await transform(values[index]);
209
+ }
210
+ });
211
+ await Promise.all(workers);
212
+ return results.filter((value): value is R => value !== null);
213
+ };
214
+
111
215
  export const getPiSessions = (
112
216
  basePath: string,
113
217
  projectDir: string | null,
@@ -119,8 +223,15 @@ export const getPiSessions = (
119
223
  return allFiles;
120
224
  }
121
225
 
122
- const cwds = await Promise.all(allFiles.map((file) => readSessionCwd(file)));
123
- return allFiles.filter((_, i) => cwds[i] === projectDir);
226
+ const matching = await mapConcurrent(allFiles, async (file) => {
227
+ try {
228
+ const header = await readPiSessionHeader(file);
229
+ return header?.cwd === projectDir ? file : null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ });
234
+ return matching;
124
235
  },
125
236
  catch: (error) =>
126
237
  new SessionStorageNotFoundError({
@@ -129,6 +240,31 @@ export const getPiSessions = (
129
240
  }),
130
241
  });
131
242
 
243
+ export const readPiSessionSummaries = (
244
+ sessionFiles: string[],
245
+ ): Effect.Effect<SessionSummary[], SessionError> =>
246
+ Effect.tryPromise({
247
+ try: () =>
248
+ mapConcurrent(sessionFiles, async (sessionFile) => {
249
+ try {
250
+ const {
251
+ cwd: _cwd,
252
+ bytesRead: _bytesRead,
253
+ parsedLines: _parsedLines,
254
+ ...summary
255
+ } = await readPiSessionMetadata(sessionFile);
256
+ return summary;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }),
261
+ catch: (error) =>
262
+ new SessionReadError({
263
+ message: error instanceof Error ? error.message : "Failed to read pi session summaries",
264
+ source: "pi",
265
+ }),
266
+ });
267
+
132
268
  export const readPiMessages = (
133
269
  sessionFiles: string[],
134
270
  ): Effect.Effect<MessageSummary[], SessionError> =>
@@ -140,7 +276,7 @@ export const readPiMessages = (
140
276
  let fileContent: string;
141
277
  try {
142
278
  // eslint-disable-next-line eslint/no-await-in-loop -- sequential file read keeps memory bounded
143
- fileContent = await Bun.file(sessionFile).text();
279
+ fileContent = await readFile(sessionFile, "utf8");
144
280
  } catch {
145
281
  continue;
146
282
  }
@@ -175,13 +311,7 @@ export const readPiMessages = (
175
311
  }
176
312
  }
177
313
 
178
- return (
179
- summaries as MessageSummary[] & {
180
- toSorted(
181
- compareFn: (left: MessageSummary, right: MessageSummary) => number,
182
- ): MessageSummary[];
183
- }
184
- ).toSorted((left, right) => right.created - left.created);
314
+ return summaries.toSorted((left, right) => right.created - left.created);
185
315
  },
186
316
  catch: (error) =>
187
317
  new SessionReadError({
@@ -1,11 +1,13 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import { readdir } from "node:fs/promises";
3
3
 
4
- import type { MessageSummary, SessionInfo, SessionSource } from "./types";
4
+ import type { MessageSummary, SessionInfo, SessionSource, SessionSummary } from "./types";
5
+
6
+ import { ALL_SESSION_SOURCES } from "./types";
5
7
 
6
8
  import { getClaudeCodeSessions, readClaudeCodeMessages } from "./claude-code";
7
9
  import { getCodexSessions, getCodexSessionId, readCodexMessages } from "./codex";
8
- import { getPiSessions, getPiSessionId, readPiMessages } from "./pi";
10
+ import { getPiSessions, getPiSessionId, readPiMessages, readPiSessionSummaries } from "./pi";
9
11
  import { ResolvedPaths } from "./config";
10
12
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
11
13
 
@@ -42,12 +44,7 @@ type FileEntry = { filePath: string; content: string };
42
44
 
43
45
  type SourceFilter = ReadonlySet<SessionSource>;
44
46
 
45
- const ALL_SOURCES: SourceFilter = new Set<SessionSource>([
46
- "opencode",
47
- "claude-code",
48
- "codex",
49
- "pi",
50
- ]);
47
+ const ALL_SOURCES: SourceFilter = ALL_SESSION_SOURCES;
51
48
  const UUID_SOURCES: SourceFilter = new Set<SessionSource>(["claude-code", "codex", "pi"]);
52
49
  const OPENCODE_ONLY: SourceFilter = new Set<SessionSource>(["opencode"]);
53
50
 
@@ -80,6 +77,16 @@ const detectSourceFilter = (filterSessions: Set<string> | null): SourceFilter =>
80
77
  return ALL_SOURCES;
81
78
  };
82
79
 
80
+ const requestedSourceFilter = (
81
+ filterSessions: Set<string> | null,
82
+ requested?: ReadonlySet<SessionSource>,
83
+ ): SourceFilter => {
84
+ const detected = detectSourceFilter(filterSessions);
85
+ return requested === undefined
86
+ ? detected
87
+ : new Set([...detected].filter((source) => requested.has(source)));
88
+ };
89
+
83
90
  /**
84
91
  * Reads JSON files from a two-level directory (parent/sub/*.json) using Bun.file().
85
92
  * Required for ~100k OpenCode message files where shell-per-file would timeout.
@@ -158,9 +165,14 @@ export class SessionService extends Context.Service<
158
165
  {
159
166
  readonly getSessionsForProject: (
160
167
  projectDir: string | null,
168
+ sources?: ReadonlySet<SessionSource>,
161
169
  ) => Effect.Effect<Set<string>, SessionError>;
170
+ readonly getPiSessionSummaries: (
171
+ filterSessions: Set<string> | null,
172
+ ) => Effect.Effect<SessionSummary[], SessionError>;
162
173
  readonly getMessageSummaries: (
163
174
  filterSessions: Set<string> | null,
175
+ sources?: ReadonlySet<SessionSource>,
164
176
  ) => Effect.Effect<MessageSummary[], SessionError>;
165
177
  readonly searchSummaries: (summaries: MessageSummary[], query: string) => MessageSummary[];
166
178
  }
@@ -173,27 +185,33 @@ export class SessionService extends Context.Service<
173
185
  return {
174
186
  getSessionsForProject: Effect.fn("SessionService.getSessionsForProject")(function* (
175
187
  projectDir: string | null,
188
+ sources?: ReadonlySet<SessionSource>,
176
189
  ) {
177
- const opencodeSessions = yield* Effect.gen(function* () {
178
- const files = yield* readJsonFilesInTree(paths.sessionsPath);
179
- const matchingSessions = new Set<string>();
190
+ const sourceFilter = sources ?? ALL_SOURCES;
191
+ const opencodeSessions = !sourceFilter.has("opencode")
192
+ ? new Set<string>()
193
+ : yield* Effect.gen(function* () {
194
+ const files = yield* readJsonFilesInTree(paths.sessionsPath);
195
+ const matchingSessions = new Set<string>();
180
196
 
181
- for (const { content } of files) {
182
- const parsed = parseJson<SessionInfo>(content);
183
- if (parsed === null) continue;
197
+ for (const { content } of files) {
198
+ const parsed = parseJson<SessionInfo>(content);
199
+ if (parsed === null) continue;
184
200
 
185
- if (projectDir === null || parsed.directory === projectDir) {
186
- matchingSessions.add(parsed.id);
187
- }
188
- }
201
+ if (projectDir === null || parsed.directory === projectDir) {
202
+ matchingSessions.add(parsed.id);
203
+ }
204
+ }
189
205
 
190
- return matchingSessions;
191
- }).pipe(
192
- Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed(new Set<string>())),
193
- );
206
+ return matchingSessions;
207
+ }).pipe(
208
+ Effect.catchTag("SessionStorageNotFoundError", () =>
209
+ Effect.succeed(new Set<string>()),
210
+ ),
211
+ );
194
212
 
195
213
  const claudeSessions =
196
- paths.claudeCodePath === null
214
+ !sourceFilter.has("claude-code") || paths.claudeCodePath === null
197
215
  ? new Set<string>()
198
216
  : yield* getClaudeCodeSessions(paths.claudeCodePath, projectDir).pipe(
199
217
  Effect.map(
@@ -208,7 +226,7 @@ export class SessionService extends Context.Service<
208
226
  );
209
227
 
210
228
  const codexSessions =
211
- paths.codexPath === null
229
+ !sourceFilter.has("codex") || paths.codexPath === null
212
230
  ? new Set<string>()
213
231
  : yield* getCodexSessions(paths.codexPath, projectDir).pipe(
214
232
  Effect.map(
@@ -221,7 +239,7 @@ export class SessionService extends Context.Service<
221
239
  );
222
240
 
223
241
  const piSessions =
224
- paths.piPath === null
242
+ !sourceFilter.has("pi") || paths.piPath === null
225
243
  ? new Set<string>()
226
244
  : yield* getPiSessions(paths.piPath, projectDir).pipe(
227
245
  Effect.map(
@@ -246,10 +264,31 @@ export class SessionService extends Context.Service<
246
264
  return matchingSessions;
247
265
  }),
248
266
 
267
+ getPiSessionSummaries: Effect.fn("SessionService.getPiSessionSummaries")(function* (
268
+ filterSessions: Set<string> | null,
269
+ ) {
270
+ if (paths.piPath === null) return [];
271
+ return yield* getPiSessions(paths.piPath, null).pipe(
272
+ Effect.map((sessionFiles) =>
273
+ filterSessions === null
274
+ ? sessionFiles
275
+ : sessionFiles.filter((sessionFile) =>
276
+ filterSessions.has(getPiSessionId(sessionFile)),
277
+ ),
278
+ ),
279
+ Effect.flatMap(readPiSessionSummaries),
280
+ Effect.catchTags({
281
+ SessionStorageNotFoundError: () => Effect.succeed([]),
282
+ SessionReadError: () => Effect.succeed([]),
283
+ }),
284
+ );
285
+ }),
286
+
249
287
  getMessageSummaries: Effect.fn("SessionService.getMessageSummaries")(function* (
250
288
  filterSessions: Set<string> | null,
289
+ sources?: ReadonlySet<SessionSource>,
251
290
  ) {
252
- const sourceFilter = detectSourceFilter(filterSessions);
291
+ const sourceFilter = requestedSourceFilter(filterSessions, sources);
253
292
 
254
293
  const opencodeSummaries = !sourceFilter.has("opencode")
255
294
  ? []
@@ -0,0 +1,42 @@
1
+ import type { MessageSummary, SessionSummary } from "./types";
2
+
3
+ export const sessionSummariesFromMessages = (summaries: MessageSummary[]): SessionSummary[] => {
4
+ const bySession = new Map<string, SessionSummary>();
5
+
6
+ for (const summary of summaries) {
7
+ const key = `${summary.source}:${summary.sessionID}`;
8
+ const previous = bySession.get(key);
9
+ if (previous === undefined) {
10
+ bySession.set(key, {
11
+ sessionID: summary.sessionID,
12
+ title: summary.title,
13
+ createdAt: summary.created,
14
+ updatedAt: summary.created,
15
+ source: summary.source,
16
+ });
17
+ continue;
18
+ }
19
+ previous.createdAt = Math.min(previous.createdAt, summary.created);
20
+ if (summary.created > previous.updatedAt) {
21
+ previous.updatedAt = summary.created;
22
+ previous.title = summary.title;
23
+ }
24
+ }
25
+
26
+ return [...bySession.values()];
27
+ };
28
+
29
+ export const projectSessionFilter = (
30
+ sessionsBySource: ReadonlyMap<SessionSummary["source"], Set<string>>,
31
+ source: SessionSummary["source"],
32
+ allProjects: boolean,
33
+ ): Set<string> | null => (allProjects ? null : (sessionsBySource.get(source) ?? new Set()));
34
+
35
+ export const sortSessionSummaries = (summaries: SessionSummary[]): SessionSummary[] =>
36
+ summaries.toSorted(
37
+ (left, right) =>
38
+ right.updatedAt - left.updatedAt ||
39
+ right.createdAt - left.createdAt ||
40
+ left.source.localeCompare(right.source) ||
41
+ left.sessionID.localeCompare(right.sessionID),
42
+ );
@@ -10,8 +10,10 @@ export type SessionInfo = {
10
10
  projectID: string;
11
11
  };
12
12
 
13
- export const SessionSourceLiterals = Schema.Literals(["opencode", "claude-code", "codex", "pi"]);
14
- export type SessionSource = Schema.Schema.Type<typeof SessionSourceLiterals>;
13
+ export const SESSION_SOURCES = ["opencode", "claude-code", "codex", "pi"] as const;
14
+ export const SessionSourceLiterals = Schema.Literals(SESSION_SOURCES);
15
+ export type SessionSource = (typeof SESSION_SOURCES)[number];
16
+ export const ALL_SESSION_SOURCES: ReadonlySet<SessionSource> = new Set(SESSION_SOURCES);
15
17
 
16
18
  export type MessageSummary = {
17
19
  sessionID: string;
@@ -23,6 +25,14 @@ export type MessageSummary = {
23
25
  source: SessionSource;
24
26
  };
25
27
 
28
+ export type SessionSummary = {
29
+ sessionID: string;
30
+ title: string;
31
+ createdAt: number;
32
+ updatedAt: number;
33
+ source: SessionSource;
34
+ };
35
+
26
36
  export type SessionResult = {
27
37
  success: boolean;
28
38
  data?: unknown;