Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

> 面向使用者的更新说明。实现细节见 `docs/CHANGELOG.md`。

## 0.2.3(2026-08-31)

### 新增

- **Grok 支持**:可从 `~/.grok/sessions` 导入本机 Grok 对话,并在侧栏按「Grok → 本地 → 项目」浏览;AI 配置新增 Grok CLI,可直接用于提炼笔记,不接入 xAI API。

### 修复

- 修复新版 Cursor 对话索引迁移后只能读到旧记录、无法拉取近期会话的问题。
- 从会话或知识列表打开详情后,返回时保留原筛选、页码、选择和滚动位置。

## 0.2.2(2026-08-31)

### 修复
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

## 简介

有得是一款本地优先的 AI 对话知识库。它增量采集 Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi 的本地对话,经用户确认后通过 API 或本地 CLI Provider 提取原子知识,并提供草稿治理、全文检索和只读 MCP 回溯。
有得是一款本地优先的 AI 对话知识库。它增量采集 Claude Code、Cursor、Codex、CodeBuddy、Grok、OMP、Pi 的本地对话,经用户确认后通过 API 或本地 CLI Provider 提取原子知识,并提供草稿治理、全文检索和只读 MCP 回溯。

v0.2.0 采用固定的五类知识(决策、排障、实现、解释、片段)与开放标签,避免 AI 无限生成分类。高价值知识自动发布,中价值进入草稿,重新分析不会覆盖旧知识。

Expand All @@ -15,7 +15,7 @@ v0.2.0 采用固定的五类知识(决策、排障、实现、解释、片段

## v0.2.0 核心能力

- **六类对话来源**:Claude Code、Cursor、Codex、CodeBuddy、OMP、Pi;增量扫描不自动消耗 Token。
- **七类对话来源**:Claude Code、Cursor、Codex、CodeBuddy、Grok、OMP、Pi;增量扫描不自动消耗 Token。
- **API 与 CLI 同级 Provider**:可保存多套 OpenAI、DeepSeek、Moonshot、智谱、硅基流动、OpenAI-compatible 或本地 CLI 配置。
- **可控的分析任务**:每个任务绑定启动时的 Provider,展示真实阶段、进度、当前会话、模型和失败项。
- **本地搜索与 MCP**:SQLite FTS5 支持中英文检索;MCP 只读已发布知识,不暴露草稿、API Key 或 Provider 配置。
Expand Down Expand Up @@ -105,7 +105,7 @@ bun run build:debug

## 配置

配置默认保存到 `~/.chattake/config.toml`。可同时保存 OpenAI-compatible API 与 Claude Code、Codex、Cursor、OMP、Pi、CodeBuddy CLI 配置;每个分析任务只绑定启动时选择的一套 Provider,不自动切换。完整示例见 [`docs/config.example.toml`](docs/config.example.toml)。
配置默认保存到 `~/.chattake/config.toml`。可同时保存 OpenAI-compatible API 与 Claude Code、Codex、Cursor、Grok、OMP、Pi、CodeBuddy CLI 配置;每个分析任务只绑定启动时选择的一套 Provider,不自动切换。完整示例见 [`docs/config.example.toml`](docs/config.example.toml)。

首次运行 v0.2.0 时,如果检测到旧 Schema,会先通过 SQLite `VACUUM INTO` 备份到 `~/.chattake/db/backups/`,备份成功后才重建数据库。

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@chattake/desktop",
"private": true,
"version": "0.2.2",
"version": "0.2.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "chattake-desktop"
version = "0.2.2"
version = "0.2.3"
description = "有得 · AI 对话知识库"
authors = ["ChatTake"]
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/src/collector/agent_jsonl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ fn parse_agent_jsonl(
}))
}

