@blogic-cz/agent-tools 0.6.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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",
@@ -0,0 +1,272 @@
1
+ import { Effect } from "effect";
2
+ import { readdir } from "node:fs/promises";
3
+
4
+ import type { MessageSummary } from "./types";
5
+
6
+ import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
7
+
8
+ export const encodeProjectPath = (dir: string): string => dir.replaceAll("/", "-");
9
+
10
+ export type ContentBlock =
11
+ | { type: "text"; text: string }
12
+ | { type: "thinking"; thinking: string }
13
+ | { type: "tool_use"; id: string; name: string; input: unknown }
14
+ | { type: "tool_result"; tool_use_id: string; content: string };
15
+
16
+ export type ClaudeCodeRecord =
17
+ | { type: "summary"; summary: string }
18
+ | {
19
+ type: "user";
20
+ timestamp: string;
21
+ uuid: string;
22
+ message: { role: "user"; content: string | ReadonlyArray<ContentBlock> };
23
+ }
24
+ | {
25
+ type: "assistant";
26
+ timestamp: string;
27
+ uuid: string;
28
+ message: { role: "assistant"; content: ReadonlyArray<ContentBlock> };
29
+ };
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
32
+ typeof value === "object" && value !== null;
33
+
34
+ const isContentBlock = (value: unknown): value is ContentBlock => {
35
+ if (!isRecord(value) || typeof value.type !== "string") {
36
+ return false;
37
+ }
38
+
39
+ switch (value.type) {
40
+ case "text":
41
+ return typeof value.text === "string";
42
+ case "thinking":
43
+ return typeof value.thinking === "string";
44
+ case "tool_use":
45
+ return typeof value.id === "string" && typeof value.name === "string";
46
+ case "tool_result":
47
+ return typeof value.tool_use_id === "string" && typeof value.content === "string";
48
+ default:
49
+ return false;
50
+ }
51
+ };
52
+
53
+ const isUserRecord = (
54
+ value: Record<string, unknown>,
55
+ ): value is Extract<ClaudeCodeRecord, { type: "user" }> => {
56
+ if (
57
+ value.type !== "user" ||
58
+ typeof value.timestamp !== "string" ||
59
+ typeof value.uuid !== "string"
60
+ ) {
61
+ return false;
62
+ }
63
+
64
+ if (!isRecord(value.message) || value.message.role !== "user") {
65
+ return false;
66
+ }
67
+
68
+ if (typeof value.message.content === "string") {
69
+ return true;
70
+ }
71
+
72
+ return Array.isArray(value.message.content) && value.message.content.every(isContentBlock);
73
+ };
74
+
75
+ const isAssistantRecord = (
76
+ value: Record<string, unknown>,
77
+ ): value is Extract<ClaudeCodeRecord, { type: "assistant" }> => {
78
+ if (
79
+ value.type !== "assistant" ||
80
+ typeof value.timestamp !== "string" ||
81
+ typeof value.uuid !== "string" ||
82
+ !isRecord(value.message) ||
83
+ value.message.role !== "assistant"
84
+ ) {
85
+ return false;
86
+ }
87
+
88
+ return Array.isArray(value.message.content) && value.message.content.every(isContentBlock);
89
+ };
90
+
91
+ export const parseJsonlLine = (line: string): ClaudeCodeRecord | null => {
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(line);
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ if (!isRecord(parsed) || typeof parsed.type !== "string") {
100
+ return null;
101
+ }
102
+
103
+ if (parsed.type === "summary" && typeof parsed.summary === "string") {
104
+ return {
105
+ type: "summary",
106
+ summary: parsed.summary,
107
+ };
108
+ }
109
+
110
+ if (isUserRecord(parsed)) {
111
+ return parsed;
112
+ }
113
+
114
+ if (isAssistantRecord(parsed)) {
115
+ return parsed;
116
+ }
117
+
118
+ if (
119
+ parsed.type === "system" ||
120
+ parsed.type === "progress" ||
121
+ parsed.type === "file-history-snapshot" ||
122
+ parsed.type === "queue-operation"
123
+ ) {
124
+ return null;
125
+ }
126
+
127
+ return null;
128
+ };
129
+
130
+ export const extractTextFromContent = (content: string | ReadonlyArray<ContentBlock>): string => {
131
+ if (typeof content === "string") {
132
+ return content;
133
+ }
134
+
135
+ return content
136
+ .filter((block): block is Extract<ContentBlock, { type: "text" }> => block.type === "text")
137
+ .map((block) => block.text)
138
+ .join("\n");
139
+ };
140
+
141
+ export const extractSessionTitle = (records: ReadonlyArray<ClaudeCodeRecord>): string => {
142
+ const summaryRecord = records.find((record) => record.type === "summary");
143
+ if (summaryRecord !== undefined) {
144
+ return summaryRecord.summary;
145
+ }
146
+
147
+ const firstUserRecord = records.find((record) => record.type === "user");
148
+ if (firstUserRecord !== undefined) {
149
+ return extractTextFromContent(firstUserRecord.message.content).slice(0, 100);
150
+ }
151
+
152
+ return "Untitled session";
153
+ };
154
+
155
+ export const getClaudeCodeSessions = (
156
+ basePath: string,
157
+ projectDir: string | null,
158
+ ): Effect.Effect<string[], SessionError> =>
159
+ Effect.tryPromise({
160
+ try: async () => {
161
+ const targetProjectDir = projectDir === null ? null : encodeProjectPath(projectDir);
162
+ const projectEntries = await readdir(basePath, { withFileTypes: true });
163
+
164
+ const sessionFiles: string[] = [];
165
+
166
+ for (const entry of projectEntries) {
167
+ if (!entry.isDirectory()) {
168
+ continue;
169
+ }
170
+
171
+ if (targetProjectDir !== null && entry.name !== targetProjectDir) {
172
+ continue;
173
+ }
174
+
175
+ const projectPath = `${basePath}/${entry.name}`;
176
+ let files: string[];
177
+ try {
178
+ // eslint-disable-next-line eslint/no-await-in-loop -- directory scan must finish before filtering file list
179
+ files = await readdir(projectPath);
180
+ } catch {
181
+ continue;
182
+ }
183
+
184
+ for (const fileName of files) {
185
+ if (!fileName.endsWith(".jsonl") || fileName.startsWith("agent-")) {
186
+ continue;
187
+ }
188
+
189
+ sessionFiles.push(`${projectPath}/${fileName}`);
190
+ }
191
+ }
192
+
193
+ return sessionFiles;
194
+ },
195
+ catch: (error) =>
196
+ new SessionStorageNotFoundError({
197
+ message: error instanceof Error ? error.message : "Claude Code storage directory not found",
198
+ path: basePath,
199
+ }),
200
+ });
201
+
202
+ const getFileNameWithoutExtension = (filePath: string): string => {
203
+ const fileName = filePath.split("/").pop();
204
+ if (fileName === undefined) {
205
+ return "";
206
+ }
207
+
208
+ if (!fileName.endsWith(".jsonl")) {
209
+ return fileName;
210
+ }
211
+
212
+ return fileName.slice(0, -".jsonl".length);
213
+ };
214
+
215
+ export const readClaudeCodeMessages = (
216
+ sessionFiles: string[],
217
+ ): Effect.Effect<MessageSummary[], SessionError> =>
218
+ Effect.tryPromise({
219
+ try: async () => {
220
+ const summaries: MessageSummary[] = [];
221
+
222
+ for (const sessionFile of sessionFiles) {
223
+ let fileContent: string;
224
+ try {
225
+ // eslint-disable-next-line eslint/no-await-in-loop -- sequential file read keeps memory bounded
226
+ fileContent = await Bun.file(sessionFile).text();
227
+ } catch {
228
+ continue;
229
+ }
230
+
231
+ const records = fileContent
232
+ .split(/\r?\n/u)
233
+ .map((line) => line.trim())
234
+ .filter((line) => line.length > 0)
235
+ .map(parseJsonlLine)
236
+ .filter((record): record is ClaudeCodeRecord => record !== null);
237
+
238
+ const title = extractSessionTitle(records);
239
+ const sessionID = getFileNameWithoutExtension(sessionFile);
240
+
241
+ for (const record of records) {
242
+ if (record.type !== "user" && record.type !== "assistant") {
243
+ continue;
244
+ }
245
+
246
+ const createdTimestamp = new Date(record.timestamp).getTime();
247
+ summaries.push({
248
+ sessionID,
249
+ id: record.uuid,
250
+ title,
251
+ body: extractTextFromContent(record.message.content),
252
+ created: Number.isFinite(createdTimestamp) ? createdTimestamp : 0,
253
+ role: record.type,
254
+ source: "claude-code",
255
+ });
256
+ }
257
+ }
258
+
259
+ return (
260
+ summaries as MessageSummary[] & {
261
+ toSorted(
262
+ compareFn: (left: MessageSummary, right: MessageSummary) => number,
263
+ ): MessageSummary[];
264
+ }
265
+ ).toSorted((left, right) => right.created - left.created);
266
+ },
267
+ catch: (error) =>
268
+ new SessionReadError({
269
+ message: error instanceof Error ? error.message : "Failed to read Claude Code sessions",
270
+ source: "claude-code",
271
+ }),
272
+ });
@@ -1,4 +1,5 @@
1
1
  import { Effect, Layer, ServiceMap } from "effect";
