Step 230: Enhance session recorder

This commit is contained in:
Bill
2026-02-10 08:33:19 -07:00
parent 718c99fef9
commit 835b90bbac
9 changed files with 273 additions and 7 deletions

View File

@@ -749,3 +749,4 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi
| 2026-02-10 | Codex | Step 227: Open-source model tool definitions (ReAct-style prompt, XML tool call format, simplified schemas, adapter rules). 1/1 tests pass. |
| 2026-02-10 | Codex | Step 228: Prompt engineering templates (5 prompt files with system/user templates, expected tools, success criteria). 1/1 tests pass. |
| 2026-02-10 | Codex | Step 229: Tool definition tests (schema alignment, prompt coverage, examples validation, prompt variable checks). 1/1 tests pass. |
| 2026-02-10 | Codex | Step 230: Enhanced session recorder (metadata, event capture, ms timestamps, UI toggle, auto-record setting). 1/1 tests pass. |

View File

@@ -1172,6 +1172,9 @@ target_link_libraries(step228_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step229_test tests/step229_test.cpp)
target_include_directories(step229_test PRIVATE src)
target_link_libraries(step229_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step230_test tests/step230_test.cpp)
target_include_directories(step230_test PRIVATE src)
target_link_libraries(step230_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step213_test tests/step213_test.cpp)
target_include_directories(step213_test PRIVATE src)
target_link_libraries(step213_test PRIVATE nlohmann_json::nlohmann_json)

View File

