@blogic-cz/agent-tools 0.6.0 → 0.7.1
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
|
@@ -392,8 +392,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
392
392
|
`\u{1F6AB} Access blocked: "${filePath}" is a sensitive file.\n\n` +
|
|
393
393
|
`This file may contain credentials or secrets.\n` +
|
|
394
394
|
`If you need this file's content, ask the user to provide relevant parts.\n\n` +
|
|
395
|
-
`Think this should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the guard, and submit a PR
|
|
396
|
-
`→ Skill "agent-tools"`,
|
|
395
|
+
`Think this should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the guard, and submit a PR.`,
|
|
397
396
|
);
|
|
398
397
|
}
|
|
399
398
|
}
|
|
@@ -409,8 +408,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
409
408
|
`\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
|
|
410
409
|
`Matched: ${detected.match}\n\n` +
|
|
411
410
|
`Never commit secrets to code. Use environment variables or secret managers.\n\n` +
|
|
412
|
-
`Think this is a false positive? See https://github.com/blogic-cz/agent-tools — fork, fix the pattern, and submit a PR
|
|
413
|
-
`→ Skill "agent-tools"`,
|
|
411
|
+
`Think this is a false positive? See https://github.com/blogic-cz/agent-tools — fork, fix the pattern, and submit a PR.`,
|
|
414
412
|
);
|
|
415
413
|
}
|
|
416
414
|
}
|
|
@@ -425,8 +423,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
425
423
|
`\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
|
|
426
424
|
`Command: ${command}\n\n` +
|
|
427
425
|
`If you need environment info, ask the user directly.\n\n` +
|
|
428
|
-
`Think this is wrong? See https://github.com/blogic-cz/agent-tools — fork, adjust the patterns, and submit a PR
|
|
429
|
-
`→ Skill "agent-tools"`,
|
|
426
|
+
`Think this is wrong? See https://github.com/blogic-cz/agent-tools — fork, adjust the patterns, and submit a PR.`,
|
|
430
427
|
);
|
|
431
428
|
}
|
|
432
429
|
|
|
@@ -436,20 +433,20 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
436
433
|
`\u{26A0}\u{FE0F} Sleep-polling detected.\n\n` +
|
|
437
434
|
`Instead of polling with sleep, use the built-in watch command:\n\n` +
|
|
438
435
|
`Use instead: ${sleepSuggestion}\n\n` +
|
|
439
|
-
`Watch commands block until completion — no polling needed
|
|
440
|
-
`→ Skill "agent-tools"`,
|
|
436
|
+
`Watch commands block until completion — no polling needed.`,
|
|
441
437
|
);
|
|
442
438
|
}
|
|
443
439
|
|
|
444
440
|
const blockedTool = getBlockedCliTool(command);
|
|
445
441
|
if (blockedTool) {
|
|
442
|
+
const skillName = blockedTool.wrapper.replace("agent-tools-", "") + "-tool";
|
|
446
443
|
throw new Error(
|
|
447
444
|
`\u{1F6AB} Direct ${blockedTool.name} usage blocked.\n\n` +
|
|
448
445
|
`AI agents must use wrapper tools for security and audit.\n\n` +
|
|
449
|
-
`Use instead: bun ${
|
|
450
|
-
`Example: bun ${
|
|
446
|
+
`Use instead: bun ${skillName}\n\n` +
|
|
447
|
+
`Example: bun ${skillName} --help\n\n` +
|
|
451
448
|
`Think this tool should be allowed? See https://github.com/blogic-cz/agent-tools — fork, extend the whitelist, and submit a PR.\n` +
|
|
452
|
-
`→ Skill "
|
|
449
|
+
`→ Skill "${skillName}"`,
|
|
453
450
|
);
|
|
454
451
|
}
|
|
455
452
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
135
|
-
|
|
166
|
+
const opencodeSessions = yield* Effect.gen(function* () {
|
|
167
|
+
const files = yield* readJsonFilesInTree(paths.sessionsPath);
|
|
168
|
+
const matchingSessions = new Set<string>();
|
|
136
169
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
for (const { content } of files) {
|
|
171
|
+
const parsed = parseJson<SessionInfo>(content);
|
|
172
|
+
if (parsed === null) continue;
|
|
140
173
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 = {
|