@blogic-cz/agent-tools 0.14.56 → 0.14.58

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.14.56",
3
+ "version": "0.14.58",
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",
@@ -368,6 +368,7 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
368
368
  isVisibleOpen: !node.isResolved || needsHumanReply,
369
369
  lastReplyAuthor: lastReply?.author ?? null,
370
370
  lastReplyAt: lastReply?.createdAt ?? null,
371
+ duplicateThreadIds: [] as string[],
371
372
  };
372
373
  })
373
374
  .filter((thread): thread is ReviewThread => thread !== null);
@@ -383,8 +384,14 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
383
384
  continue;
384
385
  }
385
386
  const existing = deduped[existingIndex];
386
- if (existing !== undefined && existing.isResolved && !thread.isResolved) {
387
+ if (existing === undefined) {
388
+ continue;
389
+ }
390
+ if (existing.isResolved && !thread.isResolved) {
391
+ thread.duplicateThreadIds = [existing.threadId, ...existing.duplicateThreadIds];
387
392
  deduped[existingIndex] = thread;
393
+ } else {
394
+ existing.duplicateThreadIds.push(thread.threadId);
388
395
  }
389
396
  }
390
397
 
@@ -37,6 +37,8 @@ export type ReviewThread = {
37
37
  isVisibleOpen: boolean;
38
38
  lastReplyAuthor: string | null;
39
39
  lastReplyAt: string | null;
40
+ /** Thread ids of exact duplicates collapsed into this representative (encounter order). */
41
+ duplicateThreadIds: string[];
40
42
  };
41
43
 
42
44
  export type ReviewComment = {
@@ -45,6 +45,7 @@ export class ResolvedPaths extends Context.Service<
45
45
  readonly sessionsPath: string;
46
46
  readonly claudeCodePath: string | null;
47
47
  readonly codexPath: string | null;
48
+ readonly piPath: string | null;
48
49
  }
49
50
  >()("@agent-tools/ResolvedPaths") {}
50
51
 
@@ -57,6 +58,8 @@ export const ResolvedPathsLayer = Layer.effect(
57
58
  const claudeCodePath = existsSync(claudeCodeBasePath) ? claudeCodeBasePath : null;
58
59
  const codexBasePath = join(homedir(), ".codex/sessions");
59
60
  const codexPath = existsSync(codexBasePath) ? codexBasePath : null;
60
- return { messagesPath, sessionsPath, claudeCodePath, codexPath };
61
+ const piBasePath = join(homedir(), ".pi/agent/sessions");
62
+ const piPath = existsSync(piBasePath) ? piBasePath : null;
63
+ return { messagesPath, sessionsPath, claudeCodePath, codexPath, piPath };
61
64
  }),
62
65
  );
@@ -22,7 +22,7 @@ import { formatDate, SessionService, SessionServiceLayer, truncate } from "./ser
22
22
  const AppLayer = SessionServiceLayer.pipe(Layer.provideMerge(ResolvedPathsLayer));
23
23
 
24
24
  const sourceOption = Flag.string("source").pipe(
25
- Flag.withDescription("Filter by source: all, opencode, claude-code, codex"),
25
+ Flag.withDescription("Filter by source: all, opencode, claude-code, codex, pi"),
26
26
  Flag.withDefault("all"),
27
27
  );
28
28
 
