use crate::error::{CsmError, Result};
use crate::models::{
ChatRequest, ChatSession, ChatSessionIndex, ChatSessionIndexEntry, ChatSessionTiming,
ModelCacheEntry, StateCacheEntry,
};
use crate::workspace::{get_empty_window_sessions_path, get_workspace_storage_path};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use once_cell::sync::Lazy;
use regex::Regex;
use rusqlite::Connection;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use sysinfo::System;
#[derive(Debug, Clone)]
pub struct SessionIssue {
pub session_id: String,
pub kind: SessionIssueKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SessionIssueKind {
MultiLineJsonl,
ConcatenatedJsonl,
CancelledState,
CancelledModelState,
OrphanedSession,
StaleIndexEntry,
MissingCompatFields,
DuplicateFormat,
SkeletonJson,
}
impl std::fmt::Display for SessionIssueKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionIssueKind::MultiLineJsonl => write!(f, "multi-line JSONL"),
SessionIssueKind::ConcatenatedJsonl => write!(f, "concatenated JSONL"),
SessionIssueKind::CancelledState => write!(f, "cancelled state"),
SessionIssueKind::CancelledModelState => write!(f, "cancelled modelState in file"),
SessionIssueKind::OrphanedSession => write!(f, "orphaned session"),
SessionIssueKind::StaleIndexEntry => write!(f, "stale index entry"),
SessionIssueKind::MissingCompatFields => write!(f, "missing compat fields"),
SessionIssueKind::DuplicateFormat => write!(f, "duplicate .json/.jsonl"),
SessionIssueKind::SkeletonJson => write!(f, "skeleton .json (corrupt)"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct WorkspaceDiagnosis {
pub project_path: Option<String>,
pub workspace_hash: String,
pub sessions_on_disk: usize,
pub sessions_in_index: usize,
pub issues: Vec<SessionIssue>,
}
impl WorkspaceDiagnosis {
pub fn is_healthy(&self) -> bool {
self.issues.is_empty()
}
pub fn issue_count_by_kind(&self, kind: &SessionIssueKind) -> usize {
self.issues.iter().filter(|i| &i.kind == kind).count()
}
}
pub fn diagnose_workspace_sessions(
workspace_id: &str,
chat_sessions_dir: &Path,
) -> Result<WorkspaceDiagnosis> {
let mut diagnosis = WorkspaceDiagnosis {
workspace_hash: workspace_id.to_string(),
..Default::default()
};
if !chat_sessions_dir.exists() {
return Ok(diagnosis);
}
let mut jsonl_sessions: HashSet<String> = HashSet::new();
let mut json_sessions: HashSet<String> = HashSet::new();
let mut all_session_ids: HashSet<String> = HashSet::new();
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
match ext {
"jsonl" => {
jsonl_sessions.insert(stem.clone());
all_session_ids.insert(stem);
}
"json" if !path.to_string_lossy().ends_with(".bak") => {
json_sessions.insert(stem.clone());
all_session_ids.insert(stem);
}
_ => {}
}
}
diagnosis.sessions_on_disk = all_session_ids.len();
for id in &jsonl_sessions {
if json_sessions.contains(id) {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::DuplicateFormat,
detail: format!("Both {id}.json and {id}.jsonl exist"),
});
}
}
for id in &jsonl_sessions {
let path = chat_sessions_dir.join(format!("{id}.jsonl"));
if let Ok(content) = std::fs::read_to_string(&path) {
let line_count = content.lines().count();
if line_count > 1 {
let size_mb = content.len() / (1024 * 1024);
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::MultiLineJsonl,
detail: format!("{line_count} lines, ~{size_mb} MB — needs compaction"),
});
}
if let Some(first_line) = content.lines().next() {
if first_line.contains("}{\"kind\":") {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::ConcatenatedJsonl,
detail: "First line has concatenated JSON objects".to_string(),
});
}
}
if line_count == 1 {
if let Some(first_line) = content.lines().next() {
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(first_line) {
let is_kind_0 = obj
.get("kind")
.and_then(|k| k.as_u64())
.map(|k| k == 0)
.unwrap_or(false);
if is_kind_0 {
if let Some(v) = obj.get("v") {
let missing_fields: Vec<&str> = [
"hasPendingEdits",
"pendingRequests",
"inputState",
"sessionId",
"version",
]
.iter()
.filter(|f| v.get(**f).is_none())
.copied()
.collect();
if !missing_fields.is_empty() {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::MissingCompatFields,
detail: format!("Missing: {}", missing_fields.join(", ")),
});
}
if let Some(requests) = v.get("requests").and_then(|r| r.as_array())
{
if let Some(last_req) = requests.last() {
let model_state_value = last_req
.get("modelState")
.and_then(|ms| ms.get("value"))
.and_then(|v| v.as_u64());
match model_state_value {
Some(1) => {} Some(v) => {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::CancelledModelState,
detail: format!("Last request modelState.value={} (not Complete) in file content", v),
});
}
None => {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::CancelledModelState,
detail: "Last request missing modelState in file content".to_string(),
});
}
}
}
}
if v.get("hasPendingEdits")
.and_then(|v| v.as_bool())
.unwrap_or(false)
== true
{
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::MissingCompatFields,
detail: "hasPendingEdits is true (blocks session loading)"
.to_string(),
});
}
}
}
}
}
}
}
}
for id in &json_sessions {
if jsonl_sessions.contains(id) {
continue;
}
let path = chat_sessions_dir.join(format!("{id}.json"));
if let Ok(content) = std::fs::read_to_string(&path) {
if is_skeleton_json(&content) {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::SkeletonJson,
detail: format!(
"Legacy .json is corrupt — only structural chars remain ({} bytes)",
content.len()
),
});
}
}
}
let db_path = get_workspace_storage_db(workspace_id)?;
if db_path.exists() {
if let Ok(index) = read_chat_session_index(&db_path) {
diagnosis.sessions_in_index = index.entries.len();
for (id, _entry) in &index.entries {
if !all_session_ids.contains(id) {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::StaleIndexEntry,
detail: "In index but no file on disk".to_string(),
});
}
}
for (id, entry) in &index.entries {
if entry.last_response_state == 2 {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::CancelledState,
detail: "lastResponseState=2 (Cancelled) — blocks VS Code loading"
.to_string(),
});
}
}
let indexed_ids: HashSet<&String> = index.entries.keys().collect();
for id in &all_session_ids {
if !indexed_ids.contains(id) {
diagnosis.issues.push(SessionIssue {
session_id: id.clone(),
kind: SessionIssueKind::OrphanedSession,
detail: "File on disk but not in VS Code index".to_string(),
});
}
}
}
}
Ok(diagnosis)
}
static UNICODE_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\u[0-9a-fA-F]{4}").unwrap());
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VsCodeSessionFormat {
LegacyJson,
JsonLines,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SessionSchemaVersion {
V1 = 1,
V2 = 2,
V3 = 3,
Unknown = 0,
}
impl SessionSchemaVersion {
pub fn from_version(v: u32) -> Self {
match v {
1 => Self::V1,
2 => Self::V2,
3 => Self::V3,
_ => Self::Unknown,
}
}
pub fn version_number(&self) -> u32 {
match self {
Self::V1 => 1,
Self::V2 => 2,
Self::V3 => 3,
Self::Unknown => 0,
}
}
pub fn description(&self) -> &'static str {
match self {
Self::V1 => "v1 (basic)",
Self::V2 => "v2 (extended metadata)",
Self::V3 => "v3 (full structure)",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for SessionSchemaVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description())
}
}
#[derive(Debug, Clone)]
pub struct SessionFormatInfo {
pub format: VsCodeSessionFormat,
pub schema_version: SessionSchemaVersion,
pub confidence: f32,
pub detection_method: &'static str,
}
impl VsCodeSessionFormat {
pub fn from_path(path: &Path) -> Self {
match path.extension().and_then(|e| e.to_str()) {
Some("jsonl") => Self::JsonLines,
_ => Self::LegacyJson,
}
}
pub fn from_content(content: &str) -> Self {
let trimmed = content.trim();
if trimmed.starts_with("{\"kind\":") || trimmed.starts_with("{ \"kind\":") {
return Self::JsonLines;
}
let mut json_object_lines = 0;
let mut total_non_empty_lines = 0;
for line in trimmed.lines().take(10) {
let line = line.trim();
if line.is_empty() {
continue;
}
total_non_empty_lines += 1;
if line.starts_with('{') && line.contains("\"kind\"") {
json_object_lines += 1;
}
}
if json_object_lines >= 2
|| (json_object_lines == 1 && total_non_empty_lines == 1 && trimmed.contains("\n{"))
{
return Self::JsonLines;
}
if trimmed.starts_with('{') && trimmed.ends_with('}') {
if trimmed.contains("\"sessionId\"")
|| trimmed.contains("\"creationDate\"")
|| trimmed.contains("\"requests\"")
{
return Self::LegacyJson;
}
}
Self::LegacyJson
}
pub fn min_vscode_version(&self) -> &'static str {
match self {
Self::LegacyJson => "1.0.0",
Self::JsonLines => "1.109.0",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::LegacyJson => "Legacy JSON (single object)",
Self::JsonLines => "JSON Lines (event-sourced, VS Code 1.109.0+)",
}
}
pub fn short_name(&self) -> &'static str {
match self {
Self::LegacyJson => "json",
Self::JsonLines => "jsonl",
}
}
}
impl std::fmt::Display for VsCodeSessionFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description())
}
}
fn sanitize_json_unicode(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut last_end = 0;
let matches: Vec<_> = UNICODE_ESCAPE_RE.find_iter(content).collect();
for (i, mat) in matches.iter().enumerate() {
let start = mat.start();
let end = mat.end();
result.push_str(&content[last_end..start]);
let hex_str = &mat.as_str()[2..]; if let Ok(code_point) = u16::from_str_radix(hex_str, 16) {
if (0xD800..=0xDBFF).contains(&code_point) {
let is_valid_pair = if let Some(next_mat) = matches.get(i + 1) {
if next_mat.start() == end {
let next_hex = &next_mat.as_str()[2..];
if let Ok(next_cp) = u16::from_str_radix(next_hex, 16) {
(0xDC00..=0xDFFF).contains(&next_cp)
} else {
false
}
} else {
false
}
} else {
false
};
if is_valid_pair {
result.push_str(mat.as_str());
} else {
result.push_str("\\uFFFD");
}
}
else if (0xDC00..=0xDFFF).contains(&code_point) {
let is_valid_pair = if i > 0 {
if let Some(prev_mat) = matches.get(i - 1) {
if prev_mat.end() == start {
let prev_hex = &prev_mat.as_str()[2..];
if let Ok(prev_cp) = u16::from_str_radix(prev_hex, 16) {
(0xD800..=0xDBFF).contains(&prev_cp)
} else {
false
}
} else {
false
}
} else {
false
}
} else {
false
};
if is_valid_pair {
result.push_str(mat.as_str());
} else {
result.push_str("\\uFFFD");
}
}
else {
result.push_str(mat.as_str());
}
} else {
result.push_str(mat.as_str());
}
last_end = end;
}
result.push_str(&content[last_end..]);
result
}
pub fn parse_session_json(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
match serde_json::from_str::<ChatSession>(content) {
Ok(session) => Ok(session),
Err(e) => {
if e.to_string().contains("surrogate") || e.to_string().contains("escape") {
let sanitized = sanitize_json_unicode(content);
serde_json::from_str::<ChatSession>(&sanitized)
} else {
Err(e)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum JsonlKind {
Initial = 0,
Delta = 1,
ArraySplice = 2,
}
pub fn parse_session_jsonl(content: &str) -> std::result::Result<ChatSession, serde_json::Error> {
let content = split_concatenated_jsonl(content);
let mut session = ChatSession {
version: 3,
session_id: None,
creation_date: 0,
last_message_date: 0,
is_imported: false,
initial_location: "panel".to_string(),
custom_title: None,
requester_username: None,
requester_avatar_icon_uri: None,
responder_username: None,
responder_avatar_icon_uri: None,
requests: Vec::new(),
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
let sanitized = sanitize_json_unicode(line);
serde_json::from_str(&sanitized)?
}
};
let kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(0);
match kind {
0 => {
if let Some(v) = entry.get("v") {
if let Some(version) = v.get("version").and_then(|x| x.as_u64()) {
session.version = version as u32;
}
if let Some(sid) = v.get("sessionId").and_then(|x| x.as_str()) {
session.session_id = Some(sid.to_string());
}
if let Some(cd) = v.get("creationDate").and_then(|x| x.as_i64()) {
session.creation_date = cd;
}
if let Some(loc) = v.get("initialLocation").and_then(|x| x.as_str()) {
session.initial_location = loc.to_string();
}
if let Some(ru) = v.get("responderUsername").and_then(|x| x.as_str()) {
session.responder_username = Some(ru.to_string());
}
if let Some(title) = v.get("customTitle").and_then(|x| x.as_str()) {
session.custom_title = Some(title.to_string());
}
if let Some(imported) = v.get("isImported").and_then(|x| x.as_bool()) {
session.is_imported = imported;
}
if let Some(requests) = v.get("requests") {
if let Ok(reqs) =
serde_json::from_value::<Vec<ChatRequest>>(requests.clone())
{
session.requests = reqs;
if let Some(latest_ts) =
session.requests.iter().filter_map(|r| r.timestamp).max()
{
session.last_message_date = latest_ts;
}
}
}
if session.last_message_date == 0 {
session.last_message_date = session.creation_date;
}
}
}
1 => {
if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
if let Some(keys_arr) = keys.as_array() {
if keys_arr.len() == 1 {
if let Some(key) = keys_arr[0].as_str() {
match key {
"customTitle" => {
if let Some(title) = value.as_str() {
session.custom_title = Some(title.to_string());
}
}
"lastMessageDate" => {
if let Some(date) = value.as_i64() {
session.last_message_date = date;
}
}
"hasPendingEdits" | "isImported" => {
}
_ => {} }
}
}
else if keys_arr.len() == 3 {
if let (Some("requests"), Some(idx), Some(field)) = (
keys_arr[0].as_str(),
keys_arr[1].as_u64().map(|i| i as usize),
keys_arr[2].as_str(),
) {
while idx >= session.requests.len() {
session.requests.push(ChatRequest::default());
}
match field {
"response" => {
session.requests[idx].response = Some(value.clone());
}
"result" => {
session.requests[idx].result = Some(value.clone());
}
"followups" => {
session.requests[idx].followups =
serde_json::from_value(value.clone()).ok();
}
"isCanceled" => {
session.requests[idx].is_canceled = value.as_bool();
}
"contentReferences" => {
session.requests[idx].content_references =
serde_json::from_value(value.clone()).ok();
}
"codeCitations" => {
session.requests[idx].code_citations =
serde_json::from_value(value.clone()).ok();
}
"modelState" => {
session.requests[idx].model_state = Some(value.clone());
}
"modelId" => {
session.requests[idx].model_id =
value.as_str().map(|s| s.to_string());
}
"agent" => {
session.requests[idx].agent = Some(value.clone());
}
"variableData" => {
session.requests[idx].variable_data = Some(value.clone());
}
_ => {} }
}
}
}
}
}
2 => {
if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
let splice_index = entry.get("i").and_then(|i| i.as_u64()).map(|i| i as usize);
if let Some(keys_arr) = keys.as_array() {
if keys_arr.len() == 1 {
if let Some("requests") = keys_arr[0].as_str() {
if let Some(items) = value.as_array() {
if let Some(idx) = splice_index {
session.requests.truncate(idx);
}
for item in items {
if let Ok(req) =
serde_json::from_value::<ChatRequest>(item.clone())
{
session.requests.push(req);
}
}
if let Some(last_req) = session.requests.last() {
if let Some(ts) = last_req.timestamp {
session.last_message_date = ts;
}
}
}
}
}
else if keys_arr.len() == 3 {
if let (Some("requests"), Some(req_idx), Some(field)) = (
keys_arr[0].as_str(),
keys_arr[1].as_u64().map(|i| i as usize),
keys_arr[2].as_str(),
) {
while req_idx >= session.requests.len() {
session.requests.push(ChatRequest::default());
}
match field {
"response" => {
if let Some(idx) = splice_index {
if let Some(existing) =
session.requests[req_idx].response.as_ref()
{
if let Some(existing_arr) = existing.as_array() {
let mut new_arr: Vec<serde_json::Value> =
existing_arr[..idx.min(existing_arr.len())]
.to_vec();
if let Some(new_items) = value.as_array() {
new_arr.extend(new_items.iter().cloned());
}
session.requests[req_idx].response =
Some(serde_json::Value::Array(new_arr));
} else {
session.requests[req_idx].response =
Some(value.clone());
}
} else {
session.requests[req_idx].response =
Some(value.clone());
}
} else {
if let Some(existing) =
session.requests[req_idx].response.as_ref()
{
if let Some(existing_arr) = existing.as_array() {
let mut new_arr = existing_arr.clone();
if let Some(new_items) = value.as_array() {
new_arr.extend(new_items.iter().cloned());
}
session.requests[req_idx].response =
Some(serde_json::Value::Array(new_arr));
} else {
session.requests[req_idx].response =
Some(value.clone());
}
} else {
session.requests[req_idx].response =
Some(value.clone());
}
}
}
"contentReferences" => {
session.requests[req_idx].content_references =
serde_json::from_value(value.clone()).ok();
}
_ => {} }
}
}
}
}
}
_ => {} }
}
Ok(session)
}
pub fn is_session_file_extension(ext: &std::ffi::OsStr) -> bool {
ext == "json" || ext == "jsonl" || ext == "backup"
}
pub fn detect_session_format(content: &str) -> SessionFormatInfo {
let format = VsCodeSessionFormat::from_content(content);
let trimmed = content.trim();
let (schema_version, confidence, method) = match format {
VsCodeSessionFormat::JsonLines => {
if let Some(first_line) = trimmed.lines().next() {
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(first_line) {
if let Some(v) = entry.get("v") {
if let Some(ver) = v.get("version").and_then(|x| x.as_u64()) {
(
SessionSchemaVersion::from_version(ver as u32),
0.95,
"jsonl-version-field",
)
} else {
(SessionSchemaVersion::V3, 0.7, "jsonl-default")
}
} else {
(SessionSchemaVersion::V3, 0.6, "jsonl-no-v-field")
}
} else {
(SessionSchemaVersion::Unknown, 0.3, "jsonl-parse-error")
}
} else {
(SessionSchemaVersion::Unknown, 0.2, "jsonl-empty")
}
}
VsCodeSessionFormat::LegacyJson => {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(ver) = json.get("version").and_then(|x| x.as_u64()) {
(
SessionSchemaVersion::from_version(ver as u32),
0.95,
"json-version-field",
)
} else {
if json.get("requests").is_some() && json.get("sessionId").is_some() {
(SessionSchemaVersion::V3, 0.8, "json-structure-inference")
} else if json.get("messages").is_some() {
(SessionSchemaVersion::V1, 0.7, "json-legacy-structure")
} else {
(SessionSchemaVersion::Unknown, 0.4, "json-unknown-structure")
}
}
} else {
let sanitized = sanitize_json_unicode(trimmed);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&sanitized) {
if let Some(ver) = json.get("version").and_then(|x| x.as_u64()) {
(
SessionSchemaVersion::from_version(ver as u32),
0.9,
"json-version-after-sanitize",
)
} else {
(SessionSchemaVersion::V3, 0.6, "json-default-after-sanitize")
}
} else {
(SessionSchemaVersion::Unknown, 0.2, "json-parse-error")
}
}
}
};
SessionFormatInfo {
format,
schema_version,
confidence,
detection_method: method,
}
}
pub fn parse_session_auto(
content: &str,
) -> std::result::Result<(ChatSession, SessionFormatInfo), serde_json::Error> {
let format_info = detect_session_format(content);
let session = match format_info.format {
VsCodeSessionFormat::JsonLines => parse_session_jsonl(content)?,
VsCodeSessionFormat::LegacyJson => parse_session_json(content)?,
};
Ok((session, format_info))
}
pub fn parse_session_file(path: &Path) -> std::result::Result<ChatSession, serde_json::Error> {
let content = std::fs::read_to_string(path)
.map_err(|e| serde_json::Error::io(std::io::Error::other(e.to_string())))?;
let (session, _format_info) = parse_session_auto(&content)?;
Ok(session)
}
pub fn get_workspace_storage_db(workspace_id: &str) -> Result<PathBuf> {
let storage_path = get_workspace_storage_path()?;
Ok(storage_path.join(workspace_id).join("state.vscdb"))
}
pub fn read_chat_session_index(db_path: &Path) -> Result<ChatSessionIndex> {
let conn = Connection::open(db_path)?;
let result: std::result::Result<String, rusqlite::Error> = conn.query_row(
"SELECT value FROM ItemTable WHERE key = ?",
["chat.ChatSessionStore.index"],
|row| row.get(0),
);
match result {
Ok(json_str) => serde_json::from_str(&json_str)
.map_err(|e| CsmError::InvalidSessionFormat(e.to_string())),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ChatSessionIndex::default()),
Err(e) => Err(CsmError::SqliteError(e)),
}
}
pub fn write_chat_session_index(db_path: &Path, index: &ChatSessionIndex) -> Result<()> {
let conn = Connection::open(db_path)?;
let json_str = serde_json::to_string(index)?;
let exists: bool = conn.query_row(
"SELECT COUNT(*) > 0 FROM ItemTable WHERE key = ?",
["chat.ChatSessionStore.index"],
|row| row.get(0),
)?;
if exists {
conn.execute(
"UPDATE ItemTable SET value = ? WHERE key = ?",
[&json_str, "chat.ChatSessionStore.index"],
)?;
} else {
conn.execute(
"INSERT INTO ItemTable (key, value) VALUES (?, ?)",
["chat.ChatSessionStore.index", &json_str],
)?;
}
Ok(())
}
pub fn read_db_json(db_path: &Path, key: &str) -> Result<Option<serde_json::Value>> {
let conn = Connection::open(db_path)?;
let result: std::result::Result<String, rusqlite::Error> =
conn.query_row("SELECT value FROM ItemTable WHERE key = ?", [key], |row| {
row.get(0)
});
match result {
Ok(json_str) => {
let v = serde_json::from_str(&json_str)
.map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
Ok(Some(v))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(CsmError::SqliteError(e)),
}
}
fn write_db_json(db_path: &Path, key: &str, value: &serde_json::Value) -> Result<()> {
let conn = Connection::open(db_path)?;
let json_str = serde_json::to_string(value)?;
conn.execute(
"INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)",
rusqlite::params![key, json_str],
)?;
Ok(())
}
pub fn session_resource_uri(session_id: &str) -> String {
let b64 = BASE64.encode(session_id.as_bytes());
format!("vscode-chat-session://local/{}", b64)
}
pub fn session_id_from_resource_uri(uri: &str) -> Option<String> {
let prefix = "vscode-chat-session://local/";
if let Some(b64) = uri.strip_prefix(prefix) {
BASE64
.decode(b64)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
} else {
None
}
}
const MODEL_CACHE_KEY: &str = "agentSessions.model.cache";
pub fn read_model_cache(db_path: &Path) -> Result<Vec<ModelCacheEntry>> {
match read_db_json(db_path, MODEL_CACHE_KEY)? {
Some(v) => serde_json::from_value(v)
.map_err(|e| CsmError::InvalidSessionFormat(format!("model cache: {}", e))),
None => Ok(Vec::new()),
}
}
pub fn write_model_cache(db_path: &Path, cache: &[ModelCacheEntry]) -> Result<()> {
let v = serde_json::to_value(cache)?;
write_db_json(db_path, MODEL_CACHE_KEY, &v)
}
pub fn rebuild_model_cache(db_path: &Path, index: &ChatSessionIndex) -> Result<usize> {
let mut cache: Vec<ModelCacheEntry> = Vec::new();
for (session_id, entry) in &index.entries {
if entry.is_empty {
continue;
}
let timing = entry.timing.clone().unwrap_or(ChatSessionTiming {
created: entry.last_message_date,
last_request_started: Some(entry.last_message_date),
last_request_ended: Some(entry.last_message_date),
});
cache.push(ModelCacheEntry {
provider_type: "local".to_string(),
provider_label: "Local".to_string(),
resource: session_resource_uri(session_id),
icon: "vm".to_string(),
label: entry.title.clone(),
status: 1,
timing,
initial_location: entry.initial_location.clone(),
has_pending_edits: false,
is_empty: false,
is_external: entry.is_external.unwrap_or(false),
last_response_state: 1, });
}
let count = cache.len();
write_model_cache(db_path, &cache)?;
Ok(count)
}
const STATE_CACHE_KEY: &str = "agentSessions.state.cache";
pub fn read_state_cache(db_path: &Path) -> Result<Vec<StateCacheEntry>> {
match read_db_json(db_path, STATE_CACHE_KEY)? {
Some(v) => serde_json::from_value(v)
.map_err(|e| CsmError::InvalidSessionFormat(format!("state cache: {}", e))),
None => Ok(Vec::new()),
}
}
pub fn write_state_cache(db_path: &Path, cache: &[StateCacheEntry]) -> Result<()> {
let v = serde_json::to_value(cache)?;
write_db_json(db_path, STATE_CACHE_KEY, &v)
}
pub fn cleanup_state_cache(db_path: &Path, valid_session_ids: &HashSet<String>) -> Result<usize> {
let entries = read_state_cache(db_path)?;
let valid_resources: HashSet<String> = valid_session_ids
.iter()
.map(|id| session_resource_uri(id))
.collect();
let before = entries.len();
let cleaned: Vec<StateCacheEntry> = entries
.into_iter()
.filter(|e| valid_resources.contains(&e.resource))
.collect();
let removed = before - cleaned.len();
if removed > 0 {
write_state_cache(db_path, &cleaned)?;
}
Ok(removed)
}
const MEMENTO_KEY: &str = "memento/interactive-session-view-copilot";
pub fn read_session_memento(db_path: &Path) -> Result<Option<serde_json::Value>> {
read_db_json(db_path, MEMENTO_KEY)
}
pub fn write_session_memento(db_path: &Path, value: &serde_json::Value) -> Result<()> {
write_db_json(db_path, MEMENTO_KEY, value)
}
pub fn fix_session_memento(
db_path: &Path,
valid_session_ids: &HashSet<String>,
preferred_session_id: Option<&str>,
) -> Result<bool> {
let memento = read_session_memento(db_path)?;
let current_sid = memento
.as_ref()
.and_then(|v| v.get("sessionId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(ref sid) = current_sid {
if valid_session_ids.contains(sid) {
return Ok(false); }
}
let target = preferred_session_id
.filter(|id| valid_session_ids.contains(*id))
.or_else(|| valid_session_ids.iter().next().map(|s| s.as_str()));
if let Some(target_id) = target {
let mut new_memento = memento.unwrap_or(serde_json::json!({}));
if let Some(obj) = new_memento.as_object_mut() {
obj.insert(
"sessionId".to_string(),
serde_json::Value::String(target_id.to_string()),
);
}
write_session_memento(db_path, &new_memento)?;
Ok(true)
} else {
Ok(false) }
}
fn count_jsonl_requests(path: &Path) -> Result<usize> {
let content = std::fs::read_to_string(path)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
let first_line = content.lines().next().unwrap_or("");
let parsed: serde_json::Value = serde_json::from_str(first_line)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Parse error: {}", e)))?;
let count = parsed
.get("v")
.or_else(|| Some(&parsed)) .and_then(|v| v.get("requests"))
.and_then(|r| r.as_array())
.map(|a| a.len())
.unwrap_or(0);
Ok(count)
}
fn count_json_bak_requests(path: &Path) -> Result<usize> {
let content = std::fs::read_to_string(path)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
let parsed: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Parse error: {}", e)))?;
let count = parsed
.get("requests")
.and_then(|r| r.as_array())
.map(|a| a.len())
.unwrap_or(0);
Ok(count)
}
pub fn migrate_old_input_state(state: &mut serde_json::Value) {
if let Some(obj) = state.as_object_mut() {
if obj.contains_key("inputState") {
return;
}
let old_keys = [
"attachments",
"mode",
"inputText",
"selections",
"contrib",
"selectedModel",
];
let has_old = old_keys.iter().any(|k| obj.contains_key(*k));
if has_old {
let mut input_state = serde_json::Map::new();
input_state.insert(
"attachments".to_string(),
obj.remove("attachments").unwrap_or(serde_json::json!([])),
);
input_state.insert(
"mode".to_string(),
obj.remove("mode")
.unwrap_or(serde_json::json!({"id": "agent", "kind": "agent"})),
);
input_state.insert(
"inputText".to_string(),
obj.remove("inputText").unwrap_or(serde_json::json!("")),
);
input_state.insert(
"selections".to_string(),
obj.remove("selections").unwrap_or(serde_json::json!([])),
);
input_state.insert(
"contrib".to_string(),
obj.remove("contrib").unwrap_or(serde_json::json!({})),
);
if let Some(model) = obj.remove("selectedModel") {
input_state.insert("selectedModel".to_string(), model);
}
obj.insert(
"inputState".to_string(),
serde_json::Value::Object(input_state),
);
}
}
}
pub fn recover_from_json_bak(chat_sessions_dir: &Path) -> Result<usize> {
if !chat_sessions_dir.exists() {
return Ok(0);
}
let mut recovered = 0;
let mut bak_files: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.to_string_lossy().ends_with(".json.bak") {
bak_files.push(path);
}
}
for bak_path in &bak_files {
let bak_name = bak_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let session_id = bak_name.trim_end_matches(".json.bak");
let jsonl_path = chat_sessions_dir.join(format!("{}.jsonl", session_id));
let bak_count = match count_json_bak_requests(bak_path) {
Ok(c) => c,
Err(_) => continue, };
if bak_count == 0 {
continue; }
let jsonl_count = if jsonl_path.exists() {
count_jsonl_requests(&jsonl_path).unwrap_or(0)
} else {
0 };
if bak_count <= jsonl_count {
continue; }
println!(
" [*] .json.bak has {} requests vs .jsonl has {} for {}",
bak_count, jsonl_count, session_id
);
let bak_content = match std::fs::read_to_string(bak_path) {
Ok(c) => c,
Err(e) => {
println!(" [WARN] Failed to read .json.bak {}: {}", session_id, e);
continue;
}
};
let mut full_data: serde_json::Value = match serde_json::from_str(&bak_content) {
Ok(v) => v,
Err(e) => {
println!(" [WARN] Failed to parse .json.bak {}: {}", session_id, e);
continue;
}
};
if let Some(obj) = full_data.as_object_mut() {
obj.insert("version".to_string(), serde_json::json!(3));
if !obj.contains_key("sessionId") {
obj.insert("sessionId".to_string(), serde_json::json!(session_id));
}
obj.insert("hasPendingEdits".to_string(), serde_json::json!(false));
obj.insert("pendingRequests".to_string(), serde_json::json!([]));
if !obj.contains_key("responderUsername") {
obj.insert(
"responderUsername".to_string(),
serde_json::json!("GitHub Copilot"),
);
}
migrate_old_input_state(&mut full_data);
fix_request_model_states(&mut full_data);
}
if jsonl_path.exists() {
let pre_fix_bak = jsonl_path.with_extension("jsonl.pre_bak_recovery");
if let Err(e) = std::fs::copy(&jsonl_path, &pre_fix_bak) {
println!(
" [WARN] Failed to backup .jsonl before recovery {}: {}",
session_id, e
);
continue;
}
}
let jsonl_obj = serde_json::json!({"kind": 0, "v": full_data});
let jsonl_str = serde_json::to_string(&jsonl_obj).map_err(|e| {
CsmError::InvalidSessionFormat(format!("Failed to serialize recovered session: {}", e))
})?;
std::fs::write(&jsonl_path, format!("{}\n", jsonl_str))?;
println!(
" [OK] Recovered {} from .json.bak ({} → {} requests)",
session_id, jsonl_count, bak_count
);
recovered += 1;
}
Ok(recovered)
}
pub fn recover_from_jsonl_bak(chat_sessions_dir: &Path, dry_run: bool) -> Result<(usize, u64)> {
if !chat_sessions_dir.exists() {
return Ok((0, 0));
}
let mut restored = 0usize;
let mut bytes_recovered = 0u64;
let mut bak_files: Vec<PathBuf> = Vec::new();
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.to_string_lossy().ends_with(".jsonl.bak") {
bak_files.push(path);
}
}
for bak_path in &bak_files {
let bak_name = bak_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let session_id = bak_name.trim_end_matches(".jsonl.bak");
let jsonl_path = chat_sessions_dir.join(format!("{}.jsonl", session_id));
if !jsonl_path.exists() {
continue;
}
let orig_size = match std::fs::metadata(&jsonl_path) {
Ok(m) => m.len(),
Err(_) => continue,
};
let bak_size = match std::fs::metadata(bak_path) {
Ok(m) => m.len(),
Err(_) => continue,
};
if bak_size <= orig_size {
continue; }
let delta = bak_size - orig_size;
let orig_kb = orig_size as f64 / 1024.0;
let bak_kb = bak_size as f64 / 1024.0;
if dry_run {
println!(
" [*] Would restore {} ({:.1}KB → {:.1}KB, +{:.1}KB)",
session_id,
orig_kb,
bak_kb,
delta as f64 / 1024.0
);
} else {
let pre_restore = jsonl_path.with_extension("jsonl.pre-restore");
if let Err(e) = std::fs::copy(&jsonl_path, &pre_restore) {
println!(
" [WARN] Failed to create safety backup for {}: {}",
session_id, e
);
continue;
}
if let Err(e) = std::fs::copy(bak_path, &jsonl_path) {
println!(
" [WARN] Failed to restore {} from .jsonl.bak: {}",
session_id, e
);
let _ = std::fs::copy(&pre_restore, &jsonl_path);
continue;
}
println!(
" [OK] Restored {} from .jsonl.bak ({:.1}KB → {:.1}KB, +{:.1}KB recovered)",
session_id,
orig_kb,
bak_kb,
delta as f64 / 1024.0
);
}
restored += 1;
bytes_recovered += delta;
}
Ok((restored, bytes_recovered))
}
#[derive(Debug, Clone)]
pub struct BackupRecoveryAction {
pub session_id: String,
pub source_file: String,
pub current_requests: usize,
pub recovered_requests: usize,
pub current_size: u64,
pub recovered_size: u64,
pub converted: bool,
}
pub fn recover_from_all_backups(
chat_sessions_dir: &Path,
dry_run: bool,
) -> Result<Vec<BackupRecoveryAction>> {
use std::collections::HashMap;
if !chat_sessions_dir.exists() {
return Ok(Vec::new());
}
let mut session_files: HashMap<String, Vec<(String, PathBuf)>> = HashMap::new();
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let fname = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if fname.ends_with(".md") || fname.len() < 36 {
continue;
}
let sid = fname[..36].to_string();
if fname.contains(".json") {
session_files.entry(sid).or_default().push((fname, path));
}
}
let mut actions = Vec::new();
for (sid, files) in &session_files {
let current_jsonl_name = format!("{}.jsonl", sid);
let current_jsonl_path = chat_sessions_dir.join(¤t_jsonl_name);
let current_requests = if current_jsonl_path.exists() {
match parse_session_file(¤t_jsonl_path) {
Ok(session) => session.requests.len(),
Err(_) => 0,
}
} else {
0
};
let current_size = if current_jsonl_path.exists() {
std::fs::metadata(¤t_jsonl_path)
.map(|m| m.len())
.unwrap_or(0)
} else {
0
};
let mut best_requests = current_requests;
let mut best_file: Option<(&str, &Path)> = None;
for (fname, fpath) in files {
if fname == ¤t_jsonl_name {
continue;
}
let size = std::fs::metadata(fpath).map(|m| m.len()).unwrap_or(0);
if size < 100 {
continue;
}
match parse_session_file(fpath) {
Ok(session) => {
let req_count = session.requests.len();
if req_count > best_requests {
best_requests = req_count;
best_file = Some((fname.as_str(), fpath.as_path()));
}
}
Err(_) => {
}
}
}
if let Some((best_name, best_path)) = best_file {
let best_size = std::fs::metadata(best_path).map(|m| m.len()).unwrap_or(0);
let is_json_source = !best_name.contains(".jsonl");
if !dry_run {
if current_jsonl_path.exists() {
let pre_restore = current_jsonl_path.with_extension("jsonl.pre-restore");
if !pre_restore.exists() {
if let Err(e) = std::fs::copy(¤t_jsonl_path, &pre_restore) {
eprintln!(
" [WARN] Failed to create safety backup for {}: {}",
sid, e
);
continue;
}
}
}
if is_json_source {
match parse_session_file(best_path) {
Ok(session) => {
let raw_content =
std::fs::read_to_string(best_path).unwrap_or_default();
let raw_value: serde_json::Value =
serde_json::from_str(&raw_content).unwrap_or_default();
let jsonl_entry = serde_json::json!({"kind": 0, "v": raw_value});
if let Err(e) = std::fs::write(
¤t_jsonl_path,
serde_json::to_string(&jsonl_entry).unwrap_or_default() + "\n",
) {
eprintln!(
" [WARN] Failed to write converted JSONL for {}: {}",
sid, e
);
continue;
}
let _ = session;
}
Err(e) => {
eprintln!(" [WARN] Failed to parse JSON backup for {}: {}", sid, e);
continue;
}
}
} else {
if let Err(e) = std::fs::copy(best_path, ¤t_jsonl_path) {
eprintln!(
" [WARN] Failed to restore {} from {}: {}",
sid, best_name, e
);
continue;
}
}
}
actions.push(BackupRecoveryAction {
session_id: sid.clone(),
source_file: best_name.to_string(),
current_requests,
recovered_requests: best_requests,
current_size,
recovered_size: best_size,
converted: is_json_source,
});
}
}
actions.sort_by(|a, b| a.session_id.cmp(&b.session_id));
Ok(actions)
}
fn fix_request_model_states(session_data: &mut serde_json::Value) {
let requests = match session_data
.get_mut("requests")
.and_then(|r| r.as_array_mut())
{
Some(r) => r,
None => return,
};
for req in requests.iter_mut() {
let timestamp = req
.get("timestamp")
.and_then(|t| t.as_i64())
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
});
if let Some(ms) = req.get_mut("modelState") {
if let Some(val) = ms.get("value").and_then(|v| v.as_u64()) {
match val {
0 | 2 => {
*ms = serde_json::json!({
"value": 3,
"completedAt": timestamp
});
}
1 | 3 | 4 => {
if ms.get("completedAt").is_none() {
if let Some(ms_obj) = ms.as_object_mut() {
ms_obj.insert(
"completedAt".to_string(),
serde_json::json!(timestamp),
);
}
}
}
_ => {}
}
}
}
}
}
pub fn add_session_to_index(
db_path: &Path,
session_id: &str,
title: &str,
last_message_date_ms: i64,
_is_imported: bool,
initial_location: &str,
is_empty: bool,
) -> Result<()> {
let mut index = read_chat_session_index(db_path)?;
index.entries.insert(
session_id.to_string(),
ChatSessionIndexEntry {
session_id: session_id.to_string(),
title: title.to_string(),
last_message_date: last_message_date_ms,
timing: Some(ChatSessionTiming {
created: last_message_date_ms,
last_request_started: Some(last_message_date_ms),
last_request_ended: Some(last_message_date_ms),
}),
last_response_state: 1, initial_location: initial_location.to_string(),
is_empty,
is_imported: Some(_is_imported),
has_pending_edits: Some(false),
is_external: Some(false),
},
);
write_chat_session_index(db_path, &index)
}
#[allow(dead_code)]
pub fn remove_session_from_index(db_path: &Path, session_id: &str) -> Result<bool> {
let mut index = read_chat_session_index(db_path)?;
let removed = index.entries.remove(session_id).is_some();
if removed {
write_chat_session_index(db_path, &index)?;
}
Ok(removed)
}
pub fn sync_session_index(
workspace_id: &str,
chat_sessions_dir: &Path,
force: bool,
) -> Result<(usize, usize)> {
let db_path = get_workspace_storage_db(workspace_id)?;
if !db_path.exists() {
return Err(CsmError::WorkspaceNotFound(format!(
"Database not found: {}",
db_path.display()
)));
}
if !force && is_vscode_running() {
return Err(CsmError::VSCodeRunning);
}
let mut index = read_chat_session_index(&db_path)?;
let mut files_on_disk: std::collections::HashSet<String> = std::collections::HashSet::new();
if chat_sessions_dir.exists() {
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path
.extension()
.map(is_session_file_extension)
.unwrap_or(false)
{
if let Some(stem) = path.file_stem() {
files_on_disk.insert(stem.to_string_lossy().to_string());
}
}
}
}
let stale_ids: Vec<String> = index
.entries
.keys()
.filter(|id| !files_on_disk.contains(*id))
.cloned()
.collect();
let removed = stale_ids.len();
for id in &stale_ids {
index.entries.remove(id);
}
let mut session_files: std::collections::HashMap<String, PathBuf> =
std::collections::HashMap::new();
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path
.extension()
.map(is_session_file_extension)
.unwrap_or(false)
{
if let Some(stem) = path.file_stem() {
let stem_str = stem.to_string_lossy().to_string();
let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
if !session_files.contains_key(&stem_str) || is_jsonl {
session_files.insert(stem_str, path);
}
}
}
}
let mut added = 0;
for (_, path) in &session_files {
if let Ok(session) = parse_session_file(path) {
let session_id = session.session_id.clone().unwrap_or_else(|| {
path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
let title = session.title();
let is_empty = session.is_empty();
let last_message_date = session.last_message_date;
let initial_location = session.initial_location.clone();
index.entries.insert(
session_id.clone(),
ChatSessionIndexEntry {
session_id,
title,
last_message_date,
timing: Some(ChatSessionTiming {
created: session.creation_date,
last_request_started: Some(last_message_date),
last_request_ended: Some(last_message_date),
}),
last_response_state: 1, initial_location,
is_empty,
is_imported: Some(false),
has_pending_edits: Some(false),
is_external: Some(false),
},
);
added += 1;
}
}
write_chat_session_index(&db_path, &index)?;
Ok((added, removed))
}
pub fn register_all_sessions_from_directory(
workspace_id: &str,
chat_sessions_dir: &Path,
force: bool,
) -> Result<usize> {
let db_path = get_workspace_storage_db(workspace_id)?;
if !db_path.exists() {
return Err(CsmError::WorkspaceNotFound(format!(
"Database not found: {}",
db_path.display()
)));
}
if !force && is_vscode_running() {
return Err(CsmError::VSCodeRunning);
}
let (added, removed) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path
.extension()
.map(is_session_file_extension)
.unwrap_or(false)
{
if let Ok(session) = parse_session_file(&path) {
let session_id = session.session_id.clone().unwrap_or_else(|| {
path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
let title = session.title();
println!(
"[OK] Registered: {} ({}...)",
title,
&session_id[..12.min(session_id.len())]
);
}
}
}
if removed > 0 {
println!("[OK] Removed {} stale index entries", removed);
}
Ok(added)
}
pub fn is_vscode_running() -> bool {
let mut sys = System::new();
sys.refresh_processes();
for process in sys.processes().values() {
let name = process.name().to_lowercase();
if name.contains("code") && !name.contains("codec") {
return true;
}
}
false
}
pub fn close_vscode_and_wait(timeout_secs: u64) -> Result<()> {
use sysinfo::{ProcessRefreshKind, RefreshKind, Signal};
if !is_vscode_running() {
return Ok(());
}
let mut sys = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
);
sys.refresh_processes();
let mut signaled = 0u32;
for (pid, process) in sys.processes() {
let name = process.name().to_lowercase();
if name.contains("code") && !name.contains("codec") {
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.as_u32().to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
signaled += 1;
}
#[cfg(not(windows))]
{
if process.kill_with(Signal::Term).unwrap_or(false) {
signaled += 1;
}
}
}
}
if signaled == 0 {
return Ok(());
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
if !is_vscode_running() {
std::thread::sleep(std::time::Duration::from_secs(1));
return Ok(());
}
if std::time::Instant::now() >= deadline {
let mut sys2 = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::everything()),
);
sys2.refresh_processes();
for (_pid, process) in sys2.processes() {
let name = process.name().to_lowercase();
if name.contains("code") && !name.contains("codec") {
process.kill();
}
}
std::thread::sleep(std::time::Duration::from_secs(1));
return Ok(());
}
}
}
pub fn reopen_vscode(project_path: Option<&str>) -> Result<()> {
let mut cmd = std::process::Command::new("code");
if let Some(path) = project_path {
cmd.arg(path);
}
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
Ok(())
}
pub fn backup_workspace_sessions(workspace_dir: &Path) -> Result<Option<PathBuf>> {
let chat_sessions_dir = workspace_dir.join("chatSessions");
if !chat_sessions_dir.exists() {
return Ok(None);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let backup_dir = workspace_dir.join(format!("chatSessions-backup-{}", timestamp));
copy_dir_all(&chat_sessions_dir, &backup_dir)?;
Ok(Some(backup_dir))
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_all(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
pub fn read_empty_window_sessions() -> Result<Vec<ChatSession>> {
let sessions_path = get_empty_window_sessions_path()?;
if !sessions_path.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(&sessions_path)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(is_session_file_extension) {
if let Ok(session) = parse_session_file(&path) {
sessions.push(session);
}
}
}
sessions.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
Ok(sessions)
}
#[allow(dead_code)]
pub fn get_empty_window_session(session_id: &str) -> Result<Option<ChatSession>> {
let sessions_path = get_empty_window_sessions_path()?;
let session_path = sessions_path.join(format!("{}.json", session_id));
if !session_path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&session_path)?;
let session: ChatSession = serde_json::from_str(&content)
.map_err(|e| CsmError::InvalidSessionFormat(e.to_string()))?;
Ok(Some(session))
}
#[allow(dead_code)]
pub fn write_empty_window_session(session: &ChatSession) -> Result<PathBuf> {
let sessions_path = get_empty_window_sessions_path()?;
std::fs::create_dir_all(&sessions_path)?;
let session_id = session.session_id.as_deref().unwrap_or("unknown");
let session_path = sessions_path.join(format!("{}.json", session_id));
let content = serde_json::to_string_pretty(session)?;
std::fs::write(&session_path, content)?;
Ok(session_path)
}
#[allow(dead_code)]
pub fn delete_empty_window_session(session_id: &str) -> Result<bool> {
let sessions_path = get_empty_window_sessions_path()?;
let session_path = sessions_path.join(format!("{}.json", session_id));
if session_path.exists() {
std::fs::remove_file(&session_path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn count_empty_window_sessions() -> Result<usize> {
let sessions_path = get_empty_window_sessions_path()?;
if !sessions_path.exists() {
return Ok(0);
}
let count = std::fs::read_dir(&sessions_path)?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(is_session_file_extension))
.count();
Ok(count)
}
pub fn compact_session_jsonl(path: &Path) -> Result<PathBuf> {
let content = std::fs::read_to_string(path).map_err(|e| {
CsmError::InvalidSessionFormat(format!("Failed to read {}: {}", path.display(), e))
})?;
let content = split_concatenated_jsonl(&content);
let mut lines = content.lines();
let first_line = lines
.next()
.ok_or_else(|| CsmError::InvalidSessionFormat("Empty JSONL file".to_string()))?;
let first_entry: serde_json::Value = match serde_json::from_str(first_line.trim()) {
Ok(v) => v,
Err(_) => {
let sanitized = sanitize_json_unicode(first_line.trim());
serde_json::from_str(&sanitized).map_err(|e| {
CsmError::InvalidSessionFormat(format!("Invalid JSON on line 1: {}", e))
})?
}
};
let kind = first_entry
.get("kind")
.and_then(|k| k.as_u64())
.unwrap_or(99);
if kind != 0 {
return Err(CsmError::InvalidSessionFormat(
"First JSONL line must be kind:0".to_string(),
));
}
let mut state = first_entry
.get("v")
.cloned()
.ok_or_else(|| CsmError::InvalidSessionFormat("kind:0 missing 'v' field".to_string()))?;
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let entry: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, };
let op_kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(99);
match op_kind {
1 => {
if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
if let Some(keys_arr) = keys.as_array() {
apply_delta(&mut state, keys_arr, value.clone());
}
}
}
2 => {
if let (Some(keys), Some(value)) = (entry.get("k"), entry.get("v")) {
let splice_index = entry.get("i").and_then(|i| i.as_u64()).map(|i| i as usize);
if let Some(keys_arr) = keys.as_array() {
apply_splice(&mut state, keys_arr, value.clone(), splice_index);
}
}
}
_ => {} }
}
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
ensure_vscode_compat_fields(&mut state, session_id.as_deref());
let compact_entry = serde_json::json!({"kind": 0, "v": state});
let compact_content = serde_json::to_string(&compact_entry)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Failed to serialize: {}", e)))?;
let backup_path = path.with_extension("jsonl.bak");
std::fs::rename(path, &backup_path)?;
std::fs::write(path, format!("{}\n", compact_content))?;
Ok(backup_path)
}
pub fn trim_session_jsonl(path: &Path, keep: usize) -> Result<(usize, usize, f64, f64)> {
let content = std::fs::read_to_string(path).map_err(|e| {
CsmError::InvalidSessionFormat(format!("Failed to read {}: {}", path.display(), e))
})?;
let original_size = content.len() as f64 / (1024.0 * 1024.0);
let content = split_concatenated_jsonl(&content);
let line_count = content.lines().filter(|l| !l.trim().is_empty()).count();
let content = if line_count > 1 {
std::fs::write(path, &content)?;
compact_session_jsonl(path)?;
std::fs::read_to_string(path).map_err(|e| {
CsmError::InvalidSessionFormat(format!("Failed to read compacted file: {}", e))
})?
} else {
content
};
let first_line = content
.lines()
.next()
.ok_or_else(|| CsmError::InvalidSessionFormat("Empty JSONL file".to_string()))?;
let mut entry: serde_json::Value = serde_json::from_str(first_line.trim())
.map_err(|_| {
let sanitized = sanitize_json_unicode(first_line.trim());
serde_json::from_str::<serde_json::Value>(&sanitized)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Invalid JSON: {}", e)))
})
.unwrap_or_else(|e| e.unwrap());
let kind = entry.get("kind").and_then(|k| k.as_u64()).unwrap_or(99);
if kind != 0 {
return Err(
CsmError::InvalidSessionFormat("First JSONL line must be kind:0".to_string()).into(),
);
}
let requests = match entry
.get("v")
.and_then(|v| v.get("requests"))
.and_then(|r| r.as_array())
{
Some(r) => r.clone(),
None => {
return Err(CsmError::InvalidSessionFormat(
"Session has no requests array".to_string(),
)
.into());
}
};
let original_count = requests.len();
if original_count <= keep {
strip_bloated_content(&mut entry);
let trimmed_content = serde_json::to_string(&entry)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Failed to serialize: {}", e)))?;
let new_size = trimmed_content.len() as f64 / (1024.0 * 1024.0);
if new_size < original_size * 0.9 {
let backup_path = path.with_extension("jsonl.bak");
if !backup_path.exists() {
std::fs::copy(path, &backup_path)?;
}
std::fs::write(path, format!("{}\n", trimmed_content))?;
}
return Ok((original_count, original_count, original_size, new_size));
}
let kept_requests: Vec<serde_json::Value> = requests[original_count - keep..].to_vec();
let final_requests = kept_requests;
if let Some(v) = entry.get_mut("v") {
if let Some(obj) = v.as_object_mut() {
obj.insert("requests".to_string(), serde_json::json!(final_requests));
}
}
strip_bloated_content(&mut entry);
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
if let Some(v) = entry.get_mut("v") {
ensure_vscode_compat_fields(v, session_id.as_deref());
}
let trimmed_content = serde_json::to_string(&entry)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Failed to serialize: {}", e)))?;
let new_size = trimmed_content.len() as f64 / (1024.0 * 1024.0);
let backup_path = path.with_extension("jsonl.bak");
if !backup_path.exists() {
std::fs::copy(path, &backup_path)?;
}
std::fs::write(path, format!("{}\n", trimmed_content))?;
Ok((original_count, keep, original_size, new_size))
}
fn strip_bloated_content(entry: &mut serde_json::Value) {
let requests = match entry
.get_mut("v")
.and_then(|v| v.get_mut("requests"))
.and_then(|r| r.as_array_mut())
{
Some(r) => r,
None => return,
};
for req in requests.iter_mut() {
let obj = match req.as_object_mut() {
Some(o) => o,
None => continue,
};
if let Some(result) = obj.get_mut("result") {
if let Some(result_obj) = result.as_object_mut() {
if let Some(meta) = result_obj.get("metadata") {
let meta_str = serde_json::to_string(meta).unwrap_or_default();
if meta_str.len() > 1000 {
result_obj.insert(
"metadata".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
}
}
}
obj.remove("editedFileEvents");
obj.remove("chatEdits");
if let Some(refs) = obj.get_mut("contentReferences") {
if let Some(arr) = refs.as_array_mut() {
if arr.len() > 3 {
arr.truncate(3);
}
}
}
if let Some(response) = obj.get_mut("response") {
if let Some(resp_arr) = response.as_array_mut() {
resp_arr.retain(|r| {
let kind = r.get("kind").and_then(|k| k.as_str()).unwrap_or("");
!matches!(
kind,
"toolInvocationSerialized"
| "progressMessage"
| "confirmationWidget"
| "codeblockUri"
| "progressTaskSerialized"
| "undoStop"
| "mcpServersStarting"
| "confirmation"
)
});
for r in resp_arr.iter_mut() {
let kind = r
.get("kind")
.and_then(|k| k.as_str())
.unwrap_or("")
.to_string();
if kind == "textEditGroup" {
if let Some(edits) = r.get_mut("edits") {
if let Some(arr) = edits.as_array_mut() {
if serde_json::to_string(arr).unwrap_or_default().len() > 2000 {
arr.clear();
}
}
}
}
if kind == "thinking" {
if let Some(val) = r.get_mut("value") {
if let Some(s) = val.as_str() {
if s.len() > 500 {
*val = serde_json::Value::String(format!(
"{}... [truncated]",
&s[..500]
));
}
}
}
if let Some(thought) = r.get_mut("thought") {
if let Some(thought_val) = thought.get_mut("value") {
if let Some(s) = thought_val.as_str() {
if s.len() > 500 {
*thought_val = serde_json::Value::String(format!(
"{}... [truncated]",
&s[..500]
));
}
}
}
}
}
if kind == "markdownContent" {
if let Some(content) = r.get_mut("content") {
if let Some(val) = content.get_mut("value") {
if let Some(s) = val.as_str() {
if s.len() > 20000 {
*val = serde_json::Value::String(format!(
"{}\n\n---\n*[Chasm: Content truncated for loading performance]*",
&s[..20000]
));
}
}
}
}
}
}
let mut thinking_count = 0;
let mut indices_to_remove = Vec::new();
for (i, r) in resp_arr.iter().enumerate().rev() {
let kind = r.get("kind").and_then(|k| k.as_str()).unwrap_or("");
if kind == "thinking" {
thinking_count += 1;
if thinking_count > 5 {
indices_to_remove.push(i);
}
}
}
for idx in indices_to_remove {
resp_arr.remove(idx);
}
for r in resp_arr.iter_mut() {
if let Some(obj) = r.as_object_mut() {
obj.remove("toolSpecificData");
}
}
let fixed: Vec<serde_json::Value> = resp_arr
.drain(..)
.map(|item| {
if item.get("kind").is_none() {
if item.get("value").is_some() || item.get("supportHtml").is_some() {
serde_json::json!({
"kind": "markdownContent",
"content": item
})
} else {
item
}
} else {
item
}
})
.collect();
*resp_arr = fixed;
}
}
}
}
pub fn split_concatenated_jsonl(content: &str) -> String {
if !content.contains("}{\"kind\":") {
return content.to_string();
}
content.replace("}{\"kind\":", "}\n{\"kind\":")
}
fn apply_delta(root: &mut serde_json::Value, keys: &[serde_json::Value], value: serde_json::Value) {
if keys.is_empty() {
return;
}
let mut current = root;
for key in &keys[..keys.len() - 1] {
if let Some(k) = key.as_str() {
if !current.get(k).is_some() {
current[k] = serde_json::Value::Object(serde_json::Map::new());
}
current = &mut current[k];
} else if let Some(idx) = key.as_u64() {
if let Some(arr) = current.as_array_mut() {
while (idx as usize) >= arr.len() {
arr.push(serde_json::Value::Object(serde_json::Map::new()));
}
current = &mut arr[idx as usize];
} else {
return;
}
}
}
if let Some(last_key) = keys.last() {
if let Some(k) = last_key.as_str() {
current[k] = value;
} else if let Some(idx) = last_key.as_u64() {
if let Some(arr) = current.as_array_mut() {
while (idx as usize) >= arr.len() {
arr.push(serde_json::Value::Null);
}
arr[idx as usize] = value;
}
}
}
}
fn apply_splice(
root: &mut serde_json::Value,
keys: &[serde_json::Value],
items: serde_json::Value,
splice_index: Option<usize>,
) {
if keys.is_empty() {
return;
}
let mut current = root;
for key in keys {
if let Some(k) = key.as_str() {
if !current.get(k).is_some() {
current[k] = serde_json::json!([]);
}
current = &mut current[k];
} else if let Some(idx) = key.as_u64() {
if let Some(arr) = current.as_array_mut() {
while (idx as usize) >= arr.len() {
arr.push(serde_json::Value::Object(serde_json::Map::new()));
}
current = &mut arr[idx as usize];
} else {
return;
}
}
}
if let Some(target_arr) = current.as_array_mut() {
if let Some(idx) = splice_index {
target_arr.truncate(idx);
} else {
target_arr.clear();
}
if let Some(new_items) = items.as_array() {
target_arr.extend(new_items.iter().cloned());
}
}
}
pub fn ensure_vscode_compat_fields(state: &mut serde_json::Value, session_id: Option<&str>) {
migrate_old_input_state(state);
if let Some(obj) = state.as_object_mut() {
if !obj.contains_key("version") {
obj.insert("version".to_string(), serde_json::json!(3));
}
if !obj.contains_key("sessionId") {
if let Some(id) = session_id {
obj.insert("sessionId".to_string(), serde_json::json!(id));
}
}
if !obj.contains_key("responderUsername") {
obj.insert(
"responderUsername".to_string(),
serde_json::json!("GitHub Copilot"),
);
}
obj.insert("hasPendingEdits".to_string(), serde_json::json!(false));
obj.insert("pendingRequests".to_string(), serde_json::json!([]));
if !obj.contains_key("inputState") {
obj.insert(
"inputState".to_string(),
serde_json::json!({
"attachments": [],
"mode": { "id": "agent", "kind": "agent" },
"inputText": "",
"selections": [],
"contrib": { "chatDynamicVariableModel": [] }
}),
);
}
}
}
pub fn is_skeleton_json(content: &str) -> bool {
if content.len() < 100 {
return false;
}
let structural_chars: usize = content
.chars()
.filter(|c| {
matches!(
c,
'{' | '}' | '[' | ']' | ',' | ':' | ' ' | '\n' | '\r' | '\t' | '"'
)
})
.count();
let total_chars = content.len();
let structural_ratio = structural_chars as f64 / total_chars as f64;
if structural_ratio < 0.80 {
return false;
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(requests) = parsed.get("requests").and_then(|r| r.as_array()) {
let all_empty = requests.iter().all(|req| {
let msg = req
.get("message")
.and_then(|m| m.get("text"))
.and_then(|t| t.as_str());
msg.map_or(true, |s| s.is_empty())
});
return all_empty;
}
return true;
}
structural_ratio > 0.85
}
pub fn convert_skeleton_json_to_jsonl(
json_path: &Path,
title: Option<&str>,
last_message_date: Option<i64>,
) -> Result<Option<PathBuf>> {
let content = std::fs::read_to_string(json_path)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
if !is_skeleton_json(&content) {
return Ok(None);
}
let session_id = json_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let title = title.unwrap_or("Recovered Session");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let timestamp = last_message_date.unwrap_or(now);
let jsonl_entry = serde_json::json!({
"kind": 0,
"v": {
"sessionId": session_id,
"title": title,
"lastMessageDate": timestamp,
"requests": [],
"version": 4,
"hasPendingEdits": false,
"pendingRequests": [],
"inputState": {
"attachments": [],
"mode": { "id": "agent", "kind": "agent" },
"inputText": "",
"selections": [],
"contrib": { "chatDynamicVariableModel": [] }
},
"responderUsername": "GitHub Copilot",
"isImported": false,
"initialLocation": "panel"
}
});
let jsonl_path = json_path.with_extension("jsonl");
let corrupt_path = json_path.with_extension("json.corrupt");
if jsonl_path.exists() {
std::fs::rename(json_path, &corrupt_path)?;
return Ok(None);
}
std::fs::write(
&jsonl_path,
serde_json::to_string(&jsonl_entry)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Serialize error: {}", e)))?,
)?;
std::fs::rename(json_path, &corrupt_path)?;
Ok(Some(jsonl_path))
}
pub fn fix_cancelled_model_state(path: &Path) -> Result<bool> {
let content = std::fs::read_to_string(path)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return Ok(false);
}
if lines.len() == 1 {
let mut entry: serde_json::Value = serde_json::from_str(lines[0].trim())
.map_err(|e| CsmError::InvalidSessionFormat(format!("Invalid JSON: {}", e)))?;
let is_kind_0 = entry
.get("kind")
.and_then(|k| k.as_u64())
.map(|k| k == 0)
.unwrap_or(false);
if !is_kind_0 {
return Ok(false);
}
let requests = match entry
.get_mut("v")
.and_then(|v| v.get_mut("requests"))
.and_then(|r| r.as_array_mut())
{
Some(r) if !r.is_empty() => r,
_ => return Ok(false),
};
let last_req = requests.last_mut().unwrap();
let model_state = last_req.get("modelState");
let needs_fix = match model_state {
Some(ms) => {
ms.get("value").and_then(|v| v.as_u64()) != Some(1)
}
None => true, };
if !needs_fix {
return Ok(false);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
last_req.as_object_mut().unwrap().insert(
"modelState".to_string(),
serde_json::json!({"value": 1, "completedAt": now}),
);
let patched = serde_json::to_string(&entry)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Serialize error: {}", e)))?;
std::fs::write(path, format!("{}\n", patched))?;
return Ok(true);
}
let mut highest_req_idx: Option<usize> = None;
let mut last_model_state_value: Option<u64> = None;
if let Ok(first_entry) = serde_json::from_str::<serde_json::Value>(lines[0].trim()) {
if let Some(requests) = first_entry
.get("v")
.and_then(|v| v.get("requests"))
.and_then(|r| r.as_array())
{
if !requests.is_empty() {
let last_idx = requests.len() - 1;
highest_req_idx = Some(last_idx);
if let Some(ms) = requests[last_idx].get("modelState") {
last_model_state_value = ms.get("value").and_then(|v| v.as_u64());
}
}
}
}
static REQ_IDX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#""k":\["requests",(\d+)"#).unwrap());
for line in &lines[1..] {
if let Some(caps) = REQ_IDX_RE.captures(line) {
if let Ok(idx) = caps[1].parse::<usize>() {
if highest_req_idx.is_none() || idx > highest_req_idx.unwrap() {
highest_req_idx = Some(idx);
last_model_state_value = None; }
if Some(idx) == highest_req_idx && line.contains("\"modelState\"") {
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line.trim()) {
last_model_state_value = entry
.get("v")
.and_then(|v| v.get("value"))
.and_then(|v| v.as_u64());
}
}
}
}
}
let req_idx = match highest_req_idx {
Some(idx) => idx,
None => return Ok(false),
};
let needs_fix = match last_model_state_value {
Some(1) => false, _ => true, };
if !needs_fix {
return Ok(false);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let fix_delta = format!(
"\n{{\"kind\":1,\"k\":[\"requests\",{},\"modelState\"],\"v\":{{\"value\":1,\"completedAt\":{}}}}}",
req_idx, now
);
use std::io::Write;
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
file.write_all(fix_delta.as_bytes())?;
Ok(true)
}
pub fn repair_workspace_sessions(
workspace_id: &str,
chat_sessions_dir: &Path,
force: bool,
) -> Result<(usize, usize)> {
let db_path = get_workspace_storage_db(workspace_id)?;
if !db_path.exists() {
return Err(CsmError::WorkspaceNotFound(format!(
"Database not found: {}",
db_path.display()
)));
}
if !force && is_vscode_running() {
return Err(CsmError::VSCodeRunning);
}
let mut compacted = 0;
let mut fields_fixed = 0;
if chat_sessions_dir.exists() {
match recover_from_json_bak(chat_sessions_dir) {
Ok(n) if n > 0 => {
println!(" [OK] Recovered {} session(s) from .json.bak backups", n);
}
_ => {}
}
match recover_from_jsonl_bak(chat_sessions_dir, false) {
Ok((n, bytes)) if n > 0 => {
println!(
" [OK] Restored {} session(s) from .jsonl.bak ({:.1}MB recovered)",
n,
bytes as f64 / (1024.0 * 1024.0)
);
}
_ => {}
}
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "jsonl") {
let metadata = std::fs::metadata(&path)?;
let size_mb = metadata.len() / (1024 * 1024);
let raw_content = std::fs::read_to_string(&path)
.map_err(|e| CsmError::InvalidSessionFormat(format!("Read error: {}", e)))?;
let content = split_concatenated_jsonl(&raw_content);
if content != raw_content {
std::fs::write(&path, content.as_bytes())?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
println!(" [OK] Fixed concatenated JSONL objects: {}", stem);
}
let line_count = content.lines().count();
if line_count > 1 {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
println!(
" Compacting {} ({} lines, {}MB)...",
stem, line_count, size_mb
);
match compact_session_jsonl(&path) {
Ok(backup_path) => {
let new_size = std::fs::metadata(&path)
.map(|m| m.len() / (1024 * 1024))
.unwrap_or(0);
println!(
" [OK] Compacted: {}MB -> {}MB (backup: {})",
size_mb,
new_size,
backup_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
);
compacted += 1;
}
Err(e) => {
println!(" [WARN] Failed to compact {}: {}", stem, e);
}
}
} else {
if let Some(first_line) = content.lines().next() {
if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(first_line) {
let is_kind_0 = obj
.get("kind")
.and_then(|k| k.as_u64())
.map(|k| k == 0)
.unwrap_or(false);
if is_kind_0 {
if let Some(v) = obj.get("v") {
let needs_fix = !v.get("inputState").is_some()
|| !v.get("sessionId").is_some()
|| v.get("hasPendingEdits")
.and_then(|v| v.as_bool())
.unwrap_or(true)
!= false
|| v.get("pendingRequests")
.and_then(|v| v.as_array())
.map(|a| !a.is_empty())
.unwrap_or(true);
if needs_fix {
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string());
if let Some(v_mut) = obj.get_mut("v") {
ensure_vscode_compat_fields(
v_mut,
session_id.as_deref(),
);
}
let patched = serde_json::to_string(&obj).map_err(|e| {
CsmError::InvalidSessionFormat(format!(
"Failed to serialize: {}",
e
))
})?;
std::fs::write(&path, format!("{}\n", patched))?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
println!(" [OK] Fixed VS Code compat fields: {}", stem);
fields_fixed += 1;
} else if !content.ends_with('\n') {
std::fs::write(&path, format!("{}\n", first_line))?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
println!(
" [OK] Fixed missing trailing newline: {}",
stem
);
}
}
}
}
}
}
}
}
}
let mut skeletons_converted = 0;
if chat_sessions_dir.exists() {
let index_entries: std::collections::HashMap<String, (String, Option<i64>)> =
if let Ok(index) = read_chat_session_index(&db_path) {
index
.entries
.iter()
.map(|(id, e)| (id.clone(), (e.title.clone(), Some(e.last_message_date))))
.collect()
} else {
std::collections::HashMap::new()
};
let mut jsonl_stems: HashSet<String> = HashSet::new();
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "jsonl") {
if let Some(stem) = path.file_stem() {
jsonl_stems.insert(stem.to_string_lossy().to_string());
}
}
}
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& !path.to_string_lossy().ends_with(".bak")
&& !path.to_string_lossy().ends_with(".corrupt")
{
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if jsonl_stems.contains(&stem) {
continue;
}
let (title, timestamp) = index_entries
.get(&stem)
.map(|(t, ts)| (t.as_str(), *ts))
.unwrap_or(("Recovered Session", None));
match convert_skeleton_json_to_jsonl(&path, Some(title), timestamp) {
Ok(Some(jsonl_path)) => {
println!(
" [OK] Converted skeleton .json → .jsonl: {} (\"{}\")",
stem, title
);
jsonl_stems.insert(stem);
skeletons_converted += 1;
let _ = jsonl_path; }
Ok(None) => {} Err(e) => {
println!(" [WARN] Failed to convert skeleton {}: {}", stem, e);
}
}
}
}
}
let mut cancelled_fixed = 0;
if chat_sessions_dir.exists() {
for entry in std::fs::read_dir(chat_sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "jsonl") {
match fix_cancelled_model_state(&path) {
Ok(true) => {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
println!(" [OK] Fixed cancelled modelState: {}", stem);
cancelled_fixed += 1;
}
Ok(false) => {} Err(e) => {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
println!(" [WARN] Failed to fix modelState for {}: {}", stem, e);
}
}
}
}
}
let (index_fixed, _) = sync_session_index(workspace_id, chat_sessions_dir, force)?;
if fields_fixed > 0 {
println!(
" [OK] Injected missing VS Code fields into {} session(s)",
fields_fixed
);
}
if skeletons_converted > 0 {
println!(
" [OK] Converted {} skeleton .json file(s) to .jsonl",
skeletons_converted
);
}
if cancelled_fixed > 0 {
println!(
" [OK] Fixed cancelled modelState in {} session(s)",
cancelled_fixed
);
}
Ok((compacted, index_fixed))
}