@principal-ai/principal-view-core 0.28.8 → 0.28.10
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/dist/agent-sessions/fixture.d.ts +80 -0
- package/dist/agent-sessions/fixture.d.ts.map +1 -0
- package/dist/agent-sessions/fixture.js +130 -0
- package/dist/agent-sessions/fixture.js.map +1 -0
- package/dist/agent-sessions/index.d.ts +3 -0
- package/dist/agent-sessions/index.d.ts.map +1 -0
- package/dist/agent-sessions/index.js +6 -0
- package/dist/agent-sessions/index.js.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +8 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +22 -2
- package/dist/node.js.map +1 -1
- package/dist/opencode/OpenCodeEventStore.d.ts +25 -0
- package/dist/opencode/OpenCodeEventStore.d.ts.map +1 -0
- package/dist/opencode/OpenCodeEventStore.js +182 -0
- package/dist/opencode/OpenCodeEventStore.js.map +1 -0
- package/dist/opencode/agent-sessions.d.ts +46 -0
- package/dist/opencode/agent-sessions.d.ts.map +1 -0
- package/dist/opencode/agent-sessions.js +219 -0
- package/dist/opencode/agent-sessions.js.map +1 -0
- package/dist/opencode/index.d.ts +6 -0
- package/dist/opencode/index.d.ts.map +1 -0
- package/dist/opencode/index.js +15 -0
- package/dist/opencode/index.js.map +1 -0
- package/dist/opencode/node-path-adapter.d.ts +25 -0
- package/dist/opencode/node-path-adapter.d.ts.map +1 -0
- package/dist/opencode/node-path-adapter.js +183 -0
- package/dist/opencode/node-path-adapter.js.map +1 -0
- package/dist/opencode/pipeline.d.ts +42 -0
- package/dist/opencode/pipeline.d.ts.map +1 -0
- package/dist/opencode/pipeline.js +90 -0
- package/dist/opencode/pipeline.js.map +1 -0
- package/dist/opencode/types.d.ts +39 -0
- package/dist/opencode/types.d.ts.map +1 -0
- package/dist/opencode/types.js +3 -0
- package/dist/opencode/types.js.map +1 -0
- package/dist/storage/topic-types.d.ts +14 -0
- package/dist/storage/topic-types.d.ts.map +1 -1
- package/dist/storage/topic-types.js +1 -0
- package/dist/storage/topic-types.js.map +1 -1
- package/dist/storage/topicStore.d.ts +1 -1
- package/dist/storage/topicStore.d.ts.map +1 -1
- package/package.json +10 -1
- package/src/agent-sessions/fixture.ts +225 -0
- package/src/agent-sessions/index.ts +10 -0
- package/src/index.ts +1 -0
- package/src/node.ts +31 -0
- package/src/opencode/EVENT_MAPPING.md +82 -0
- package/src/opencode/OpenCodeEventStore.ts +214 -0
- package/src/opencode/__fixtures__/event-shapes.json +443 -0
- package/src/opencode/__fixtures__/session-events.json +13491 -0
- package/src/opencode/agent-sessions.ts +282 -0
- package/src/opencode/index.ts +23 -0
- package/src/opencode/node-path-adapter.ts +199 -0
- package/src/opencode/pipeline.ts +141 -0
- package/src/opencode/types.ts +43 -0
- package/src/storage/topic-types.ts +15 -0
- package/src/storage/topicStore.test.ts +38 -1
- package/src/storage/topicStore.ts +1 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClineSessionReader,
|
|
3
|
+
GrokSessionReader,
|
|
4
|
+
PiSessionReader,
|
|
5
|
+
type UniversalAgentSessionEvent,
|
|
6
|
+
} from "@principal-ai/agent-monitoring";
|
|
7
|
+
import { OpenCodeEventStore } from "./OpenCodeEventStore";
|
|
8
|
+
import {
|
|
9
|
+
NodePathNormalizationAdapter,
|
|
10
|
+
accumulateEvents,
|
|
11
|
+
collectRepositories,
|
|
12
|
+
normalizeEvents,
|
|
13
|
+
normalizeEventsWithAdapter,
|
|
14
|
+
opencodeRowsToUniversalEvents,
|
|
15
|
+
} from "./pipeline";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
NodePathNormalizationAdapter,
|
|
19
|
+
normalizeEvents,
|
|
20
|
+
normalizeEventsWithAdapter,
|
|
21
|
+
accumulateEvents,
|
|
22
|
+
collectRepositories,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Shared, agent-agnostic session access: list + fetch across Cline, opencode,
|
|
27
|
+
* pi, and Grok, normalizing raw events into repo-aware universal events.
|
|
28
|
+
*
|
|
29
|
+
* This is the single source of truth for CLI and trail-viewer session pulling.
|
|
30
|
+
* opencode listing delegates to `OpenCodeEventStore.listSessionsWithSummaries()`,
|
|
31
|
+
* which already builds parent/child groups and excludes subagent (child) sessions
|
|
32
|
+
* from the standalone list.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export type SupportedSessionAgent = "cline" | "opencode" | "pi" | "grok" | "unknown";
|
|
36
|
+
|
|
37
|
+
export interface AgentSessionSummary {
|
|
38
|
+
agent: SupportedSessionAgent;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
title: string;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
eventCount: number;
|
|
43
|
+
isFinished: boolean;
|
|
44
|
+
parentID?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgentSessionMeta {
|
|
48
|
+
sessionName: string;
|
|
49
|
+
sessionTask: string;
|
|
50
|
+
workingDirectory: string;
|
|
51
|
+
createdAt?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const clineReader = new ClineSessionReader();
|
|
55
|
+
const piReader = new PiSessionReader();
|
|
56
|
+
const grokReader = new GrokSessionReader();
|
|
57
|
+
|
|
58
|
+
function isClineSession(sessionId: string): boolean {
|
|
59
|
+
return clineReader.readSession(sessionId) !== null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isPiSession(sessionId: string): boolean {
|
|
63
|
+
return piReader.readSession(sessionId) !== null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isGrokSession(sessionId: string): boolean {
|
|
67
|
+
return grokReader.readSession(sessionId) !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** List Cline CLI sessions from the durable on-disk transcript. */
|
|
71
|
+
function listClineSessions(): AgentSessionSummary[] {
|
|
72
|
+
return clineReader.listSessions().map((record) => {
|
|
73
|
+
const meta = record.metadata;
|
|
74
|
+
const promptText = (meta.prompt ?? "").trim();
|
|
75
|
+
const title =
|
|
76
|
+
promptText.length > 0
|
|
77
|
+
? promptText.length > 80
|
|
78
|
+
? `${promptText.slice(0, 80)}…`
|
|
79
|
+
: promptText
|
|
80
|
+
: "Cline session";
|
|
81
|
+
return {
|
|
82
|
+
agent: "cline" as const,
|
|
83
|
+
sessionId: record.sessionId,
|
|
84
|
+
title,
|
|
85
|
+
createdAt: meta.started_at ?? "",
|
|
86
|
+
eventCount: clineReader.readMessages(record.sessionId)?.messages.length ?? 0,
|
|
87
|
+
isFinished: meta.status === "completed" || meta.status === "failed",
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** List pi CLI sessions from the durable on-disk JSONL transcripts. */
|
|
93
|
+
function listPiSessions(): AgentSessionSummary[] {
|
|
94
|
+
return piReader.listSessions().map((record) => {
|
|
95
|
+
const promptText = (record.firstPrompt ?? "").trim();
|
|
96
|
+
const title =
|
|
97
|
+
promptText.length > 0
|
|
98
|
+
? promptText.length > 80
|
|
99
|
+
? `${promptText.slice(0, 80)}…`
|
|
100
|
+
: promptText
|
|
101
|
+
: "pi session";
|
|
102
|
+
return {
|
|
103
|
+
agent: "pi" as const,
|
|
104
|
+
sessionId: record.sessionId,
|
|
105
|
+
title,
|
|
106
|
+
createdAt: record.header.timestamp ?? "",
|
|
107
|
+
eventCount: record.messageCount,
|
|
108
|
+
// pi transcripts carry no finished marker; the trail-viewer appends a
|
|
109
|
+
// synthesized session-end event at read time.
|
|
110
|
+
isFinished: false,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** List Grok Build sessions from ~/.grok/sessions durable JSONL. */
|
|
116
|
+
function listGrokSessions(): AgentSessionSummary[] {
|
|
117
|
+
return grokReader.listSessions().map((record) => {
|
|
118
|
+
return {
|
|
119
|
+
agent: "grok" as const,
|
|
120
|
+
sessionId: record.sessionId,
|
|
121
|
+
title: record.title || "Grok session",
|
|
122
|
+
createdAt: record.createdAt,
|
|
123
|
+
eventCount: record.messageCount,
|
|
124
|
+
isFinished: false,
|
|
125
|
+
parentID: record.parentSessionId,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* List opencode sessions, top-level only by default. Delegates to the store's
|
|
132
|
+
* grouped `listSessionsWithSummaries()` which excludes subagent (child) sessions.
|
|
133
|
+
*/
|
|
134
|
+
function listOpencodeSessions(dbPath?: string): AgentSessionSummary[] {
|
|
135
|
+
const store = new OpenCodeEventStore({ dbPath });
|
|
136
|
+
try {
|
|
137
|
+
const result = store.listSessionsWithSummaries(50);
|
|
138
|
+
const toSummary = (s: {
|
|
139
|
+
id: string;
|
|
140
|
+
title: string;
|
|
141
|
+
createdAt: string;
|
|
142
|
+
eventCount: number;
|
|
143
|
+
isFinished: boolean;
|
|
144
|
+
}): AgentSessionSummary => ({
|
|
145
|
+
agent: "opencode",
|
|
146
|
+
sessionId: s.id,
|
|
147
|
+
title: s.title,
|
|
148
|
+
createdAt: s.createdAt,
|
|
149
|
+
eventCount: s.eventCount,
|
|
150
|
+
isFinished: s.isFinished,
|
|
151
|
+
});
|
|
152
|
+
const standalone: AgentSessionSummary[] = [
|
|
153
|
+
...result.standalone.map(toSummary),
|
|
154
|
+
...result.groups.map((g) => toSummary(g.parent)),
|
|
155
|
+
];
|
|
156
|
+
// Sort newest-first by createdAt
|
|
157
|
+
return standalone.sort((a, b) =>
|
|
158
|
+
b.createdAt < a.createdAt ? -1 : b.createdAt > a.createdAt ? 1 : 0,
|
|
159
|
+
);
|
|
160
|
+
} finally {
|
|
161
|
+
store.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** List sessions from all supported agents. Top-level only by default. */
|
|
166
|
+
export function listAgentSessions(
|
|
167
|
+
options: { dbPath?: string } = {},
|
|
168
|
+
): AgentSessionSummary[] {
|
|
169
|
+
const all: AgentSessionSummary[] = [];
|
|
170
|
+
let firstError: Error | null = null;
|
|
171
|
+
try {
|
|
172
|
+
all.push(...listOpencodeSessions(options.dbPath));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
firstError = err as Error;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
all.push(...listClineSessions());
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (!firstError) firstError = err as Error;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
all.push(...listPiSessions());
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!firstError) firstError = err as Error;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
all.push(...listGrokSessions());
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (!firstError) firstError = err as Error;
|
|
190
|
+
}
|
|
191
|
+
if (all.length === 0 && firstError) {
|
|
192
|
+
throw firstError;
|
|
193
|
+
}
|
|
194
|
+
return all;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Detect which agent a session id belongs to. */
|
|
198
|
+
export function detectAgent(
|
|
199
|
+
sessionId: string,
|
|
200
|
+
): "cline" | "opencode" | "pi" | "grok" {
|
|
201
|
+
if (isClineSession(sessionId)) return "cline";
|
|
202
|
+
if (isPiSession(sessionId)) return "pi";
|
|
203
|
+
if (isGrokSession(sessionId)) return "grok";
|
|
204
|
+
return "opencode";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fetch a session's raw universal events from the appropriate source.
|
|
209
|
+
*/
|
|
210
|
+
export function fetchRawEvents(
|
|
211
|
+
sessionId: string,
|
|
212
|
+
options: {
|
|
213
|
+
agent?: "cline" | "opencode" | "pi" | "grok";
|
|
214
|
+
dbPath?: string;
|
|
215
|
+
} = {},
|
|
216
|
+
): {
|
|
217
|
+
agent: "cline" | "opencode" | "pi" | "grok";
|
|
218
|
+
events: UniversalAgentSessionEvent[];
|
|
219
|
+
sessionMeta: AgentSessionMeta;
|
|
220
|
+
} {
|
|
221
|
+
const agent = options.agent ?? detectAgent(sessionId);
|
|
222
|
+
if (agent === "cline") {
|
|
223
|
+
const record = clineReader.readSession(sessionId);
|
|
224
|
+
const promptText = (record?.metadata.prompt ?? "").trim();
|
|
225
|
+
return {
|
|
226
|
+
agent,
|
|
227
|
+
events: clineReader.toUniversalEvents(sessionId),
|
|
228
|
+
sessionMeta: {
|
|
229
|
+
sessionName: "cline",
|
|
230
|
+
sessionTask:
|
|
231
|
+
(promptText.length > 80 ? `${promptText.slice(0, 80)}…` : promptText) ||
|
|
232
|
+
"Cline session",
|
|
233
|
+
workingDirectory: record?.metadata.workspace_root ?? record?.metadata.cwd ?? "",
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (agent === "pi") {
|
|
238
|
+
const record = piReader.readSession(sessionId);
|
|
239
|
+
const promptText = (record?.firstPrompt ?? "").trim();
|
|
240
|
+
return {
|
|
241
|
+
agent,
|
|
242
|
+
events: piReader.toUniversalEvents(sessionId),
|
|
243
|
+
sessionMeta: {
|
|
244
|
+
sessionName: "pi",
|
|
245
|
+
sessionTask:
|
|
246
|
+
(promptText.length > 80 ? `${promptText.slice(0, 80)}…` : promptText) || "pi session",
|
|
247
|
+
workingDirectory: "",
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (agent === "grok") {
|
|
252
|
+
const record = grokReader.readSession(sessionId);
|
|
253
|
+
return {
|
|
254
|
+
agent,
|
|
255
|
+
events: grokReader.toUniversalEvents(sessionId),
|
|
256
|
+
sessionMeta: {
|
|
257
|
+
sessionName: "grok",
|
|
258
|
+
sessionTask: record?.title || "Grok session",
|
|
259
|
+
workingDirectory: record?.cwd ?? "",
|
|
260
|
+
createdAt: record?.createdAt,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const store = new OpenCodeEventStore({ dbPath: options.dbPath });
|
|
265
|
+
try {
|
|
266
|
+
const { events } = store.readAggregate(sessionId, { limit: 10000 });
|
|
267
|
+
const meta = store.getSessionMeta(sessionId);
|
|
268
|
+
const title =
|
|
269
|
+
meta.title && !meta.title.startsWith("New session") ? meta.title : meta.slug;
|
|
270
|
+
return {
|
|
271
|
+
agent,
|
|
272
|
+
events: opencodeRowsToUniversalEvents(events),
|
|
273
|
+
sessionMeta: {
|
|
274
|
+
sessionName: meta.slug || "opencode",
|
|
275
|
+
sessionTask: title || meta.slug || "opencode",
|
|
276
|
+
workingDirectory: meta.directory,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
} finally {
|
|
280
|
+
store.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
OpenCodeRawEvent,
|
|
3
|
+
OpenCodeSessionEvents,
|
|
4
|
+
OpenCodeEventRetriever,
|
|
5
|
+
OpenCodeStoreOptions,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export { listAgentSessions, detectAgent, fetchRawEvents } from "./agent-sessions";
|
|
9
|
+
export type {
|
|
10
|
+
AgentSessionSummary,
|
|
11
|
+
AgentSessionMeta,
|
|
12
|
+
SupportedSessionAgent,
|
|
13
|
+
} from "./agent-sessions";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
NodePathNormalizationAdapter,
|
|
17
|
+
normalizeEvents,
|
|
18
|
+
normalizeEventsWithAdapter,
|
|
19
|
+
accumulateEvents,
|
|
20
|
+
collectRepositories,
|
|
21
|
+
opencodeRowsToUniversalEvents,
|
|
22
|
+
} from "./pipeline";
|
|
23
|
+
export type { OpenCodeEventRow } from "./pipeline";
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute as pathIsAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import type {
|
|
6
|
+
PathNormalizationAdapter,
|
|
7
|
+
RepositoryInfo,
|
|
8
|
+
SystemInfo,
|
|
9
|
+
} from "@principal-ai/agent-monitoring";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* NodePathNormalizationAdapter — a Node.js implementation of the
|
|
13
|
+
* `PathNormalizationAdapter` interface from `@principal-ai/agent-monitoring`.
|
|
14
|
+
*
|
|
15
|
+
* This is the shared, agent-agnostic adapter used by the CLI's `agent-session`
|
|
16
|
+
* command. It discovers git roots by walking up directories and enriches them
|
|
17
|
+
* with git metadata via the `git` CLI (spawnSync). It keeps the same interface
|
|
18
|
+
* so `PathNormalizationService` can be shared verbatim. It is the Node.js
|
|
19
|
+
* analogue of the trail-viewer's `BunNormalizationAdapter` and the desktop
|
|
20
|
+
* app's `ServerPathNormalizationAdapter`.
|
|
21
|
+
*/
|
|
22
|
+
function gitCommand(cwd: string, subcommand: string): string | undefined {
|
|
23
|
+
const result = spawnSync("git", ["-C", cwd, ...subcommand.split(" ")], {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
timeout: 5000,
|
|
27
|
+
});
|
|
28
|
+
if (result.status !== 0 || !result.stdout) return undefined;
|
|
29
|
+
return result.stdout.trim() || undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Basic GitHub owner/repo extraction from a remote URL. */
|
|
33
|
+
function githubIdentityFromRemoteUrl(remoteUrl: string): {
|
|
34
|
+
owner: string;
|
|
35
|
+
name: string;
|
|
36
|
+
} | null {
|
|
37
|
+
const cleaned = remoteUrl
|
|
38
|
+
.replace(/^git@github\.com:/, "")
|
|
39
|
+
.replace(/^https?:\/\/github\.com\//, "")
|
|
40
|
+
.replace(/\.git$/, "");
|
|
41
|
+
const parts = cleaned.split("/").filter(Boolean);
|
|
42
|
+
if (parts.length >= 2) {
|
|
43
|
+
return { owner: parts[0], name: parts[1] };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class NodePathNormalizationAdapter implements PathNormalizationAdapter {
|
|
49
|
+
private homeDir: string;
|
|
50
|
+
private gitRootCache = new Map<string, string | null>();
|
|
51
|
+
private canonicalCaseCache = new Map<string, string>();
|
|
52
|
+
private knownRoots: Map<string, RepositoryInfo>;
|
|
53
|
+
/** Roots discovered via .git walk-up that weren't in the initial knownRoots */
|
|
54
|
+
readonly newlyDiscovered: RepositoryInfo[] = [];
|
|
55
|
+
|
|
56
|
+
constructor(knownRoots?: Map<string, RepositoryInfo>) {
|
|
57
|
+
this.homeDir = os.homedir();
|
|
58
|
+
this.knownRoots = knownRoots ?? new Map();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the on-disk spelling of a directory path segment by segment. On
|
|
63
|
+
* case-insensitive filesystems (macOS APFS) the same directory is reachable
|
|
64
|
+
* under any casing, so git-root discovery can surface `/repo` and `/Repo` as
|
|
65
|
+
* two distinct roots unless we pin them to the actual on-disk spelling.
|
|
66
|
+
*/
|
|
67
|
+
private async canonicalizeCase(input: string): Promise<string> {
|
|
68
|
+
const cached = this.canonicalCaseCache.get(input);
|
|
69
|
+
if (cached !== undefined) return cached;
|
|
70
|
+
const segments = input.split("/").filter(Boolean);
|
|
71
|
+
let current = "/";
|
|
72
|
+
for (const seg of segments) {
|
|
73
|
+
let real = seg;
|
|
74
|
+
try {
|
|
75
|
+
const entries = await fs.readdir(current);
|
|
76
|
+
const match = entries.find(
|
|
77
|
+
(name) => name.toLowerCase() === seg.toLowerCase(),
|
|
78
|
+
);
|
|
79
|
+
if (match) real = match;
|
|
80
|
+
} catch {
|
|
81
|
+
// segment vanished; keep the given spelling
|
|
82
|
+
}
|
|
83
|
+
current = current === "/" ? `/${real}` : `${current}/${real}`;
|
|
84
|
+
}
|
|
85
|
+
this.canonicalCaseCache.set(input, current);
|
|
86
|
+
return current;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getRawRepositoryInfo(
|
|
90
|
+
absolutePath: string,
|
|
91
|
+
): Promise<RepositoryInfo | null> {
|
|
92
|
+
// Canonicalize the input: strip trailing slashes and collapse redundant
|
|
93
|
+
// separators (e.g. a tool that reads a directory as "/repo/" vs "/repo").
|
|
94
|
+
// findGitRoot returns its input verbatim when it IS the git root, so an
|
|
95
|
+
// uncanonical input would surface the same repo under two distinct root
|
|
96
|
+
// strings and duplicate it in the session's repo list. NOTE: use resolve,
|
|
97
|
+
// not normalize — path.normalize preserves trailing slashes.
|
|
98
|
+
const target = resolve(absolutePath);
|
|
99
|
+
|
|
100
|
+
// Fast path: prefix-match against known roots (zero I/O).
|
|
101
|
+
// Match on a path boundary so sibling dirs (/repo vs /repo2) don't collide.
|
|
102
|
+
let bestMatch: RepositoryInfo | null = null;
|
|
103
|
+
let bestLen = 0;
|
|
104
|
+
for (const [root, info] of this.knownRoots) {
|
|
105
|
+
const rootCanon = resolve(root);
|
|
106
|
+
if ((target === rootCanon || target.startsWith(`${rootCanon}/`)) && rootCanon.length > bestLen) {
|
|
107
|
+
bestMatch = info;
|
|
108
|
+
bestLen = rootCanon.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (bestMatch) return bestMatch;
|
|
112
|
+
|
|
113
|
+
const gitRoot = await this.findGitRoot(target);
|
|
114
|
+
if (!gitRoot) return null;
|
|
115
|
+
|
|
116
|
+
// Pin the root to its on-disk casing so a case-variant spelling of the same
|
|
117
|
+
// directory never surfaces as a second repo (see canonicalizeCase).
|
|
118
|
+
const canonicalRoot = await this.canonicalizeCase(gitRoot);
|
|
119
|
+
|
|
120
|
+
const remoteUrl = gitCommand(canonicalRoot, "remote get-url origin");
|
|
121
|
+
const branch = gitCommand(canonicalRoot, "rev-parse --abbrev-ref HEAD");
|
|
122
|
+
const headCommit = gitCommand(canonicalRoot, "rev-parse HEAD");
|
|
123
|
+
|
|
124
|
+
let owner: string | undefined;
|
|
125
|
+
let repo: string | undefined;
|
|
126
|
+
if (remoteUrl) {
|
|
127
|
+
const identity = githubIdentityFromRemoteUrl(remoteUrl);
|
|
128
|
+
if (identity) {
|
|
129
|
+
owner = identity.owner;
|
|
130
|
+
repo = identity.name;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!repo) {
|
|
134
|
+
const parts = canonicalRoot.replace(/\/+$/, "").split("/");
|
|
135
|
+
repo = parts[parts.length - 1] ?? undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const info: RepositoryInfo = {
|
|
139
|
+
root: canonicalRoot,
|
|
140
|
+
remoteUrl,
|
|
141
|
+
owner,
|
|
142
|
+
repo,
|
|
143
|
+
branch,
|
|
144
|
+
headCommit,
|
|
145
|
+
};
|
|
146
|
+
if (!this.knownRoots.has(canonicalRoot)) {
|
|
147
|
+
this.knownRoots.set(canonicalRoot, info);
|
|
148
|
+
this.newlyDiscovered.push(info);
|
|
149
|
+
}
|
|
150
|
+
return info;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async findGitRoot(dir: string): Promise<string | null> {
|
|
154
|
+
const cached = this.gitRootCache.get(dir);
|
|
155
|
+
if (cached !== undefined) return cached;
|
|
156
|
+
try {
|
|
157
|
+
const s = await fs.stat(resolve(dir, ".git"));
|
|
158
|
+
if (s.isDirectory() || s.isFile()) {
|
|
159
|
+
this.gitRootCache.set(dir, dir);
|
|
160
|
+
return dir;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// not a git root; walk up
|
|
164
|
+
}
|
|
165
|
+
const parent = dirname(dir);
|
|
166
|
+
if (parent === dir || parent.length >= dir.length) {
|
|
167
|
+
this.gitRootCache.set(dir, null);
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const result = await this.findGitRoot(parent);
|
|
171
|
+
this.gitRootCache.set(dir, result);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getSystemInfo(): SystemInfo {
|
|
176
|
+
return {
|
|
177
|
+
homeDir: this.homeDir,
|
|
178
|
+
pathSeparator: "/",
|
|
179
|
+
platform: process.platform,
|
|
180
|
+
machineId: "",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
resolvePath(relativePath: string, workingDirectory: string): string {
|
|
185
|
+
return resolve(workingDirectory, relativePath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
isAbsolutePath(path: string): boolean {
|
|
189
|
+
return pathIsAbsolute(path);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getRelativePath(fromPath: string, toPath: string): string {
|
|
193
|
+
return relative(fromPath, toPath);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
isAvailable(): boolean {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PathNormalizationService,
|
|
3
|
+
V1EventBridgeProcessor,
|
|
4
|
+
createAccumulatedState,
|
|
5
|
+
eventOp,
|
|
6
|
+
type RepoNormalizedUniversalAgentSessionEvent,
|
|
7
|
+
type RepositoryInfo,
|
|
8
|
+
type UniversalAgentSessionEvent,
|
|
9
|
+
} from "@principal-ai/agent-monitoring";
|
|
10
|
+
import { NodePathNormalizationAdapter } from "./node-path-adapter";
|
|
11
|
+
|
|
12
|
+
export { NodePathNormalizationAdapter };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pure, agent-agnostic session pipeline shared by CLI and trail-viewer.
|
|
16
|
+
*
|
|
17
|
+
* This module intentionally imports NO better-sqlite3 / OpenCodeEventStore so
|
|
18
|
+
* it can be loaded in any runtime (the trail-viewer runs under Bun, where
|
|
19
|
+
* better-sqlite3's native NAPI binding crashes). Runtime-specific DB access
|
|
20
|
+
* feeds this pipeline; everything after the raw rows are in is shared.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A raw opencode event DB row, before JSON decoding of its `data` payload. */
|
|
24
|
+
export interface OpenCodeEventRow {
|
|
25
|
+
id: string;
|
|
26
|
+
aggregateId: string;
|
|
27
|
+
seq: number;
|
|
28
|
+
type: string;
|
|
29
|
+
data: string | Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert raw opencode event rows into universal events. Handles both string
|
|
34
|
+
* (SQLite TEXT, unparsed) and already-parsed object `data` payloads.
|
|
35
|
+
*/
|
|
36
|
+
export function opencodeRowsToUniversalEvents(
|
|
37
|
+
rows: OpenCodeEventRow[],
|
|
38
|
+
): UniversalAgentSessionEvent[] {
|
|
39
|
+
const processor = new V1EventBridgeProcessor();
|
|
40
|
+
return rows.map((row) =>
|
|
41
|
+
processor.normalize({
|
|
42
|
+
type: row.type as
|
|
43
|
+
| "session.created.1"
|
|
44
|
+
| "session.updated.1"
|
|
45
|
+
| "message.updated.1"
|
|
46
|
+
| "message.part.updated.1"
|
|
47
|
+
| "message.removed.1",
|
|
48
|
+
data:
|
|
49
|
+
typeof row.data === "string"
|
|
50
|
+
? (JSON.parse(row.data) as Record<string, unknown>)
|
|
51
|
+
: row.data,
|
|
52
|
+
id: row.id,
|
|
53
|
+
aggregateId: row.aggregateId,
|
|
54
|
+
seq: row.seq,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Normalize raw universal events into repo-normalized universal events. */
|
|
60
|
+
export async function normalizeEvents(
|
|
61
|
+
events: UniversalAgentSessionEvent[],
|
|
62
|
+
workingDirectory = "",
|
|
63
|
+
knownRoots?: Map<string, RepositoryInfo>,
|
|
64
|
+
): Promise<RepoNormalizedUniversalAgentSessionEvent[]> {
|
|
65
|
+
const { normalized } = await normalizeEventsWithAdapter(events, workingDirectory, knownRoots);
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Like `normalizeEvents`, but also returns the adapter so callers can inspect
|
|
71
|
+
* `adapter.newlyDiscovered` for auto-registration of newly found git roots.
|
|
72
|
+
*/
|
|
73
|
+
export async function normalizeEventsWithAdapter(
|
|
74
|
+
events: UniversalAgentSessionEvent[],
|
|
75
|
+
workingDirectory = "",
|
|
76
|
+
knownRoots?: Map<string, RepositoryInfo>,
|
|
77
|
+
): Promise<{ normalized: RepoNormalizedUniversalAgentSessionEvent[]; adapter: NodePathNormalizationAdapter }> {
|
|
78
|
+
const adapter = new NodePathNormalizationAdapter(knownRoots);
|
|
79
|
+
const service = new PathNormalizationService(adapter);
|
|
80
|
+
const normalized = await service.normalizePathsBatch(events, workingDirectory);
|
|
81
|
+
return { normalized, adapter };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compute the accumulated agent-session event rows for a normalized session. */
|
|
85
|
+
export function accumulateEvents(
|
|
86
|
+
normalizedEvents: RepoNormalizedUniversalAgentSessionEvent[],
|
|
87
|
+
sessionTitle = "",
|
|
88
|
+
): Array<{ normalized: RepoNormalizedUniversalAgentSessionEvent; accumulated: unknown }> {
|
|
89
|
+
const accState = createAccumulatedState(sessionTitle);
|
|
90
|
+
return normalizedEvents.map((normalizedEvent) => ({
|
|
91
|
+
normalized: normalizedEvent,
|
|
92
|
+
accumulated: eventOp(accState, normalizedEvent),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Collect the distinct repositories referenced across a normalized session. */
|
|
97
|
+
export function collectRepositories(
|
|
98
|
+
normalizedEvents: RepoNormalizedUniversalAgentSessionEvent[],
|
|
99
|
+
): RepositoryInfo[] {
|
|
100
|
+
const byRoot = new Map<string, RepositoryInfo>();
|
|
101
|
+
|
|
102
|
+
// Event-level repository context uses the RepositoryInfo shape (key: `root`).
|
|
103
|
+
const putEventRepo = (root: string, repo: RepositoryInfo) => {
|
|
104
|
+
byRoot.set(root, {
|
|
105
|
+
root,
|
|
106
|
+
remoteUrl: repo.remoteUrl,
|
|
107
|
+
owner: repo.owner,
|
|
108
|
+
repo: repo.repo,
|
|
109
|
+
branch: repo.branch,
|
|
110
|
+
headCommit: repo.headCommit,
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Per-file repository uses the NormalizedPathInfo shape (key: `gitRoot`, no
|
|
115
|
+
// branch/headCommit). Normalize into the RepositoryInfo shape.
|
|
116
|
+
const putFileRepo = (
|
|
117
|
+
root: string,
|
|
118
|
+
repo: { remoteUrl?: string; owner?: string; repo?: string },
|
|
119
|
+
) => {
|
|
120
|
+
const existing = byRoot.get(root);
|
|
121
|
+
byRoot.set(root, {
|
|
122
|
+
root,
|
|
123
|
+
remoteUrl: repo.remoteUrl ?? existing?.remoteUrl,
|
|
124
|
+
owner: repo.owner ?? existing?.owner,
|
|
125
|
+
repo: repo.repo ?? existing?.repo,
|
|
126
|
+
branch: existing?.branch,
|
|
127
|
+
headCommit: existing?.headCommit,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const event of normalizedEvents) {
|
|
132
|
+
if (event.repository?.root) putEventRepo(event.repository.root, event.repository);
|
|
133
|
+
for (const file of event.files ?? []) {
|
|
134
|
+
const fileRepo = file.repository;
|
|
135
|
+
if (fileRepo?.gitRoot) {
|
|
136
|
+
putFileRepo(fileRepo.gitRoot, fileRepo);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return Array.from(byRoot.values());
|
|
141
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface OpenCodeRawEvent {
|
|
2
|
+
id: string
|
|
3
|
+
aggregateId: string
|
|
4
|
+
seq: number
|
|
5
|
+
type: string
|
|
6
|
+
data: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OpenCodeSessionEvents {
|
|
10
|
+
sessionId: string
|
|
11
|
+
events: OpenCodeRawEvent[]
|
|
12
|
+
hasMore: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OpenCodeEventRetriever {
|
|
16
|
+
readAggregate(
|
|
17
|
+
sessionId: string,
|
|
18
|
+
options?: { after?: number; limit?: number }
|
|
19
|
+
): OpenCodeSessionEvents
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OpenCodeStoreOptions {
|
|
23
|
+
dbPath?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SessionSummary {
|
|
27
|
+
id: string
|
|
28
|
+
title: string
|
|
29
|
+
slug: string
|
|
30
|
+
createdAt: string
|
|
31
|
+
durationMs: number
|
|
32
|
+
eventCount: number
|
|
33
|
+
isFinished: boolean
|
|
34
|
+
children: SessionSummary[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SessionListResult {
|
|
38
|
+
groups: Array<{
|
|
39
|
+
parent: SessionSummary
|
|
40
|
+
children: SessionSummary[]
|
|
41
|
+
}>
|
|
42
|
+
standalone: SessionSummary[]
|
|
43
|
+
}
|