@@ -733,6 +733,91 @@ struct EditorState {
uiAnimations.recordTabSwitch(path, ImGui::GetTime());
}
static std::string osLabel() {
#ifdef _WIN32
return "windows";
#elif __APPLE__
return "macos";
#else
return "linux";
#endif
}
std::string detectProjectType() const {
std::filesystem::path root(workspaceRoot);
std::error_code ec;
auto exists = [&](const std::string& name) {
return std::filesystem::exists(root / name, ec);
};
if (exists("CMakeLists.txt")) return "cmake";
if (exists("package.json")) return "node";
if (exists("pyproject.toml") || exists("requirements.txt")) return "python";
if (exists("Cargo.toml")) return "rust";
if (exists("go.mod")) return "go";
if (exists("pom.xml") || exists("build.gradle")) return "java";
return "unknown";
}
json buildSessionMetadata(bool autoRecording) const {
json meta;
meta["editorVersion"] = welcome.getVersion();
meta["os"] = osLabel();
meta["language"] = active() ? active()->language : std::string("unknown");
meta["projectType"] = detectProjectType();
meta["autoRecording"] = autoRecording;
return meta;
}
void startSessionRecording(const std::string& name, bool autoRecording) {
WorkflowRecorder::RecordingConfig cfg;
cfg.recordAllSessions = true;
cfg.recordControlMethods = true;
cfg.recordEditorEvents = true;
cfg.autoRecording = autoRecording;
agent.workflowRecorder.startRecording(name, "", cfg, buildSessionMetadata(autoRecording));
agent.workflowRecorder.recordEvent("session_start", {{"name", name}, {"autoRecording", autoRecording}});
notify(NotificationLevel::Info, "Session recording started.");
}
void stopSessionRecording() {
if (!agent.workflowRecorder.isRecording()) return;
agent.workflowRecorder.recordEvent("session_stop", json::object());
agent.workflowRecorder.stopRecording();
notify(NotificationLevel::Info, "Session recording stopped.");
}
void toggleSessionRecording() {
if (agent.workflowRecorder.isRecording()) stopSessionRecording();
else startSessionRecording("session", false);
}
static std::string uiEventLabel(UIEventType type) {
switch (type) {
case UIEventType::ASTChanged: return "ast_changed";
case UIEventType::BufferSwitched: return "buffer_switched";
case UIEventType::DiagnosticsUpdated: return "diagnostics_updated";
case UIEventType::ThemeChanged: return "theme_changed";
case UIEventType::SettingsChanged: return "settings_changed";
case UIEventType::FileModified: return "file_modified";
case UIEventType::AgentConnected: return "agent_connected";
case UIEventType::NotificationPosted: return "notification_posted";
}
return "unknown";
}
void recordUIEvent(const UIEvent& ev) {
json payload = {
{"path", ev.path},
{"detail", ev.detail},
{"uiTimestamp", ev.timestamp}
};
agent.workflowRecorder.recordEvent(uiEventLabel(ev.type), payload);
}
void recordEditorEvent(const std::string& type, const json& payload) {
agent.workflowRecorder.recordEvent(type, payload);
}
std::filesystem::path configDir() const {
const char* home = std::getenv("USERPROFILE");
if (!home) home = std::getenv("HOME");
@@ -1076,10 +1161,13 @@ struct EditorState {
loadRecentFiles();
loadFeatureHints(featureHints, workspaceRoot);
loadSettingsFromDisk();
if (settings.getAutoRecordSessions() && !agent.workflowRecorder.isRecording()) {
startSessionRecording("auto-session", true);
}
registerCommands();
library.vulnDb.startBackgroundRefresh();
library.semanticTags.load();
events.subscribe(UIEventType::BufferSwitched, [this](const UIEvent&) {
events.subscribe(UIEventType::BufferSwitched, [this](const UIEvent& ev) {
if (!active()) return;
library.primitives.setRoot(activeAST());
library.primitives.setLanguage(active()->language);
@@ -1089,6 +1177,7 @@ struct EditorState {
if (active()->language == "elisp") {
emacsState.emacsFunctionIndexDirty = true;
}
recordUIEvent(ev);
});
events.subscribe(UIEventType::ASTChanged, [this](const UIEvent&) {
if (!active()) return;
@@ -1098,8 +1187,18 @@ struct EditorState {
symbolsLastChange = ImGui::GetTime();
if (lsp) lsp->clearDocumentSymbols();
});
events.subscribe(UIEventType::SettingsChanged, [this](const UIEvent&) {
events.subscribe(UIEventType::SettingsChanged, [this](const UIEvent& ev) {
applySettingsToState();
recordUIEvent(ev);
});
events.subscribe(UIEventType::ThemeChanged, [this](const UIEvent& ev) {
recordUIEvent(ev);
});
events.subscribe(UIEventType::FileModified, [this](const UIEvent& ev) {
recordUIEvent(ev);
});
events.subscribe(UIEventType::NotificationPosted, [this](const UIEvent& ev) {
recordUIEvent(ev);
});
startEmacsDaemonFromSettings();
initAgentServer();
@@ -1209,8 +1308,10 @@ struct EditorState {
logAgentEvent("Agent " + s.sessionId + " " + event);
if (event == "connected") {
agent.roles[s.sessionId] = agent.defaultRole;
recordEditorEvent("agent_connected", {{"sessionId", s.sessionId}, {"agentName", s.agentName}});
} else if (event == "disconnected") {
agent.roles.erase(s.sessionId);
recordEditorEvent("agent_disconnected", {{"sessionId", s.sessionId}});
}
});
agent.server->setRequestLogCallback([this](const std::string& sid,
@@ -1510,7 +1611,8 @@ struct EditorState {
if (method == "startWorkflowRecording") {
auto params = request.contains("params") ? request["params"] : json::object();
std::string name = params.value("name", "workflow");
agent.workflowRecorder.startRecording(name, sessionId);
agent.workflowRecorder.startRecording(name, sessionId, WorkflowRecorder::RecordingConfig{},
buildSessionMetadata(false));
response["result"] = {
{"recording", true},
{"name", name}
@@ -2740,6 +2842,7 @@ struct EditorState {
active()->modified = false;
notify(NotificationLevel::Success,
"Saved: " + active()->path);
recordEditorEvent("file_saved", {{"path", active()->path}});
if (lsp) lsp->didSave(toFileUri(active()->path), active()->editBuf);
} else {
notify(NotificationLevel::Error,
@@ -2809,6 +2912,12 @@ struct EditorState {
largeFilePromptBytes = fileSizeBytes;
}
notify(NotificationLevel::Success, "Opened: " + path);
recordEditorEvent("file_opened", {
{"path", path},
{"language", language},
{"mode", bufferModeToString(effectiveMode)},
{"sizeBytes", fileSizeBytes}
});
} else {
notify(NotificationLevel::Error, "Error opening: " + path);
}

View File

@@ -62,6 +62,8 @@ public:
void setUseAnnotationShapes(bool value) { useAnnotationShapes_ = value; }
bool getBlockVulnerableImports() const { return blockVulnerableImports_; }
void setBlockVulnerableImports(bool value) { blockVulnerableImports_ = value; }
bool getAutoRecordSessions() const { return autoRecordSessions_; }
void setAutoRecordSessions(bool value) { autoRecordSessions_ = value; }
int getLargeFileWarnMB() const { return largeFileWarnMB_; }
void setLargeFileWarnMB(int value) { largeFileWarnMB_ = value; }
int getLargeFileTextMB() const { return largeFileTextMB_; }
@@ -101,6 +103,7 @@ public:
reduceMotion_ = j.value("reduceMotion", reduceMotion_);
useAnnotationShapes_ = j.value("useAnnotationShapes", useAnnotationShapes_);
blockVulnerableImports_ = j.value("blockVulnerableImports", blockVulnerableImports_);
autoRecordSessions_ = j.value("autoRecordSessions", autoRecordSessions_);
largeFileWarnMB_ = j.value("largeFileWarnMB", largeFileWarnMB_);
largeFileTextMB_ = j.value("largeFileTextMB", largeFileTextMB_);
largeFileDisableHighlightMB_ = j.value("largeFileDisableHighlightMB", largeFileDisableHighlightMB_);
@@ -147,6 +150,7 @@ public:
j["reduceMotion"] = reduceMotion_;
j["useAnnotationShapes"] = useAnnotationShapes_;
j["blockVulnerableImports"] = blockVulnerableImports_;
j["autoRecordSessions"] = autoRecordSessions_;
j["largeFileWarnMB"] = largeFileWarnMB_;
j["largeFileTextMB"] = largeFileTextMB_;
j["largeFileDisableHighlightMB"] = largeFileDisableHighlightMB_;
@@ -230,6 +234,7 @@ private:
bool reduceMotion_ = false;
bool useAnnotationShapes_ = true;
bool blockVulnerableImports_ = false;
bool autoRecordSessions_ = false;
int largeFileWarnMB_ = 1;
int largeFileTextMB_ = 5;
int largeFileDisableHighlightMB_ = 10;

View File

@@ -12,19 +12,44 @@ using json = nlohmann::json;
class WorkflowRecorder {
public:
struct RecordingConfig {
bool recordAllSessions = false;
bool recordControlMethods = false;
bool recordEditorEvents = true;
bool autoRecording = false;
};
struct WorkflowCall {
json request;
json response;
std::string timestamp;
std::string sessionId;
std::string method;
};
struct WorkflowEvent {
std::string type;
json payload;
std::string timestamp;
};
void startRecording(const std::string& name, const std::string& sessionId) {
startRecording(name, sessionId, RecordingConfig{}, json::object());
}
void startRecording(const std::string& name,
const std::string& sessionId,
const RecordingConfig& config,
const json& metadata) {
name_ = name.empty() ? "workflow" : name;
sessionId_ = sessionId;
calls_.clear();
events_.clear();
recording_ = true;
createdAt_ = currentTimestamp();
stoppedAt_.clear();
config_ = config;
metadata_ = metadata;
}
json stopRecording() {
@@ -37,36 +62,60 @@ public:
bool isReplaying() const { return replaying_; }
void setReplaying(bool replaying) { replaying_ = replaying; }
bool isAutoRecording() const { return config_.autoRecording; }
void record(const std::string& sessionId,
const json& request,
const json& response) {
if (!recording_ || replaying_) return;
if (!sessionId_.empty() && sessionId != sessionId_) return;
if (!config_.recordAllSessions && !sessionId_.empty() && sessionId != sessionId_) return;
std::string method = request.value("method", "");
if (isControlMethod(method)) return;
if (!config_.recordControlMethods && isControlMethod(method)) return;
WorkflowCall call;
call.request = request;
call.response = response;
call.timestamp = currentTimestamp();
call.sessionId = sessionId;
call.method = method;
calls_.push_back(std::move(call));
}
void recordEvent(const std::string& type, const json& payload) {
if (!recording_ || replaying_ || !config_.recordEditorEvents) return;
WorkflowEvent ev;
ev.type = type;
ev.payload = payload;
ev.timestamp = currentTimestamp();
events_.push_back(std::move(ev));
}
json exportWorkflow() const {
json out;
out["name"] = name_;
out["sessionId"] = sessionId_;
out["createdAt"] = createdAt_;
out["stoppedAt"] = stoppedAt_;
out["metadata"] = metadata_;
json arr = json::array();
for (const auto& c : calls_) {
arr.push_back({
{"timestamp", c.timestamp},
{"sessionId", c.sessionId},
{"method", c.method},
{"request", c.request},
{"response", c.response}
});
}
out["calls"] = arr;
json events = json::array();
for (const auto& e : events_) {
events.push_back({
{"timestamp", e.timestamp},
{"type", e.type},
{"payload", e.payload}
});
}
out["events"] = events;
return out;
}
@@ -76,14 +125,27 @@ public:
sessionId_ = workflow.value("sessionId", "");
createdAt_ = workflow.value("createdAt", "");
stoppedAt_ = workflow.value("stoppedAt", "");
metadata_ = workflow.value("metadata", json::object());
calls_.clear();
for (const auto& c : workflow["calls"]) {
WorkflowCall call;
call.timestamp = c.value("timestamp", "");
call.sessionId = c.value("sessionId", "");
call.method = c.value("method", "");
call.request = c.value("request", json::object());
call.response = c.value("response", json::object());
calls_.push_back(std::move(call));
}
events_.clear();
if (workflow.contains("events") && workflow["events"].is_array()) {
for (const auto& e : workflow["events"]) {
WorkflowEvent ev;
ev.timestamp = e.value("timestamp", "");
ev.type = e.value("type", "");
ev.payload = e.value("payload", json::object());
events_.push_back(std::move(ev));
}
}
return true;
}
@@ -105,12 +167,17 @@ public:
createdAt_.clear();
stoppedAt_.clear();
calls_.clear();
events_.clear();
metadata_.clear();
recording_ = false;
config_ = RecordingConfig{};
}
private:
static std::string currentTimestamp() {
auto now = std::chrono::system_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now.time_since_epoch()) % 1000;
std::time_t tt = std::chrono::system_clock::to_time_t(now);
std::tm tm{};
#ifdef _WIN32
@@ -119,7 +186,8 @@ private:
localtime_r(&tt, &tm);
#endif
std::ostringstream oss;
oss << std::put_time(&tm, "%H:%M:%S");
oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S")
<< '.' << std::setw(3) << std::setfill('0') << ms.count();
return oss.str();
}
@@ -134,7 +202,10 @@ private:
std::string sessionId_;
std::string createdAt_;
std::string stoppedAt_;
json metadata_;
std::vector<WorkflowCall> calls_;
std::vector<WorkflowEvent> events_;
bool recording_ = false;
bool replaying_ = false;
RecordingConfig config_;
};

View File

@@ -359,6 +359,18 @@ static void renderSettingsPanel(EditorState& state) {
state.settings.setBlockVulnerableImports(blockVulnImports);
settingsChanged = true;
}
bool autoRecordSessions = state.settings.getAutoRecordSessions();
if (ImGui::Checkbox("Auto-record Sessions", &autoRecordSessions)) {
state.settings.setAutoRecordSessions(autoRecordSessions);
if (autoRecordSessions && !state.agent.workflowRecorder.isRecording()) {
state.startSessionRecording("auto-session", true);
} else if (!autoRecordSessions &&
state.agent.workflowRecorder.isRecording() &&
state.agent.workflowRecorder.isAutoRecording()) {
state.stopSessionRecording();
}
settingsChanged = true;
}
ImGui::Separator();
ImGui::TextUnformatted("Large File Thresholds (MB)");

View File

@@ -143,6 +143,23 @@ static void renderStatusBar(EditorState& state) {
state.notifications.showHistory = !state.notifications.showHistory;
}
// Recording indicator
ImGui::SameLine(0, 10);
bool recording = state.agent.workflowRecorder.isRecording();
if (recording) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.75f, 0.15f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.85f, 0.20f, 0.20f, 1.0f));
}
if (ImGui::Button("REC")) {
state.toggleSessionRecording();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(recording ? "Session recording active" : "Start session recording");
}
if (recording) {
ImGui::PopStyleColor(2);
}
// Right cluster: Ln/Col, selection, zoom, git
int selStart = -1;
int selEnd = -1;

View File

@@ -0,0 +1,48 @@
// Step 230: Enhanced session recorder.
#include "WorkflowRecorder.h"
#include <iostream>
static void expect(bool cond, const std::string& name, int& passed, int& failed) {
if (cond) {
std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n";
++passed;
} else {
std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n";
++failed;
}
}
int main() {
int passed = 0;
int failed = 0;
WorkflowRecorder rec;
WorkflowRecorder::RecordingConfig cfg;
cfg.recordAllSessions = true;
cfg.recordControlMethods = true;
cfg.recordEditorEvents = true;
cfg.autoRecording = true;
json meta = {{"editorVersion", "0.3.0"}, {"os", "test"}};
rec.startRecording("session", "", cfg, meta);
json req = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "getAST"}};
json res = {{"jsonrpc", "2.0"}, {"id", 1}, {"result", true}};
rec.record("s1", req, res);
rec.recordEvent("file_opened", {"path", "main.py"});
json out = rec.stopRecording();
expect(out.value("name", "") == "session", "recording name", passed, failed);
expect(out["metadata"].value("editorVersion", "") == "0.3.0",
"metadata preserved", passed, failed);
expect(out["calls"].size() == 1, "records rpc calls", passed, failed);
expect(out["calls"][0].value("sessionId", "") == "s1", "session id captured", passed, failed);
expect(out["events"].size() == 1, "records editor events", passed, failed);
expect(out["events"][0].value("type", "") == "file_opened", "event type captured", passed, failed);
expect(out["events"][0].value("timestamp", "").find('.') != std::string::npos,
"timestamp includes milliseconds", passed, failed);
std::cout << "\n=== Step 230 Results: " << passed << " passed, "
<< failed << " failed ===\n";
return failed == 0 ? 0 : 1;
}

View File

@@ -401,7 +401,7 @@ Optimized tool definitions and prompts for specific LLM families.
Capture real editing sessions, anonymize them, and export as training data.
- [ ] **Step 230: Enhanced session recorder**
- [x] **Step 230: Enhanced session recorder**
Extend `WorkflowRecorder` for full session capture:
- Record all JSON-RPC traffic (not just agent-initiated)
- Capture editor events: file open, buffer switch, theme change, etc.