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