@blogic-cz/agent-tools 0.5.7 → 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/README.md +74 -50
- package/package.json +9 -2
- package/src/gh-tool/index.ts +8 -5
- package/src/gh-tool/types.ts +18 -0
- package/src/gh-tool/workflow.ts +90 -0
- package/src/session-tool/claude-code.ts +272 -0
- package/src/session-tool/config.ts +5 -1
- package/src/session-tool/index.ts +37 -11
- package/src/session-tool/service.ts +138 -53
- package/src/session-tool/types.ts +3 -0
package/README.md
CHANGED
|
@@ -27,6 +27,25 @@ These tools wrap each CLI with:
|
|
|
27
27
|
bun add -d @blogic-cz/agent-tools
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
### Skill installation
|
|
31
|
+
|
|
32
|
+
Install per-tool skills for only the tools your project uses:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx skills add blogic-cz/agent-tools --path skill/gh-tool
|
|
36
|
+
npx skills add blogic-cz/agent-tools --path skill/db-tool
|
|
37
|
+
npx skills add blogic-cz/agent-tools --path skill/k8s-tool
|
|
38
|
+
npx skills add blogic-cz/agent-tools --path skill/az-tool
|
|
39
|
+
npx skills add blogic-cz/agent-tools --path skill/logs-tool
|
|
40
|
+
npx skills add blogic-cz/agent-tools --path skill/session-tool
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or install the all-in-one skill that covers every tool:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx skills add blogic-cz/agent-tools --path skill/agent-tools
|
|
47
|
+
```
|
|
48
|
+
|
|
30
49
|
**Requirements:** [Bun](https://bun.sh/) >=1.0.0
|
|
31
50
|
|
|
32
51
|
### For agents reading this
|
|
@@ -41,8 +60,8 @@ Follow these steps to set up agent-tools in the consumer project.
|
|
|
41
60
|
```
|
|
42
61
|
- **Non-JavaScript project** (no `package.json`): Skip installation — run tools directly with `bunx`:
|
|
43
62
|
```bash
|
|
44
|
-
bunx
|
|
45
|
-
bunx
|
|
63
|
+
bunx gh-tool pr status
|
|
64
|
+
bunx k8s-tool pods --env test
|
|
46
65
|
```
|
|
47
66
|
|
|
48
67
|
**Step 2 — Configure**
|
|
@@ -68,32 +87,54 @@ Set up the credential guard for the agent's platform:
|
|
|
68
87
|
|
|
69
88
|
For each tool the user selected in Step 2:
|
|
70
89
|
|
|
71
|
-
1. Run `
|
|
90
|
+
1. Run `bun <tool-name> --help` to verify it works (e.g. `bun gh-tool --help`)
|
|
72
91
|
2. Summarize the key commands available
|
|
73
92
|
3. Show the user what data they have access to based on their `agent-tools.json5` — e.g. which environments, clusters, namespaces, databases, or profiles are configured and reachable
|
|
74
93
|
|
|
75
94
|
**Step 5 — Skill & Agent Docs**
|
|
76
95
|
|
|
77
|
-
|
|
96
|
+
Install **only the per-tool skills the project needs** (recommended), or the all-in-one skill:
|
|
78
97
|
|
|
79
98
|
```bash
|
|
80
|
-
|
|
99
|
+
# Per-tool skills (recommended) — install only what the project uses
|
|
100
|
+
npx skills add blogic-cz/agent-tools --path skill/gh-tool
|
|
101
|
+
npx skills add blogic-cz/agent-tools --path skill/db-tool
|
|
102
|
+
npx skills add blogic-cz/agent-tools --path skill/k8s-tool
|
|
103
|
+
npx skills add blogic-cz/agent-tools --path skill/az-tool
|
|
104
|
+
npx skills add blogic-cz/agent-tools --path skill/logs-tool
|
|
105
|
+
npx skills add blogic-cz/agent-tools --path skill/session-tool
|
|
106
|
+
|
|
107
|
+
# All-in-one skill (alternative) — all tools in a single skill
|
|
108
|
+
npx skills add blogic-cz/agent-tools --path skill/agent-tools
|
|
81
109
|
```
|
|
82
110
|
|
|
83
|
-
**Do not run
|
|
111
|
+
**Do not run these commands for the user** — they require interactive selection.
|
|
112
|
+
|
|
113
|
+
Available per-tool skills:
|
|
114
|
+
|
|
115
|
+
| Skill | Install when project uses |
|
|
116
|
+
| -------------- | ------------------------------------------ |
|
|
117
|
+
| `gh-tool` | GitHub PRs, issues, workflows, CI checks |
|
|
118
|
+
| `db-tool` | SQL queries, schema introspection |
|
|
119
|
+
| `k8s-tool` | Kubernetes pods, logs, deployments |
|
|
120
|
+
| `az-tool` | Azure DevOps pipelines, builds |
|
|
121
|
+
| `logs-tool` | Application log reading (local and remote) |
|
|
122
|
+
| `session-tool` | OpenCode session history browsing |
|
|
123
|
+
| `agent-tools` | All of the above in a single skill |
|
|
84
124
|
|
|
85
125
|
Then update the project's `AGENTS.md` and/or `CLAUDE.md`:
|
|
86
126
|
|
|
87
|
-
1. Add
|
|
127
|
+
1. Add rows to the skills table for each installed skill (if one exists):
|
|
88
128
|
```markdown
|
|
89
|
-
|
|
|
129
|
+
| GitHub PRs, issues, workflows | `gh-tool` |
|
|
130
|
+
| Database queries, schema | `db-tool` |
|
|
90
131
|
```
|
|
91
132
|
2. Add or update the **Tooling** section:
|
|
92
133
|
|
|
93
134
|
```markdown
|
|
94
135
|
## Tooling
|
|
95
136
|
|
|
96
|
-
For tool wrappers and operational patterns, load `
|
|
137
|
+
For tool wrappers and operational patterns, load the relevant tool skill (`gh-tool`, `db-tool`, etc.).
|
|
97
138
|
```
|
|
98
139
|
|
|
99
140
|
**Step 6 — Custom Tool Scaffold**
|
|
@@ -146,34 +187,17 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
146
187
|
3. Run tools:
|
|
147
188
|
|
|
148
189
|
```bash
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
190
|
+
bun gh-tool pr status
|
|
191
|
+
bun k8s-tool kubectl --env test --cmd "get pods"
|
|
192
|
+
bun logs-tool list --env local
|
|
193
|
+
bun audit-tool list --limit 20
|
|
153
194
|
```
|
|
154
195
|
|
|
155
196
|
```bash
|
|
156
|
-
|
|
157
|
-
|
|
197
|
+
bun gh-tool pr review-triage # interactive summary of PR feedback
|
|
198
|
+
bun k8s-tool pods --env test # list pods (structured command)
|
|
158
199
|
```
|
|
159
200
|
|
|
160
|
-
Optionally, add script aliases to your `package.json` for shorter invocation:
|
|
161
|
-
|
|
162
|
-
```json
|
|
163
|
-
{
|
|
164
|
-
"scripts": {
|
|
165
|
-
"gh-tool": "agent-tools-gh",
|
|
166
|
-
"audit-tool": "agent-tools-audit",
|
|
167
|
-
"k8s-tool": "agent-tools-k8s",
|
|
168
|
-
"db-tool": "agent-tools-db",
|
|
169
|
-
"logs-tool": "agent-tools-logs",
|
|
170
|
-
"session-tool": "agent-tools-session"
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
Then run via `bun run k8s-tool -- pods --env test` instead of `bunx agent-tools-k8s pods --env test`.
|
|
176
|
-
|
|
177
201
|
4. Hook up the credential guard in your agent config (Claude Code, OpenCode, etc.):
|
|
178
202
|
|
|
179
203
|
```typescript
|
|
@@ -184,19 +208,19 @@ export default { handleToolExecuteBefore };
|
|
|
184
208
|
|
|
185
209
|
## Tools
|
|
186
210
|
|
|
187
|
-
| Binary
|
|
188
|
-
|
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
211
|
+
| Binary | Description |
|
|
212
|
+
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
213
|
+
| `gh-tool` | GitHub CLI wrapper — PR management, issues, workflows, composite commands (`review-triage`, `reply-and-resolve`) |
|
|
214
|
+
| `audit-tool` | Audit trail browser — inspect recent tool invocations and purge old entries |
|
|
215
|
+
| `db-tool` | Database query tool — SQL execution, schema introspection |
|
|
216
|
+
| `k8s-tool` | Kubernetes tool — kubectl wrapper + structured commands (`pods`, `logs`, `describe`, `exec`, `top`) |
|
|
217
|
+
| `az-tool` | Azure DevOps tool — pipelines, builds, repos |
|
|
218
|
+
| `logs-tool` | Application logs — read local and remote (k8s pod) logs |
|
|
219
|
+
| `session-tool` | OpenCode session browser — list, read, search sessions |
|
|
196
220
|
|
|
197
|
-
All tools support `--help` for full usage documentation.
|
|
221
|
+
All tools support `--help` for full usage documentation. Legacy `agent-tools-*` binary names (e.g. `agent-tools-gh`) still work for backwards compatibility.
|
|
198
222
|
|
|
199
|
-
`
|
|
223
|
+
`audit-tool` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
|
|
200
224
|
|
|
201
225
|
## Audit Logging
|
|
202
226
|
|
|
@@ -212,19 +236,19 @@ Entries older than `retentionDays` (default: 90) are automatically purged on eac
|
|
|
212
236
|
|
|
213
237
|
```bash
|
|
214
238
|
# Recent 20 entries (default)
|
|
215
|
-
|
|
239
|
+
bun audit-tool list
|
|
216
240
|
|
|
217
241
|
# Last 50 entries, JSON format
|
|
218
|
-
|
|
242
|
+
bun audit-tool list --limit 50 --format json
|
|
219
243
|
|
|
220
244
|
# Filter by tool
|
|
221
|
-
|
|
245
|
+
bun audit-tool list --tool gh
|
|
222
246
|
|
|
223
247
|
# Filter by project directory
|
|
224
|
-
|
|
248
|
+
bun audit-tool list --project /Users/me/my-repo
|
|
225
249
|
|
|
226
250
|
# Purge entries older than 30 days
|
|
227
|
-
|
|
251
|
+
bun audit-tool purge --days 30
|
|
228
252
|
```
|
|
229
253
|
|
|
230
254
|
### Audit Configuration
|
|
@@ -293,8 +317,8 @@ Each tool section supports multiple named profiles. Select with `--profile <name
|
|
|
293
317
|
```
|
|
294
318
|
|
|
295
319
|
```bash
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
bun az-tool cmd --cmd "pipelines list" # uses "default" profile
|
|
321
|
+
bun az-tool cmd --cmd "pipelines list" --profile legacy # uses "legacy" profile
|
|
298
322
|
```
|
|
299
323
|
|
|
300
324
|
**Profile resolution:** `--profile` flag > auto-select (single profile) > `"default"` key > error.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogic-cz/agent-tools",
|
|
3
|
-
"version": "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",
|
|
@@ -25,7 +25,14 @@
|
|
|
25
25
|
"agent-tools-gh": "./src/gh-tool/index.ts",
|
|
26
26
|
"agent-tools-k8s": "./src/k8s-tool/index.ts",
|
|
27
27
|
"agent-tools-logs": "./src/logs-tool/index.ts",
|
|
28
|
-
"agent-tools-session": "./src/session-tool/index.ts"
|
|
28
|
+
"agent-tools-session": "./src/session-tool/index.ts",
|
|
29
|
+
"audit-tool": "./src/audit-tool/index.ts",
|
|
30
|
+
"az-tool": "./src/az-tool/index.ts",
|
|
31
|
+
"db-tool": "./src/db-tool/index.ts",
|
|
32
|
+
"gh-tool": "./src/gh-tool/index.ts",
|
|
33
|
+
"k8s-tool": "./src/k8s-tool/index.ts",
|
|
34
|
+
"logs-tool": "./src/logs-tool/index.ts",
|
|
35
|
+
"session-tool": "./src/session-tool/index.ts"
|
|
29
36
|
},
|
|
30
37
|
"files": [
|
|
31
38
|
"src/",
|
package/src/gh-tool/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { repoInfoCommand, repoListCommand, repoSearchCodeCommand } from "./repo";
|
|
48
48
|
import { GitHubService } from "./service";
|
|
49
49
|
import {
|
|
50
|
+
workflowAnnotationsCommand,
|
|
50
51
|
workflowCancelCommand,
|
|
51
52
|
workflowJobLogsCommand,
|
|
52
53
|
workflowJobsCommand,
|
|
@@ -104,7 +105,7 @@ const repoCommand = Command.make("repo", {}).pipe(
|
|
|
104
105
|
|
|
105
106
|
const workflowCommand = Command.make("workflow", {}).pipe(
|
|
106
107
|
Command.withDescription(
|
|
107
|
-
"GitHub Actions workflow operations (list runs, view, jobs, logs, job-logs, rerun, cancel, watch)",
|
|
108
|
+
"GitHub Actions workflow operations (list runs, view, jobs, logs, job-logs, annotations, rerun, cancel, watch)",
|
|
108
109
|
),
|
|
109
110
|
Command.withSubcommands([
|
|
110
111
|
workflowListCommand,
|
|
@@ -112,6 +113,7 @@ const workflowCommand = Command.make("workflow", {}).pipe(
|
|
|
112
113
|
workflowJobsCommand,
|
|
113
114
|
workflowLogsCommand,
|
|
114
115
|
workflowJobLogsCommand,
|
|
116
|
+
workflowAnnotationsCommand,
|
|
115
117
|
workflowRerunCommand,
|
|
116
118
|
workflowCancelCommand,
|
|
117
119
|
workflowWatchCommand,
|
|
@@ -152,10 +154,11 @@ WORKFLOW FOR AI AGENTS:
|
|
|
152
154
|
12. Use 'workflow view --run N' to inspect a specific run with jobs/steps
|
|
153
155
|
13. Use 'workflow logs --run N' to get logs (failed jobs by default)
|
|
154
156
|
14. Use 'workflow job-logs --run N --job "build-web-app"' to get clean parsed logs for a specific job
|
|
155
|
-
15. Use 'workflow
|
|
156
|
-
16. Use '
|
|
157
|
-
17. Use 'release
|
|
158
|
-
18. Use 'release
|
|
157
|
+
15. Use 'workflow annotations --run N' to list CI annotations (errors, warnings, notices)
|
|
158
|
+
16. Use 'workflow watch --run N' to watch until completion
|
|
159
|
+
17. Use 'release status' to inspect latest release + repository context
|
|
160
|
+
18. Use 'release create --tag vX.Y.Z --generate-notes' to publish a release
|
|
161
|
+
19. Use 'release edit/view/list/delete' to maintain existing releases`,
|
|
159
162
|
),
|
|
160
163
|
Command.withSubcommands([prCommand, issueCommand, repoCommand, workflowCommand, releaseCommand]),
|
|
161
164
|
);
|
package/src/gh-tool/types.ts
CHANGED
|
@@ -96,3 +96,21 @@ export type PRStatusNone = {
|
|
|
96
96
|
};
|
|
97
97
|
|
|
98
98
|
export type PRStatusResult = PRStatusSingle | PRStatusMultiple | PRStatusNone;
|
|
99
|
+
|
|
100
|
+
export type CheckRunAnnotation = {
|
|
101
|
+
path: string;
|
|
102
|
+
start_line: number;
|
|
103
|
+
end_line: number;
|
|
104
|
+
start_column: number | null;
|
|
105
|
+
end_column: number | null;
|
|
106
|
+
annotation_level: "notice" | "warning" | "failure";
|
|
107
|
+
title: string | null;
|
|
108
|
+
message: string;
|
|
109
|
+
raw_details: string | null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type JobAnnotations = {
|
|
113
|
+
jobId: number;
|
|
114
|
+
jobName: string;
|
|
115
|
+
annotations: CheckRunAnnotation[];
|
|
116
|
+
};
|
package/src/gh-tool/workflow.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Console, Effect, Option } from "effect";
|
|
|
4
4
|
import { formatOption, logFormatted } from "#shared";
|
|
5
5
|
import { GitHubCommandError, GitHubNotFoundError } from "./errors";
|
|
6
6
|
import { GitHubService } from "./service";
|
|
7
|
+
import type { CheckRunAnnotation, JobAnnotations } from "./types";
|
|
7
8
|
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Types
|
|
@@ -235,6 +236,69 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo:
|
|
|
235
236
|
};
|
|
236
237
|
});
|
|
237
238
|
|
|
239
|
+
const fetchAnnotations = Effect.fn("workflow.fetchAnnotations")(function* (opts: {
|
|
240
|
+
runId: number;
|
|
241
|
+
job: string | null;
|
|
242
|
+
repo: string | null;
|
|
243
|
+
}) {
|
|
244
|
+
const gh = yield* GitHubService;
|
|
245
|
+
|
|
246
|
+
let owner: string;
|
|
247
|
+
let repoName: string;
|
|
248
|
+
if (opts.repo !== null) {
|
|
249
|
+
const parts = opts.repo.split("/");
|
|
250
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
251
|
+
return yield* new GitHubCommandError({
|
|
252
|
+
message: `Invalid --repo format: "${opts.repo}". Expected "owner/name" (e.g. "blogic-cz/agent-tools").`,
|
|
253
|
+
command: "workflow annotations",
|
|
254
|
+
exitCode: 1,
|
|
255
|
+
stderr: "",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
owner = parts[0];
|
|
259
|
+
repoName = parts[1];
|
|
260
|
+
} else {
|
|
261
|
+
const info = yield* gh.getRepoInfo();
|
|
262
|
+
owner = info.owner;
|
|
263
|
+
repoName = info.name;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const jobs = yield* listJobs(opts.runId, opts.repo);
|
|
267
|
+
|
|
268
|
+
let targetJobs = jobs;
|
|
269
|
+
if (opts.job !== null) {
|
|
270
|
+
const jobId = yield* resolveJobId(opts.runId, opts.job, opts.repo);
|
|
271
|
+
targetJobs = jobs.filter((j) => j.databaseId === jobId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const results: JobAnnotations[] = [];
|
|
275
|
+
for (const job of targetJobs) {
|
|
276
|
+
const annotations = yield* gh
|
|
277
|
+
.runGhJson<CheckRunAnnotation[]>([
|
|
278
|
+
"api",
|
|
279
|
+
`repos/${owner}/${repoName}/check-runs/${job.databaseId}/annotations`,
|
|
280
|
+
"--paginate",
|
|
281
|
+
])
|
|
282
|
+
.pipe(
|
|
283
|
+
Effect.catchTag("GitHubCommandError", () => Effect.succeed([] as CheckRunAnnotation[])),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (annotations.length > 0) {
|
|
287
|
+
results.push({
|
|
288
|
+
jobId: job.databaseId,
|
|
289
|
+
jobName: job.name,
|
|
290
|
+
annotations,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
runId: opts.runId,
|
|
297
|
+
totalAnnotations: results.reduce((sum, r) => sum + r.annotations.length, 0),
|
|
298
|
+
jobs: results,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
|
|
238
302
|
// ---------------------------------------------------------------------------
|
|
239
303
|
// Log parsing utilities (pure functions)
|
|
240
304
|
// ---------------------------------------------------------------------------
|
|
@@ -605,3 +669,29 @@ export const workflowJobLogsCommand = Command.make(
|
|
|
605
669
|
"Fetch parsed, clean logs for a specific job in a workflow run. Resolves job name to ID, strips timestamps/ANSI, groups by step.",
|
|
606
670
|
),
|
|
607
671
|
);
|
|
672
|
+
|
|
673
|
+
export const workflowAnnotationsCommand = Command.make(
|
|
674
|
+
"annotations",
|
|
675
|
+
{
|
|
676
|
+
format: formatOption,
|
|
677
|
+
job: Flag.string("job").pipe(
|
|
678
|
+
Flag.withDescription("Filter to a specific job name (exact or partial match)"),
|
|
679
|
+
Flag.optional,
|
|
680
|
+
),
|
|
681
|
+
repo: repoOption,
|
|
682
|
+
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
|
|
683
|
+
},
|
|
684
|
+
({ format, job, repo, run }) =>
|
|
685
|
+
Effect.gen(function* () {
|
|
686
|
+
const result = yield* fetchAnnotations({
|
|
687
|
+
runId: run,
|
|
688
|
+
job: Option.getOrNull(job),
|
|
689
|
+
repo: Option.getOrNull(repo),
|
|
690
|
+
});
|
|
691
|
+
yield* logFormatted(result, format);
|
|
692
|
+
}),
|
|
693
|
+
).pipe(
|
|
694
|
+
Command.withDescription(
|
|
695
|
+
"List annotations (errors, warnings, notices) from check runs in a workflow run. Shows problem matcher output, test failures, and other CI annotations.",
|
|
696
|
+
),
|
|
697
|
+
);
|
|
@@ -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 = {
|