2
+ import { existsSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
 
@@ -42,6 +43,7 @@ export class ResolvedPaths extends ServiceMap.Service<
42
43
  {
43
44
  readonly messagesPath: string;
44
45
  readonly sessionsPath: string;
46
+ readonly claudeCodePath: string | null;
45
47
  }
46
48
  >()("@agent-tools/ResolvedPaths") {}
47
49
 
@@ -50,6 +52,8 @@ export const ResolvedPathsLayer = Layer.effect(
50
52
  Effect.gen(function* () {
51
53
  const messagesPath = yield* resolveMessagesPath;
52
54
  const sessionsPath = yield* resolveSessionsPath;
53
- return { messagesPath, sessionsPath };
55
+ const claudeCodeBasePath = join(homedir(), ".claude/projects");
56
+ const claudeCodePath = existsSync(claudeCodeBasePath) ? claudeCodeBasePath : null;
57
+ return { messagesPath, sessionsPath, claudeCodePath };
54
58
  }),
55
59
  );
@@ -11,7 +11,7 @@ import { Argument, Command, Flag } from "effect/unstable/cli";
11
11
  import { BunRuntime, BunServices } from "@effect/platform-bun";
12
12
  import { Console, Effect, Layer, Result } from "effect";
13
13
 
14
- import type { MessageSummary, SessionResult } from "./types";
14
+ import type { MessageSummary, SessionResult, SessionSource } from "./types";
15
15
 
16
16
  import { formatOption, formatOutput, VERSION } from "#shared";
17
17
  import { AuditServiceLayer, withAudit } from "#shared/audit";
@@ -21,6 +21,16 @@ import { formatDate, SessionService, SessionServiceLayer, truncate } from "./ser
21
21
 
22
22
  const AppLayer = SessionServiceLayer.pipe(Layer.provideMerge(ResolvedPathsLayer));
23
23
 
24
+ const sourceOption = Flag.string("source").pipe(
25
+ Flag.withDescription("Filter by source: all, opencode, claude-code"),
26
+ Flag.withDefault("all"),
27
+ );
28
+
29
+ const filterBySource = (summaries: MessageSummary[], source: string): MessageSummary[] => {
30
+ if (source === "all") return summaries;
31
+ return summaries.filter((s) => s.source === (source as SessionSource));
32
+ };
33
+
24
34
  const buildScopeLabel = (searchAll: boolean, currentDir: string) => {
25
35
  if (searchAll) {
26
36
  return "all projects";
@@ -39,9 +49,14 @@ const mapSummary = (summary: MessageSummary) => {
39
49
  title: summary.title,
40
50
  body: truncate(summary.body, 500),
41
51
  created: formatDate(summary.created),
42
- messagePath: `${paths.messagesPath}/${summary.sessionID}/${summary.id}.json`,
52
+ ...(summary.source === "opencode"
53
+ ? {
54
+ messagePath: `${paths.messagesPath}/${summary.sessionID}/${summary.id}.json`,
55
+ sessionPath: `${paths.messagesPath}/${summary.sessionID}`,
56
+ }
57
+ : {}),
43
58
  role: summary.role,
44
- sessionPath: `${paths.messagesPath}/${summary.sessionID}`,
59
+ source: summary.source,
45
60
  };
46
61
  });
47
62
  };
