Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ WayLog CLI is a lightweight tool written in Rust that automatically saves your A

## ✨ Features

- **🔄 Auto-Sync**: Real-time synchronization of chat history to `.waylog/history/` as you type.
- **🔄 Auto-Sync**: Real-time synchronization of chat history to `~/.waylog/history/<project_name>` as you type.
- **📦 Full History Recovery**: The `pull` command scans your entire machine to recover past sessions into the current project.
- **📝 Markdown Native**: All history is saved as high-quality Markdown files with frontmatter metadata.

Expand Down Expand Up @@ -64,7 +64,7 @@ waylog run codex

### 2. Full Sync / Recover History (`pull`)

Scans your local AI provider storage and "pulls" all relevant sessions into your project's `.waylog` folder.
Scans your local AI provider storage and "pulls" all relevant sessions into your project's `~/.waylog/history/<project_name>` folder.



Expand Down
4 changes: 2 additions & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ WayLog CLI 是一个轻量级的工具,自动捕捉并存档你的 AI 编程

## ✨ 特性

- **🔄 自动同步**:实时同步聊天历史至 `.waylog/history/`,边聊边记。
- **🔄 自动同步**:实时同步聊天历史至 `~/.waylog/history/<project_name>`,边聊边记。
- **📦 全量历史恢复**:使用 `pull` 命令扫描全机,将过去或丢失的会话恢复到当前项目中。
- **📝 Markdown 原生**:所有历史记录均保存为带 Frontmatter 元数据的高质量 Markdown 文件。

Expand Down Expand Up @@ -52,7 +52,7 @@ waylog run gemini

### 2. 全量同步 / 恢复历史 (`pull`)

扫描本地 AI 供应商的存储,并将所有相关的会话“拉取”到项目的 `.waylog` 文件夹中。
扫描本地 AI 供应商的存储,并将所有相关的会话“拉取”到项目的 `~/.waylog/history/<project_name>` 文件夹中。



Expand Down
11 changes: 9 additions & 2 deletions scripts/test-local-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,16 @@ test_case "Non-terminal output (piped, no color)" "cargo run -- --help 2>&1 | ca
# 6. Logging Tests
section "Logging Tests"

# Determine project name to locate log file in ~/.waylog/logs/
PROJECT_NAME=$(basename "$PWD")
if [ -z "$PROJECT_NAME" ]; then
PROJECT_NAME="default"
fi
LOG_DIR="$HOME/.waylog/logs/$PROJECT_NAME"