fn visible_text(content: Option<&Value>) -> String {
pub(super) fn visible_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(text)) => text.trim().to_string(),
Some(Value::Array(parts)) => parts
Expand Down
148 changes: 146 additions & 2 deletions apps/desktop/src-tauri/src/collector/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ impl CursorCollector {
continue;
}
batch.found += 1;
let (raw_mtime_ms, raw_size_bytes) = match sqlite_fingerprint(&global_db_path) {
let (raw_mtime_ms, raw_size_bytes) = match cursor_fingerprint(&global_db_path) {
Some(fingerprint) => fingerprint,
None => continue,
};
Expand Down Expand Up @@ -109,7 +109,8 @@ impl CursorCollector {
raw_size_bytes: i64,
) -> Result<CollectionBatch, Box<dyn std::error::Error>> {
// Step 1: 扫描所有 workspace,建立 composerId → (project_path, name) 映射
let workspace_map = scan_workspaces(ws_base)?;
let mut workspace_map = scan_workspaces(ws_base)?;
workspace_map.extend(read_global_composers(global_db_path, ws_base)?);
log::debug!("发现 {} 个 workspace", workspace_map.len());

// Step 2: 打开全局数据库(只读),读取所有 composer 的 bubble 数据
Expand Down Expand Up @@ -159,6 +160,13 @@ fn sqlite_fingerprint(db_path: &Path) -> Option<(i64, i64)> {
Some(fingerprint)
}

fn cursor_fingerprint(global_db_path: &Path) -> Option<(i64, i64)> {
let mut fingerprint = sqlite_fingerprint(global_db_path)?;
// ponytail: 指纹版本放在 size 盐值里;新增解析格式时递增即可触发一次补采,无需新增迁移表。
fingerprint.1 = fingerprint.1.saturating_add(1);
Some(fingerprint)
}

// ── Workspace 扫描 ──

/// Workspace 信息(从 workspaceStorage 目录解析)
Expand Down Expand Up @@ -233,6 +241,62 @@ fn scan_workspaces(
Ok(map)
}

/// 新版 Cursor 将全部会话头集中到全局库的 composerHeaders 表。
fn read_global_composers(
global_db_path: &Path,
ws_base: &Path,
) -> Result<HashMap<String, WorkspaceInfo>, Box<dyn std::error::Error>> {
let conn = Connection::open_with_flags(
global_db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let mut map = HashMap::new();
let mut statement = match conn.prepare(
"SELECT composerId,workspaceId,createdAt,value FROM composerHeaders WHERE COALESCE(isSubagent,0)=0",
) {
Ok(statement) => statement,
Err(_) => return Ok(map),
};
let rows = statement.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
let (id, workspace_id, created_at, value) = row?;
let header: serde_json::Value = serde_json::from_str(&value)?;
let workspace = &header["workspaceIdentifier"];
let project_path = workspace
.pointer("/uri/fsPath")
.or_else(|| workspace.pointer("/configPath/fsPath"))
.and_then(serde_json::Value::as_str)
.map(String::from)
.or_else(|| {
workspace_id.as_deref().and_then(|workspace_id| {
read_workspace_project_path(&ws_base.join(workspace_id).join("workspace.json"))
})
});
let project_name = project_path
.as_deref()
.and_then(|path| Path::new(path).file_name())
.and_then(|name| name.to_str())
.map(String::from);
map.insert(
id,
WorkspaceInfo {
project_path,
project_name,
composer_name: header["name"].as_str().map(String::from),
created_at,
},
);
}
Ok(map)
}

/// 从 workspace.json 读取项目路径(本地绝对路径字符串)
///
/// 解码逻辑见 [`crate::path_local::decode_cursor_folder_to_local_path`]。
Expand Down Expand Up @@ -506,6 +570,86 @@ fn millis_to_rfc3339(ms: i64) -> String {
mod tests {
use super::*;

#[test]
fn collects_modern_global_composer_headers_without_legacy_workspace_index() {
let root = tempfile::tempdir().unwrap();
let global_dir = root.path().join("User/globalStorage");
std::fs::create_dir_all(&global_dir).unwrap();
let db_path = global_dir.join("state.vscdb");
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"CREATE TABLE composerHeaders (composerId TEXT PRIMARY KEY, workspaceId TEXT, createdAt INTEGER, isSubagent INTEGER, value TEXT)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)",
[],
)
.unwrap();
let headers = serde_json::json!({"allComposers":[
{
"composerId":"modern-1",
"name":"Modern chat",
"createdAt":1760000000000_i64,
"workspaceIdentifier":{"uri":{"fsPath":"/tmp/modern-project"}}
},
{
"composerId":"subagent-1",
"createdAt":1760000000000_i64,
"subagentInfo":{"subagentTypeName":"explore"}
}
]});
for header in headers["allComposers"].as_array().unwrap() {
conn.execute(
"INSERT INTO composerHeaders(composerId,workspaceId,createdAt,isSubagent,value) VALUES(?1,?2,?3,?4,?5)",
rusqlite::params![
header["composerId"].as_str(),
header.pointer("/workspaceIdentifier/id").and_then(serde_json::Value::as_str),
header["createdAt"].as_i64(),
i64::from(header["subagentInfo"].is_object()),
header.to_string(),
],
)
.unwrap();
}
let conversation = serde_json::json!({
"createdAt":1760000000000_i64,
"fullConversationHeadersOnly":[
{"bubbleId":"user-1","type":1},
{"bubbleId":"assistant-1","type":2}
]
});
conn.execute(
"INSERT INTO cursorDiskKV(key,value) VALUES('composerData:modern-1',?1)",
[conversation.to_string()],
)
.unwrap();
conn.execute(
"INSERT INTO cursorDiskKV(key,value) VALUES('bubbleId:modern-1:user-1',?1)",
[serde_json::json!({"text":"hello","createdAt":1760000001000_i64}).to_string()],
)
.unwrap();
conn.execute(
"INSERT INTO cursorDiskKV(key,value) VALUES('bubbleId:modern-1:assistant-1',?1)",
[serde_json::json!({"text":"hi","createdAt":1760000002000_i64}).to_string()],
)
.unwrap();
drop(conn);

let batch =
CursorCollector::new(vec![root.path().to_path_buf()]).collect_changed(|_, _, _| false);

assert!(batch.failures.is_empty());
assert_eq!(batch.sessions.len(), 1);
assert_eq!(batch.sessions[0].session_id, "modern-1");
assert_eq!(
batch.sessions[0].project_name.as_deref(),
Some("modern-project")
);
assert_eq!(batch.sessions[0].messages.len(), 2);
}

#[test]
fn test_extract_lexical_text_simple() {
let lexical = serde_json::json!({
Expand Down
Loading