@@ -0,0 +1,191 @@
1
+ import { Effect } from "effect";
2
+
3
+ import type { MessageSummary } from "./types";
4
+
5
+ import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
6
+
7
+ export type PiContentBlock =
8
+ | { type: "text"; text: string }
9
+ | { type: string; [key: string]: unknown };
10
+
11
+ export type PiRecord =
12
+ | { type: "session"; id: string; timestamp?: string; cwd?: string }
13
+ | {
14
+ type: "message";
15
+ timestamp: string;
16
+ message: { role: string; content: string | ReadonlyArray<PiContentBlock> };
17
+ };
18
+
19
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
+ typeof value === "object" && value !== null;
21
+
22
+ export const parsePiLine = (line: string): PiRecord | null => {
23
+ let parsed: unknown;
24
+ try {
25
+ parsed = JSON.parse(line);
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ if (!isRecord(parsed) || typeof parsed.type !== "string") {
31
+ return null;
32
+ }
33
+
34
+ if (parsed.type === "session" && typeof parsed.id === "string") {
35
+ return {
36
+ type: "session",
37
+ id: parsed.id,
38
+ timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : undefined,
39
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
40
+ };
41
+ }
42
+
43
+ if (
44
+ parsed.type === "message" &&
45
+ typeof parsed.timestamp === "string" &&
46
+ isRecord(parsed.message) &&
47
+ typeof parsed.message.role === "string" &&
48
+ (typeof parsed.message.content === "string" || Array.isArray(parsed.message.content))
49
+ ) {
50
+ return parsed as Extract<PiRecord, { type: "message" }>;
51
+ }
52
+
53
+ return null;
54
+ };
55
+
56
+ export const extractPiText = (content: string | ReadonlyArray<PiContentBlock>): string => {
57
+ if (typeof content === "string") {
58
+ return content;
59
+ }
60
+
61
+ return content
62
+ .filter(
63
+ (block): block is { type: "text"; text: string } =>
64
+ block.type === "text" && typeof (block as { text?: unknown }).text === "string",
65
+ )
66
+ .map((block) => block.text)
67
+ .join("\n");
68
+ };
69
+
70
+ export const extractPiTitle = (records: ReadonlyArray<PiRecord>): string => {
71
+ const firstUser = records.find(
72
+ (record): record is Extract<PiRecord, { type: "message" }> =>
73
+ record.type === "message" && record.message.role === "user",
74
+ );
75
+ if (firstUser !== undefined) {
76
+ return extractPiText(firstUser.message.content).slice(0, 100);
77
+ }
78
+
79
+ return "Untitled session";
80
+ };
81
+
82
+ const SESSION_ID_REGEX =
83
+ /_([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/u;
84
+
85
+ export const getPiSessionId = (filePath: string): string => {
86
+ const fileName = filePath.split("/").pop() ?? "";
87
+ const match = SESSION_ID_REGEX.exec(fileName);
88
+ return match?.[1] ?? fileName.replace(/\.jsonl$/u, "");
89
+ };
90
+
91
+ 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 }));
95
+ };
96
+
97
+ const readSessionCwd = async (sessionFile: string): Promise<string | null> => {
98
+ 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;
108
+ }
109
+ };
110
+
111
+ export const getPiSessions = (
112
+ basePath: string,
113
+ projectDir: string | null,
114
+ ): Effect.Effect<string[], SessionError> =>
115
+ Effect.tryPromise({
116
+ try: async () => {
117
+ const allFiles = await walkSessionFiles(basePath);
118
+ if (projectDir === null) {
119
+ return allFiles;
120
+ }
121
+
122
+ const cwds = await Promise.all(allFiles.map((file) => readSessionCwd(file)));
123
+ return allFiles.filter((_, i) => cwds[i] === projectDir);
124
+ },
125
+ catch: (error) =>
126
+ new SessionStorageNotFoundError({
127
+ message: error instanceof Error ? error.message : "pi storage directory not found",
128
+ path: basePath,
129
+ }),
130
+ });
131
+
132
+ export const readPiMessages = (
133
+ sessionFiles: string[],
134
+ ): Effect.Effect<MessageSummary[], SessionError> =>
135
+ Effect.tryPromise({
136
+ try: async () => {
137
+ const summaries: MessageSummary[] = [];
138
+
139
+ for (const sessionFile of sessionFiles) {
140
+ let fileContent: string;
141
+ try {
142
+ // eslint-disable-next-line eslint/no-await-in-loop -- sequential file read keeps memory bounded
143
+ fileContent = await Bun.file(sessionFile).text();
144
+ } catch {
145
+ continue;
146
+ }
147
+
148
+ const records = fileContent
149
+ .split(/\r?\n/u)
150
+ .map((line) => line.trim())
151
+ .filter((line) => line.length > 0)
152
+ .map(parsePiLine)
153
+ .filter((record): record is PiRecord => record !== null);
154
+
155
+ const title = extractPiTitle(records);
156
+ const sessionID = getPiSessionId(sessionFile);
157
+
158
+ for (const record of records) {
159
+ if (record.type !== "message") continue;
160
+ if (record.message.role !== "user" && record.message.role !== "assistant") continue;
161
+
162
+ const body = extractPiText(record.message.content);
163
+ if (body.length === 0) continue;
164
+
165
+ const createdTimestamp = new Date(record.timestamp).getTime();
166
+ summaries.push({
167
+ sessionID,
168
+ id: `${sessionID}:${record.timestamp}`,
169
+ title,
170
+ body,
171
+ created: Number.isFinite(createdTimestamp) ? createdTimestamp : 0,
172
+ role: record.message.role,
173
+ source: "pi",
174
+ });
175
+ }
176
+ }
177
+
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);
185
+ },
186
+ catch: (error) =>
187
+ new SessionReadError({
188
+ message: error instanceof Error ? error.message : "Failed to read pi sessions",
189
+ source: "pi",
190
+ }),
191
+ });
@@ -5,6 +5,7 @@ import type { MessageSummary, SessionInfo, SessionSource } from "./types";
5
5
 