@@ -58,8 +73,9 @@ const listCommand = Command.make(
58
73
  Flag.withDescription("Limit result count"),
59
74
  Flag.withDefault(10),
60
75
  ),
76
+ source: sourceOption,
61
77
  },
62
- ({ all, format, limit }) =>
78
+ ({ all, format, limit, source }) =>
63
79
  Effect.gen(function* () {
64
80
  const sessionService = yield* SessionService;
65
81
  const startTime = Date.now();
@@ -83,11 +99,13 @@ const listCommand = Command.make(
83
99
  } satisfies SessionResult;
84
100
  }
85
101
 
86
- const summaries = yield* sessionService.getMessageSummaries(sessionFilter);
102
+ const allSummaries = yield* sessionService.getMessageSummaries(sessionFilter);
103
+ const summaries = filterBySource(allSummaries, source);
87
104
  const results = summaries.slice(0, limit).map((summary) => ({
88
105
  created: formatDate(summary.created),
89
106
  sessionID: summary.sessionID,
90
107
  title: summary.title,
108
+ source: summary.source,
91
109
  }));
92
110
 
93
111
  return {
@@ -132,8 +150,9 @@ const searchCommand = Command.make(
132
150
  Flag.withDescription("Limit result count"),
133
151
  Flag.withDefault(10),
134
152
  ),
153
+ source: sourceOption,
135
154
  },
136
- ({ all, format, limit, query }) =>
155
+ ({ all, format, limit, query, source }) =>
137
156
  Effect.gen(function* () {
138
157
  const sessionService = yield* SessionService;
139
158
  const startTime = Date.now();
@@ -158,7 +177,8 @@ const searchCommand = Command.make(
158
177
  } satisfies SessionResult;
159
178
  }
160
179
 
161
- const summaries = yield* sessionService.getMessageSummaries(sessionFilter);
180
+ const allSummaries = yield* sessionService.getMessageSummaries(sessionFilter);
181
+ const summaries = filterBySource(allSummaries, source);
162
182
  const matched = sessionService.searchSummaries(summaries, query);
163
183
  const mappedResults = yield* Effect.all(matched.slice(0, limit).map(mapSummary));
164
184
 
@@ -196,15 +216,16 @@ const searchCommand = Command.make(
196
216
 
197
217
  yield* Console.log(formatOutput(output, format));
198
218
  }),
199
- ).pipe(Command.withDescription("Search OpenCode message history"));
219
+ ).pipe(Command.withDescription("Search message history"));
200
220
 
