177 lines
5.1 KiB
TypeScript
177 lines
5.1 KiB
TypeScript
/**
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { Response } from './response.js';
|
|
import { logUnhandledError } from './utils/log.js';
|
|
import { outputFile } from './config.js';
|
|
|
|
import type { FullConfig } from './config.js';
|
|
import type * as actions from './actions.js';
|
|
import type { Tab, TabSnapshot } from './tab.js';
|
|
|
|
type LogEntry = {
|
|
timestamp: number;
|
|
toolCall?: {
|
|
toolName: string;
|
|
toolArgs: Record<string, any>;
|
|
result: string;
|
|
isError?: boolean;
|
|
};
|
|
userAction?: actions.Action;
|
|
code: string;
|
|
tabSnapshot?: TabSnapshot;
|
|
};
|
|
|
|
export class SessionLog {
|
|
private _folder: string;
|
|
private _file: string;
|
|
private _ordinal = 0;
|
|
private _pendingEntries: LogEntry[] = [];
|
|
private _sessionFileQueue = Promise.resolve();
|
|
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
|
|
|
|
constructor(sessionFolder: string) {
|
|
this._folder = sessionFolder;
|
|
this._file = path.join(this._folder, 'session.md');
|
|
}
|
|
|
|
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
|
|
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
|
|
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
|
// eslint-disable-next-line no-console
|
|
console.error(`Session: ${sessionFolder}`);
|
|
return new SessionLog(sessionFolder);
|
|
}
|
|
|
|
logResponse(response: Response) {
|
|
const entry: LogEntry = {
|
|
timestamp: performance.now(),
|
|
toolCall: {
|
|
toolName: response.toolName,
|
|
toolArgs: response.toolArgs,
|
|
result: response.result(),
|
|
isError: response.isError(),
|
|
},
|
|
code: response.code(),
|
|
tabSnapshot: response.tabSnapshot(),
|
|
};
|
|
this._appendEntry(entry);
|
|
}
|
|
|
|
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
|
|
code = code.trim();
|
|
if (isUpdate) {
|
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
|
if (lastEntry.userAction?.name === action.name) {
|
|
lastEntry.userAction = action;
|
|
lastEntry.code = code;
|
|
return;
|
|
}
|
|
}
|
|
if (action.name === 'navigate') {
|
|
// Already logged at this location.
|
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
|
if (lastEntry?.tabSnapshot?.url === action.url)
|
|
return;
|
|
}
|
|
const entry: LogEntry = {
|
|
timestamp: performance.now(),
|
|
userAction: action,
|
|
code,
|
|
tabSnapshot: {
|
|
url: tab.page.url(),
|
|
title: '',
|
|
ariaSnapshot: action.ariaSnapshot || '',
|
|
modalStates: [],
|
|
consoleMessages: [],
|
|
downloads: [],
|
|
},
|
|
};
|
|
this._appendEntry(entry);
|
|
}
|
|
|
|
private _appendEntry(entry: LogEntry) {
|
|
this._pendingEntries.push(entry);
|
|
if (this._flushEntriesTimeout)
|
|
clearTimeout(this._flushEntriesTimeout);
|
|
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
|
|
}
|
|
|
|
private async _flushEntries() {
|
|
clearTimeout(this._flushEntriesTimeout);
|
|
const entries = this._pendingEntries;
|
|
this._pendingEntries = [];
|
|
const lines: string[] = [''];
|
|
|
|
for (const entry of entries) {
|
|
const ordinal = (++this._ordinal).toString().padStart(3, '0');
|
|
if (entry.toolCall) {
|
|
lines.push(
|
|
`### Tool call: ${entry.toolCall.toolName}`,
|
|
`- Args`,
|
|
'```json',
|
|
JSON.stringify(entry.toolCall.toolArgs, null, 2),
|
|
'```',
|
|
);
|
|
if (entry.toolCall.result) {
|
|
lines.push(
|
|
entry.toolCall.isError ? `- Error` : `- Result`,
|
|
'```',
|
|
entry.toolCall.result,
|
|
'```',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (entry.userAction) {
|
|
const actionData = { ...entry.userAction } as any;
|
|
delete actionData.ariaSnapshot;
|
|
delete actionData.selector;
|
|
delete actionData.signals;
|
|
|
|
lines.push(
|
|
`### User action: ${entry.userAction.name}`,
|
|
`- Args`,
|
|
'```json',
|
|
JSON.stringify(actionData, null, 2),
|
|
'```',
|
|
);
|
|
}
|
|
|
|
if (entry.code) {
|
|
lines.push(
|
|
`- Code`,
|
|
'```js',
|
|
entry.code,
|
|
'```');
|
|
}
|
|
|
|
if (entry.tabSnapshot) {
|
|
const fileName = `${ordinal}.snapshot.yml`;
|
|
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
|
|
lines.push(`- Snapshot: ${fileName}`);
|
|
}
|
|
|
|
lines.push('', '');
|
|
}
|
|
|
|
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
|
|
}
|
|
}
|