# Check if log file is created in verbose mode
if [ -d ".waylog/logs" ]; then
test_case "Log file creation (verbose mode)" "[ -f .waylog/logs/waylog.log.\$(date +%Y-%m-%d) ]" 0
if [ -d "$LOG_DIR" ]; then
test_case "Log file creation (verbose mode)" "[ -f \"$LOG_DIR/waylog.log.\$(date +%Y-%m-%d)\" ]" 0
else
echo -e " ${YELLOW}⚠ SKIP${NC} (log directory not found, run with --verbose first)"
((SKIPPED++))
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub enum OutputFormat {
pub enum Commands {
/// Run an AI CLI tool and automatically sync its chat history
Run {
/// The AI tool to run (codex, claude, gemini)
/// The AI tool to run (codex, claude, gemini, coco)
agent: Option<String>,

/// Additional arguments to pass to the agent
Expand Down
4 changes: 2 additions & 2 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub fn resolve_project_root(command: &Commands, output: &mut Output) -> Result<(
None => {
// Interactive prompt for initialization
let current_dir = std::env::current_dir()?;
let waylog_path = current_dir.join(WAYLOG_DIR);
let waylog_path = crate::utils::path::get_waylog_dir(&current_dir);

output.not_initialized()?;
output.init_prompt(&waylog_path)?;
Expand Down Expand Up @@ -78,7 +78,7 @@ pub fn setup_logging(project_root: &Path, verbose: bool, quiet: bool) -> Result<

// Build subscriber with conditional layers
if verbose {
let log_dir = project_root.join(WAYLOG_DIR).join(subdirs::LOGS);
let log_dir = crate::utils::path::get_log_dir(project_root);

// Create log directory if it doesn't exist
std::fs::create_dir_all(&log_dir)?;
Expand Down
1 change: 0 additions & 1 deletion src/providers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,6 @@ struct ClaudeUsage {
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::base::{MessageRole, Provider};

// Helper to create a user message event with content
fn create_user_event(content: &str) -> ClaudeEvent {
Expand Down
221 changes: 221 additions & 0 deletions src/providers/coco.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use crate::error::{Result, WaylogError};
use crate::providers::base::*;
use crate::utils::path;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use tokio::fs;

pub struct CocoProvider;

impl CocoProvider {
pub fn new() -> Self {
Self
}
}

#[async_trait]
impl Provider for CocoProvider {
fn name(&self) -> &str {
"coco"
}

fn data_dir(&self) -> Result<PathBuf> {
let home = path::home_dir()?;
Ok(home.join(".cache").join("coco").join("sessions"))
}

fn session_dir(&self, _project_path: &Path) -> Result<PathBuf> {
// Coco stores sessions in a flat directory structure, not per project
// So we return the main sessions directory
self.data_dir()
}

async fn find_latest_session(&self, project_path: &Path) -> Result<Option<PathBuf>> {
let candidates = self.get_all_sessions(project_path).await?;
Ok(candidates.into_iter().next())
}

async fn get_all_sessions(&self, project_path: &Path) -> Result<Vec<PathBuf>> {
let session_dir = self.data_dir()?;

if !session_dir.exists() {
return Ok(Vec::new());
}

let mut entries = fs::read_dir(&session_dir).await?;
let mut candidates = Vec::new();

while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
// Check session.json for cwd match
let session_json_path = path.join("session.json");
if session_json_path.exists() {
if let Ok(content) = fs::read_to_string(&session_json_path).await {
if let Ok(session_data) = serde_json::from_str::<CocoSessionData>(&content)
{
// Normalize paths for comparison
let session_cwd = std::fs::canonicalize(&session_data.metadata.cwd)
.unwrap_or_else(|_| session_data.metadata.cwd.clone());

let target_cwd = std::fs::canonicalize(project_path)
.unwrap_or_else(|_| project_path.to_path_buf());

if session_cwd == target_cwd {
// Found a match, use events.jsonl as the session file
let events_path = path.join("events.jsonl");
if events_path.exists() {
// Parse updated_at to sort
let updated_at =
DateTime::parse_from_rfc3339(&session_data.updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());

candidates.push((events_path, updated_at));
}
}
}
}
}
}
}

// Sort by updated_at, newest first
candidates.sort_by(|a, b| b.1.cmp(&a.1));

Ok(candidates.into_iter().map(|(p, _)| p).collect())
}

async fn parse_session(&self, file_path: &Path) -> Result<ChatSession> {
// file_path is events.jsonl
// session.json is in the same directory
let session_dir = file_path.parent().ok_or_else(|| {
WaylogError::PathError("Could not find parent directory of session file".to_string())
})?;

let session_json_path = session_dir.join("session.json");
let session_content = fs::read_to_string(&session_json_path).await?;
let session_data: CocoSessionData =
serde_json::from_str(&session_content).map_err(WaylogError::Json)?;

let events_content = fs::read_to_string(file_path).await?;
let mut messages = Vec::new();

for line in events_content.lines() {
if line.trim().is_empty() {
continue;
}

if let Ok(event) = serde_json::from_str::<CocoEventLine>(line) {
if let Some(msg) = self.parse_event(event) {
messages.push(msg);
}
}
}

let started_at = DateTime::parse_from_rfc3339(&session_data.created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());

let updated_at = DateTime::parse_from_rfc3339(&session_data.updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or(started_at);

Ok(ChatSession {
session_id: session_data.id,
provider: self.name().to_string(),
project_path: session_data.metadata.cwd,
started_at,
updated_at,
messages,
})
}

fn is_installed(&self) -> bool {
self.data_dir().map(|d| d.exists()).unwrap_or(false)
}

fn command(&self) -> &str {
"coco" // Assumed command name
}
}

impl CocoProvider {
fn parse_event(&self, event: CocoEventLine) -> Option<ChatMessage> {
let (role, content) = if let Some(agent_start) = event.agent_start {
// User input
if let Some(first_input) = agent_start.input.first() {
(MessageRole::User, first_input.content.clone())
} else {
return None;
}
} else if let Some(msg_event) = event.message {
// Assistant output
let role = match msg_event.message.role.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
_ => return None,
};
(role, msg_event.message.content)
} else {
return None;
};

if content.is_empty() {
return None;
}

let timestamp = DateTime::parse_from_rfc3339(&event.created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());

Some(ChatMessage {
id: event.id,
timestamp,
role,
content,
metadata: MessageMetadata::default(),
})
}
}

// Coco JSON structures

#[derive(Debug, Deserialize)]
struct CocoSessionData {
id: String,
created_at: String,
updated_at: String,
metadata: CocoSessionMetadata,
}

#[derive(Debug, Deserialize)]
struct CocoSessionMetadata {
cwd: PathBuf,
}

#[derive(Debug, Deserialize)]
struct CocoEventLine {
id: String,
created_at: String,
agent_start: Option<CocoAgentStart>,
message: Option<CocoMessageEvent>,
}

#[derive(Debug, Deserialize)]
struct CocoAgentStart {
input: Vec<CocoMessageContent>,
}

#[derive(Debug, Deserialize)]
struct CocoMessageEvent {
message: CocoMessageContent,
}

#[derive(Debug, Deserialize)]
struct CocoMessageContent {
role: String,
content: String,
}
5 changes: 4 additions & 1 deletion src/providers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod base;
pub mod claude;
pub mod coco;
pub mod codex;
pub mod gemini;

Expand All @@ -12,6 +13,7 @@ pub fn get_provider(name: &str) -> Result<Arc<dyn base::Provider>> {
"codex" => Ok(Arc::new(codex::CodexProvider::new())),
"claude" | "claude-code" => Ok(Arc::new(claude::ClaudeProvider::new())),
"gemini" => Ok(Arc::new(gemini::GeminiProvider::new())),
"coco" => Ok(Arc::new(coco::CocoProvider::new())),
_ => Err(WaylogError::ProviderNotFound(name.to_string())),
}
}
Expand All @@ -23,9 +25,10 @@ pub fn all_providers() -> Vec<Arc<dyn base::Provider>> {
Arc::new(codex::CodexProvider::new()),
Arc::new(claude::ClaudeProvider::new()),
Arc::new(gemini::GeminiProvider::new()),
Arc::new(coco::CocoProvider::new()),
]
}
/// Get a list of supported provider names
pub fn list_providers() -> Vec<&'static str> {
vec!["claude", "gemini", "codex"]
vec!["claude", "gemini", "codex", "coco"]
}
Loading