201
221
  const readCommand = Command.make(
202
222
  "read",
203
223
  {
204
224
  session: Flag.string("session").pipe(Flag.withDescription("Session ID to read")),
205
225
  format: formatOption,
226
+ source: sourceOption,
206
227
  },
207
- ({ format, session }) =>
228
+ ({ format, session, source }) =>
208
229
  Effect.gen(function* () {
209
230
  const sessionService = yield* SessionService;
210
231
  const startTime = Date.now();
@@ -226,14 +247,19 @@ const readCommand = Command.make(
226
247
  executionTimeMs: Date.now() - startTime,
227
248
  } satisfies SessionResult),
228
249
  onSuccess: (summaries) => {
229
- const sessionResults = summaries.filter((summary) => summary.sessionID === session);
250
+ const filtered = filterBySource(summaries, source);
251
+ const sessionResults = filtered.filter((summary) => summary.sessionID === session);
230
252
  return Effect.all(sessionResults.map(mapSummary)).pipe(
231
253
  Effect.map(
232
254
  (mapped) =>
233
255
  ({
234
256
  success: true,
235
257
  data: {
236
- files: mapped.map((message) => message.messagePath),
258
+ files: mapped
259
+ .map((message) =>
260
+ "messagePath" in message ? (message.messagePath as string) : null,
261
+ )
262
+ .filter((filePath): filePath is string => filePath !== null),
237
263
  messages: mapped,
238
264
  session,
239
265
  },
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
3
3
 
4
4
  import type { MessageSummary, SessionInfo } from "./types";
5
5
 
6
+ import { getClaudeCodeSessions, readClaudeCodeMessages } from "./claude-code";
6
7
  import { ResolvedPaths } from "./config";
7
8
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
8
9
 
@@ -37,6 +38,37 @@ export const truncate = (value: string, maxLen: number): string => {
37
38
 
38
39
  type FileEntry = { filePath: string; content: string };
39
40
 
41
+ type SourceFilter = "both" | "opencode" | "claude-code";
42
+
43
+ const UUID_SESSION_ID_REGEX =
44
+ /^[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}$/u;
45
+
46
+ const getSessionIdFromClaudeFile = (filePath: string): string => {
47
+ const fileName = filePath.split("/").pop() ?? "";
48
+ return fileName.endsWith(".jsonl") ? fileName.slice(0, -".jsonl".length) : fileName;
49
+ };
50
+
51
+ const detectSourceFilter = (filterSessions: Set<string> | null): SourceFilter => {
52
+ if (filterSessions === null || filterSessions.size !== 1) {
53
+ return "both";
54
+ }
55
+
56
+ const sessionId = filterSessions.values().next().value;
57
+ if (typeof sessionId !== "string") {
58
+ return "both";
59
+ }
60
+
61
+ if (sessionId.startsWith("ses_")) {
62
+ return "opencode";
63
+ }
64
+
65
+ if (UUID_SESSION_ID_REGEX.test(sessionId)) {
66
+ return "claude-code";
67
+ }
68
+
69
+ return "both";
70
+ };
71
+
40
72
  /**
41
73
  * Reads JSON files from a two-level directory (parent/sub/*.json) using Bun.file().
42
74
  * Required for ~100k OpenCode message files where shell-per-file would timeout.
@@ -131,16 +163,42 @@ export class SessionService extends ServiceMap.Service<
131
163
  getSessionsForProject: Effect.fn("SessionService.getSessionsForProject")(function* (
132
164
  projectDir: string | null,
133
165
  ) {
134
- const files = yield* readJsonFilesInTree(paths.sessionsPath);
135
- const matchingSessions = new Set<string>();
166
+ const opencodeSessions = yield* Effect.gen(function* () {
167
+ const files = yield* readJsonFilesInTree(paths.sessionsPath);
168
+ const matchingSessions = new Set<string>();
136
169
 
137
- for (const { content } of files) {
138
- const parsed = parseJson<SessionInfo>(content);
139
- if (parsed === null) continue;
170
+ for (const { content } of files) {
171
+ const parsed = parseJson<SessionInfo>(content);
172
+ if (parsed === null) continue;
140
173
 
141
- if (projectDir === null || parsed.directory === projectDir) {
142
- matchingSessions.add(parsed.id);
174
+ if (projectDir === null || parsed.directory === projectDir) {
175
+ matchingSessions.add(parsed.id);
176
+ }
143
177
  }
178
+
179
+ return matchingSessions;
180
+ }).pipe(
181
+ Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed(new Set<string>())),
182
+ );
183
+
184
+ const claudeSessions =
185
+ paths.claudeCodePath === null
186
+ ? new Set<string>()
187
+ : yield* getClaudeCodeSessions(paths.claudeCodePath, projectDir).pipe(
188
+ Effect.map(
189
+ (files) =>
190
+ new Set<string>(
191
+ files.map((filePath) => getSessionIdFromClaudeFile(filePath)),
192
+ ),
193
+ ),
194
+ Effect.catchTag("SessionStorageNotFoundError", () =>
195
+ Effect.succeed(new Set<string>()),
196
+ ),
197
+ );
198
+
199
+ const matchingSessions = new Set<string>(opencodeSessions);
200
+ for (const sessionId of claudeSessions) {
201
+ matchingSessions.add(sessionId);
144
202
  }
145
203
 
146
204
  return matchingSessions;
@@ -149,54 +207,81 @@ export class SessionService extends ServiceMap.Service<
149
207
  getMessageSummaries: Effect.fn("SessionService.getMessageSummaries")(function* (
150
208
  filterSessions: Set<string> | null,
151
209
  ) {
152
- const sessionDirs = yield* Effect.tryPromise({
153
- try: async () => {
154
- const dirs = await readdir(paths.messagesPath);
155
- return dirs
156
- .filter((name) => name.startsWith("ses_"))
157
- .filter((name) => filterSessions === null || filterSessions.has(name));
158
- },
159
- catch: () =>
160
- new SessionStorageNotFoundError({
161
- message: "Message storage directory not found",
162
- path: paths.messagesPath,
163
- }),
164
- });
210
+ const sourceFilter = detectSourceFilter(filterSessions);
165
211
 
166
- const summaries: MessageSummary[] = [];
167
-
168
- for (const sessionId of sessionDirs) {
169
- const sessionPath = `${paths.messagesPath}/${sessionId}`;
170
- const files = yield* readJsonFilesFlat(sessionPath);
171
-
172
- for (const { filePath, content } of files) {
173
- const parsed = parseJson<{
174
- id?: string;
175
- role?: string;
176
- sessionID?: string;
177
- summary?: {
178
- body?: string;
179
- title?: string;
180
- };
181
- time?: {
182
- created?: number;
183
- };
184
- }>(content);
185
-
186
- if (parsed === null || parsed.summary?.title === undefined) {
187
- continue;
188
- }
212
+ const opencodeSummaries =
213
+ sourceFilter === "claude-code"
214
+ ? []
215
+ : yield* Effect.gen(function* () {
216
+ const sessionDirs = yield* Effect.tryPromise({
217
+ try: async () => {
218
+ const dirs = await readdir(paths.messagesPath);
219
+ return dirs
220
+ .filter((name) => name.startsWith("ses_"))
221
+ .filter((name) => filterSessions === null || filterSessions.has(name));
222
+ },
223
+ catch: () =>
224
+ new SessionStorageNotFoundError({
225
+ message: "Message storage directory not found",
226
+ path: paths.messagesPath,
227
+ }),
228
+ });
189
229
 
190
- summaries.push({
191
- sessionID: parsed.sessionID ?? sessionId,
192
- id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
193
- title: parsed.summary.title,
194
- body: parsed.summary.body ?? "",
195
- created: parsed.time?.created ?? 0,
196
- role: parsed.role ?? "unknown",
197
- });
198
- }
199
- }
230
+ const summaries: MessageSummary[] = [];
231
+
232
+ for (const sessionId of sessionDirs) {
233
+ const sessionPath = `${paths.messagesPath}/${sessionId}`;
234
+ const files = yield* readJsonFilesFlat(sessionPath);
235
+
236
+ for (const { filePath, content } of files) {
237
+ const parsed = parseJson<{
238
+ id?: string;
239
+ role?: string;
240
+ sessionID?: string;
241
+ summary?: {
242
+ body?: string;
243
+ title?: string;
244
+ };
245
+ time?: {
246
+ created?: number;
247
+ };
248
+ }>(content);
249
+
250
+ if (parsed === null || parsed.summary?.title === undefined) {
251
+ continue;
252
+ }
253
+
254
+ summaries.push({
255
+ sessionID: parsed.sessionID ?? sessionId,
256
+ id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
257
+ title: parsed.summary.title,
258
+ body: parsed.summary.body ?? "",
259
+ created: parsed.time?.created ?? 0,
260
+ role: parsed.role ?? "unknown",
261
+ source: "opencode",
262
+ });
263
+ }
264
+ }
265
+
266
+ return summaries;
267
+ }).pipe(Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])));
268
+
269
+ const claudeSummaries =
270
+ sourceFilter === "opencode" || paths.claudeCodePath === null
271
+ ? []
272
+ : yield* getClaudeCodeSessions(paths.claudeCodePath, null).pipe(
273
+ Effect.map((sessionFiles) =>
274
+ filterSessions === null
275
+ ? sessionFiles
276
+ : sessionFiles.filter((sessionFile) =>
277
+ filterSessions.has(getSessionIdFromClaudeFile(sessionFile)),
278
+ ),
279
+ ),
280
+ Effect.flatMap(readClaudeCodeMessages),
281
+ Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])),
282
+ );
283
+
284
+ const summaries = [...opencodeSummaries, ...claudeSummaries];
200
285
 
201
286
  return (
202
287
  summaries as MessageSummary[] & {
@@ -8,6 +8,8 @@ export type SessionInfo = {
8
8
  projectID: string;
9
9
  };
10
10
 
11
+ export type SessionSource = "opencode" | "claude-code";
12
+
11
13
  export type MessageSummary = {
12
14
  sessionID: string;
13
15
  id: string;
@@ -15,6 +17,7 @@ export type MessageSummary = {
15
17
  body: string;
16
18
  created: number;
17
19
  role: string;
20
+ source: SessionSource;
18
21
  };
19
22
 
20
23
  export type SessionResult = {