6
6
  import { getClaudeCodeSessions, readClaudeCodeMessages } from "./claude-code";
7
7
  import { getCodexSessions, getCodexSessionId, readCodexMessages } from "./codex";
8
+ import { getPiSessions, getPiSessionId, readPiMessages } from "./pi";
8
9
  import { ResolvedPaths } from "./config";
9
10
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
10
11
 
@@ -41,8 +42,13 @@ type FileEntry = { filePath: string; content: string };
41
42
 
42
43
  type SourceFilter = ReadonlySet<SessionSource>;
43
44
 
44
- const ALL_SOURCES: SourceFilter = new Set<SessionSource>(["opencode", "claude-code", "codex"]);
45
- const UUID_SOURCES: SourceFilter = new Set<SessionSource>(["claude-code", "codex"]);
45
+ const ALL_SOURCES: SourceFilter = new Set<SessionSource>([
46
+ "opencode",
47
+ "claude-code",
48
+ "codex",
49
+ "pi",
50
+ ]);
51
+ const UUID_SOURCES: SourceFilter = new Set<SessionSource>(["claude-code", "codex", "pi"]);
46
52
  const OPENCODE_ONLY: SourceFilter = new Set<SessionSource>(["opencode"]);
47
53
 
48
54
  const UUID_SESSION_ID_REGEX =
@@ -214,6 +220,18 @@ export class SessionService extends Context.Service<
214
220
  ),
215
221
  );
216
222
 
223
+ const piSessions =
224
+ paths.piPath === null
225
+ ? new Set<string>()
226
+ : yield* getPiSessions(paths.piPath, projectDir).pipe(
227
+ Effect.map(
228
+ (files) => new Set<string>(files.map((filePath) => getPiSessionId(filePath))),
229
+ ),
230
+ Effect.catchTag("SessionStorageNotFoundError", () =>
231
+ Effect.succeed(new Set<string>()),
232
+ ),
233
+ );
234
+
217
235
  const matchingSessions = new Set<string>(opencodeSessions);
218
236
  for (const sessionId of claudeSessions) {
219
237
  matchingSessions.add(sessionId);
@@ -221,6 +239,9 @@ export class SessionService extends Context.Service<
221
239
  for (const sessionId of codexSessions) {
222
240
  matchingSessions.add(sessionId);
223
241
  }
242
+ for (const sessionId of piSessions) {
243
+ matchingSessions.add(sessionId);
244
+ }
224
245
 
225
246
  return matchingSessions;
226
247
  }),
@@ -319,7 +340,30 @@ export class SessionService extends Context.Service<
319
340
  }),
320
341
  );
321
342
 
322
- const summaries = [...opencodeSummaries, ...claudeSummaries, ...codexSummaries];
343
+ const piSummaries =
344
+ !sourceFilter.has("pi") || paths.piPath === null
345
+ ? []
346
+ : yield* getPiSessions(paths.piPath, null).pipe(
347
+ Effect.map((sessionFiles) =>
348
+ filterSessions === null
349
+ ? sessionFiles
350
+ : sessionFiles.filter((sessionFile) =>
351
+ filterSessions.has(getPiSessionId(sessionFile)),
352
+ ),
353
+ ),
354
+ Effect.flatMap(readPiMessages),
355
+ Effect.catchTags({
356
+ SessionStorageNotFoundError: () => Effect.succeed([]),
357
+ SessionReadError: () => Effect.succeed([]),
358
+ }),
359
+ );
360
+
361
+ const summaries = [
362
+ ...opencodeSummaries,
363
+ ...claudeSummaries,
364
+ ...codexSummaries,
365
+ ...piSummaries,
366
+ ];
323
367
 
324
368
  return (
325
369
  summaries as MessageSummary[] & {
@@ -10,7 +10,7 @@ export type SessionInfo = {
10
10
  projectID: string;
11
11
  };
12
12
 
13
- export const SessionSourceLiterals = Schema.Literals(["opencode", "claude-code", "codex"]);
13
+ export const SessionSourceLiterals = Schema.Literals(["opencode", "claude-code", "codex", "pi"]);
14
14
  export type SessionSource = Schema.Schema.Type<typeof SessionSourceLiterals>;
15
15
 
16
16
  export type MessageSummary = {