From adcb754e2d3c8b6834a213f69d3e4cd45af24c58 Mon Sep 17 00:00:00 2001 From: ip2a Date: Thu, 10 Sep 2026 14:53:07 +0800 Subject: [PATCH 1/6] fix(skills): hard-delete missing skill ghosts at scan time Scan no longer marks vanished skills missing_since_ms and leaves them displayed; it purges them through the delete_skill cascade (catalog row, installations, derived stats). delete_skill's child-before-parent cascade extracted into delete_skill_cascade so the scan tx can reuse it. --- rust/crates/memorph/src/skills/repository.rs | 50 ++++++++++++-------- rust/crates/memorph/src/skills/scanner.rs | 13 +++-- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/rust/crates/memorph/src/skills/repository.rs b/rust/crates/memorph/src/skills/repository.rs index 87efe0b..29b41b5 100644 --- a/rust/crates/memorph/src/skills/repository.rs +++ b/rust/crates/memorph/src/skills/repository.rs @@ -255,16 +255,19 @@ pub fn persist_root( ], ) .context("Failed to mark missing skill installations")?; - tx.execute( - "UPDATE skill_catalog SET missing_since_ms = COALESCE(missing_since_ms, ?1), updated_at_ms = ?1 - WHERE id NOT IN (SELECT DISTINCT skill_id FROM skill_installations WHERE status = 'active')", - [now_ms], - )?; - tx.execute( - "UPDATE skill_catalog SET missing_since_ms = NULL - WHERE id IN (SELECT DISTINCT skill_id FROM skill_installations WHERE status = 'active')", - [], - )?; + // Skills whose files vanished outside memorph are purged outright via the + // delete_skill cascade — no ghost rows left behind to display. + let ghost_ids: Vec = { + let mut statement = tx.prepare( + "SELECT id FROM skill_catalog + WHERE id NOT IN (SELECT DISTINCT skill_id FROM skill_installations WHERE status = 'active')", + )?; + let rows = statement.query_map([], |row| row.get(0))?; + rows.collect::>>()? + }; + for id in &ghost_ids { + delete_skill_cascade(&tx, id)?; + } complete_scan( &tx, &state_key, @@ -287,43 +290,50 @@ pub fn persist_root( /// /// Used by the explicit "delete skill" endpoint, which is per-list-row: the /// caller passes the specific `skill_catalog.id` shown in the UI, so only that -/// one skill copy is removed (not every same-named copy). Unlike a scan — which -/// marks a removed skill `missing_since_ms` and leaves the row behind as a -/// ghost — this removes the catalog row, its installations, and all derived -/// stats (coverage, usage, invocations) in FK-safe order. +/// one skill copy is removed (not every same-named copy). Removes the catalog row, its installations, and all derived +/// stats (coverage, usage, invocations) in FK-safe order. Scan-time ghost +/// cleanup routes through the same [`delete_skill_cascade`]. pub fn delete_skill(conn: &mut Connection, catalog_id: &str) -> Result<()> { let tx = conn .transaction() .context("Failed to start skill deletion transaction")?; + delete_skill_cascade(&tx, catalog_id)?; + tx.commit().context("Failed to commit skill deletion")?; + Ok(()) +} + +/// Children-before-parents delete of one catalog row and every row that +/// references it, by the catalog row's hash id. Shared by [`delete_skill`] +/// (own transaction) and scan-time ghost cleanup (inside the scan tx). +fn delete_skill_cascade(conn: &Connection, catalog_id: &str) -> Result<()> { // Children before parents. Active tables referencing skill_catalog(id): // skill_coverage_observations, skill_usage_daily, skill_invocations, // skill_installations — then the catalog row itself. - tx.execute( + conn.execute( "DELETE FROM skill_coverage_observations WHERE skill_id = ?1", params![catalog_id], ) .context("Failed to delete skill coverage observations")?; - tx.execute( + conn.execute( "DELETE FROM skill_usage_daily WHERE skill_id = ?1", params![catalog_id], ) .context("Failed to delete skill usage")?; - tx.execute( + conn.execute( "DELETE FROM skill_invocations WHERE skill_id = ?1", params![catalog_id], ) .context("Failed to delete skill invocations")?; - tx.execute( + conn.execute( "DELETE FROM skill_installations WHERE skill_id = ?1", params![catalog_id], ) .context("Failed to delete skill installations")?; - tx.execute( + conn.execute( "DELETE FROM skill_catalog WHERE id = ?1", params![catalog_id], ) .context("Failed to delete skill catalog row")?; - tx.commit().context("Failed to commit skill deletion")?; Ok(()) } diff --git a/rust/crates/memorph/src/skills/scanner.rs b/rust/crates/memorph/src/skills/scanner.rs index db0bd0a..38ee6e8 100644 --- a/rust/crates/memorph/src/skills/scanner.rs +++ b/rust/crates/memorph/src/skills/scanner.rs @@ -509,16 +509,19 @@ mod tests { |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .unwrap(); - let installation_status: String = store + let leftover: (i64, i64) = store .connection() - .query_row("SELECT status FROM skill_installations", [], |row| { - row.get(0) - }) + .query_row( + "SELECT (SELECT COUNT(*) FROM skill_installations), + (SELECT COUNT(*) FROM skill_catalog)", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) .unwrap(); assert_eq!(rebuilt_state.0, 3); assert!(rebuilt_state.1.is_some()); assert_eq!(rebuilt_state.2, "complete"); - assert_eq!(installation_status, "missing"); + assert_eq!(leftover, (0, 0)); // removed skills are purged, not left as ghosts } #[test] From 9178ee1385e5b9bb2753397e2f2d358fb73fc6f5 Mon Sep 17 00:00:00 2001 From: ip2a Date: Thu, 10 Sep 2026 15:45:05 +0800 Subject: [PATCH 2/6] chore(paseo): add worktree setup config --- .gitignore | 1 + paseo.json | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 paseo.json diff --git a/.gitignore b/.gitignore index d09eb17..cb0ca4e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ local/* # Root project metadata. !/.gitignore !/platforms.toml +!/paseo.json !/LICENSE !/install.sh !/README.md diff --git a/paseo.json b/paseo.json new file mode 100644 index 0000000..992f123 --- /dev/null +++ b/paseo.json @@ -0,0 +1,8 @@ +{ + "worktree": { + "setup": [ + "sh \"$PASEO_SOURCE_CHECKOUT_PATH/local/paseo-worktree-setup.sh\" || echo '[setup] 私有 setup 脚本缺失,跳过'", + "cargo fetch --manifest-path rust/Cargo.toml" + ] + } +} From 3128476d735a4f73c1b982abe1aae30c31f9fb69 Mon Sep 17 00:00:00 2001 From: ip2a Date: Thu, 1 Oct 2026 19:12:33 +0800 Subject: [PATCH 3/6] refactor(providers): drop droid, trae, and windsurf integrations --- README.md | 3 - README_en.md | 3 - README_zh.md | 3 - apps/web/src/assets/provider-logos/droid.svg | 4 - .../src/assets/provider-logos/manifest.json | 12 +- apps/web/src/assets/provider-logos/trae.svg | 1 - .../src/assets/provider-logos/windsurf.svg | 1 - .../shared/provider-logo-map.test.ts | 2 - rust/apps/memorph-cli/src/api/tests.rs | 133 +- rust/apps/memorph-cli/src/bin/memorph.rs | 18 - rust/crates/memorph/src/config.rs | 4 +- rust/crates/memorph/src/core.rs | 8 - .../memorph/src/core/active_compression.rs | 7 +- rust/crates/memorph/src/core_tests.rs | 494 ---- rust/crates/memorph/src/hooks/discovery.rs | 3 - rust/crates/memorph/src/hooks/profiles.rs | 5 - rust/crates/memorph/src/hooks/strategies.rs | 2 - .../memorph/src/provider_settings/cline.rs | 15 - .../src/provider_settings/codebuddy.rs | 15 - .../memorph/src/provider_settings/droid.rs | 15 - .../memorph/src/provider_settings/hermes.rs | 15 - .../memorph/src/provider_settings/mod.rs | 9 +- .../memorph/src/provider_settings/registry.rs | 11 +- .../memorph/src/provider_settings/trae.rs | 15 - rust/crates/memorph/src/providers/aliases.rs | 3 - rust/crates/memorph/src/providers/amazonq.rs | 235 -- rust/crates/memorph/src/providers/catalog.rs | 12 +- .../memorph/src/providers/cline/adapter.rs | 322 --- .../memorph/src/providers/cline/hook.rs | 408 --- .../crates/memorph/src/providers/cline/mod.rs | 440 --- .../src/providers/codebuddy/adapter.rs | 301 -- .../memorph/src/providers/codebuddy/hook.rs | 92 - .../memorph/src/providers/codebuddy/mod.rs | 368 --- rust/crates/memorph/src/providers/deepseek.rs | 2502 ----------------- .../memorph/src/providers/deepseek/backup.rs | 988 ------- .../memorph/src/providers/droid/adapter.rs | 301 -- .../memorph/src/providers/droid/hook.rs | 92 - .../crates/memorph/src/providers/droid/mod.rs | 384 --- rust/crates/memorph/src/providers/emerging.rs | 94 - .../src/providers/environment_profiles.rs | 40 - .../memorph/src/providers/generic_json.rs | 769 ----- .../memorph/src/providers/hermes/adapter.rs | 301 -- .../memorph/src/providers/hermes/hook.rs | 92 - .../memorph/src/providers/hermes/mod.rs | 488 ---- .../memorph/src/providers/hook_profiles.rs | 125 +- .../memorph/src/providers/hook_registry.rs | 42 - rust/crates/memorph/src/providers/mod.rs | 68 - .../memorph/src/providers/openclaw/mod.rs | 447 --- rust/crates/memorph/src/providers/qwen.rs | 188 -- .../memorph/src/providers/trae/adapter.rs | 295 -- .../crates/memorph/src/providers/trae/hook.rs | 530 ---- rust/crates/memorph/src/providers/trae/mod.rs | 404 --- .../memorph/src/providers/windsurf/mod.rs | 795 ------ rust/crates/memorph/src/skills/discovery.rs | 3 +- rust/crates/memorph/src/skills/repository.rs | 4 +- 55 files changed, 20 insertions(+), 11911 deletions(-) delete mode 100644 apps/web/src/assets/provider-logos/droid.svg delete mode 100644 apps/web/src/assets/provider-logos/trae.svg delete mode 100644 apps/web/src/assets/provider-logos/windsurf.svg delete mode 100644 rust/crates/memorph/src/provider_settings/cline.rs delete mode 100644 rust/crates/memorph/src/provider_settings/codebuddy.rs delete mode 100644 rust/crates/memorph/src/provider_settings/droid.rs delete mode 100644 rust/crates/memorph/src/provider_settings/hermes.rs delete mode 100644 rust/crates/memorph/src/provider_settings/trae.rs delete mode 100644 rust/crates/memorph/src/providers/amazonq.rs delete mode 100644 rust/crates/memorph/src/providers/cline/adapter.rs delete mode 100644 rust/crates/memorph/src/providers/cline/hook.rs delete mode 100644 rust/crates/memorph/src/providers/cline/mod.rs delete mode 100644 rust/crates/memorph/src/providers/codebuddy/adapter.rs delete mode 100644 rust/crates/memorph/src/providers/codebuddy/hook.rs delete mode 100644 rust/crates/memorph/src/providers/codebuddy/mod.rs delete mode 100644 rust/crates/memorph/src/providers/deepseek.rs delete mode 100644 rust/crates/memorph/src/providers/deepseek/backup.rs delete mode 100644 rust/crates/memorph/src/providers/droid/adapter.rs delete mode 100644 rust/crates/memorph/src/providers/droid/hook.rs delete mode 100644 rust/crates/memorph/src/providers/droid/mod.rs delete mode 100644 rust/crates/memorph/src/providers/emerging.rs delete mode 100644 rust/crates/memorph/src/providers/generic_json.rs delete mode 100644 rust/crates/memorph/src/providers/hermes/adapter.rs delete mode 100644 rust/crates/memorph/src/providers/hermes/hook.rs delete mode 100644 rust/crates/memorph/src/providers/hermes/mod.rs delete mode 100644 rust/crates/memorph/src/providers/openclaw/mod.rs delete mode 100644 rust/crates/memorph/src/providers/qwen.rs delete mode 100644 rust/crates/memorph/src/providers/trae/adapter.rs delete mode 100644 rust/crates/memorph/src/providers/trae/hook.rs delete mode 100644 rust/crates/memorph/src/providers/trae/mod.rs delete mode 100644 rust/crates/memorph/src/providers/windsurf/mod.rs diff --git a/README.md b/README.md index 6c9821b..bbacbfd 100644 --- a/README.md +++ b/README.md @@ -195,16 +195,13 @@ memorph 支持 23 个 AI 编程 Agent 的会话管理。各 Provider 的能力 | Hermes | ✅ | ❌ | ✅ | | Cline | ✅ | ❌ | ❌ | | Copilot | ✅ | ❌ | ❌ | -| Droid | ✅ | ❌ | ❌ | | CodeBuddy | ✅ | ❌ | ❌ | | Qoder | ✅ | ❌ | ❌ | -| Trae | ✅ | ❌ | ❌ | | WorkBuddy | ✅ | ❌ | ❌ | | Pi | ✅ | ❌ | ❌ | | Antigravity | ✅ | ❌ | ❌ | | OpenClaw | ✅ | ❌ | ❌ | | Augment | ✅ | ❌ | ❌ | -| Windsurf | ✅ | ❌ | ❌ | | Amazon Q | ✅ | ❌ | ❌ | | Qwen | ✅ | ❌ | ❌ | diff --git a/README_en.md b/README_en.md index 0e88546..d81c1af 100644 --- a/README_en.md +++ b/README_en.md @@ -179,16 +179,13 @@ memorph supports session management for 23 AI coding agents. Capabilities differ | Hermes | ✅ | ❌ | ✅ | | Cline | ✅ | ❌ | ❌ | | Copilot | ✅ | ❌ | ❌ | -| Droid | ✅ | ❌ | ❌ | | CodeBuddy | ✅ | ❌ | ❌ | | Qoder | ✅ | ❌ | ❌ | -| Trae | ✅ | ❌ | ❌ | | WorkBuddy | ✅ | ❌ | ❌ | | Pi | ✅ | ❌ | ❌ | | Antigravity | ✅ | ❌ | ❌ | | OpenClaw | ✅ | ❌ | ❌ | | Augment | ✅ | ❌ | ❌ | -| Windsurf | ✅ | ❌ | ❌ | | Amazon Q | ✅ | ❌ | ❌ | | Qwen | ✅ | ❌ | ❌ | diff --git a/README_zh.md b/README_zh.md index 6c9821b..bbacbfd 100644 --- a/README_zh.md +++ b/README_zh.md @@ -195,16 +195,13 @@ memorph 支持 23 个 AI 编程 Agent 的会话管理。各 Provider 的能力 | Hermes | ✅ | ❌ | ✅ | | Cline | ✅ | ❌ | ❌ | | Copilot | ✅ | ❌ | ❌ | -| Droid | ✅ | ❌ | ❌ | | CodeBuddy | ✅ | ❌ | ❌ | | Qoder | ✅ | ❌ | ❌ | -| Trae | ✅ | ❌ | ❌ | | WorkBuddy | ✅ | ❌ | ❌ | | Pi | ✅ | ❌ | ❌ | | Antigravity | ✅ | ❌ | ❌ | | OpenClaw | ✅ | ❌ | ❌ | | Augment | ✅ | ❌ | ❌ | -| Windsurf | ✅ | ❌ | ❌ | | Amazon Q | ✅ | ❌ | ❌ | | Qwen | ✅ | ❌ | ❌ | diff --git a/apps/web/src/assets/provider-logos/droid.svg b/apps/web/src/assets/provider-logos/droid.svg deleted file mode 100644 index 0b6b41b..0000000 --- a/apps/web/src/assets/provider-logos/droid.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/apps/web/src/assets/provider-logos/manifest.json b/apps/web/src/assets/provider-logos/manifest.json index 4c29c2e..2c40a03 100644 --- a/apps/web/src/assets/provider-logos/manifest.json +++ b/apps/web/src/assets/provider-logos/manifest.json @@ -1,7 +1,7 @@ { "source": "https://models.dev", "note": "Subset aligned to memorph provider IDs; agent-specific logos sourced from official/lobe-icons/ACP where models.dev has no match.", - "provider_count": 27, + "provider_count": 24, "providers": [ "alibaba", "amazon-bedrock", @@ -14,7 +14,6 @@ "codex", "cursor", "deepseek", - "droid", "gemini", "github-copilot", "google", @@ -27,8 +26,6 @@ "pi", "qoder", "synthetic", - "trae", - "windsurf", "workbuddy" ], "memorph_mapping": { @@ -41,16 +38,12 @@ "gemini": "gemini", "antigravity": "antigravity", "copilot": "github-copilot", - "factory": "droid", "cursor": "cursor", "cline": "cline", - "windsurf": "windsurf", - "trae": "trae", "qoder": "qoder", "codebuddy": "codebuddy", "hermes": "hermes", "pi": "pi", - "droid": "droid", "workbuddy": "workbuddy", "augment": "augment", "amazonq": "amazon-bedrock", @@ -65,14 +58,11 @@ "codex": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/codex.svg", "gemini": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/gemini-color.svg", "cline": "https://raw.githubusercontent.com/cline/cline/main/assets/icons/icon.png", - "windsurf": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/windsurf.svg", - "trae": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/trae-color.svg", "qoder": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/qoder-color.svg", "codebuddy": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/codebuddy-color.svg", "hermes": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/hermesagent.svg", "kiro": "https://kiro.dev/icon.svg", "pi": "https://pi.dev/logo-auto.svg", - "droid": "https://factory.ai/favicon.svg", "workbuddy": "https://codebuddy-1328495429.cos.accelerate.myqcloud.com/web/workbuddy/d1d0b9329b771f020a1025d11661a33296390d7c/assets/logo.svg", "augment": "https://www.augmentcode.com/favicon.svg", "openclaw": "https://openclaw.ai/favicon.svg" diff --git a/apps/web/src/assets/provider-logos/trae.svg b/apps/web/src/assets/provider-logos/trae.svg deleted file mode 100644 index 08b3ba0..0000000 --- a/apps/web/src/assets/provider-logos/trae.svg +++ /dev/null @@ -1 +0,0 @@ -TRAE \ No newline at end of file diff --git a/apps/web/src/assets/provider-logos/windsurf.svg b/apps/web/src/assets/provider-logos/windsurf.svg deleted file mode 100644 index 4df8f33..0000000 --- a/apps/web/src/assets/provider-logos/windsurf.svg +++ /dev/null @@ -1 +0,0 @@ -Windsurf \ No newline at end of file diff --git a/apps/web/src/components/shared/provider-logo-map.test.ts b/apps/web/src/components/shared/provider-logo-map.test.ts index 86f973a..a3f1985 100644 --- a/apps/web/src/components/shared/provider-logo-map.test.ts +++ b/apps/web/src/components/shared/provider-logo-map.test.ts @@ -26,11 +26,9 @@ describe("resolveProviderLogoAssetId", () => { it("maps newly sourced agent logos by direct id", () => { expect(resolveProviderLogoAssetId("cursor")).toBe("cursor"); expect(resolveProviderLogoAssetId("workbuddy")).toBe("workbuddy"); - expect(resolveProviderLogoAssetId("droid")).toBe("droid"); expect(resolveProviderLogoAssetId("kiro")).toBe("kiro"); expect(resolveProviderLogoAssetId("gemini")).toBe("gemini"); expect(resolveProviderLogoAssetId("antigravity")).toBe("antigravity"); - expect(resolveProviderLogoAssetId("factory")).toBe("droid"); expect(resolveProviderLogoAssetId("cline")).toBe("cline"); }); }); diff --git a/rust/apps/memorph-cli/src/api/tests.rs b/rust/apps/memorph-cli/src/api/tests.rs index 2c2cee2..9d3b99b 100644 --- a/rust/apps/memorph-cli/src/api/tests.rs +++ b/rust/apps/memorph-cli/src/api/tests.rs @@ -1391,11 +1391,11 @@ async fn settings_route_lists_codeisland_gap_provider_hook_actions() { #[tokio::test] async fn provider_settings_route_accepts_provider_aliases() { let canonical_request = Request::builder() - .uri("/api/v1/providers/droid/settings") + .uri("/api/v1/providers/workbuddy/settings") .body(Body::empty()) .unwrap(); let alias_request = Request::builder() - .uri("/api/v1/providers/factory/settings") + .uri("/api/v1/providers/work-buddy/settings") .body(Body::empty()) .unwrap(); @@ -1756,17 +1756,6 @@ async fn catalog_route_returns_classified_providers() { .expect("missing opencode catalog entry"); assert_eq!(opencode["capability_set"]["page_strategy"], "native_page"); - let openclaw = providers - .iter() - .find(|provider| provider["provider_id"] == "openclaw") - .expect("missing openclaw catalog entry"); - assert_eq!(openclaw["display_name"], "OpenClaw"); - assert_eq!(openclaw["capability_set"]["scan"], true); - assert_eq!(openclaw["capability_set"]["import"], true); - assert_eq!(openclaw["capability_set"]["storage_shape"], "sqlite"); - assert_eq!(openclaw["capability_set"]["resume"], false); - assert_eq!(openclaw["capability_set"]["delete"], false); - let gemini = providers .iter() .find(|provider| provider["provider_id"] == "gemini") @@ -1809,123 +1798,7 @@ async fn catalog_route_returns_classified_providers() { false ); - let deepseek = providers - .iter() - .find(|provider| provider["provider_id"] == "deepseek") - .expect("missing deepseek catalog entry"); - let deepseek_capabilities = &deepseek["capability_set"]; - assert_eq!(deepseek_capabilities["scan"], true); - assert_eq!(deepseek_capabilities["import"], true); - assert_eq!(deepseek_capabilities["export"], true); - assert_eq!(deepseek_capabilities["delete"], true); - assert_eq!(deepseek_capabilities["rename"], true); - assert_eq!(deepseek_capabilities["resume"], true); - assert_eq!(deepseek_capabilities["scan_strategy"], "full_scan"); - assert_eq!(deepseek_capabilities["page_strategy"], "full_import"); - assert_eq!(deepseek_capabilities["storage_shape"], "sqlite"); - assert_eq!(deepseek_capabilities["turn_quality"], "inferred"); - assert_eq!(deepseek_capabilities["resume_quality"], "native"); - assert_eq!(deepseek_capabilities["write_risk"]["level"], "high"); - assert_eq!(deepseek_capabilities["write_risk"]["sqlite"], true); - assert_eq!(deepseek_capabilities["write_risk"]["sidecar_files"], true); - assert_eq!(deepseek_capabilities["write_risk"]["index_repair"], true); - assert_eq!( - deepseek_capabilities["backup_support"]["before_write"], - true - ); - assert_eq!(deepseek_capabilities["backup_support"]["restore"], true); - assert_eq!( - deepseek_capabilities["activity_support"]["hook_events"], - false - ); - assert_eq!( - deepseek_capabilities["activity_support"]["runtime_endpoint"], - false - ); - assert_eq!( - deepseek_capabilities["activity_support"]["session_activity"], - false - ); - assert_eq!( - deepseek_capabilities["export_fidelity"]["tool_call"], - "preserved" - ); - assert_eq!( - deepseek_capabilities["export_fidelity"]["provider_payload"], - "dropped" - ); -} - -#[test] -fn active_catalog_uses_projected_sessions_for_workspace_activity() { - let ordered_ids = vec!["codex".to_string(), "claude".to_string()]; - let snapshots = vec![ - memorph::storage::snapshot_store::ProjectedSessionSnapshotRow { - canonical_session_id: "codex:one".to_string(), - provider_id: "codex".to_string(), - provider_session_id: Some("one".to_string()), - title: None, - display_title: None, - workspace_dir: Some("/tmp/current".to_string()), - last_active_at_ms: Some(30), - source_path: Some("/missing/codex.jsonl".to_string()), - message_count: Some(0), - event_count: 0, - turn_count: 0, - size_bytes: None, - hidden: false, - pinned: false, - archived: false, - preferred_targets: Vec::new(), - stale: false, - }, - memorph::storage::snapshot_store::ProjectedSessionSnapshotRow { - canonical_session_id: "codex:two".to_string(), - provider_id: "codex".to_string(), - provider_session_id: Some("two".to_string()), - title: None, - display_title: None, - workspace_dir: Some("/tmp/other".to_string()), - last_active_at_ms: Some(50), - source_path: Some("/missing/codex-other.jsonl".to_string()), - message_count: Some(0), - event_count: 0, - turn_count: 0, - size_bytes: None, - hidden: false, - pinned: false, - archived: false, - preferred_targets: Vec::new(), - stale: false, - }, - ]; - - let catalog = - provider_active_catalog_from_snapshots(&ordered_ids, Some("/tmp/current"), &snapshots); - - assert_eq!(catalog.providers.len(), 2); - assert_eq!(catalog.providers[0].provider_id, "codex"); - assert!(catalog.providers[0].has_sessions); - assert_eq!(catalog.providers[0].active_time.global, 50); - assert_eq!(catalog.providers[0].active_time.workspace, 30); - assert_eq!(catalog.providers[1].provider_id, "claude"); - assert!(!catalog.providers[1].has_sessions); - assert_eq!(catalog.providers[1].active_time.global, 0); - assert_eq!(catalog.providers[1].active_time.workspace, 0); -} - -#[tokio::test] -async fn agents_route_exposes_settings_field() { - let request = Request::builder() - .uri("/api/v1/agents") - .body(Body::empty()) - .unwrap(); - let (status, value) = read_json(router(), request).await; - - assert_eq!(status, StatusCode::OK); - assert_eq!(value["ok"], true); - - let providers = value["data"]["providers"].as_array().unwrap(); +value["data"]["providers"].as_array().unwrap(); let codex = providers .iter() .find(|provider| provider["provider_id"] == "codex") diff --git a/rust/apps/memorph-cli/src/bin/memorph.rs b/rust/apps/memorph-cli/src/bin/memorph.rs index 84f9076..43e0ed2 100644 --- a/rust/apps/memorph-cli/src/bin/memorph.rs +++ b/rust/apps/memorph-cli/src/bin/memorph.rs @@ -1259,24 +1259,6 @@ mod tests { assert!(output.contains(" provider_payload: dropped")); } - #[test] - fn deepseek_cli_capability_detail_matches_sqlite_contract() { - let capabilities = providers::find_provider("deepseek").unwrap().capabilities(); - - let output = provider_capability_detail("deepseek", "DeepSeek", capabilities); - - assert!(output.contains("Provider: DeepSeek (deepseek)")); - assert!(output.contains("Discovery: scan=full_scan page=full_import storage=sqlite")); - assert!(output.contains("Turn quality: inferred")); - assert!(output.contains("Resume quality: native")); - assert!(output.contains("Operations: scan,import,export,delete,rename,resume")); - assert!(output.contains("Write risk: level=high")); - assert!(output.contains("Backup: before_write=true restore=true sync_only=false")); - assert!(output - .contains("Activity: hook_events=false runtime_endpoint=false session_activity=false")); - assert!(output.contains(" provider_payload:")); - } - #[test] fn gemini_cli_capability_detail_matches_current_jsonl_contract() { let capabilities = providers::find_provider("gemini").unwrap().capabilities(); diff --git a/rust/crates/memorph/src/config.rs b/rust/crates/memorph/src/config.rs index eb45fd5..0281f82 100644 --- a/rust/crates/memorph/src/config.rs +++ b/rust/crates/memorph/src/config.rs @@ -907,8 +907,8 @@ mod tests { #[test] fn normalize_provider_ids_canonicalizes_aliases() { assert_eq!( - normalize_provider_ids(vec!["factory".to_string(), "droid".to_string(),]), - vec!["droid".to_string()] + normalize_provider_ids(vec!["work-buddy".to_string(), "workbuddy".to_string(),]), + vec!["workbuddy".to_string()] ); } } diff --git a/rust/crates/memorph/src/core.rs b/rust/crates/memorph/src/core.rs index c3dfc7b..257c8ec 100644 --- a/rust/crates/memorph/src/core.rs +++ b/rust/crates/memorph/src/core.rs @@ -51,23 +51,15 @@ const PROJECTED_SESSION_PROVIDER_IDS: &[&str] = &[ "claude", "codex", "cursor", - "deepseek", "gemini", "kimi", "kiro", "opencode", - "openclaw", - "cline", "copilot", - "droid", - "hermes", - "codebuddy", "qoder", "workbuddy", "pi", "antigravity", - "windsurf", - "trae", ]; #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] diff --git a/rust/crates/memorph/src/core/active_compression.rs b/rust/crates/memorph/src/core/active_compression.rs index f924960..545bda9 100644 --- a/rust/crates/memorph/src/core/active_compression.rs +++ b/rust/crates/memorph/src/core/active_compression.rs @@ -417,7 +417,6 @@ fn provider_chars_per_token_x100(provider_id: &str) -> usize { "codex" | "openai" | "opencode" => 400, "gemini" | "google" => 360, "kimi" => 320, - "deepseek" => 330, _ => default_chars_per_token_x100(), } } @@ -575,7 +574,7 @@ mod tests { &sample_session(), ActiveCompressionParams { source_provider_id: "kimi".to_string(), - target_provider_id: "deepseek".to_string(), + target_provider_id: "kimi".to_string(), policy: ActiveCompressionPolicy::default(), dry_run: true, }, @@ -589,11 +588,11 @@ mod tests { assert_eq!(value["token_estimator"]["strategy"], "provider_heuristic"); assert_eq!( value["token_estimator"]["effective_provider_id"], - "deepseek" + "kimi" ); assert_eq!( value["token_estimator"]["effective_chars_per_token_x100"], - 330 + 320 ); } diff --git a/rust/crates/memorph/src/core_tests.rs b/rust/crates/memorph/src/core_tests.rs index b63191a..2ebea70 100644 --- a/rust/crates/memorph/src/core_tests.rs +++ b/rust/crates/memorph/src/core_tests.rs @@ -967,189 +967,6 @@ fn activity_bucket_selection_follows_span_thresholds() { ); } -#[test] -fn bootstrap_projects_hermes_native_sqlite_source() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("state.db"); - let conn = rusqlite::Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, cwd TEXT, model TEXT, started_at REAL NOT NULL, ended_at REAL, message_count INTEGER, tool_call_count INTEGER, archived INTEGER NOT NULL DEFAULT 0); - CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, effect_disposition TEXT, timestamp REAL NOT NULL, reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, compacted INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, api_content TEXT); - INSERT INTO sessions VALUES ('hermes-1','Hermes fixture','/tmp/hermes-project','model-x',1000,NULL,1,0,0); - INSERT INTO messages (id,session_id,role,content,tool_call_id,tool_calls,tool_name,effect_disposition,timestamp,reasoning,reasoning_content,reasoning_details,compacted,active,api_content) VALUES (1,'hermes-1','user','hello',NULL,NULL,NULL,NULL,1000,NULL,NULL,NULL,0,1,NULL);" - ).unwrap(); - drop(conn); - - let provider = providers::find_provider("hermes").unwrap(); - assert!(provider.capabilities().scan); - assert!(provider.capabilities().import); - - let session = ProviderSessionSummary { - archived: false, - session_id: "hermes-1".into(), - title: Some("Hermes fixture".into()), - project_dir: Some("/tmp/hermes-project".into()), - created_at: None, - last_active_at: Some(1_000_000), - source_path: Some(format!("{}#session=hermes-1", db.display())), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut report = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "hermes", &session, &mut report); - - assert_eq!(report.projected_sessions, 1); - assert_eq!(report.failed_sessions, 0); - assert_eq!(report.missing_sources, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'hermes' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "Hermes fixture"); -} - -#[test] -fn hermes_projection_refreshes_after_source_change() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("state.db"); - let conn = rusqlite::Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, cwd TEXT, model TEXT, started_at REAL NOT NULL, ended_at REAL, message_count INTEGER, tool_call_count INTEGER, archived INTEGER NOT NULL DEFAULT 0); - CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, effect_disposition TEXT, timestamp REAL NOT NULL, reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, compacted INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, api_content TEXT); - INSERT INTO sessions VALUES ('hermes-1','Before','/tmp/hermes-project','model-x',1000,NULL,1,0,0); - INSERT INTO messages (id,session_id,role,content,tool_call_id,tool_calls,tool_name,effect_disposition,timestamp,reasoning,reasoning_content,reasoning_details,compacted,active,api_content) VALUES (1,'hermes-1','user','before',NULL,NULL,NULL,NULL,1000,NULL,NULL,NULL,0,1,NULL);" - ).unwrap(); - drop(conn); - - let session = ProviderSessionSummary { - archived: false, - session_id: "hermes-1".into(), - title: Some("Before".into()), - project_dir: Some("/tmp/hermes-project".into()), - created_at: None, - last_active_at: Some(1_000_000), - source_path: Some(format!("{}#session=hermes-1", db.display())), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut first = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "hermes", &session, &mut first); - assert_eq!(first.projected_sessions, 1); - - let conn = rusqlite::Connection::open(&db).unwrap(); - conn.execute( - "UPDATE sessions SET title = 'After' WHERE id = 'hermes-1'", - [], - ) - .unwrap(); - conn.execute("UPDATE messages SET content = 'after' WHERE id = 1", []) - .unwrap(); - drop(conn); - - let session = ProviderSessionSummary { - archived: false, - title: Some("After".into()), - ..session - }; - let mut second = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "hermes", &session, &mut second); - assert_eq!(second.projected_sessions, 1); - assert_eq!(second.unchanged_sessions, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'hermes' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "After"); -} - -#[test] -fn bootstrap_projects_cline_task_source() { - let dir = tempfile::tempdir().unwrap(); - let task = dir.path().join("task-cline-1"); - std::fs::create_dir_all(&task).unwrap(); - let history = task.join("api_conversation_history.json"); - std::fs::write( - &history, - r#"[{"role":"user","content":"hello"},{"role":"assistant","content":[{"type":"thinking","thinking":"reason"},{"type":"text","text":"done"}]}]"#, - ) - .unwrap(); - - let session = ProviderSessionSummary { - archived: false, - session_id: "task-cline-1".into(), - title: Some("hello".into()), - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(history.to_string_lossy().into_owned()), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut report = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "cline", &session, &mut report); - - assert_eq!(report.projected_sessions, 1); - assert_eq!(report.failed_sessions, 0); - assert_eq!(report.missing_sources, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'cline' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "hello"); -} - -#[test] -fn cline_projection_refreshes_after_task_history_change() { - let dir = tempfile::tempdir().unwrap(); - let task = dir.path().join("task-cline-1"); - std::fs::create_dir_all(&task).unwrap(); - let history = task.join("api_conversation_history.json"); - std::fs::write(&history, r#"[{"role":"user","content":"before"}]"#).unwrap(); - - let mut session = ProviderSessionSummary { - archived: false, - session_id: "task-cline-1".into(), - title: Some("before".into()), - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(history.to_string_lossy().into_owned()), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut first = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "cline", &session, &mut first); - assert_eq!(first.projected_sessions, 1); - - std::fs::write(&history, r#"[{"role":"user","content":"after"}]"#).unwrap(); - session.title = Some("after".into()); - let mut second = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "cline", &session, &mut second); - assert_eq!(second.projected_sessions, 1); - assert_eq!(second.unchanged_sessions, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'cline' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "after"); -} - #[test] fn bootstrap_projects_copilot_event_source() { let dir = tempfile::tempdir().unwrap(); @@ -1240,165 +1057,6 @@ fn copilot_projection_refreshes_after_event_source_change() { assert_eq!(title, "after"); } -#[test] -fn bootstrap_projects_droid_session_source() { - let dir = tempfile::tempdir().unwrap(); - let sessions = dir.path().join("encoded-cwd"); - std::fs::create_dir_all(&sessions).unwrap(); - let source = sessions.join("droid-session-1.jsonl"); - std::fs::write( - &source, - r#"{"role":"user","content":"before","cwd":"/tmp/droid"} -{"role":"assistant","content":"done"} -"#, - ) - .unwrap(); - let session = ProviderSessionSummary { - archived: false, - session_id: "droid-session-1".into(), - title: Some("before".into()), - project_dir: Some("/tmp/droid".into()), - created_at: None, - last_active_at: None, - source_path: Some(source.to_string_lossy().into_owned()), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut report = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "droid", &session, &mut report); - assert_eq!(report.projected_sessions, 1); - assert_eq!(report.failed_sessions, 0); - assert_eq!(report.missing_sources, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'droid' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "before"); -} - -#[test] -fn droid_projection_refreshes_after_session_source_change() { - let dir = tempfile::tempdir().unwrap(); - let sessions = dir.path().join("encoded-cwd"); - std::fs::create_dir_all(&sessions).unwrap(); - let source = sessions.join("droid-session-1.jsonl"); - std::fs::write( - &source, - r#"{"role":"user","content":"before"} -"#, - ) - .unwrap(); - let mut session = ProviderSessionSummary { - archived: false, - session_id: "droid-session-1".into(), - title: Some("before".into()), - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(source.to_string_lossy().into_owned()), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut first = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "droid", &session, &mut first); - assert_eq!(first.projected_sessions, 1); - std::fs::write( - &source, - r#"{"role":"user","content":"after"} -"#, - ) - .unwrap(); - session.title = Some("after".into()); - let mut second = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "droid", &session, &mut second); - assert_eq!(second.projected_sessions, 1); - assert_eq!(second.unchanged_sessions, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'droid' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "after"); -} - -#[test] -fn bootstrap_projects_codebuddy_session_source() { - let dir = tempfile::tempdir().unwrap(); - let source_dir = dir.path().join("project"); - std::fs::create_dir_all(&source_dir).unwrap(); - let source = source_dir.join("codebuddy-session-1.jsonl"); - std::fs::write( - &source, - r#"{"role":"user","content":"before","cwd":"/tmp/codebuddy"} -"#, - ) - .unwrap(); - let session = ProviderSessionSummary { - archived: false, - session_id: "codebuddy-session-1".into(), - title: Some("before".into()), - project_dir: Some("/tmp/codebuddy".into()), - created_at: None, - last_active_at: None, - source_path: Some(source.to_string_lossy().into_owned()), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut report = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "codebuddy", &session, &mut report); - assert_eq!(report.projected_sessions, 1); - assert_eq!(report.failed_sessions, 0); - assert_eq!(report.missing_sources, 0); -} - -#[test] -fn codebuddy_projection_refreshes_after_session_source_change() { - let dir = tempfile::tempdir().unwrap(); - let source_dir = dir.path().join("project"); - std::fs::create_dir_all(&source_dir).unwrap(); - let source = source_dir.join("codebuddy-session-1.jsonl"); - std::fs::write( - &source, - r#"{"role":"user","content":"before"} -"#, - ) - .unwrap(); - let mut session = ProviderSessionSummary { - archived: false, - session_id: "codebuddy-session-1".into(), - title: Some("before".into()), - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(source.to_string_lossy().into_owned()), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut first = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "codebuddy", &session, &mut first); - assert_eq!(first.projected_sessions, 1); - std::fs::write( - &source, - r#"{"role":"user","content":"after"} -"#, - ) - .unwrap(); - session.title = Some("after".into()); - let mut second = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "codebuddy", &session, &mut second); - assert_eq!(second.projected_sessions, 1); - assert_eq!(second.unchanged_sessions, 0); -} - #[test] fn bootstrap_projects_qoder_session_source() { let dir = tempfile::tempdir().unwrap(); @@ -1672,142 +1330,6 @@ fn antigravity_projection_refreshes_after_session_source_change() { assert_eq!(second.unchanged_sessions, 0); } -#[test] -fn bootstrap_projects_windsurf_active_trajectory_source() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("state.vscdb"); - let conn = rusqlite::Connection::open(&db).unwrap(); - conn.execute( - "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO ItemTable(key,value) VALUES (?1,?2)", - rusqlite::params![ - "windsurf.state.cachedActiveTrajectory:workspace-1", - "CgZ3aW5kLTE=" - ], - ) - .unwrap(); - drop(conn); - let source = format!("{}#workspace=workspace-1", db.display()); - let session = ProviderSessionSummary { - archived: false, - session_id: "wind-1".into(), - title: None, - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(source), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut report = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "windsurf", &session, &mut report); - assert_eq!(report.projected_sessions, 1); - assert_eq!(report.failed_sessions, 0); - assert_eq!(report.missing_sources, 0); -} - -#[test] -fn windsurf_projection_refreshes_after_active_trajectory_change() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("state.vscdb"); - let conn = rusqlite::Connection::open(&db).unwrap(); - conn.execute( - "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO ItemTable(key,value) VALUES (?1,?2)", - rusqlite::params![ - "windsurf.state.cachedActiveTrajectory:workspace-1", - "CgZ3aW5kLTE=" - ], - ) - .unwrap(); - drop(conn); - let source = format!("{}#workspace=workspace-1", db.display()); - let session = ProviderSessionSummary { - archived: false, - session_id: "wind-1".into(), - title: None, - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(source), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut first = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "windsurf", &session, &mut first); - assert_eq!(first.projected_sessions, 1); - let conn = rusqlite::Connection::open(&db).unwrap(); - conn.execute( - "UPDATE ItemTable SET value = ?1 WHERE key = ?2", - rusqlite::params![ - "CgZ3aW5kLTEQYQ==", - "windsurf.state.cachedActiveTrajectory:workspace-1" - ], - ) - .unwrap(); - drop(conn); - let mut second = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "windsurf", &session, &mut second); - assert_eq!(second.projected_sessions, 1); - assert_eq!(second.unchanged_sessions, 0); -} - -#[test] -fn windsurf_legacy_chat_projection_refreshes_after_source_change() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("history.pbtxt"); - std::fs::write( - &path, - r#"message: {source: CHAT_MESSAGE_SOURCE_USER conversation_id: "legacy-1" intent: {text: "before"}} -"#, - ) - .unwrap(); - let mut session = ProviderSessionSummary { - archived: false, - session_id: "legacy-1".into(), - title: Some("before".into()), - project_dir: None, - created_at: None, - last_active_at: None, - source_path: Some(format!("{}#conversation=legacy-1", path.display())), - }; - let mut projection = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&projection).unwrap(); - local_store::apply_schema(&mut projection).unwrap(); - let mut first = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "windsurf", &session, &mut first); - assert_eq!(first.projected_sessions, 1); - std::fs::write( - &path, - r#"message: {source: CHAT_MESSAGE_SOURCE_USER conversation_id: "legacy-1" intent: {text: "after"}} -"#, - ) - .unwrap(); - session.title = Some("after".into()); - let mut second = SessionProjectionBootstrapReport::default(); - bootstrap_provider_session(&mut projection, "windsurf", &session, &mut second); - assert_eq!(second.projected_sessions, 1); - assert_eq!(second.unchanged_sessions, 0); - let title: String = projection - .query_row( - "SELECT title FROM session_snapshots WHERE provider_id = 'windsurf' LIMIT 1", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(title, "after"); -} - #[test] fn bootstrap_discovers_projects_skips_unchanged_and_refreshes_changed_indexes() { let opencode_dir = tempfile::tempdir().unwrap(); @@ -1996,22 +1518,6 @@ fn kiro_is_enabled_for_default_projection_bootstrap() { assert!(provider_supports_session_projection("kiro")); } -#[test] -fn bootstrap_scans_deepseek_without_source() { - let home = tempfile::tempdir().unwrap(); - let _home_guard = TestConfigHomeGuard::new(home.path()); - let mut conn = rusqlite::Connection::open_in_memory().unwrap(); - local_store::configure_connection(&conn).unwrap(); - local_store::apply_schema(&mut conn).unwrap(); - - let report = bootstrap_session_projections_in_connection(&mut conn, Some("deepseek")).unwrap(); - - assert_eq!(report.scanned_providers, 1); - assert_eq!(report.unsupported_providers, 0); - assert_eq!(report.discovered_sessions, 0); - assert!(report.failures.is_empty()); -} - #[test] fn non_default_session_list_reads_index_without_provider_source() { let home = tempfile::tempdir().unwrap(); diff --git a/rust/crates/memorph/src/hooks/discovery.rs b/rust/crates/memorph/src/hooks/discovery.rs index d23eff7..5a0de3f 100644 --- a/rust/crates/memorph/src/hooks/discovery.rs +++ b/rust/crates/memorph/src/hooks/discovery.rs @@ -174,11 +174,8 @@ fn scan_target(provider: &str) -> Option<(&'static str, PathBuf)> { "cursor" => home.join(".cursor/hooks.json"), "gemini" => home.join(".gemini/settings.json"), "qoder" => crate::providers::qoder::hook::settings_path(), - "droid" => crate::providers::droid::hook::settings_path(), - "codebuddy" => crate::providers::codebuddy::hook::settings_path(), "antigravity" => crate::providers::antigravity::hook::settings_path(), "workbuddy" => crate::providers::workbuddy::hook::settings_path(), - "hermes" => crate::providers::hermes::hook::settings_path(), _ => return None, }; Some((profile.provider, path)) diff --git a/rust/crates/memorph/src/hooks/profiles.rs b/rust/crates/memorph/src/hooks/profiles.rs index 4d43d74..5259dd5 100644 --- a/rust/crates/memorph/src/hooks/profiles.rs +++ b/rust/crates/memorph/src/hooks/profiles.rs @@ -17,15 +17,10 @@ pub enum HookFormat { KimiToml, KiroAgentJson, OpenCodePlugin, - TraeYaml, QoderClaudeJson, - FactoryClaudeJson, - CodeBuddyClaudeJson, AntiGravityClaudeJson, WorkBuddyClaudeJson, - HermesClaudeJson, PiExtension, - ClineFiles, } #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] diff --git a/rust/crates/memorph/src/hooks/strategies.rs b/rust/crates/memorph/src/hooks/strategies.rs index 3def53f..8934781 100644 --- a/rust/crates/memorph/src/hooks/strategies.rs +++ b/rust/crates/memorph/src/hooks/strategies.rs @@ -13,7 +13,6 @@ use crate::hooks::model::{HookInstallStatus, HookOperationReport}; #[serde(rename_all = "snake_case")] pub enum HookConfigStrategyKind { ClaudeLikeJson, - ClineFiles, CodexJson, CopilotJson, FlatJson, @@ -22,7 +21,6 @@ pub enum HookConfigStrategyKind { KiroAgentJson, OpenCodePlugin, PiExtension, - TraeYaml, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/rust/crates/memorph/src/provider_settings/cline.rs b/rust/crates/memorph/src/provider_settings/cline.rs deleted file mode 100644 index a7425d9..0000000 --- a/rust/crates/memorph/src/provider_settings/cline.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::{ProviderSettingModule, SettingDefinition}; - -pub struct ClineSettingModule; - -const SETTINGS: &[SettingDefinition] = &[]; - -impl ProviderSettingModule for ClineSettingModule { - fn provider_id(&self) -> &'static str { - "cline" - } - - fn settings(&self) -> &'static [SettingDefinition] { - SETTINGS - } -} diff --git a/rust/crates/memorph/src/provider_settings/codebuddy.rs b/rust/crates/memorph/src/provider_settings/codebuddy.rs deleted file mode 100644 index d973305..0000000 --- a/rust/crates/memorph/src/provider_settings/codebuddy.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::{ProviderSettingModule, SettingDefinition}; - -pub struct CodeBuddySettingModule; - -const SETTINGS: &[SettingDefinition] = &[]; - -impl ProviderSettingModule for CodeBuddySettingModule { - fn provider_id(&self) -> &'static str { - "codebuddy" - } - - fn settings(&self) -> &'static [SettingDefinition] { - SETTINGS - } -} diff --git a/rust/crates/memorph/src/provider_settings/droid.rs b/rust/crates/memorph/src/provider_settings/droid.rs deleted file mode 100644 index 450f9c9..0000000 --- a/rust/crates/memorph/src/provider_settings/droid.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::{ProviderSettingModule, SettingDefinition}; - -pub struct DroidSettingModule; - -const SETTINGS: &[SettingDefinition] = &[]; - -impl ProviderSettingModule for DroidSettingModule { - fn provider_id(&self) -> &'static str { - "droid" - } - - fn settings(&self) -> &'static [SettingDefinition] { - SETTINGS - } -} diff --git a/rust/crates/memorph/src/provider_settings/hermes.rs b/rust/crates/memorph/src/provider_settings/hermes.rs deleted file mode 100644 index 06b4042..0000000 --- a/rust/crates/memorph/src/provider_settings/hermes.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::{ProviderSettingModule, SettingDefinition}; - -pub struct HermesSettingModule; - -const SETTINGS: &[SettingDefinition] = &[]; - -impl ProviderSettingModule for HermesSettingModule { - fn provider_id(&self) -> &'static str { - "hermes" - } - - fn settings(&self) -> &'static [SettingDefinition] { - SETTINGS - } -} diff --git a/rust/crates/memorph/src/provider_settings/mod.rs b/rust/crates/memorph/src/provider_settings/mod.rs index 2a9a889..14c6b8c 100644 --- a/rust/crates/memorph/src/provider_settings/mod.rs +++ b/rust/crates/memorph/src/provider_settings/mod.rs @@ -1,21 +1,16 @@ mod antigravity; mod claude; -mod cline; -mod codebuddy; mod codex; mod common_hooks; mod copilot; mod cursor; -mod droid; mod gemini; -mod hermes; mod kimi; mod kiro; mod opencode; mod pi; mod qoder; mod registry; -mod trae; mod workbuddy; use anyhow::{Context as _, Result}; @@ -307,8 +302,8 @@ mod tests { #[test] fn alias_lists_same_provider_specific_settings_as_canonical_id() { - let canonical = list_provider_settings("droid").unwrap(); - let alias = list_provider_settings("factory").unwrap(); + let canonical = list_provider_settings("workbuddy").unwrap(); + let alias = list_provider_settings("work-buddy").unwrap(); assert_eq!(alias.len(), canonical.len()); assert_eq!( diff --git a/rust/crates/memorph/src/provider_settings/registry.rs b/rust/crates/memorph/src/provider_settings/registry.rs index d0d5162..9e59a82 100644 --- a/rust/crates/memorph/src/provider_settings/registry.rs +++ b/rust/crates/memorph/src/provider_settings/registry.rs @@ -9,7 +9,6 @@ pub(super) fn find_provider_setting_module( let provider_id = crate::providers::canonical_provider_id(provider_id); match provider_id.as_str() { "claude" => Some(&super::claude::ClaudeSettingModule), - "cline" => Some(&super::cline::ClineSettingModule), "codex" => Some(&super::codex::CodexSettingModule), "copilot" => Some(&super::copilot::CopilotSettingModule), "cursor" => Some(&super::cursor::CursorSettingModule), @@ -19,12 +18,8 @@ pub(super) fn find_provider_setting_module( "opencode" => Some(&super::opencode::OpenCodeSettingModule), "pi" => Some(&super::pi::PiSettingModule), "qoder" => Some(&super::qoder::QoderSettingModule), - "droid" => Some(&super::droid::DroidSettingModule), - "codebuddy" => Some(&super::codebuddy::CodeBuddySettingModule), "antigravity" => Some(&super::antigravity::AntiGravitySettingModule), "workbuddy" => Some(&super::workbuddy::WorkBuddySettingModule), - "hermes" => Some(&super::hermes::HermesSettingModule), - "trae" => Some(&super::trae::TraeSettingModule), _ => None, } } @@ -52,10 +47,10 @@ mod tests { #[test] fn registry_resolves_setting_aliases() { assert_eq!( - find_provider_setting_module("factory") - .expect("factory alias module") + find_provider_setting_module("work-buddy") + .expect("work-buddy alias module") .provider_id(), - "droid" + "workbuddy" ); } } diff --git a/rust/crates/memorph/src/provider_settings/trae.rs b/rust/crates/memorph/src/provider_settings/trae.rs deleted file mode 100644 index 4af0072..0000000 --- a/rust/crates/memorph/src/provider_settings/trae.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::{ProviderSettingModule, SettingDefinition}; - -pub struct TraeSettingModule; - -const SETTINGS: &[SettingDefinition] = &[]; - -impl ProviderSettingModule for TraeSettingModule { - fn provider_id(&self) -> &'static str { - "trae" - } - - fn settings(&self) -> &'static [SettingDefinition] { - SETTINGS - } -} diff --git a/rust/crates/memorph/src/providers/aliases.rs b/rust/crates/memorph/src/providers/aliases.rs index 821954b..2d90d11 100644 --- a/rust/crates/memorph/src/providers/aliases.rs +++ b/rust/crates/memorph/src/providers/aliases.rs @@ -1,7 +1,5 @@ pub(crate) fn canonical_provider_id(provider_id: &str) -> String { match provider_id.trim().to_ascii_lowercase().as_str() { - "amazon_q" | "amazon-q" => "amazonq".to_string(), - "factory" => "droid".to_string(), "work-buddy" | "work_buddy" => "workbuddy".to_string(), other => other.to_string(), } @@ -11,7 +9,6 @@ pub(crate) fn canonical_provider_id(provider_id: &str) -> String { mod tests { #[test] fn canonicalizes_known_provider_aliases() { - assert_eq!(super::canonical_provider_id("factory"), "droid"); assert_eq!(super::canonical_provider_id("work-buddy"), "workbuddy"); } } diff --git a/rust/crates/memorph/src/providers/amazonq.rs b/rust/crates/memorph/src/providers/amazonq.rs deleted file mode 100644 index 57ff141..0000000 --- a/rust/crates/memorph/src/providers/amazonq.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Amazon Q CLI provider backed by `data_local_dir()/amazon-q/data.sqlite3`. - -use crate::provider::{ - Provider, ProviderCapabilities, ProviderContentFidelity, ProviderSessionImportPage, - ProviderSessionSummary, ProviderSourceFingerprint, ScanStrategy, StorageShape, TurnQuality, -}; -use crate::session::{ - Context, Identity, ImportedSession, MappingDirection, MappingReport, Provenance, ProviderRef, - Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::Utc; -use rusqlite::{Connection, OpenFlags, OptionalExtension}; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -pub struct AmazonQProvider; -const PROVIDER_ID: &str = "amazonq"; -const SOURCE_PREFIX: &str = "amazonq://"; - -impl Provider for AmazonQProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "Amazon Q" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - scan_strategy: ScanStrategy::Indexed, - page_strategy: crate::provider::PageStrategy::FullImport, - storage_shape: StorageShape::Sqlite, - turn_quality: TurnQuality::Exact, - import_fidelity: ProviderContentFidelity { - text: Some(crate::session::Fidelity::Preserved), - tool_call: Some(crate::session::Fidelity::Preserved), - tool_result: Some(crate::session::Fidelity::Preserved), - provider_payload: Some(crate::session::Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - ..ProviderCapabilities::default() - } - } - - fn scan_sessions(&self) -> Result> { - let Some(path) = db_path() else { - return Ok(Vec::new()); - }; - if !path.is_file() { - return Ok(Vec::new()); - } - let conn = open_db(&path)?; - let mut stmt = conn.prepare("SELECT key, value FROM conversations")?; - let rows = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - let mut out = Vec::new(); - for row in rows.flatten() { - let (key, value) = row; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events = crate::providers::q_conversation::parse_history( - PROVIDER_ID, - &value, - &key, - &mut report, - ); - if events.is_empty() { - continue; - } - let title = crate::providers::q_conversation::first_prompt_text_value( - &serde_json::from_str(&value).unwrap_or_default(), - 100, - ); - let (_, last) = crate::providers::q_conversation::history_time_bounds(&value); - out.push(ProviderSessionSummary { - archived: false, - session_id: key.clone(), - title, - project_dir: Some(key.clone()), - created_at: last.map(|v| v.timestamp_millis()), - last_active_at: last.map(|v| v.timestamp_millis()), - source_path: Some(format!("{SOURCE_PREFIX}{key}")), - }); - } - Ok(out) - } - - fn import_session(&self, source_path: &str) -> Result { - let key = source_path - .strip_prefix(SOURCE_PREFIX) - .unwrap_or(source_path); - let path = db_path().context("Amazon Q database path unavailable")?; - let conn = open_db(&path)?; - let value: String = conn - .query_row( - "SELECT value FROM conversations WHERE key = ?1", - [key], - |row| row.get(0), - ) - .optional()? - .context("Amazon Q conversation not found")?; - let json: serde_json::Value = serde_json::from_str(&value)?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events = crate::providers::q_conversation::parse_history_value( - PROVIDER_ID, - &json, - key, - &mut report, - ); - let (created_at, last_active_at) = - crate::providers::q_conversation::history_time_bounds_value(&json); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect(); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: key.to_string(), - title: crate::providers::q_conversation::first_prompt_text_value(&json, 100), - }, - context: Context { - workspace: Some(key.to_string()), - created_at, - last_active_at, - tags: Vec::new(), - }, - events, - extensions: BTreeMap::new(), - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: key.into(), - source_path: Some(format!("{SOURCE_PREFIX}{key}")), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - - fn import_session_page( - &self, - source_path: &str, - event_offset: usize, - event_limit: Option, - ) -> Result { - let imported = self.import_session(source_path)?; - let all = imported.session.events.clone(); - let event_count = all.len(); - let message_count = all - .iter() - .filter(|e| crate::provider::event_is_visible_message(e)) - .count(); - let turns = crate::session_projection::project_session_turns( - &imported.session.identity.id, - &all, - TurnQuality::Exact, - ); - let page_events: Vec<_> = all - .into_iter() - .skip(event_offset) - .take(event_limit.unwrap_or(usize::MAX)) - .collect(); - let page_turns = crate::session_projection::project_session_turns( - &imported.session.identity.id, - &page_events, - TurnQuality::Inferred, - ); - Ok(ProviderSessionImportPage { - imported: ImportedSession { - session: Session { - events: page_events, - ..imported.session - }, - ..imported - }, - event_count, - message_count, - turn_count: Some(turns.len()), - turns: page_turns, - }) - } - - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - let key = source_path - .strip_prefix(SOURCE_PREFIX) - .unwrap_or(source_path); - let Some(path) = db_path() else { - return Ok(None); - }; - let metadata = std::fs::metadata(&path)?; - let mut hash = Sha256::new(); - hash.update(key.as_bytes()); - hash.update(std::fs::read(&path)?); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: metadata - .modified()? - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() - .min(i64::MAX as u128) as i64, - size_bytes: metadata.len().min(i64::MAX as u64) as i64, - value: format!("amazonq-v1:{key}:{:x}", hash.finalize()), - })) - } - - fn data_source_paths(&self) -> Vec { - db_path().into_iter().collect() - } -} - -fn db_path() -> Option { - Some( - dirs::data_local_dir()? - .join("amazon-q") - .join("data.sqlite3"), - ) -} -fn open_db(path: &Path) -> Result { - let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; - conn.busy_timeout(std::time::Duration::from_secs(5))?; - Ok(conn) -} diff --git a/rust/crates/memorph/src/providers/catalog.rs b/rust/crates/memorph/src/providers/catalog.rs index 74b480d..acb073c 100644 --- a/rust/crates/memorph/src/providers/catalog.rs +++ b/rust/crates/memorph/src/providers/catalog.rs @@ -83,25 +83,15 @@ fn curated_display_name(canonical_id: &str) -> Option<&'static str> { Some(match canonical_id { "claude" => "Claude", "codex" => "Codex", - "cline" => "Cline", "cursor" => "Cursor", "opencode" => "OpenCode", - "openclaw" => "OpenClaw", "kiro" => "Kiro", "kimi" => "Kimi", "gemini" => "Gemini", - "deepseek" => "DeepSeek", "antigravity" => "Antigravity", "copilot" => "Copilot", - "windsurf" => "Windsurf", - "codebuddy" => "CodeBuddy", "qoder" => "Qoder", - "trae" => "Trae", - "droid" => "Droid", "workbuddy" => "WorkBuddy", - "hermes" => "Hermes", - "qwen" => "Qwen", - "amazonq" => "Amazon Q", "pi" => "Pi", _ => return None, }) @@ -269,7 +259,7 @@ mod tests { fn display_name_uses_curated_map_and_canonicalizes() { assert_eq!(display_name("claude"), "Claude"); assert_eq!(display_name("opencode"), "OpenCode"); - assert_eq!(display_name("deepseek"), "DeepSeek"); + assert_eq!(display_name("cursor"), "Cursor"); assert_eq!(display_name("gemini"), "Gemini"); } diff --git a/rust/crates/memorph/src/providers/cline/adapter.rs b/rust/crates/memorph/src/providers/cline/adapter.rs deleted file mode 100644 index f0c43f4..0000000 --- a/rust/crates/memorph/src/providers/cline/adapter.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! Cline hook adapter. -//! -//! Cline invokes executable files in `~/Documents/Cline/Hooks/` and -//! sends JSON on stdin. This adapter maps those file-based hook events into -//! memorph's canonical runtime model. - -use anyhow::Result; -use chrono::{DateTime, TimeZone, Utc}; -use serde_json::Value; -use std::path::PathBuf; - -use crate::hooks::contract::HookAdapter; -use crate::hooks::model::{HookEvent, HookEventType, HookMessage, HookToolCall, PermissionRequest}; -use crate::hooks::protocol::HookIngestRequest; - -pub struct ClineHookAdapter; - -impl HookAdapter for ClineHookAdapter { - fn provider_id(&self) -> &'static str { - "cline" - } - - fn normalize(&self, request: &HookIngestRequest) -> Result> { - let raw = request.raw.clone(); - let event_name = string_at( - &raw, - &[ - "hook_event_name", - "hookEventName", - "event_name", - "eventName", - "event", - "type", - ], - ) - .unwrap_or_else(|| request.event_name.clone()); - let mut event = HookEvent::new("cline", cline_event_type(&event_name), raw.clone()); - - event.event_id = string_at(&raw, &["event_id", "eventId", "id"]).unwrap_or(event.event_id); - event.provider_session_id = string_at( - &raw, - &[ - "session_id", - "sessionId", - "task_id", - "taskId", - "conversation_id", - "conversationId", - ], - ); - event.run_id = string_at( - &raw, - &[ - "run_id", - "runId", - "request_id", - "requestId", - "task_id", - "taskId", - ], - ) - .or_else(|| Some(request.request_id.clone())); - event.cwd = path_at( - &raw, - &[ - "cwd", - "workspace", - "workspace_dir", - "workspaceDir", - "project_dir", - "projectDir", - ], - ) - .or_else(|| first_path_at(&raw, &["workspace_roots", "workspaceRoots"])) - .or_else(|| request.environment.cwd.clone()); - event.pid = u32_at(&raw, &["pid"]).or(request.environment.pid); - event.parent_pid = - u32_at(&raw, &["parent_pid", "parentPid", "ppid"]).or(request.environment.parent_pid); - event.tty = string_at(&raw, &["tty", "terminal_tty", "terminalTty"]) - .or_else(|| request.environment.tty.clone()); - event.timestamp = timestamp_at(&raw).unwrap_or(request.received_at); - - let tool = tool_at(&raw); - event.tool = tool.clone(); - event.message = message_at(&raw, &event_name); - if event.event_type == HookEventType::PermissionRequested { - event.permission = Some(permission_at(&raw, tool)); - } - - Ok(vec![event]) - } -} - -fn cline_event_type(event_name: &str) -> HookEventType { - match normalize_name(event_name).as_str() { - "taskstart" => HookEventType::SessionStarted, - "taskresume" => HookEventType::Heartbeat, - "userpromptsubmit" => HookEventType::MessageCreated, - "pretooluse" => HookEventType::ToolStarted, - "posttooluse" => HookEventType::ToolFinished, - "permissionrequest" => HookEventType::PermissionRequested, - "taskend" | "taskcomplete" | "stop" => HookEventType::SessionCompleted, - _ => HookEventType::from_provider_name(event_name), - } -} - -fn tool_at(value: &Value) -> Option { - if let Some(tool) = value.get("tool").filter(|tool| tool.is_object()) { - let name = string_at(tool, &["name", "tool_name", "toolName"])?; - return Some(HookToolCall { - id: string_at(tool, &["id", "tool_use_id", "toolUseId"]), - name, - input: value_at(tool, &["input", "arguments", "args", "params"]).unwrap_or(Value::Null), - }); - } - - let name = string_at(value, &["tool_name", "toolName", "tool", "name"])?; - Some(HookToolCall { - id: string_at(value, &["tool_use_id", "toolUseId", "id"]), - name, - input: value_at( - value, - &[ - "tool_input", - "toolInput", - "input", - "arguments", - "args", - "params", - ], - ) - .unwrap_or(Value::Null), - }) -} - -fn message_at(value: &Value, event_name: &str) -> Option { - let normalized = normalize_name(event_name); - let (role, keys): (Option, &[&str]) = match normalized.as_str() { - "userpromptsubmit" => ( - Some("user".to_string()), - &[ - "prompt", - "user_prompt", - "userPrompt", - "last_user_message", - "lastUserMessage", - "message", - "text", - "content", - ], - ), - "taskend" | "taskcomplete" | "stop" => ( - Some("assistant".to_string()), - &[ - "last_assistant_message", - "lastAssistantMessage", - "assistant_message", - "assistantMessage", - "summary", - "message", - "text", - "content", - ], - ), - _ => (string_at(value, &["role"]), &["message", "text", "content"]), - }; - let text = string_at(value, keys)?; - Some(HookMessage { role, text }) -} - -fn permission_at(value: &Value, tool: Option) -> PermissionRequest { - PermissionRequest { - request_id: string_at( - value, - &[ - "permission_id", - "permissionId", - "request_id", - "requestId", - "tool_use_id", - "toolUseId", - ], - ) - .or_else(|| tool.as_ref().and_then(|tool| tool.id.clone())), - tool, - prompt: string_at(value, &["permission_prompt", "prompt", "reason", "message"]), - } -} - -fn string_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::String(s)) if !s.trim().is_empty() => Some(s.to_string()), - Some(Value::Number(n)) => Some(n.to_string()), - Some(Value::Bool(b)) => Some(b.to_string()), - _ => None, - }) -} - -fn u32_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()), - Some(Value::String(s)) => s.parse().ok(), - _ => None, - }) -} - -fn path_at(value: &Value, keys: &[&str]) -> Option { - string_at(value, keys).map(PathBuf::from) -} - -fn first_path_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::Array(items)) => items.iter().find_map(|item| match item { - Value::String(path) if !path.trim().is_empty() => Some(PathBuf::from(path.trim())), - _ => None, - }), - Some(Value::String(path)) if !path.trim().is_empty() => Some(PathBuf::from(path.trim())), - _ => None, - }) -} - -fn value_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| value.get(*key).cloned()) -} - -fn timestamp_at(value: &Value) -> Option> { - let raw = value - .get("timestamp") - .or_else(|| value.get("created_at")) - .or_else(|| value.get("createdAt"))?; - match raw { - Value::String(s) => DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|| s.parse::().ok().and_then(unix_timestamp)), - Value::Number(n) => n.as_i64().and_then(unix_timestamp), - _ => None, - } -} - -fn unix_timestamp(value: i64) -> Option> { - Utc.timestamp_opt(value, 0).single() -} - -fn normalize_name(value: &str) -> String { - value - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::protocol::HookIngestRequest; - use serde_json::json; - - #[test] - fn maps_cline_pre_tool_use() { - let request = HookIngestRequest::new( - "cline", - "PreToolUse", - json!({ - "task_id": "task-1", - "tool_name": "execute_command", - "tool_input": {"command": "cargo check"} - }), - ); - let event = ClineHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.provider, "cline"); - assert_eq!(event.event_type, HookEventType::ToolStarted); - assert_eq!(event.provider_session_id.as_deref(), Some("task-1")); - assert_eq!(event.tool.as_ref().unwrap().name, "execute_command"); - } - - #[test] - fn maps_cline_user_prompt_to_user_message() { - let request = HookIngestRequest::new( - "cline", - "UserPromptSubmit", - json!({"task_id": "task-1", "userPrompt": "continue"}), - ); - let event = ClineHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.event_type, HookEventType::MessageCreated); - assert_eq!( - event.message.as_ref().unwrap().role.as_deref(), - Some("user") - ); - assert_eq!(event.message.as_ref().unwrap().text, "continue"); - } - - #[test] - fn maps_cline_task_end_to_assistant_message() { - let request = HookIngestRequest::new( - "cline", - "TaskEnd", - json!({"task_id": "task-1", "lastAssistantMessage": "complete"}), - ); - let event = ClineHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.event_type, HookEventType::SessionCompleted); - assert_eq!( - event.message.as_ref().unwrap().role.as_deref(), - Some("assistant") - ); - assert_eq!(event.message.as_ref().unwrap().text, "complete"); - } - - #[test] - fn uses_first_workspace_root_as_cwd() { - let request = HookIngestRequest::new( - "cline", - "TaskStart", - json!({"workspaceRoots": ["/tmp/cline-a", "/tmp/cline-b"]}), - ); - let event = ClineHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!( - event.cwd.as_deref(), - Some(std::path::Path::new("/tmp/cline-a")) - ); - } -} diff --git a/rust/crates/memorph/src/providers/cline/hook.rs b/rust/crates/memorph/src/providers/cline/hook.rs deleted file mode 100644 index 3796bc0..0000000 --- a/rust/crates/memorph/src/providers/cline/hook.rs +++ /dev/null @@ -1,408 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context as _, Result}; - -use crate::hooks::contract::ProviderHook; -use crate::hooks::model::{HookHealthStatus, HookInstallStatus, HookOperationReport}; -use crate::storage::atomic_write; - -const CLINE_HOOK_MARKER: &str = "memorph-cline-hook"; -const CLINE_HOOK_VERSION: &str = crate::hooks::shared::HOOK_MANAGED_VERSION; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ClineHookEvent { - name: &'static str, - blocking: bool, -} - -const EVENTS: &[ClineHookEvent] = &[ - event("UserPromptSubmit", false), - event("PreToolUse", true), - event("PostToolUse", false), - event("TaskStart", false), - event("TaskResume", false), - event("TaskCancel", false), - event("TaskComplete", false), -]; - -const fn event(name: &'static str, blocking: bool) -> ClineHookEvent { - ClineHookEvent { name, blocking } -} - -pub struct ClineHook; - -pub static CLINE_HOOK: ClineHook = ClineHook; - -impl ProviderHook for ClineHook { - fn provider_id(&self) -> &'static str { - "cline" - } - - fn status(&self) -> Result { - status() - } - - fn install(&self) -> Result { - install() - } - - fn verify(&self) -> Result { - let status = self.status()?; - Ok(HookOperationReport { - provider: self.provider_id().to_string(), - operation: "verify".to_string(), - changed: false, - backup_path: None, - message: status.message.clone(), - status, - }) - } - - fn repair(&self) -> Result { - let before = self.status()?; - let mut report = self.install()?; - report.operation = "repair".to_string(); - report.changed = before.status != HookHealthStatus::InstalledOk; - Ok(report) - } - - fn uninstall(&self) -> Result { - uninstall() - } -} - -pub(crate) fn hooks_dir() -> PathBuf { - crate::hooks::shared::hook_home_dir() - .join("Documents") - .join("Cline") - .join("Rules") - .join("Hooks") -} - -fn hooks_dirs() -> Vec { - vec![hooks_dir()] -} - -fn required_events() -> impl Iterator { - EVENTS.iter().map(|event| event.name) -} - -fn status() -> Result { - let dirs = hooks_dirs(); - let config_path = Some( - dirs.iter() - .map(|dir| dir.display().to_string()) - .collect::>() - .join(", "), - ); - if !dirs.iter().any(|dir| dir.exists()) { - return Ok(HookInstallStatus { - provider: "cline".to_string(), - status: HookHealthStatus::NotInstalled, - config_path, - installed_version: None, - current_version: Some(crate::hooks::shared::current_hook_managed_version().to_string()), - message: Some("Cline hooks directory does not exist.".to_string()), - last_event_at: crate::hooks::health::last_event_at("cline"), - }); - } - - let missing: Vec<&str> = required_events() - .filter(|event| !event_has_memorph_hook(event)) - .collect(); - let installed_version = crate::hooks::health::summarize_versions( - required_events().filter_map(event_memorph_hook_version), - ); - let current_version = Some(crate::hooks::shared::current_hook_managed_version().to_string()); - let stale = missing.is_empty() - && installed_version.as_deref() - != Some(crate::hooks::shared::current_hook_managed_version()); - let health = if missing.is_empty() && !stale { - HookHealthStatus::InstalledOk - } else if missing.len() == EVENTS.len() { - HookHealthStatus::NotInstalled - } else if stale { - HookHealthStatus::InstalledStaleBinary - } else { - HookHealthStatus::Repairable - }; - let message = if stale { - Some(format!( - "Cline memorph hooks are installed but stale: installed {}, current {}.", - installed_version.as_deref().unwrap_or("unknown"), - crate::hooks::shared::current_hook_managed_version() - )) - } else if missing.is_empty() { - Some("Cline memorph hooks are installed.".to_string()) - } else { - Some(format!( - "Missing memorph hook files: {}", - missing.join(", ") - )) - }; - - Ok(HookInstallStatus { - provider: "cline".to_string(), - status: health, - config_path, - installed_version, - current_version, - message, - last_event_at: crate::hooks::health::last_event_at("cline"), - }) -} - -fn install() -> Result { - let command_base = crate::hooks::shared::bridge_command_base()?; - let mut changed = false; - let mut backup_path = None; - for dir in hooks_dirs() { - fs::create_dir_all(&dir).with_context(|| { - format!("Failed to create Cline hooks directory: {}", dir.display()) - })?; - for event in EVENTS { - let path = dir.join(event.name); - let original = fs::read_to_string(&path).ok(); - let preserved_path = preserved_hook_path(&path); - if let Some(contents) = original.as_deref() { - if !contents.contains(CLINE_HOOK_MARKER) - && !crate::hooks::shared::command_contains_memorph_hook(contents) - && !preserved_path.exists() - { - if backup_path.is_none() && path.exists() { - backup_path = crate::hooks::shared::backup_if_exists(&path)?; - } - atomic_write::write_string_atomic(&preserved_path, contents)?; - make_executable(&preserved_path)?; - } - } - let rendered = hook_script( - &command_base, - event, - preserved_path.exists().then_some(preserved_path.as_path()), - )?; - if original.as_deref() != Some(rendered.as_str()) { - if backup_path.is_none() && path.exists() { - backup_path = crate::hooks::shared::backup_if_exists(&path)?; - } - atomic_write::write_string_atomic(&path, &rendered)?; - make_executable(&path)?; - changed = true; - } else { - make_executable(&path)?; - } - } - } - - let status = status()?; - Ok(HookOperationReport { - provider: "cline".to_string(), - operation: "install".to_string(), - changed, - backup_path: backup_path.map(|path| path.display().to_string()), - message: Some("Cline file-based hook entries installed.".to_string()), - status, - }) -} - -fn uninstall() -> Result { - let dirs = hooks_dirs(); - if !dirs.iter().any(|dir| dir.exists()) { - let status = status()?; - return Ok(HookOperationReport { - provider: "cline".to_string(), - operation: "uninstall".to_string(), - changed: false, - status, - backup_path: None, - message: Some("Cline hooks directory does not exist.".to_string()), - }); - } - - let mut changed = false; - let mut backup_path = None; - for dir in dirs { - for event in EVENTS { - let path = dir.join(event.name); - let Ok(contents) = fs::read_to_string(&path) else { - continue; - }; - if !contents.contains(CLINE_HOOK_MARKER) - || !crate::hooks::shared::command_contains_memorph_hook(&contents) - { - continue; - } - if backup_path.is_none() { - backup_path = crate::hooks::shared::backup_if_exists(&path)?; - } - let preserved_path = preserved_hook_path(&path); - if preserved_path.exists() { - fs::copy(&preserved_path, &path).with_context(|| { - format!( - "Failed to restore preserved Cline hook file: {}", - path.display() - ) - })?; - make_executable(&path)?; - fs::remove_file(&preserved_path).with_context(|| { - format!( - "Failed to remove preserved Cline hook file: {}", - preserved_path.display() - ) - })?; - } else { - fs::remove_file(&path).with_context(|| { - format!("Failed to remove Cline hook file: {}", path.display()) - })?; - } - changed = true; - } - } - - let status = status()?; - Ok(HookOperationReport { - provider: "cline".to_string(), - operation: "uninstall".to_string(), - changed, - status, - backup_path: backup_path.map(|path| path.display().to_string()), - message: Some("Cline memorph hook entries removed.".to_string()), - }) -} - -fn event_has_memorph_hook(event: &str) -> bool { - event_memorph_hook_version(event).is_some() -} - -fn event_memorph_hook_version(event: &str) -> Option> { - hooks_dirs().into_iter().find_map(|dir| { - let path = dir.join(event); - let contents = fs::read_to_string(path).ok()?; - if !contents.contains(CLINE_HOOK_MARKER) - || !crate::hooks::shared::command_contains_memorph_hook(&contents) - { - return None; - } - Some(command_managed_version(&contents)) - }) -} - -pub(crate) fn preserved_hook_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("hook"); - path.with_file_name(format!("{file_name}.memorph-original")) -} - -fn hook_script( - command_base: &str, - event: &ClineHookEvent, - preserved_hook_path: Option<&Path>, -) -> Result { - let command = format!( - "{} --managed-version {} --provider cline --event {}{}", - command_base, - crate::hooks::shared::current_hook_managed_version(), - event.name, - if event.blocking { " --blocking" } else { "" } - ); - let preserved_hook = preserved_hook_path - .map(|path| shell_quote(&path.to_string_lossy())) - .unwrap_or_default(); - Ok(format!( - "#!/bin/bash\n# {marker}\n# version: {version}\nINPUT=$(cat)\nMEMORPH_OUTPUT=$(printf '%s' \"$INPUT\" | {command} 2>/dev/null)\nORIGINAL_HOOK={preserved_hook}\nORIGINAL_OUTPUT=\"\"\nif [ -n \"$ORIGINAL_HOOK\" ] && [ -x \"$ORIGINAL_HOOK\" ]; then\n ORIGINAL_OUTPUT=$(printf '%s' \"$INPUT\" | \"$ORIGINAL_HOOK\" 2>/dev/null)\nfi\nif printf '%s' \"$MEMORPH_OUTPUT\" | grep -q '\"cancel\"[[:space:]]*:[[:space:]]*true'; then\n printf '%s' \"$MEMORPH_OUTPUT\"\nelif [ -n \"$ORIGINAL_OUTPUT\" ]; then\n printf '%s' \"$ORIGINAL_OUTPUT\"\nelif [ -n \"$MEMORPH_OUTPUT\" ]; then\n printf '%s' \"$MEMORPH_OUTPUT\"\nelse\n printf '{{\"cancel\":false}}'\nfi\n", - marker = CLINE_HOOK_MARKER, - version = CLINE_HOOK_VERSION, - command = command, - preserved_hook = preserved_hook, - )) -} - -#[cfg(unix)] -fn make_executable(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let mut permissions = fs::metadata(path)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions) - .with_context(|| format!("Failed to chmod Cline hook file: {}", path.display())) -} - -#[cfg(not(unix))] -fn make_executable(_path: &Path) -> Result<()> { - Ok(()) -} - -fn command_managed_version(command: &str) -> Option { - let mut parts = command.split_whitespace(); - while let Some(part) = parts.next() { - if part == "--managed-version" { - return parts.next().map(ToString::to_string); - } - if let Some(value) = part.strip_prefix("--managed-version=") { - return Some(value.to_string()); - } - } - None -} - -fn shell_quote(value: &str) -> String { - if value.chars().any(char::is_whitespace) { - format!("'{}'", value.replace('\'', "'\\''")) - } else { - value.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::model::HookHealthStatus; - use crate::hooks::test_support::TestHookHomeGuard; - - #[test] - fn descriptor_matches_hook_registry() { - let descriptor = CLINE_HOOK.descriptor().expect("cline descriptor"); - assert_eq!(descriptor.provider(), CLINE_HOOK.provider_id()); - } - - #[test] - fn renders_hook_script_with_valid_fallback_response() { - let script = hook_script( - "memorph __hook-bridge", - &ClineHookEvent { - name: "PreToolUse", - blocking: true, - }, - None, - ) - .unwrap(); - assert!(script.contains(CLINE_HOOK_MARKER)); - assert!(script.contains("--provider cline --event PreToolUse --blocking")); - assert!(script.contains("printf '{\"cancel\":false}'")); - } - #[test] - fn cline_install_preserves_and_restores_existing_user_hook_file() { - let _home = TestHookHomeGuard::new(); - let dir = hooks_dir(); - std::fs::create_dir_all(&dir).unwrap(); - let hook_path = dir.join("PreToolUse"); - let original = "#!/bin/bash\nprintf '{\"cancel\":true,\"reason\":\"user hook\"}'\n"; - crate::storage::atomic_write::write_string_atomic(&hook_path, original).unwrap(); - - let installed = CLINE_HOOK.install().unwrap(); - assert_eq!(installed.status.status, HookHealthStatus::InstalledOk); - let preserved_path = preserved_hook_path(&hook_path); - assert_eq!(std::fs::read_to_string(&preserved_path).unwrap(), original); - let installed_script = std::fs::read_to_string(&hook_path).unwrap(); - assert!(installed_script.contains("memorph-cline-hook")); - assert!(installed_script.contains("ORIGINAL_HOOK=")); - assert!(installed_script.contains("PreToolUse.memorph-original")); - - let removed = CLINE_HOOK.uninstall().unwrap(); - assert_eq!(removed.status.status, HookHealthStatus::NotInstalled); - assert_eq!(std::fs::read_to_string(&hook_path).unwrap(), original); - assert!(!preserved_path.exists()); - } -} diff --git a/rust/crates/memorph/src/providers/cline/mod.rs b/rust/crates/memorph/src/providers/cline/mod.rs deleted file mode 100644 index b6002d3..0000000 --- a/rust/crates/memorph/src/providers/cline/mod.rs +++ /dev/null @@ -1,440 +0,0 @@ -pub mod adapter; -pub mod hook; - -use crate::provider::{ - PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, - ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, - ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; -use walkdir::WalkDir; - -pub struct ClineProvider; -const PROVIDER_ID: &str = "cline"; -const HISTORY_FILE: &str = "api_conversation_history.json"; - -impl Provider for ClineProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "Cline" - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - storage_shape: StorageShape::Directory, - scan_strategy: ScanStrategy::FullScan, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - resume_quality: ResumeQuality::None, - write_risk: ProviderWriteRisk { - level: WriteRiskLevel::High, - multiple_files: true, - sqlite: false, - sidecar_files: true, - index_repair: true, - }, - backup_support: ProviderBackupSupport { - before_write: false, - restore: false, - sync_only: false, - }, - activity_support: ProviderActivitySupport { - hook_events: false, - runtime_endpoint: false, - session_activity: false, - }, - ..ProviderCapabilities::default() - } - } - - fn scan_sessions(&self) -> Result> { - let mut sessions = Vec::new(); - for root in cline_task_roots() { - if !root.exists() { - continue; - } - for entry in WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if path.file_name().and_then(|value| value.to_str()) != Some(HISTORY_FILE) { - continue; - } - let Some(task_id) = task_id_from_history_path(path) else { - continue; - }; - let Ok(value) = read_history(path) else { - continue; - }; - let title = first_text(&value); - sessions.push(ProviderSessionSummary { - archived: false, - session_id: task_id, - title, - project_dir: task_workspace(path), - created_at: None, - last_active_at: file_modified_ms(path), - source_path: Some(path.to_string_lossy().into_owned()), - }); - } - } - sessions.sort_by_key(|session| std::cmp::Reverse(session.last_active_at.unwrap_or(0))); - sessions.dedup_by(|left, right| left.session_id == right.session_id); - Ok(sessions) - } - - fn import_session(&self, source_path: &str) -> Result { - let path = canonical_path(Path::new(source_path))?; - let task_id = task_id_from_history_path(&path) - .context("Cline source must be tasks//api_conversation_history.json")?; - let value = read_history(&path)?; - let timestamp = file_modified_datetime(&path).unwrap_or_else(Utc::now); - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events = history_events(&value, timestamp, &mut report); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - let title = first_text(&value); - let mut extensions = BTreeMap::new(); - extensions.insert("cline_api_conversation_history".into(), value); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: task_id.clone(), - title, - }, - context: Context { - workspace: task_workspace(&path), - created_at: Some(timestamp), - last_active_at: Some(timestamp), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: task_id, - source_path: Some(path.to_string_lossy().into_owned()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - let path = canonical_path(Path::new(source_path))?; - if !path.exists() { - return Ok(None); - } - let metadata = std::fs::metadata(&path)?; - let digest = Sha256::digest(std::fs::read(&path)?); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: file_modified_ms(&path).unwrap_or(0), - size_bytes: metadata.len().min(i64::MAX as u64) as i64, - value: format!( - "cline-task-history-v1:{}:{digest:x}", - task_id_from_history_path(&path).unwrap_or_default() - ), - })) - } - - fn data_source_paths(&self) -> Vec { - cline_task_roots() - } -} - -fn cline_task_roots() -> Vec { - let mut roots = Vec::new(); - const EXTENSION_IDS: &[&str] = &[ - "saoudrizwan.claude-dev", - "rooveterinaryinc.roo-cline", - "kilocode.kilo-code", - ]; - #[cfg(target_os = "macos")] - for app in ["Code", "Code - Insiders", "VSCodium", "Cursor"] { - if let Some(home) = dirs::home_dir() { - for ext in EXTENSION_IDS { - roots.push( - home.join("Library/Application Support") - .join(app) - .join("User/globalStorage") - .join(ext) - .join("tasks"), - ); - } - } - } - #[cfg(target_os = "linux")] - for app in ["Code", "Code - Insiders", "VSCodium", "Cursor"] { - if let Some(home) = dirs::home_dir() { - for ext in EXTENSION_IDS { - roots.push( - home.join(".config") - .join(app) - .join("User/globalStorage") - .join(ext) - .join("tasks"), - ); - } - } - } - if let Some(home) = dirs::home_dir() { - roots.push(home.join("Documents/Cline/tasks")); - } - roots -} - -fn canonical_path(path: &Path) -> Result { - std::fs::canonicalize(path) - .with_context(|| format!("Cline source does not exist: {}", path.display())) -} -fn read_history(path: &Path) -> Result { - serde_json::from_str(&std::fs::read_to_string(path)?) - .with_context(|| format!("invalid Cline history: {}", path.display())) -} -fn task_id_from_history_path(path: &Path) -> Option { - path.parent()? - .file_name()? - .to_str() - .filter(|value| !value.is_empty()) - .map(str::to_string) -} -fn task_workspace(path: &Path) -> Option { - let metadata = path.parent()?.join("task_metadata.json"); - let value: Value = serde_json::from_str(&std::fs::read_to_string(metadata).ok()?).ok()?; - ["cwd", "workspace", "workspaceDir", "rootPath"] - .iter() - .find_map(|key| value.get(*key).and_then(Value::as_str).map(str::to_string)) -} -fn file_modified_ms(path: &Path) -> Option { - std::fs::metadata(path) - .ok()? - .modified() - .ok()? - .duration_since(UNIX_EPOCH) - .ok() - .map(|value| value.as_millis().min(i64::MAX as u128) as i64) -} -fn file_modified_datetime(path: &Path) -> Option> { - file_modified_ms(path).and_then(DateTime::::from_timestamp_millis) -} -fn first_text(value: &Value) -> Option { - value.as_array()?.iter().find_map(|item| { - message_blocks(item) - .into_iter() - .find_map(|block| match block { - Block::Text { text } if !text.trim().is_empty() => Some(text), - _ => None, - }) - }) -} - -fn history_events( - value: &Value, - timestamp: DateTime, - report: &mut MappingReport, -) -> Vec { - value - .as_array() - .into_iter() - .flatten() - .enumerate() - .filter_map(|(index, item)| { - let role_raw = item - .get("role") - .and_then(Value::as_str) - .unwrap_or("unknown"); - let role = match role_raw { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - _ => Role::Other, - }; - let blocks = message_blocks(item); - if blocks.is_empty() { - return None; - } - let kind = if blocks - .iter() - .any(|block| matches!(block, Block::ToolResult { .. })) - { - EventKind::Observation - } else if blocks - .iter() - .any(|block| matches!(block, Block::ToolCall { .. })) - { - EventKind::Action - } else { - EventKind::Message - }; - report.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "cline-native-block".into(), - message: "Mapped current Cline API conversation history block".into(), - path: Some(format!("messages[{index}]")), - raw: None, - }); - Some(Event { - id: item - .get("id") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_else(|| format!("cline:message:{index}")), - kind, - role, - timestamp, - links: Links::default(), - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) - }) - .collect() -} - -fn message_blocks(item: &Value) -> Vec { - let content = item.get("content").unwrap_or(item); - if let Some(text) = content.as_str() { - return vec![Block::Text { text: text.into() }]; - } - content - .as_array() - .into_iter() - .flatten() - .filter_map( - |block| match block.get("type").and_then(Value::as_str).unwrap_or("") { - "text" => block - .get("text") - .and_then(Value::as_str) - .map(|text| Block::Text { text: text.into() }), - "thinking" => { - block - .get("thinking") - .and_then(Value::as_str) - .map(|text| Block::Thinking { - text: text.into(), - signature: None, - }) - } - "tool_use" => Some(Block::ToolCall { - tool_call_id: block - .get("id") - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - name: block - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .into(), - input: block.get("input").cloned(), - }), - "tool_result" => Some(Block::ToolResult { - tool_call_id: block - .get("tool_use_id") - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - content: block.get("content").map(value_text).unwrap_or_default(), - outcome: crate::session::execution_outcome( - block - .get("is_error") - .and_then(Value::as_bool) - .unwrap_or(false), - ), - }), - _ => None, - }, - ) - .collect() -} -fn value_text(value: &Value) -> String { - value - .as_str() - .map(str::to_string) - .unwrap_or_else(|| value.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn capabilities_match_native_read_only_contract() { - let capabilities = ClineProvider.capabilities(); - assert!(capabilities.scan); - assert!(capabilities.import); - assert!(!capabilities.export); - assert!(!capabilities.delete); - assert!(!capabilities.rename); - assert!(!capabilities.resume); - assert_eq!(capabilities.storage_shape, StorageShape::Directory); - assert_eq!(capabilities.scan_strategy, ScanStrategy::FullScan); - assert_eq!(capabilities.page_strategy, PageStrategy::FullImport); - assert_eq!(capabilities.resume_quality, ResumeQuality::None); - assert!(!capabilities.backup_support.before_write); - assert!(!capabilities.backup_support.restore); - } - #[test] - fn task_identity_comes_from_parent_directory() { - let path = Path::new("/tmp/tasks/task-123/api_conversation_history.json"); - assert_eq!(task_id_from_history_path(path).as_deref(), Some("task-123")); - } - #[test] - fn maps_current_cline_content_blocks() { - let value = serde_json::json!({"role":"assistant","content":[{"type":"thinking","thinking":"reason"},{"type":"tool_use","id":"call-1","name":"bash","input":{"cmd":"pwd"}}]}); - let blocks = message_blocks(&value); - assert!(matches!(blocks[0], Block::Thinking { .. })); - assert!(matches!(blocks[1], Block::ToolCall { ref name, .. } if name == "bash")); - } - #[test] - fn event_with_tool_call_and_result_is_observation() { - let value = serde_json::json!({"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"bash","input":{"cmd":"pwd"}},{"type":"tool_result","tool_use_id":"call-1","content":"done"}]}); - let blocks = message_blocks(&value); - let mut report = MappingReport::new("cline", crate::session::MappingDirection::Import); - let events = history_events(&serde_json::json!([value]), chrono::Utc::now(), &mut report); - assert_eq!(events.len(), 1); - assert_eq!(events[0].kind, crate::session::EventKind::Observation); - } -} diff --git a/rust/crates/memorph/src/providers/codebuddy/adapter.rs b/rust/crates/memorph/src/providers/codebuddy/adapter.rs deleted file mode 100644 index e2ffac3..0000000 --- a/rust/crates/memorph/src/providers/codebuddy/adapter.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! CodeBuddy hook adapter. -//! -//! This adapter is intentionally registered as its own provider module even -//! when the current payload shape is Claude-like. Keeping it separate makes -//! future provider-specific hook changes local to this file. - -use anyhow::Result; -use chrono::{DateTime, TimeZone, Utc}; -use serde_json::Value; -use std::path::PathBuf; - -use crate::hooks::contract::HookAdapter; -use crate::hooks::model::{ - HookEvent, HookEventType, HookMessage, HookToolCall, PermissionRequest, QuestionRequest, -}; -use crate::hooks::protocol::{HookIngestRequest, HookIngestResponse}; - -pub struct CodeBuddyHookAdapter; - -impl HookAdapter for CodeBuddyHookAdapter { - fn provider_id(&self) -> &'static str { - "codebuddy" - } - - fn blocking_response_json( - &self, - event_name: &str, - response: &HookIngestResponse, - ) -> Option { - crate::hooks::contract::hook_specific_output_response_json(event_name, response) - } - - fn normalize(&self, request: &HookIngestRequest) -> Result> { - let raw = request.raw.clone(); - let event_name = string_at( - &raw, - &[ - "hook_event_name", - "hookEventName", - "event_name", - "eventName", - "event", - "type", - ], - ) - .unwrap_or_else(|| request.event_name.clone()); - let mut event = HookEvent::new("codebuddy", provider_event_type(&event_name), raw.clone()); - - event.event_id = string_at(&raw, &["event_id", "eventId", "id"]).unwrap_or(event.event_id); - event.provider_session_id = string_at( - &raw, - &[ - "session_id", - "sessionId", - "conversation_id", - "conversationId", - "thread_id", - "threadId", - ], - ); - event.run_id = string_at( - &raw, - &[ - "run_id", - "runId", - "request_id", - "requestId", - "call_id", - "callId", - ], - ) - .or_else(|| Some(request.request_id.clone())); - event.cwd = path_at( - &raw, - &[ - "cwd", - "workspace", - "workspace_dir", - "workspaceDir", - "project_dir", - "projectDir", - ], - ) - .or_else(|| request.environment.cwd.clone()); - event.pid = u32_at(&raw, &["pid"]).or(request.environment.pid); - event.parent_pid = - u32_at(&raw, &["parent_pid", "parentPid", "ppid"]).or(request.environment.parent_pid); - event.tty = string_at(&raw, &["tty", "terminal_tty", "terminalTty"]) - .or_else(|| request.environment.tty.clone()); - event.timestamp = timestamp_at(&raw).unwrap_or(request.received_at); - - let tool = tool_at(&raw); - event.tool = tool.clone(); - event.message = message_at(&raw); - event.permission = match event.event_type { - HookEventType::PermissionRequested => Some(permission_at(&raw, tool)), - _ => None, - }; - event.question = match event.event_type { - HookEventType::QuestionRequested => question_at(&raw), - _ => None, - }; - - Ok(vec![event]) - } -} - -fn provider_event_type(event_name: &str) -> HookEventType { - match normalize_name(event_name).as_str() { - "sessionstart" | "session_start" | "taskstart" => HookEventType::SessionStarted, - "sessionend" | "session_end" | "taskcomplete" | "stop" => HookEventType::SessionCompleted, - "taskcancel" | "erroroccurred" => HookEventType::SessionFailed, - "userpromptsubmit" | "user_prompt_submit" | "userpromptsubmitted" | "notification" => { - HookEventType::MessageCreated - } - "pretooluse" | "pre_tool_use" => HookEventType::ToolStarted, - "posttooluse" | "post_tool_use" | "posttoolusefailure" | "post_tool_use_failure" => { - HookEventType::ToolFinished - } - "permissionrequest" | "permission_request" => HookEventType::PermissionRequested, - "askuserquestion" | "questionrequest" | "question_request" => { - HookEventType::QuestionRequested - } - "subagentstart" | "subagent_start" | "subagentstop" | "subagent_stop" | "precompact" - | "pre_compact" | "postcompact" | "post_compact" | "taskresume" => HookEventType::Heartbeat, - _ => HookEventType::from_provider_name(event_name), - } -} - -fn normalize_name(value: &str) -> String { - value - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn tool_at(value: &Value) -> Option { - if let Some(tool) = value.get("tool").filter(|tool| tool.is_object()) { - let name = string_at(tool, &["name", "tool_name", "toolName"])?; - return Some(HookToolCall { - id: string_at( - tool, - &["id", "tool_use_id", "toolUseId", "call_id", "callId"], - ), - name, - input: value_at(tool, &["input", "arguments", "args", "params"]).unwrap_or(Value::Null), - }); - } - - let name = string_at(value, &["tool_name", "toolName", "tool", "name"])?; - Some(HookToolCall { - id: string_at( - value, - &["tool_use_id", "toolUseId", "call_id", "callId", "id"], - ), - name, - input: value_at( - value, - &[ - "tool_input", - "toolInput", - "input", - "arguments", - "args", - "params", - ], - ) - .unwrap_or(Value::Null), - }) -} - -fn permission_at(value: &Value, tool: Option) -> PermissionRequest { - PermissionRequest { - request_id: string_at( - value, - &[ - "permission_id", - "permissionId", - "request_id", - "requestId", - "tool_use_id", - "toolUseId", - "call_id", - "callId", - ], - ) - .or_else(|| tool.as_ref().and_then(|tool| tool.id.clone())), - tool, - prompt: string_at( - value, - &[ - "permission_prompt", - "permissionPrompt", - "prompt", - "reason", - "message", - ], - ), - } -} - -fn question_at(value: &Value) -> Option { - let prompt = string_at( - value, - &[ - "question", - "question_prompt", - "questionPrompt", - "prompt", - "message", - ], - )?; - Some(QuestionRequest { - request_id: string_at( - value, - &["question_id", "questionId", "request_id", "requestId"], - ), - prompt, - }) -} - -fn message_at(value: &Value) -> Option { - let text = string_at( - value, - &[ - "message", - "text", - "content", - "prompt", - "user_prompt", - "userPrompt", - ], - )?; - Some(HookMessage { - role: string_at(value, &["role"]), - text, - }) -} - -fn string_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::String(s)) if !s.trim().is_empty() => Some(s.to_string()), - Some(Value::Number(n)) => Some(n.to_string()), - Some(Value::Bool(b)) => Some(b.to_string()), - _ => None, - }) -} - -fn u32_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()), - Some(Value::String(s)) => s.parse().ok(), - _ => None, - }) -} - -fn path_at(value: &Value, keys: &[&str]) -> Option { - string_at(value, keys).map(PathBuf::from) -} - -fn value_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| value.get(*key).cloned()) -} - -fn timestamp_at(value: &Value) -> Option> { - let raw = value - .get("timestamp") - .or_else(|| value.get("created_at")) - .or_else(|| value.get("createdAt"))?; - match raw { - Value::String(s) => DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|| s.parse::().ok().and_then(unix_timestamp)), - Value::Number(n) => n.as_i64().and_then(unix_timestamp), - _ => None, - } -} - -fn unix_timestamp(value: i64) -> Option> { - Utc.timestamp_opt(value, 0).single() -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn maps_pre_tool_use() { - let request = HookIngestRequest::new( - "codebuddy", - "PreToolUse", - json!({"session_id": "s1", "tool_name": "Bash"}), - ); - let event = CodeBuddyHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.provider, "codebuddy"); - assert_eq!(event.event_type, HookEventType::ToolStarted); - } -} diff --git a/rust/crates/memorph/src/providers/codebuddy/hook.rs b/rust/crates/memorph/src/providers/codebuddy/hook.rs deleted file mode 100644 index 5b3a46e..0000000 --- a/rust/crates/memorph/src/providers/codebuddy/hook.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::path::PathBuf; - -use anyhow::Result; - -use crate::hooks::contract::ProviderHook; -use crate::hooks::json_settings_hook::{event, JsonSettingsHookEvent, JsonSettingsHookSpec}; -use crate::hooks::model::{HookHealthStatus, HookInstallStatus, HookOperationReport}; - -pub struct CodeBuddyHook; - -pub static CODEBUDDY_HOOK: CodeBuddyHook = CodeBuddyHook; - -const EVENTS: &[JsonSettingsHookEvent] = &[ - event("UserPromptSubmit", 5, true), - event("PreToolUse", 5, false), - event("PostToolUse", 5, true), - event("SessionStart", 5, false), - event("SessionEnd", 5, true), - event("Stop", 5, true), - event("SubagentStart", 5, true), - event("SubagentStop", 5, true), - event("Notification", 86400, false), - event("PreCompact", 5, true), -]; - -impl ProviderHook for CodeBuddyHook { - fn provider_id(&self) -> &'static str { - "codebuddy" - } - - fn status(&self) -> Result { - crate::hooks::json_settings_hook::status(spec()) - } - - fn install(&self) -> Result { - crate::hooks::json_settings_hook::install(spec()) - } - - fn verify(&self) -> Result { - let status = self.status()?; - Ok(HookOperationReport { - provider: self.provider_id().to_string(), - operation: "verify".to_string(), - changed: false, - backup_path: None, - message: status.message.clone(), - status, - }) - } - - fn repair(&self) -> Result { - let before = self.status()?; - let mut report = self.install()?; - report.operation = "repair".to_string(); - report.changed = before.status != HookHealthStatus::InstalledOk; - Ok(report) - } - - fn uninstall(&self) -> Result { - crate::hooks::json_settings_hook::uninstall(spec()) - } -} - -pub(crate) fn settings_path() -> PathBuf { - crate::hooks::shared::hook_home_dir() - .join(".codebuddy") - .join("settings.json") -} - -fn spec() -> JsonSettingsHookSpec { - JsonSettingsHookSpec { - provider: "codebuddy", - display_name: "CodeBuddy", - settings_path, - events: EVENTS, - missing_config_message: "CodeBuddy settings.json does not exist.", - install_message: "CodeBuddy hook entries installed.", - uninstall_missing_message: "CodeBuddy settings file does not exist.", - uninstall_message: "CodeBuddy memorph hook entries removed.", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn descriptor_matches_hook_registry() { - let descriptor = CODEBUDDY_HOOK.descriptor().expect("codebuddy descriptor"); - assert_eq!(descriptor.provider(), CODEBUDDY_HOOK.provider_id()); - } -} diff --git a/rust/crates/memorph/src/providers/codebuddy/mod.rs b/rust/crates/memorph/src/providers/codebuddy/mod.rs deleted file mode 100644 index 00f46ab..0000000 --- a/rust/crates/memorph/src/providers/codebuddy/mod.rs +++ /dev/null @@ -1,368 +0,0 @@ -pub mod adapter; -pub mod hook; - -use crate::provider::{ - PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, - ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, - ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; -use walkdir::WalkDir; - -pub struct CodeBuddyProvider; -const PROVIDER_ID: &str = "codebuddy"; - -impl Provider for CodeBuddyProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "CodeBuddy" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - storage_shape: StorageShape::Directory, - scan_strategy: ScanStrategy::FullScan, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - resume_quality: ResumeQuality::None, - write_risk: ProviderWriteRisk { - level: WriteRiskLevel::High, - multiple_files: true, - sqlite: false, - sidecar_files: true, - index_repair: false, - }, - backup_support: ProviderBackupSupport { - before_write: false, - restore: false, - sync_only: false, - }, - activity_support: ProviderActivitySupport { - hook_events: false, - runtime_endpoint: false, - session_activity: false, - }, - ..ProviderCapabilities::default() - } - } - fn scan_sessions(&self) -> Result> { - let mut sessions = Vec::new(); - for root in roots() { - if !root.exists() { - continue; - } - for entry in WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { - continue; - } - let Some(id) = session_id(path) else { - continue; - }; - let events = read_events(path).unwrap_or_default(); - let title = events - .iter() - .find_map(|e| text_for(e).filter(|s| !s.trim().is_empty())); - sessions.push(ProviderSessionSummary { - archived: false, - session_id: id, - title, - project_dir: events.iter().find_map(workspace), - created_at: None, - last_active_at: modified_ms(path), - source_path: Some(path.to_string_lossy().into_owned()), - }); - } - } - sessions.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); - sessions.dedup_by(|a, b| a.session_id == b.session_id); - Ok(sessions) - } - fn import_session(&self, source_path: &str) -> Result { - let path = std::fs::canonicalize(source_path) - .with_context(|| format!("CodeBuddy source does not exist: {source_path}"))?; - let id = session_id(&path) - .context("CodeBuddy source must be .codebuddy/projects//.jsonl")?; - let raw = read_events(&path)?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events: Vec = raw - .iter() - .enumerate() - .filter_map(|(i, e)| map_event(e, i, &mut report)) - .collect(); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - let mut extensions = BTreeMap::new(); - extensions.insert("codebuddy_session_jsonl".into(), Value::Array(raw.clone())); - let created = raw - .iter() - .find_map(timestamp) - .or_else(|| modified_datetime(&path)) - .unwrap_or_else(Utc::now); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.clone(), - title: raw.iter().find_map(text_for), - }, - context: Context { - workspace: raw.iter().find_map(workspace), - created_at: Some(created), - last_active_at: modified_datetime(&path), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: id, - source_path: Some(path.to_string_lossy().into_owned()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - let path = Path::new(source_path); - if !path.exists() { - return Ok(None); - } - let digest = Sha256::digest(std::fs::read(path)?); - let size = std::fs::metadata(path)?.len(); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: modified_ms(path).unwrap_or(0), - size_bytes: size.min(i64::MAX as u64) as i64, - value: format!( - "codebuddy-session-v1:{}:{digest:x}", - session_id(path).unwrap_or_default() - ), - })) - } - fn data_source_paths(&self) -> Vec { - roots() - } -} -fn roots() -> Vec { - dirs::home_dir() - .into_iter() - .map(|h| h.join(".codebuddy/projects")) - .collect() -} -fn session_id(path: &Path) -> Option { - path.file_stem()? - .to_str() - .filter(|s| !s.is_empty()) - .map(str::to_string) -} -fn read_events(path: &Path) -> Result> { - std::fs::read_to_string(path)? - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| { - serde_json::from_str(l) - .with_context(|| format!("invalid CodeBuddy event in {}", path.display())) - }) - .collect() -} -fn modified_ms(path: &Path) -> Option { - std::fs::metadata(path) - .ok()? - .modified() - .ok()? - .duration_since(UNIX_EPOCH) - .ok() - .map(|d| d.as_millis().min(i64::MAX as u128) as i64) -} -fn modified_datetime(path: &Path) -> Option> { - modified_ms(path).and_then(DateTime::::from_timestamp_millis) -} -fn timestamp(v: &Value) -> Option> { - v.get("timestamp") - .or_else(|| v.get("createdAt")) - .and_then(|x| { - x.as_str() - .and_then(|s| { - DateTime::parse_from_rfc3339(s) - .ok() - .map(|d| d.with_timezone(&Utc)) - }) - .or_else(|| x.as_i64().and_then(DateTime::::from_timestamp_millis)) - }) -} -fn workspace(v: &Value) -> Option { - [ - "cwd", - "workspace", - "workspaceDirectory", - "projectDir", - "rootPath", - ] - .iter() - .find_map(|k| v.get(*k).and_then(Value::as_str).map(str::to_string)) - .or_else(|| v.get("message").and_then(workspace)) -} -fn text_for(v: &Value) -> Option { - let m = v.get("message").unwrap_or(v); - let c = m.get("content").unwrap_or(m); - c.as_str().map(str::to_string).or_else(|| { - c.as_array()? - .iter() - .find_map(|b| b.get("text").and_then(Value::as_str).map(str::to_string)) - }) -} -fn blocks(v: &Value) -> Vec { - let m = v.get("message").unwrap_or(v); - let c = m.get("content").unwrap_or(m); - if let Some(s) = c.as_str() { - return vec![Block::Text { text: s.into() }]; - }; - c.as_array() - .into_iter() - .flatten() - .filter_map( - |b| match b.get("type").and_then(Value::as_str).unwrap_or("") { - "text" | "input_text" | "output_text" => b - .get("text") - .and_then(Value::as_str) - .map(|s| Block::Text { text: s.into() }), - "thinking" => b - .get("thinking") - .and_then(Value::as_str) - .map(|s| Block::Thinking { - text: s.into(), - signature: None, - }), - "tool_use" | "toolCall" => Some(Block::ToolCall { - tool_call_id: b - .get("id") - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - name: b - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .into(), - input: b.get("input").or_else(|| b.get("arguments")).cloned(), - }), - "tool_result" | "toolResult" => Some(Block::ToolResult { - tool_call_id: b - .get("tool_use_id") - .or_else(|| b.get("toolCallId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - content: b.get("content").map(|x| x.to_string()).unwrap_or_default(), - outcome: crate::session::execution_outcome( - b.get("is_error").and_then(Value::as_bool).unwrap_or(false), - ), - }), - _ => None, - }, - ) - .collect() -} -fn map_event(v: &Value, i: usize, r: &mut MappingReport) -> Option { - let m = v.get("message").unwrap_or(v); - let role_raw = m - .get("role") - .or_else(|| v.get("role")) - .or_else(|| v.get("type"))? - .as_str()?; - let role = match role_raw.to_ascii_lowercase().as_str() { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - _ => Role::Other, - }; - let bs = blocks(v); - if bs.is_empty() { - return None; - }; - let kind = if bs.iter().any(|b| matches!(b, Block::ToolResult { .. })) { - EventKind::Observation - } else if bs.iter().any(|b| matches!(b, Block::ToolCall { .. })) { - EventKind::Action - } else { - EventKind::Message - }; - r.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "codebuddy-native-event".into(), - message: "Mapped Factory/CodeBuddy JSONL event".into(), - path: Some(format!("events[{i}]")), - raw: None, - }); - Some(Event { - id: format!("codebuddy:event:{i}"), - kind, - role, - timestamp: timestamp(v).unwrap_or_else(Utc::now), - links: Links::default(), - blocks: bs, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn identity_comes_from_transcript_filename() { - assert_eq!( - session_id(Path::new("/tmp/.codebuddy/projects/project/abc.jsonl")).as_deref(), - Some("abc") - ); - } - #[test] - fn maps_claude_style_codebuddy_event() { - let v = serde_json::json!({"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}); - let mut r = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let e = map_event(&v, 0, &mut r).unwrap(); - assert!(matches!(e.blocks[0],Block::Text{ref text} if text=="ok")); - } -} diff --git a/rust/crates/memorph/src/providers/deepseek.rs b/rust/crates/memorph/src/providers/deepseek.rs deleted file mode 100644 index 3ecfbf9..0000000 --- a/rust/crates/memorph/src/providers/deepseek.rs +++ /dev/null @@ -1,2502 +0,0 @@ -use crate::provider::{ - event_role_label, event_visible_message_role, event_visible_text, export_result, session_title, - PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, - ProviderContentFidelity, ProviderSessionBackup, ProviderSessionSummary, - ProviderSourceFingerprint, ProviderSourceMutation, ProviderWriteRisk, ResumeQuality, - ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, -}; -use crate::session::{ - Block, Context, Event, EventKind, EventMeta, EventSource, ExportedSession, Fidelity, Identity, - ImportedSession, Links, MappingDirection, MappingIssue, MappingIssueLevel, MappingReport, - Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::Utc; -use rusqlite::types::ValueRef; -use rusqlite::{Connection, OpenFlags, OptionalExtension}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; -use uuid::Uuid; - -mod backup; - -pub struct DeepseekProvider; - -const PROVIDER_ID: &str = "deepseek"; - -#[cfg(test)] -static TEST_DEEPSEEK_DIR: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -#[cfg(test)] -static TEST_DEEPSEEK_MUTATION_FAILURE: std::sync::OnceLock< - std::sync::Mutex>, -> = std::sync::OnceLock::new(); - -impl Provider for DeepseekProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - - fn name(&self) -> &'static str { - "DeepSeek" - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - export: true, - delete: true, - rename: true, - resume: true, - lightweight_scan: false, - single_session_lookup: true, - scan_strategy: ScanStrategy::FullScan, - page_strategy: PageStrategy::FullImport, - storage_shape: StorageShape::Sqlite, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Unsupported), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - patch: Some(Fidelity::Unsupported), - image: Some(Fidelity::Unsupported), - file: Some(Fidelity::Unsupported), - compressed: Some(Fidelity::Unsupported), - provider_payload: Some(Fidelity::Preserved), - }, - export_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Unsupported), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - patch: Some(Fidelity::Unsupported), - image: Some(Fidelity::Unsupported), - file: Some(Fidelity::Unsupported), - compressed: Some(Fidelity::Unsupported), - provider_payload: Some(Fidelity::Dropped), - }, - resume_quality: ResumeQuality::Native, - write_risk: ProviderWriteRisk { - level: WriteRiskLevel::High, - multiple_files: true, - sqlite: true, - sidecar_files: true, - index_repair: true, - }, - backup_support: ProviderBackupSupport { - before_write: true, - restore: true, - sync_only: false, - }, - activity_support: ProviderActivitySupport { - hook_events: false, - runtime_endpoint: false, - session_activity: false, - }, - } - } - - fn scan_sessions(&self) -> Result> { - let db_path = get_state_db_path(); - if !db_path.exists() { - return Ok(Vec::new()); - } - let conn = Connection::open(&db_path).with_context(|| { - format!("failed to open DeepSeek state db at {}", db_path.display()) - })?; - - let mut stmt = conn.prepare( - "SELECT id, preview, cwd, title, created_at, updated_at, archived FROM threads ORDER BY updated_at DESC" - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, i64>(4)?, - row.get::<_, i64>(5)?, - row.get::<_, i64>(6)?, - )) - })?; - - let mut sessions = Vec::new(); - for row in rows { - let (id, preview, cwd, title, _created, updated, archived) = row?; - sessions.push(ProviderSessionSummary { - archived: archived != 0, - session_id: id.clone(), - title: title.or_else(|| { - let p = preview.trim(); - if p.is_empty() { - None - } else { - Some(p.to_string()) - } - }), - project_dir: Some(cwd), - created_at: None, - last_active_at: Some(updated), - source_path: Some(deepseek_source_locator(&id)?), - }); - } - Ok(sessions) - } - - fn find_session_by_id(&self, session_id: &str) -> Result> { - let db_path = get_state_db_path(); - if !db_path.exists() { - return Ok(None); - } - let conn = Connection::open(&db_path).with_context(|| { - format!("failed to open DeepSeek state db at {}", db_path.display()) - })?; - - let meta = conn - .query_row( - "SELECT id, preview, cwd, title, updated_at, archived FROM threads WHERE id = ?1", - [session_id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, i64>(4)?, - row.get::<_, i64>(5)?, - )) - }, - ) - .optional()?; - - meta.map(|(id, preview, cwd, title, updated, archived)| { - Ok(ProviderSessionSummary { - archived: archived != 0, - session_id: id.clone(), - title: title.or_else(|| { - let p = preview.trim(); - if p.is_empty() { - None - } else { - Some(p.to_string()) - } - }), - project_dir: Some(cwd), - created_at: None, - last_active_at: Some(updated), - source_path: Some(deepseek_source_locator(&id)?), - }) - }) - .transpose() - } - - fn import_session(&self, source_path: &str) -> Result { - let (db_path, thread_id) = parse_deepseek_source_locator(source_path)?; - let conn = open_deepseek_read_only_db(&db_path)?; - import_canonical_session_from_connection(&conn, &thread_id, source_path) - } - - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - deepseek_source_fingerprint(source_path) - } - - fn export_session(&self, session: &Session, target_dir: &Path) -> Result { - let session_id = export_canonical_session(session, target_dir)?; - Ok(export_result( - PROVIDER_ID, - session_id.clone(), - self.resume_command(&session_id), - session, - self.capabilities(), - )) - } - - fn delete_session(&self, session_id: &str) -> Result<()> { - let db_path = get_state_db_path(); - if !db_path.exists() { - return Ok(()); - } - let mut conn = Connection::open(&db_path)?; - backup::validate_mutation_source(&conn, ProviderSourceMutation::Delete, session_id)?; - let tx = conn.transaction()?; - tx.execute("DELETE FROM messages WHERE thread_id = ?1", [session_id])?; - tx.execute("DELETE FROM checkpoints WHERE thread_id = ?1", [session_id])?; - tx.execute( - "DELETE FROM thread_dynamic_tools WHERE thread_id = ?1", - [session_id], - )?; - let deleted = tx.execute("DELETE FROM threads WHERE id = ?1", [session_id])?; - if deleted != 1 { - anyhow::bail!("DeepSeek thread not found: {session_id}"); - } - tx.commit()?; - fail_deepseek_mutation_after_write(ProviderSourceMutation::Delete)?; - Ok(()) - } - - fn rename_session(&self, session_id: &str, new_title: &str) -> Result<()> { - let db_path = get_state_db_path(); - if !db_path.exists() { - return Ok(()); - } - let mut conn = Connection::open(&db_path)?; - backup::validate_mutation_source(&conn, ProviderSourceMutation::Rename, session_id)?; - let now = Utc::now().timestamp(); - let tx = conn.transaction()?; - let updated = tx.execute( - "UPDATE threads SET title = ?1, preview = ?1, updated_at = ?2 WHERE id = ?3", - [new_title, &now.to_string(), session_id], - )?; - if updated != 1 { - anyhow::bail!("DeepSeek thread not found: {session_id}"); - } - tx.commit()?; - - append_session_index(session_id, Some(new_title), now, None)?; - fail_deepseek_mutation_after_write(ProviderSourceMutation::Rename)?; - Ok(()) - } - - fn create_session_backup( - &self, - mutation: ProviderSourceMutation, - operation_id: &str, - session_id: &str, - backup_root: &Path, - ) -> Result { - backup::create_session_backup(mutation, operation_id, session_id, backup_root) - } - - fn restore_session_backup(&self, backup: &ProviderSessionBackup) -> Result<()> { - backup::restore_session_backup(backup) - } - - fn resume_command(&self, session_id: &str) -> Option { - Some(format!("deepseek resume {}", session_id)) - } - - fn session_size(&self, session_id: &str) -> Result { - let db_path = get_state_db_path(); - if !db_path.exists() { - return Ok(0); - } - let conn = Connection::open(&db_path)?; - deepseek_session_size_with_conn(&conn, session_id) - } - - fn session_sizes(&self, session_ids: &[&str]) -> HashMap { - let db_path = get_state_db_path(); - let Ok(conn) = Connection::open(&db_path) else { - return HashMap::new(); - }; - session_ids - .iter() - .filter_map(|session_id| { - deepseek_session_size_with_conn(&conn, session_id) - .ok() - .filter(|size| *size > 0) - .map(|size| ((*session_id).to_string(), size)) - }) - .collect() - } - - fn data_source_paths(&self) -> Vec { - get_state_db_path() - .parent() - .map(PathBuf::from) - .into_iter() - .collect() - } -} - -fn deepseek_source_fingerprint(source_locator: &str) -> Result> { - let (db_path, thread_id) = parse_deepseek_source_locator(source_locator)?; - let Some(db_metadata) = std::fs::metadata(&db_path).ok() else { - return Ok(None); - }; - let conn = open_deepseek_read_only_db(&db_path)?; - let thread_exists = conn - .query_row("SELECT 1 FROM threads WHERE id = ?1", [&thread_id], |_| { - Ok(()) - }) - .optional()?; - if thread_exists.is_none() { - return Ok(None); - } - - let mut hasher = Sha256::new(); - hasher.update(b"deepseek-source-v1\0"); - hash_deepseek_bytes(&mut hasher, b"thread", thread_id.as_bytes()); - hash_deepseek_table_rows(&conn, &mut hasher, "threads", "id", &thread_id)?; - for table in ["messages", "checkpoints", "thread_dynamic_tools"] { - hash_deepseek_table_rows(&conn, &mut hasher, table, "thread_id", &thread_id)?; - } - - let wal_path = PathBuf::from(format!("{}-wal", db_path.display())); - let wal_metadata = std::fs::metadata(&wal_path).ok(); - let modified_at_ms = deepseek_metadata_modified_at_ms(&db_metadata).max( - wal_metadata - .as_ref() - .map(deepseek_metadata_modified_at_ms) - .unwrap_or(0), - ); - let size_bytes = i64::try_from(db_metadata.len()) - .unwrap_or(i64::MAX) - .saturating_add( - wal_metadata - .as_ref() - .map(|metadata| i64::try_from(metadata.len()).unwrap_or(i64::MAX)) - .unwrap_or(0), - ); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms, - size_bytes, - value: format!("sqlite-rows-v1:{:x}", hasher.finalize()), - })) -} - -fn hash_deepseek_table_rows( - conn: &Connection, - hasher: &mut Sha256, - table: &str, - selection_column: &str, - thread_id: &str, -) -> Result<()> { - let columns = deepseek_table_columns(conn, table)?; - anyhow::ensure!( - columns.iter().any(|column| column == selection_column), - "DeepSeek table {table} must contain selection column {selection_column}" - ); - let selected_columns = columns - .iter() - .map(|column| quote_deepseek_identifier(column)) - .collect::>() - .join(", "); - let order_columns = selected_columns.clone(); - let query = format!( - "SELECT {selected_columns} FROM {} WHERE {} = ?1 ORDER BY {order_columns}", - quote_deepseek_identifier(table), - quote_deepseek_identifier(selection_column), - ); - let mut stmt = conn.prepare(&query)?; - let mut rows = stmt.query([thread_id])?; - hash_deepseek_bytes(hasher, b"table", table.as_bytes()); - for column in &columns { - hash_deepseek_bytes(hasher, b"column", column.as_bytes()); - } - while let Some(row) = rows.next()? { - hasher.update(b"row\0"); - for index in 0..columns.len() { - hash_deepseek_value(hasher, row.get_ref(index)?); - } - } - Ok(()) -} - -fn deepseek_table_columns(conn: &Connection, table: &str) -> Result> { - let mut stmt = conn.prepare(&format!( - "PRAGMA table_info({})", - quote_deepseek_identifier(table) - ))?; - let columns = stmt - .query_map([], |row| row.get::<_, String>(1))? - .collect::>>()?; - anyhow::ensure!( - !columns.is_empty(), - "DeepSeek managed table does not exist: {table}" - ); - Ok(columns) -} - -fn hash_deepseek_value(hasher: &mut Sha256, value: ValueRef<'_>) { - match value { - ValueRef::Null => hasher.update(b"null\0"), - ValueRef::Integer(value) => { - hasher.update(b"integer\0"); - hasher.update(value.to_le_bytes()); - } - ValueRef::Real(value) => { - hasher.update(b"real\0"); - hasher.update(value.to_bits().to_le_bytes()); - } - ValueRef::Text(value) => hash_deepseek_bytes(hasher, b"text", value), - ValueRef::Blob(value) => hash_deepseek_bytes(hasher, b"blob", value), - } -} - -fn hash_deepseek_bytes(hasher: &mut Sha256, kind: &[u8], value: &[u8]) { - hasher.update(kind); - hasher.update((value.len() as u64).to_le_bytes()); - hasher.update(value); - hasher.update(b"\0"); -} - -fn quote_deepseek_identifier(identifier: &str) -> String { - format!("\"{}\"", identifier.replace('"', "\"\"")) -} - -fn deepseek_metadata_modified_at_ms(metadata: &std::fs::Metadata) -> i64 { - metadata - .modified() - .ok() - .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) - .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) - .unwrap_or(0) -} - -fn deepseek_session_size_with_conn(conn: &Connection, session_id: &str) -> Result { - let mut total: u64 = 0; - - if let Ok(size) = conn.query_row( - "SELECT COALESCE(length(id), 0) + COALESCE(length(preview), 0) + COALESCE(length(cwd), 0) + COALESCE(length(title), 0) + COALESCE(length(status), 0) + COALESCE(length(model_provider), 0) + COALESCE(length(cli_version), 0) + COALESCE(length(source), 0) FROM threads WHERE id = ?1", - [session_id], - |row| row.get::<_, i64>(0), - ) { - total += size as u64; - } - - let mut stmt = conn.prepare("SELECT COALESCE(length(role), 0) + COALESCE(length(content), 0) + COALESCE(length(item_json), 0) FROM messages WHERE thread_id = ?1")?; - let rows = stmt.query_map([session_id], |row| row.get::<_, i64>(0))?; - for row in rows.flatten() { - total += row as u64; - } - - Ok(total) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn get_deepseek_dir() -> PathBuf { - #[cfg(test)] - if let Some(path) = TEST_DEEPSEEK_DIR - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .expect("test DeepSeek dir lock") - .clone() - { - return path; - } - - dirs::home_dir() - .map(|h| h.join(".deepseek")) - .unwrap_or_else(|| PathBuf::from(".deepseek")) -} - -fn get_state_db_path() -> PathBuf { - get_deepseek_dir().join("state.db") -} - -fn deepseek_source_locator(thread_id: &str) -> Result { - anyhow::ensure!(!thread_id.is_empty(), "DeepSeek thread ID cannot be empty"); - anyhow::ensure!( - !thread_id.contains('#'), - "DeepSeek thread ID cannot contain locator separators" - ); - Ok(format!( - "{}#thread={thread_id}", - get_state_db_path().display() - )) -} - -fn parse_deepseek_source_locator(source_locator: &str) -> Result<(PathBuf, String)> { - let (database_path, thread_id) = source_locator - .rsplit_once("#thread=") - .context("DeepSeek source locator must use '#thread='")?; - anyhow::ensure!( - !database_path.is_empty() && !thread_id.is_empty(), - "DeepSeek source locator must include a database path and thread ID" - ); - anyhow::ensure!( - !thread_id.contains('#'), - "DeepSeek source locator contains an invalid thread ID" - ); - Ok((PathBuf::from(database_path), thread_id.to_string())) -} - -fn open_deepseek_read_only_db(path: &Path) -> Result { - if !path.is_file() { - anyhow::bail!("DeepSeek state database not found: {}", path.display()); - } - Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("failed to open DeepSeek state db at {}", path.display())) -} - -fn get_session_index_path() -> PathBuf { - get_deepseek_dir().join("session_index.jsonl") -} - -fn export_canonical_session(session: &Session, target_dir: &Path) -> Result { - let db_path = get_state_db_path(); - if !db_path.exists() { - anyhow::bail!( - "DeepSeek state database does not exist at {}. Please launch DeepSeek TUI once to initialize storage before importing.", - db_path.display() - ); - } - let mut conn = Connection::open(&db_path) - .with_context(|| format!("failed to open DeepSeek state db at {}", db_path.display()))?; - - let thread_id = format!("thread-{}", Uuid::new_v4()); - let now = Utc::now().timestamp(); - let cwd = target_dir.to_string_lossy().to_string(); - let title = session_title(session); - let tx = conn.transaction()?; - - tx.execute( - "INSERT INTO threads (id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, title, archived) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - [ - &thread_id, - &title, - "0", - "deepseek", - &now.to_string(), - &now.to_string(), - "idle", - &cwd, - env!("CARGO_PKG_VERSION"), - "interactive", - &title, - "0", - ], - ) - .context("failed to insert thread")?; - - for event in &session.events { - let Some(visible_role) = event_visible_message_role(event) else { - continue; - }; - let Some(content) = deepseek_message_content(event) else { - continue; - }; - let role = match visible_role { - Role::Assistant => "assistant", - Role::Tool => "tool", - Role::User => "user", - Role::System | Role::Developer | _ => continue, - }; - let item_json = deepseek_item_json(event); - let created_at = event.timestamp.timestamp(); - tx.execute( - "INSERT INTO messages (thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - [ - &thread_id, - role, - &content, - &serde_json::to_string(&item_json)?, - &created_at.to_string(), - ], - ) - .context("failed to insert message")?; - } - - tx.commit()?; - append_session_index(&thread_id, Some(&title), now, None)?; - Ok(thread_id) -} - -fn deepseek_message_content(event: &Event) -> Option { - event_visible_message_role(event)?; - // When item_json carries tool structure (ToolCall/ToolResult), the content - // column should only hold the event's Text/Thinking blocks — not the full - // visible text blob that includes stringified tool representations. This - // prevents the reimport parser from embedding tool text into a Text block. - let has_tool = event - .blocks - .iter() - .any(|b| matches!(b, Block::ToolCall { .. } | Block::ToolResult { .. })); - let text = if has_tool { - event - .blocks - .iter() - .filter_map(|block| match block { - Block::Text { text } => Some(text.clone()), - Block::Thinking { text, .. } => Some(text.clone()), - _ => None, - }) - .collect::>() - .join("\n") - } else { - event_visible_text(event) - }; - (!text.trim().is_empty()).then_some(text) -} - -/// Build the item_json payload for a DeepSeek message row. -/// -/// The import parser (`blocks_from_message`) looks for specific keys in -/// item_json: `tool_name`/`call_id`/`arguments` for tool calls, and -/// `output`/`tool_use_id`/`is_error` for tool results. Writing blocks in -/// this shape lets DeepSeek exports round-trip tool structure instead of -/// silently degrading to text. -fn deepseek_item_json(event: &Event) -> Value { - for block in &event.blocks { - match block { - Block::ToolCall { - tool_call_id, - name, - input, - } => { - return serde_json::json!({ - "source": "memorph-canonical", - "tool_name": name, - "call_id": tool_call_id, - "arguments": input.clone().unwrap_or(Value::Null), - }); - } - Block::ToolResult { - tool_call_id, - content, - outcome, - } => { - return serde_json::json!({ - "source": "memorph-canonical", - "output": content, - "tool_use_id": tool_call_id, - "is_error": crate::session::execution_outcome_is_error(*outcome), - }); - } - _ => {} - } - } - serde_json::json!({ - "source": "memorph-canonical", - "event_id": event.id, - "event_kind": event.kind, - "event_role": event_role_label(event.role), - }) -} - -#[derive(Debug)] -struct ThreadRow { - id: String, - preview: String, - cwd: String, - title: Option, - created_at: i64, - updated_at: i64, - model_provider: String, -} - -#[derive(Debug)] -struct MessageRow { - id: i64, - role: String, - content: String, - item_json: Option, - created_at: i64, -} - -fn import_canonical_session_from_connection( - conn: &Connection, - thread_id: &str, - source_path: &str, -) -> Result { - let thread = load_thread_row(conn, thread_id)?; - let messages = load_message_rows(conn, thread_id)?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let mut events = Vec::new(); - let mut event_meta = Vec::new(); - - for message in messages { - let raw_message = deepseek_message_value(&message); - let timestamp = - chrono::DateTime::from_timestamp(message.created_at, 0).unwrap_or_else(Utc::now); - let role = deepseek_event_role(&message.role, &raw_message, &mut report); - let (blocks, fidelity) = blocks_from_message(&message, &raw_message, &mut report); - let mut provider_ext = BTreeMap::new(); - provider_ext.insert("deepseek_message".to_string(), raw_message); - if !thread.model_provider.trim().is_empty() { - provider_ext.insert( - "model_provider".to_string(), - Value::String(thread.model_provider.clone()), - ); - } - - events.push(Event { - id: format!("deepseek:message:{}", message.id), - kind: deepseek_event_kind(&message.role, &blocks), - role, - timestamp, - links: Links::default(), - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }); - event_meta.push(EventMeta { - source: EventSource { - provider_id: PROVIDER_ID.to_string(), - original_id: Some(message.id.to_string()), - original_role: Some(message.role), - phase: None, - }, - fidelity, - provider_ext, - }); - } - - let title = deepseek_thread_title(&thread); - let mut extensions = BTreeMap::new(); - extensions.insert( - "deepseek_thread".to_string(), - deepseek_thread_value(&thread), - ); - - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: thread.id.clone(), - title, - }, - context: Context { - workspace: Some(thread.cwd.clone()), - created_at: chrono::DateTime::from_timestamp(thread.created_at, 0), - last_active_at: chrono::DateTime::from_timestamp(thread.updated_at, 0), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".to_string()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.to_string(), - session_id: thread.id.clone(), - source_path: Some(source_path.to_string()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) -} - -fn load_thread_row(conn: &Connection, source_path: &str) -> Result { - let thread = conn - .query_row( - "SELECT id, preview, cwd, title, created_at, updated_at, model_provider FROM threads WHERE id = ?1", - [source_path], - |row| { - Ok(ThreadRow { - id: row.get(0)?, - preview: row.get(1)?, - cwd: row.get(2)?, - title: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - model_provider: row.get(6)?, - }) - }, - ) - .optional() - .context("failed to read thread")?; - - thread.with_context(|| format!("thread not found: {}", source_path)) -} - -fn load_message_rows(conn: &Connection, source_path: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC" - )?; - let rows = stmt.query_map([source_path], |row| { - Ok(MessageRow { - id: row.get(0)?, - role: row.get(1)?, - content: row.get(2)?, - item_json: row.get(3)?, - created_at: row.get(4)?, - }) - })?; - - let mut messages = Vec::new(); - for row in rows { - messages.push(row?); - } - Ok(messages) -} - -fn deepseek_thread_title(thread: &ThreadRow) -> Option { - thread.title.clone().or_else(|| { - let preview = thread.preview.trim(); - if preview.is_empty() { - None - } else { - Some(preview.to_string()) - } - }) -} - -fn deepseek_event_role(role: &str, raw_message: &Value, report: &mut MappingReport) -> Role { - match role { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - "developer" => Role::Developer, - "history" => Role::System, - other => { - report.push_issue(MappingIssue { - level: MappingIssueLevel::Info, - disposition: Fidelity::Normalized, - code: "unknown_role_normalized".to_string(), - message: format!("Normalized unknown DeepSeek role '{}'", other), - path: None, - raw: Some(raw_message.clone()), - }); - Role::Other - } - } -} - -fn blocks_from_message( - message: &MessageRow, - raw_message: &Value, - report: &mut MappingReport, -) -> (Vec, Fidelity) { - let mut blocks = Vec::new(); - let mut fidelity = Fidelity::Preserved; - let content = message.content.trim(); - - match message.item_json.as_deref() { - Some(raw_item) => match serde_json::from_str::(raw_item) { - Ok(item) => { - if message.role == "history" && !content.is_empty() { - blocks.push(Block::Text { - text: message.content.clone(), - }); - } - - if let Some(tool_name) = item.get("tool_name").and_then(|value| value.as_str()) { - blocks.push(Block::ToolCall { - tool_call_id: item - .get("call_id") - .and_then(|value| value.as_str()) - .map(str::to_string) - .unwrap_or_else(|| message.id.to_string()), - name: tool_name.to_string(), - input: item.get("arguments").cloned(), - }); - if !content.is_empty() { - blocks.push(Block::Text { - text: message.content.clone(), - }); - } - } else if let Some(output) = item.get("output").and_then(|value| value.as_str()) { - blocks.push(Block::ToolResult { - tool_call_id: item - .get("tool_use_id") - .and_then(|value| value.as_str()) - .map(str::to_string) - .unwrap_or_else(|| message.id.to_string()), - content: output.to_string(), - outcome: crate::session::execution_outcome( - item.get("is_error") - .and_then(|value| value.as_bool()) - .unwrap_or(false), - ), - }); - if !content.is_empty() && content != output { - blocks.push(Block::Text { - text: message.content.clone(), - }); - } - } else if !content.is_empty() { - blocks.push(Block::Text { - text: message.content.clone(), - }); - } - - blocks.push(Block::Other { raw: item }); - } - Err(error) => { - fidelity = Fidelity::Normalized; - report.push_issue(MappingIssue { - level: MappingIssueLevel::Warning, - disposition: Fidelity::Normalized, - code: "invalid_item_json".to_string(), - message: format!("Failed to parse DeepSeek item_json: {}", error), - path: Some(format!("message:{}", message.id)), - raw: Some(raw_message.clone()), - }); - if !content.is_empty() { - blocks.push(Block::Text { - text: message.content.clone(), - }); - } - blocks.push(Block::Other { - raw: Value::String(raw_item.to_string()), - }); - } - }, - None if !content.is_empty() => { - blocks.push(Block::Text { - text: message.content.clone(), - }); - } - None => {} - } - - if blocks.is_empty() { - fidelity = Fidelity::Normalized; - blocks.push(Block::Other { - raw: raw_message.clone(), - }); - } - - (blocks, fidelity) -} - -fn deepseek_event_kind(role: &str, blocks: &[Block]) -> EventKind { - if role == "history" { - EventKind::Lifecycle - } else if blocks - .iter() - .any(|block| matches!(block, Block::ToolResult { .. })) - { - EventKind::Observation - } else if blocks - .iter() - .any(|block| matches!(block, Block::ToolCall { .. })) - { - EventKind::Action - } else if blocks - .iter() - .all(|block| matches!(block, Block::Other { .. })) - { - EventKind::Other - } else { - EventKind::Message - } -} - -fn deepseek_thread_value(thread: &ThreadRow) -> Value { - serde_json::json!({ - "id": thread.id, - "preview": thread.preview, - "cwd": thread.cwd, - "title": thread.title, - "created_at": thread.created_at, - "updated_at": thread.updated_at, - "model_provider": thread.model_provider, - }) -} - -fn deepseek_message_value(message: &MessageRow) -> Value { - serde_json::json!({ - "id": message.id, - "role": message.role, - "content": message.content, - "item_json": message.item_json, - "created_at": message.created_at, - }) -} - -fn append_session_index( - thread_id: &str, - thread_name: Option<&str>, - updated_at: i64, - rollout_path: Option<&Path>, -) -> Result<()> { - let index_path = get_session_index_path(); - - if let Some(parent) = index_path.parent() { - std::fs::create_dir_all(parent)?; - } - - let entry = serde_json::json!({ - "thread_id": thread_id, - "thread_name": thread_name, - "updated_at": updated_at, - "rollout_path": rollout_path.map(|p| p.to_string_lossy().to_string()), - }); - - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&index_path)?; - use std::io::Write; - writeln!(file, "{}", entry)?; - Ok(()) -} - -#[cfg(test)] -fn set_test_deepseek_dir(path: Option) { - *TEST_DEEPSEEK_DIR - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .expect("test DeepSeek dir lock") = path; -} - -#[cfg(test)] -fn set_test_deepseek_mutation_failure(mutation: Option) { - *TEST_DEEPSEEK_MUTATION_FAILURE - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .expect("test DeepSeek mutation failure lock") = mutation; -} - -#[cfg(test)] -fn fail_deepseek_mutation_after_write(mutation: ProviderSourceMutation) -> Result<()> { - let mut failure = TEST_DEEPSEEK_MUTATION_FAILURE - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .expect("test DeepSeek mutation failure lock"); - if *failure == Some(mutation) { - *failure = None; - anyhow::bail!("injected DeepSeek mutation failure after provider write"); - } - Ok(()) -} - -#[cfg(not(test))] -fn fail_deepseek_mutation_after_write(_mutation: ProviderSourceMutation) -> Result<()> { - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{core::session_management, storage::local_store}; - use rusqlite::params; - use rusqlite::types::Value as SqliteValue; - use serde_json::json; - use tempfile::tempdir; - - static TEST_DEEPSEEK_TEST_LOCK: std::sync::OnceLock> = - std::sync::OnceLock::new(); - - struct TestDeepseekDirGuard { - _lock: std::sync::MutexGuard<'static, ()>, - } - - impl Drop for TestDeepseekDirGuard { - fn drop(&mut self) { - crate::cache::global_cache().invalidate(PROVIDER_ID); - backup::set_test_backup_failure(false); - set_test_deepseek_mutation_failure(None); - set_test_deepseek_dir(None); - } - } - - struct TestConfigHomeGuard; - - impl TestConfigHomeGuard { - fn new(path: &Path) -> Self { - crate::config::set_test_home_dir(path.to_path_buf()); - Self - } - } - - impl Drop for TestConfigHomeGuard { - fn drop(&mut self) { - crate::config::reset_test_home_dir(); - } - } - - fn use_test_deepseek_dir(path: PathBuf) -> TestDeepseekDirGuard { - let lock = TEST_DEEPSEEK_TEST_LOCK - .get_or_init(|| std::sync::Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - set_test_deepseek_dir(Some(path)); - crate::cache::global_cache().invalidate(PROVIDER_ID); - TestDeepseekDirGuard { _lock: lock } - } - - struct NativeDeepseekFixture { - index_path: PathBuf, - original_index_bytes: Vec, - } - - fn write_native_deepseek_fixture( - deepseek_dir: &Path, - session_id: &str, - ) -> NativeDeepseekFixture { - std::fs::create_dir_all(deepseek_dir).unwrap(); - let db_path = deepseek_dir.join("state.db"); - let conn = Connection::open(&db_path).unwrap(); - conn.execute_batch( - " - CREATE TABLE threads ( - id TEXT PRIMARY KEY, - preview TEXT NOT NULL, - cwd TEXT NOT NULL, - title TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - model_provider TEXT NOT NULL, - archived INTEGER NOT NULL DEFAULT 0, - unrelated_note TEXT NOT NULL, - native_blob BLOB - ); - CREATE TABLE messages ( - id INTEGER PRIMARY KEY, - thread_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - item_json TEXT, - created_at INTEGER NOT NULL, - native_blob BLOB, - FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE - ); - CREATE TABLE checkpoints ( - id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL, - payload BLOB, - note TEXT NOT NULL, - FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE - ); - CREATE TABLE thread_dynamic_tools ( - thread_id TEXT NOT NULL, - position INTEGER NOT NULL, - tool_name TEXT NOT NULL, - payload BLOB, - PRIMARY KEY (thread_id, position), - FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE - ); - ", - ) - .unwrap(); - conn.execute( - "INSERT INTO threads ( - id, preview, cwd, title, created_at, updated_at, model_provider, - archived, unrelated_note, native_blob - ) VALUES (?1, 'Before preview', '/tmp/deepseek', 'Before', 100, 200, - 'deepseek', 0, 'target note', ?2)", - params![session_id, vec![0_u8, 1, 127, 128, 255]], - ) - .unwrap(); - conn.execute( - "INSERT INTO threads ( - id, preview, cwd, title, created_at, updated_at, model_provider, - archived, unrelated_note, native_blob - ) VALUES ('thread-other', 'Other preview', '/tmp/other', 'Other', 300, - 400, 'deepseek', 0, 'other note', X'1020')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO messages ( - id, thread_id, role, content, item_json, created_at, native_blob - ) VALUES (1, ?1, 'user', 'hello', '{\"kind\":\"message\"}', 101, ?2)", - params![session_id, vec![255_u8, 0, 128]], - ) - .unwrap(); - conn.execute( - "INSERT INTO messages ( - id, thread_id, role, content, item_json, created_at, native_blob - ) VALUES (2, 'thread-other', 'user', 'other', NULL, 301, X'22')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO checkpoints (id, thread_id, payload, note) - VALUES ('checkpoint-1', ?1, ?2, 'target checkpoint')", - params![session_id, vec![1_u8, 2, 3, 250]], - ) - .unwrap(); - conn.execute( - "INSERT INTO checkpoints (id, thread_id, payload, note) - VALUES ('checkpoint-other', 'thread-other', X'33', 'other checkpoint')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO thread_dynamic_tools (thread_id, position, tool_name, payload) - VALUES (?1, 0, 'shell', ?2)", - params![session_id, vec![4_u8, 5, 200]], - ) - .unwrap(); - conn.execute( - "INSERT INTO thread_dynamic_tools (thread_id, position, tool_name, payload) - VALUES ('thread-other', 0, 'read', X'44')", - [], - ) - .unwrap(); - - let index_path = deepseek_dir.join("session_index.jsonl"); - let original_index_bytes = [ - serde_json::to_string(&json!({ - "thread_id": session_id, - "thread_name": "Before", - "updated_at": 200, - "rollout_path": null - })) - .unwrap(), - serde_json::to_string(&json!({ - "thread_id": "thread-other", - "thread_name": "Other", - "updated_at": 400, - "rollout_path": null - })) - .unwrap(), - ] - .join("\n") - .into_bytes(); - let mut original_index_bytes = original_index_bytes; - original_index_bytes.push(b'\n'); - std::fs::write(&index_path, &original_index_bytes).unwrap(); - - NativeDeepseekFixture { - index_path, - original_index_bytes, - } - } - - fn deepseek_session_row_counts(deepseek_dir: &Path, session_id: &str) -> Vec { - let conn = Connection::open(deepseek_dir.join("state.db")).unwrap(); - [ - ("threads", "id"), - ("messages", "thread_id"), - ("checkpoints", "thread_id"), - ("thread_dynamic_tools", "thread_id"), - ] - .into_iter() - .map(|(table, column)| { - conn.query_row( - &format!("SELECT COUNT(*) FROM {table} WHERE {column} = ?1"), - [session_id], - |row| row.get(0), - ) - .unwrap() - }) - .collect() - } - - fn target_thread_values( - deepseek_dir: &Path, - session_id: &str, - ) -> Option<(String, String, i64, String, SqliteValue)> { - Connection::open(deepseek_dir.join("state.db")) - .unwrap() - .query_row( - "SELECT title, preview, updated_at, unrelated_note, native_blob - FROM threads WHERE id = ?1", - [session_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - }, - ) - .optional() - .unwrap() - } - - #[test] - fn deepseek_scan_locator_roundtrips_and_import_uses_the_locator() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-locator"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let db_path = get_state_db_path(); - let expected_locator = format!("{}#thread={session_id}", db_path.display()); - - let sessions = DeepseekProvider.scan_sessions().unwrap(); - let summary = sessions - .iter() - .find(|session| session.session_id == session_id) - .unwrap(); - assert_eq!( - summary.source_path.as_deref(), - Some(expected_locator.as_str()) - ); - assert_eq!( - parse_deepseek_source_locator(summary.source_path.as_deref().unwrap()).unwrap(), - (db_path.clone(), session_id.to_string()) - ); - assert!(parse_deepseek_source_locator(session_id).is_err()); - - let meta = DeepseekProvider - .get_session_meta(session_id) - .unwrap() - .unwrap(); - assert_eq!(meta.source_path, summary.source_path); - let imported = DeepseekProvider - .import_session(summary.source_path.as_deref().unwrap()) - .unwrap(); - assert_eq!( - imported.provenance.primary_source.source_path.as_deref(), - Some(expected_locator.as_str()) - ); - assert_eq!(imported.session.identity.id, session_id); - } - - #[test] - fn deepseek_capabilities_describe_sqlite_projection_and_management_boundaries() { - let capabilities = DeepseekProvider.capabilities(); - - assert!(capabilities.scan); - assert!(capabilities.import); - assert!(capabilities.export); - assert!(capabilities.delete); - assert!(capabilities.rename); - assert!(capabilities.resume); - assert_eq!(capabilities.scan_strategy, ScanStrategy::FullScan); - assert_eq!(capabilities.page_strategy, PageStrategy::FullImport); - assert_eq!(capabilities.storage_shape, StorageShape::Sqlite); - assert_eq!(capabilities.turn_quality, TurnQuality::Inferred); - assert_eq!(capabilities.resume_quality, ResumeQuality::Native); - assert_eq!(capabilities.write_risk.level, WriteRiskLevel::High); - assert!(capabilities.write_risk.multiple_files); - assert!(capabilities.write_risk.sqlite); - assert!(capabilities.write_risk.sidecar_files); - assert!(capabilities.write_risk.index_repair); - assert!(!capabilities.activity_support.hook_events); - assert!(!capabilities.activity_support.runtime_endpoint); - assert!(!capabilities.activity_support.session_activity); - assert_eq!( - capabilities.import_fidelity.provider_payload, - Some(Fidelity::Preserved) - ); - assert_eq!( - capabilities.export_fidelity.provider_payload, - Some(Fidelity::Dropped) - ); - } - - #[test] - fn deepseek_projection_bootstrap_is_bodyless_and_detail_reads_locator_source() -> Result<()> { - let source_root = tempdir()?; - let home = tempdir()?; - let _home_guard = TestConfigHomeGuard::new(home.path()); - let _deepseek_guard = use_test_deepseek_dir(source_root.path().join("deepseek")); - let session_id = "thread-projection"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - - let first = crate::core::projection::bootstrap_session_projections( - Some(PROVIDER_ID), - crate::storage::activity_store::ActivityActor::Cli, - )?; - assert_eq!(first.scanned_providers, 1); - assert_eq!(first.discovered_sessions, 2); - assert_eq!(first.projected_sessions, 2); - assert_eq!(first.unchanged_sessions, 0); - assert!(first.failures.is_empty()); - - let conn = local_store::open_database()?; - let stored: (String, String, String, String, i64) = conn.query_row( - "SELECT s.id, src.source_path, src.storage_shape, src.source_cursor, - ss.stale - FROM sessions s - JOIN session_sources src ON src.id = s.primary_source_id - JOIN session_snapshots ss ON ss.session_id = s.id - WHERE s.provider_id = ?1 AND s.provider_session_id = ?2", - params![PROVIDER_ID, session_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - }, - )?; - let expected_locator = deepseek_source_locator(session_id)?; - let expected_fingerprint = DeepseekProvider - .session_source_fingerprint(&expected_locator)? - .expect("fixture fingerprint"); - assert_eq!(stored.1, expected_locator); - assert_eq!(stored.2, "sqlite"); - assert_eq!(stored.3, expected_fingerprint.value); - assert_eq!(stored.4, 0); - - let body_table_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'table' - AND name IN ('session_turns', 'session_events', 'session_event_blocks')", - [], - |row| row.get(0), - )?; - assert_eq!(body_table_count, 0); - drop(conn); - - let detail = crate::core::sessions::get_session_detail_view_page( - PROVIDER_ID, - session_id, - 0, - Some(0), - )?; - assert!(detail.events.is_empty()); - assert!(detail.turns.is_empty()); - assert_eq!(detail.event_count, 1); - assert_eq!(detail.message_count, 1); - assert!(!detail.stale); - assert_eq!( - detail.source_path.as_deref(), - Some(expected_locator.as_str()) - ); - assert_eq!( - detail.projection_report.as_ref().unwrap().id, - format!("source-read:{PROVIDER_ID}:{session_id}") - ); - - let conn = local_store::open_database()?; - let cached_counts: (i64, i64, i64, i64) = conn.query_row( - "SELECT event_count, message_count, turn_count, counts_complete - FROM session_snapshots WHERE session_id = ?1", - [stored.0.as_str()], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - )?; - assert_eq!(cached_counts, (1, 1, 1, 1)); - drop(conn); - - let second = crate::core::projection::bootstrap_session_projections( - Some(PROVIDER_ID), - crate::storage::activity_store::ActivityActor::Cli, - )?; - assert_eq!(second.projected_sessions, 0); - assert_eq!(second.unchanged_sessions, 2); - assert!(second.failures.is_empty()); - - let conn = Connection::open(get_state_db_path())?; - conn.execute( - "UPDATE messages SET content = 'changed' WHERE thread_id = ?1", - [session_id], - )?; - drop(conn); - - let stale_detail = crate::core::sessions::get_session_detail_view_page( - PROVIDER_ID, - session_id, - 0, - Some(0), - )?; - assert!(stale_detail.stale); - - let refreshed = crate::core::projection::bootstrap_session_projections( - Some(PROVIDER_ID), - crate::storage::activity_store::ActivityActor::Cli, - )?; - assert_eq!(refreshed.projected_sessions, 1); - assert_eq!(refreshed.unchanged_sessions, 1); - assert!(refreshed.failures.is_empty()); - - let fresh_detail = crate::core::sessions::get_session_detail_view_page( - PROVIDER_ID, - session_id, - 0, - Some(0), - )?; - assert!(!fresh_detail.stale); - assert_eq!(fresh_detail.message_count, 1); - Ok(()) - } - - #[test] - fn deepseek_locator_rejects_raw_thread_ids_and_reports_missing_sources() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-source-errors"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - - assert!(DeepseekProvider.import_session(session_id).is_err()); - assert!(DeepseekProvider - .session_source_fingerprint(session_id) - .is_err()); - - let missing_thread_locator = deepseek_source_locator("thread-missing").unwrap(); - assert!(DeepseekProvider - .session_source_fingerprint(&missing_thread_locator) - .unwrap() - .is_none()); - let missing_db_locator = format!( - "{}#thread={session_id}", - dir.path().join("missing").join("state.db").display() - ); - assert!(DeepseekProvider - .session_source_fingerprint(&missing_db_locator) - .unwrap() - .is_none()); - assert!(DeepseekProvider - .import_session(&missing_db_locator) - .is_err()); - } - - #[test] - fn deepseek_fingerprint_is_thread_scoped_and_allows_empty_message_sets() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-fingerprint"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let locator = deepseek_source_locator(session_id).unwrap(); - - let first = DeepseekProvider - .session_source_fingerprint(&locator) - .unwrap() - .unwrap(); - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute( - "UPDATE threads SET unrelated_note = 'changed other' WHERE id = 'thread-other'", - [], - ) - .unwrap(); - drop(conn); - let unrelated_change = DeepseekProvider - .session_source_fingerprint(&locator) - .unwrap() - .unwrap(); - assert_eq!(first.value, unrelated_change.value); - - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute("DELETE FROM messages WHERE thread_id = ?1", [session_id]) - .unwrap(); - drop(conn); - let empty_messages = DeepseekProvider - .session_source_fingerprint(&locator) - .unwrap() - .unwrap(); - assert_ne!(first.value, empty_messages.value); - let imported = DeepseekProvider.import_session(&locator).unwrap(); - assert!(imported.session.events.is_empty()); - - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute( - "UPDATE checkpoints SET note = 'changed target' WHERE thread_id = ?1", - [session_id], - ) - .unwrap(); - drop(conn); - let target_change = DeepseekProvider - .session_source_fingerprint(&locator) - .unwrap() - .unwrap(); - assert_ne!(empty_messages.value, target_change.value); - } - - #[test] - fn delete_backup_restores_exact_deepseek_rows_and_preserves_unrelated_rows() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-delete-backup"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let original_thread = target_thread_values(&get_deepseek_dir(), session_id).unwrap(); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-delete-1", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - - DeepseekProvider.delete_session(session_id).unwrap(); - assert_eq!( - deepseek_session_row_counts(&get_deepseek_dir(), session_id), - vec![0, 0, 0, 0] - ); - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute( - "UPDATE threads - SET unrelated_note = 'other changed', native_blob = X'AABB' - WHERE id = 'thread-other'", - [], - ) - .unwrap(); - drop(conn); - - DeepseekProvider.restore_session_backup(&backup).unwrap(); - DeepseekProvider.restore_session_backup(&backup).unwrap(); - - assert_eq!( - deepseek_session_row_counts(&get_deepseek_dir(), session_id), - vec![1, 1, 1, 1] - ); - assert_eq!( - target_thread_values(&get_deepseek_dir(), session_id).as_ref(), - Some(&original_thread) - ); - let conn = Connection::open(get_state_db_path()).unwrap(); - let other: (String, Vec) = conn - .query_row( - "SELECT unrelated_note, native_blob FROM threads WHERE id = 'thread-other'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(other, ("other changed".to_string(), vec![0xAA, 0xBB])); - let message_blob: Vec = conn - .query_row( - "SELECT native_blob FROM messages WHERE thread_id = ?1", - [session_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(message_blob, vec![255_u8, 0, 128]); - } - - #[test] - fn rename_backup_restores_owned_fields_and_removes_only_target_index_append() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-rename-backup"; - let fixture = write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Rename, - "operation-rename-1", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - - DeepseekProvider - .rename_session(session_id, "After") - .unwrap(); - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute( - "UPDATE threads - SET cwd = '/tmp/changed', unrelated_note = 'changed independently', - native_blob = X'FEED' - WHERE id = ?1", - [session_id], - ) - .unwrap(); - drop(conn); - append_session_index("thread-concurrent", Some("Concurrent"), 999, None).unwrap(); - - DeepseekProvider.restore_session_backup(&backup).unwrap(); - DeepseekProvider.restore_session_backup(&backup).unwrap(); - - let conn = Connection::open(get_state_db_path()).unwrap(); - let thread: (String, String, i64, String, String, Vec) = conn - .query_row( - "SELECT title, preview, updated_at, cwd, unrelated_note, native_blob - FROM threads WHERE id = ?1", - [session_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - )) - }, - ) - .unwrap(); - assert_eq!( - thread, - ( - "Before".to_string(), - "Before preview".to_string(), - 200, - "/tmp/changed".to_string(), - "changed independently".to_string(), - vec![0xFE, 0xED], - ) - ); - let current_index = std::fs::read(&fixture.index_path).unwrap(); - assert!(current_index.starts_with(&fixture.original_index_bytes)); - let suffix = ¤t_index[fixture.original_index_bytes.len()..]; - assert_eq!( - std::str::from_utf8(suffix) - .unwrap() - .lines() - .map( - |line| serde_json::from_str::(line).unwrap()["thread_id"] - .as_str() - .unwrap() - .to_string() - ) - .collect::>(), - vec!["thread-concurrent".to_string()] - ); - } - - #[test] - fn rename_restore_does_not_recreate_concurrently_deleted_thread() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-concurrent-delete"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Rename, - "operation-concurrent-delete", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - DeepseekProvider - .rename_session(session_id, "After") - .unwrap(); - Connection::open(get_state_db_path()) - .unwrap() - .execute("DELETE FROM threads WHERE id = ?1", [session_id]) - .unwrap(); - - DeepseekProvider.restore_session_backup(&backup).unwrap(); - - assert!(target_thread_values(&get_deepseek_dir(), session_id).is_none()); - } - - #[test] - fn deepseek_backup_contract_and_capabilities_are_truthful() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-backup-contract"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Rename, - "operation-contract-1", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - - let capabilities = DeepseekProvider.capabilities(); - assert!(capabilities.backup_support.before_write); - assert!(capabilities.backup_support.restore); - assert!(!capabilities.backup_support.sync_only); - assert_eq!(backup.mutation, ProviderSourceMutation::Rename); - assert_eq!(backup.operation_id, "operation-contract-1"); - assert_eq!(backup.provider_session_id, session_id); - assert_eq!( - backup.source_path, - get_state_db_path().canonicalize().unwrap() - ); - assert_eq!(backup.format, "deepseek-session-backup-v1"); - assert_eq!( - backup.mime_type, - "application/vnd.memorph.deepseek-session-backup" - ); - assert_eq!( - backup - .restore_metadata - .get("restore_mode") - .and_then(Value::as_str), - Some("deepseek_session_restore") - ); - assert!(backup.backup_path.join("metadata.json").is_file()); - assert!(backup - .backup_path - .join("sqlite/deepseek-session.db") - .is_file()); - assert!(backup - .backup_path - .join("files/session_index.jsonl") - .is_file()); - } - - #[test] - fn backup_registration_failure_prevents_deepseek_provider_write() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-registration-failure"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup_root = dir.path().join("backups"); - let mut artifact_conn = Connection::open_in_memory().unwrap(); - - let results = session_management::delete_sessions( - PROVIDER_ID, - &[session_id], - &["operation-registration-failure".to_string()], - &backup_root, - &mut artifact_conn, - ); - - let error = results.into_iter().next().unwrap().unwrap_err(); - assert!(error - .to_string() - .contains("Delete cancelled before provider write")); - assert_eq!( - deepseek_session_row_counts(&get_deepseek_dir(), session_id), - vec![1, 1, 1, 1] - ); - assert!(backup_root - .join(PROVIDER_ID) - .join("operation-registration-failure") - .exists()); - } - - #[test] - fn partial_deepseek_delete_failure_restores_registered_backup() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-partial-delete"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let mut artifact_conn = Connection::open_in_memory().unwrap(); - local_store::configure_connection(&artifact_conn).unwrap(); - local_store::apply_schema(&mut artifact_conn).unwrap(); - set_test_deepseek_mutation_failure(Some(ProviderSourceMutation::Delete)); - - let results = session_management::delete_sessions( - PROVIDER_ID, - &[session_id], - &["operation-partial-delete".to_string()], - &dir.path().join("backups"), - &mut artifact_conn, - ); - - let error = results.into_iter().next().unwrap().unwrap_err(); - assert!(error - .to_string() - .contains("Provider source was restored from registered backup")); - assert_eq!( - deepseek_session_row_counts(&get_deepseek_dir(), session_id), - vec![1, 1, 1, 1] - ); - } - - #[test] - fn partial_deepseek_rename_failure_restores_registered_backup() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-partial-rename"; - let fixture = write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let mut artifact_conn = Connection::open_in_memory().unwrap(); - local_store::configure_connection(&artifact_conn).unwrap(); - local_store::apply_schema(&mut artifact_conn).unwrap(); - set_test_deepseek_mutation_failure(Some(ProviderSourceMutation::Rename)); - - let error = session_management::rename_session( - PROVIDER_ID, - session_id, - "After", - "operation-partial-rename", - &dir.path().join("backups"), - &mut artifact_conn, - ) - .unwrap_err(); - - assert!(error - .to_string() - .contains("Provider source was restored from registered backup")); - assert_eq!( - target_thread_values(&get_deepseek_dir(), session_id) - .unwrap() - .0, - "Before" - ); - assert_eq!( - std::fs::read(&fixture.index_path).unwrap(), - fixture.original_index_bytes - ); - } - - #[test] - fn deepseek_backup_rejects_unsafe_schema_before_mutation() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - std::fs::create_dir_all(get_deepseek_dir()).unwrap(); - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute_batch( - " - CREATE TABLE threads ( - id TEXT, - title TEXT, - preview TEXT, - updated_at INTEGER - ); - CREATE TABLE messages (thread_id TEXT); - CREATE TABLE checkpoints (thread_id TEXT); - CREATE TABLE thread_dynamic_tools (thread_id TEXT); - INSERT INTO threads (id, title, preview, updated_at) - VALUES ('unsafe-thread', 'Before', 'Before', 1); - ", - ) - .unwrap(); - drop(conn); - - let error = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-unsafe-schema", - "unsafe-thread", - &dir.path().join("backups"), - ) - .unwrap_err(); - - assert!(error.to_string().contains("does not enforce a unique id")); - assert!(!dir - .path() - .join("backups/deepseek/operation-unsafe-schema") - .exists()); - assert_eq!( - Connection::open(get_state_db_path()) - .unwrap() - .query_row( - "SELECT COUNT(*) FROM threads WHERE id = 'unsafe-thread'", - [], - |row| row.get::<_, i64>(0) - ) - .unwrap(), - 1 - ); - - std::fs::remove_file(get_state_db_path()).unwrap(); - let conn = Connection::open(get_state_db_path()).unwrap(); - conn.execute_batch( - " - CREATE TABLE threads ( - id TEXT PRIMARY KEY, - title TEXT, - preview TEXT, - updated_at INTEGER - ); - CREATE TABLE messages (thread_id TEXT); - CREATE TABLE thread_dynamic_tools (thread_id TEXT); - INSERT INTO threads (id, title, preview, updated_at) - VALUES ('missing-table-thread', 'Before', 'Before', 1); - ", - ) - .unwrap(); - drop(conn); - let error = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-missing-table", - "missing-table-thread", - &dir.path().join("backups"), - ) - .unwrap_err(); - assert!(error.to_string().contains("table does not exist")); - assert!(!dir - .path() - .join("backups/deepseek/operation-missing-table") - .exists()); - } - - #[test] - fn deepseek_restore_rejects_manifest_selection_and_schema_tampering() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-tampering"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup_root = dir.path().join("backups"); - - let manifest_backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-manifest-tamper", - session_id, - &backup_root, - ) - .unwrap(); - let metadata_path = manifest_backup.backup_path.join("metadata.json"); - let mut metadata: Value = - serde_json::from_slice(&std::fs::read(&metadata_path).unwrap()).unwrap(); - metadata["sqlite_tables"][0]["row_count"] = json!(99); - std::fs::write( - &metadata_path, - serde_json::to_vec_pretty(&metadata).unwrap(), - ) - .unwrap(); - assert!(DeepseekProvider - .restore_session_backup(&manifest_backup) - .unwrap_err() - .to_string() - .contains("row count")); - - let selection_backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-selection-tamper", - session_id, - &backup_root, - ) - .unwrap(); - Connection::open( - selection_backup - .backup_path - .join("sqlite/deepseek-session.db"), - ) - .unwrap() - .execute("UPDATE threads SET id = 'thread-outside-selection'", []) - .unwrap(); - assert!(DeepseekProvider - .restore_session_backup(&selection_backup) - .unwrap_err() - .to_string() - .contains("outside the target session")); - - let schema_backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-schema-tamper", - session_id, - &backup_root, - ) - .unwrap(); - Connection::open(get_state_db_path()) - .unwrap() - .execute("ALTER TABLE messages ADD COLUMN schema_drift TEXT", []) - .unwrap(); - assert!(DeepseekProvider - .restore_session_backup(&schema_backup) - .unwrap_err() - .to_string() - .contains("schema does not match")); - } - - #[test] - fn deepseek_index_restore_rejects_ambiguous_target_appends() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-ambiguous-index"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Rename, - "operation-ambiguous-index", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - DeepseekProvider - .rename_session(session_id, "After") - .unwrap(); - append_session_index(session_id, Some("Concurrent target"), 999, None).unwrap(); - - let error = DeepseekProvider - .restore_session_backup(&backup) - .unwrap_err(); - - assert!( - error.to_string().contains("multiple target records"), - "{error:#}" - ); - } - - #[test] - fn restore_rejects_backup_when_current_state_database_changes() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek-a")); - let session_id = "thread-source-boundary"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Delete, - "operation-source-boundary", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - - let other_dir = dir.path().join("deepseek-b"); - write_native_deepseek_fixture(&other_dir, session_id); - set_test_deepseek_dir(Some(other_dir.clone())); - crate::cache::global_cache().invalidate(PROVIDER_ID); - - let error = DeepseekProvider - .restore_session_backup(&backup) - .unwrap_err(); - assert!( - error - .to_string() - .contains("does not match the current state database"), - "{error:#}" - ); - assert_eq!( - deepseek_session_row_counts(&other_dir, session_id), - vec![1, 1, 1, 1] - ); - } - - #[test] - fn rename_restore_removes_new_index_file_when_target_append_is_only_content() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-index-absent"; - let fixture = write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - std::fs::remove_file(&fixture.index_path).unwrap(); - let backup = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Rename, - "operation-index-absent", - session_id, - &dir.path().join("backups"), - ) - .unwrap(); - DeepseekProvider - .rename_session(session_id, "After") - .unwrap(); - - DeepseekProvider.restore_session_backup(&backup).unwrap(); - DeepseekProvider.restore_session_backup(&backup).unwrap(); - - assert!(!fixture.index_path.exists()); - } - - #[test] - fn failed_deepseek_backup_creation_removes_operation_directory() { - let dir = tempdir().unwrap(); - let _guard = use_test_deepseek_dir(dir.path().join("deepseek")); - let session_id = "thread-backup-cleanup"; - write_native_deepseek_fixture(&get_deepseek_dir(), session_id); - let backup_root = dir.path().join("backups"); - backup::set_test_backup_failure(true); - - let error = DeepseekProvider - .create_session_backup( - ProviderSourceMutation::Rename, - "operation-backup-cleanup", - session_id, - &backup_root, - ) - .unwrap_err(); - - assert!(error - .to_string() - .contains("injected DeepSeek backup failure")); - assert!(!backup_root - .join(PROVIDER_ID) - .join("operation-backup-cleanup") - .exists()); - } - - #[test] - fn import_canonical_session_preserves_workspace_tool_payloads_and_history() { - let dir = tempfile::tempdir().unwrap(); - let db_path = dir.path().join("state.db"); - let conn = Connection::open(&db_path).unwrap(); - - conn.execute_batch( - " - CREATE TABLE threads ( - id TEXT PRIMARY KEY, - preview TEXT NOT NULL, - cwd TEXT NOT NULL, - title TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - model_provider TEXT NOT NULL, - archived INTEGER DEFAULT 0 - ); - CREATE TABLE messages ( - id INTEGER PRIMARY KEY, - thread_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - item_json TEXT, - created_at INTEGER NOT NULL - ); - ", - ) - .unwrap(); - - conn.execute( - "INSERT INTO threads (id, preview, cwd, title, created_at, updated_at, model_provider, archived) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)", - ( - "thread-1", - "preview title", - "/tmp/workspace", - "Named Thread", - 1710000000_i64, - 1710000100_i64, - "deepseek", - ), - ) - .unwrap(); - conn.execute( - "INSERT INTO messages (id, thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - ( - 1_i64, - "thread-1", - "history", - "forked from another thread", - "{\"kind\":\"fork\"}", - 1710000001_i64, - ), - ) - .unwrap(); - conn.execute( - "INSERT INTO messages (id, thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - ( - 2_i64, - "thread-1", - "assistant", - "", - "{\"tool_name\":\"read_file\",\"call_id\":\"call-1\",\"arguments\":{\"path\":\"Cargo.toml\"}}", - 1710000002_i64, - ), - ) - .unwrap(); - conn.execute( - "INSERT INTO messages (id, thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - ( - 3_i64, - "thread-1", - "tool", - "file contents", - "{\"output\":\"file contents\",\"tool_use_id\":\"call-1\",\"is_error\":false}", - 1710000003_i64, - ), - ) - .unwrap(); - - let imported = - import_canonical_session_from_connection(&conn, "thread-1", "state.db#thread=thread-1") - .unwrap(); - - assert_eq!( - imported.session.context.workspace.as_deref(), - Some("/tmp/workspace") - ); - assert_eq!( - imported.session.identity.title.as_deref(), - Some("Named Thread") - ); - assert_eq!(imported.session.events.len(), 3); - assert_eq!(imported.session.events[0].kind, EventKind::Lifecycle); - assert!(matches!( - imported.session.events[1].blocks.first(), - Some(Block::ToolCall { - name, - tool_call_id, - .. - }) if name == "read_file" && tool_call_id == "call-1" - )); - assert!(matches!( - imported.session.events[2].blocks.first(), - Some(Block::ToolResult { - tool_call_id, - content, - outcome - }) if tool_call_id == "call-1" && content == "file contents" && *outcome == crate::session::ExecutionOutcome::Succeeded - )); - assert!(imported.session.extensions.contains_key("deepseek_thread")); - } - - #[test] - fn compressed_segment_exports_as_portable_deepseek_message_content() { - let event = Event { - id: "compressed-source".to_string(), - kind: EventKind::Message, - role: Role::Assistant, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![Block::Compressed { - raw: serde_json::json!({ - "format": "memorph.compressed.v1", - "source_provider_id": "opencode", - "summary": "compressed summary", - "source_event_ids": ["old-event-1", "old-event-2", "old-event-3"], - "source_event_count": 3, - "archive_ref": "memorph-archive://s1/archive.json.gz", - }), - }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }; - - let content = deepseek_message_content(&event).expect("visible compressed content"); - - assert!(content.contains("[Compressed session segment from opencode]")); - assert!(content.contains("compressed summary")); - assert!(content.contains("Source event count: 3")); - assert!(content.contains("Archive: memorph-archive://s1/archive.json.gz")); - assert!(content.contains("memorph compression retrieve memorph-archive://s1/archive.json.gz --query --max-results 5")); - assert!(!content.contains("old-event-1")); - assert!(!content.contains("old-event-2")); - assert!(!content.contains("old-event-3")); - } - - #[test] - fn internal_events_do_not_export_as_deepseek_message_content() { - let event = Event { - id: "internal".to_string(), - kind: EventKind::Lifecycle, - role: Role::System, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![Block::Text { - text: "internal context".to_string(), - }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }; - - assert!(deepseek_message_content(&event).is_none()); - } - - #[test] - fn deepseek_item_json_roundtrips_tool_call_and_result_through_blocks_from_message() { - // Build a ToolCall event that export would process. - let tool_call_event = Event { - id: "evt-call-1".to_string(), - kind: EventKind::Action, - role: Role::Assistant, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![Block::ToolCall { - tool_call_id: "call-42".to_string(), - name: "read_file".to_string(), - input: Some(json!({"path": "src/lib.rs"})), - }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }; - - let item_json_value = deepseek_item_json(&tool_call_event); - assert_eq!(item_json_value["tool_name"], "read_file"); - assert_eq!(item_json_value["call_id"], "call-42"); - assert_eq!(item_json_value["arguments"]["path"], "src/lib.rs"); - - // Simulate what export writes into the DB row. - let call_row = MessageRow { - id: 10, - role: "assistant".to_string(), - content: "[Tool use: read_file (call-42)]\n{\"path\":\"src/lib.rs\"}".to_string(), - item_json: Some(serde_json::to_string(&item_json_value).unwrap()), - created_at: 1710000002, - }; - let raw_call = serde_json::json!({"role": "assistant", "content": call_row.content}); - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let (blocks, _fidelity) = blocks_from_message(&call_row, &raw_call, &mut report); - - // The first block must be a ToolCall, not degraded text. - assert!( - blocks.iter().any(|b| matches!( - b, - Block::ToolCall { name, tool_call_id, .. } - if name == "read_file" && tool_call_id == "call-42" - )), - "ToolCall did not round-trip; blocks = {:?}", - blocks - ); - - // Now a ToolResult event. - let tool_result_event = Event { - id: "evt-result-1".to_string(), - kind: EventKind::Observation, - role: Role::Tool, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![Block::ToolResult { - tool_call_id: "call-42".to_string(), - content: "file contents here".to_string(), - outcome: crate::session::ExecutionOutcome::Succeeded, - }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }; - - let result_item = deepseek_item_json(&tool_result_event); - assert_eq!(result_item["output"], "file contents here"); - assert_eq!(result_item["tool_use_id"], "call-42"); - assert_eq!(result_item["is_error"], false); - - let result_row = MessageRow { - id: 11, - role: "tool".to_string(), - content: "file contents here".to_string(), - item_json: Some(serde_json::to_string(&result_item).unwrap()), - created_at: 1710000003, - }; - let raw_result = serde_json::json!({"role": "tool", "content": result_row.content}); - let mut report2 = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let (blocks2, _) = blocks_from_message(&result_row, &raw_result, &mut report2); - - assert!( - blocks2.iter().any(|b| matches!( - b, - Block::ToolResult { tool_call_id, content, outcome } - if tool_call_id == "call-42" - && content == "file contents here" - && *outcome == crate::session::ExecutionOutcome::Succeeded - )), - "ToolResult did not round-trip; blocks = {:?}", - blocks2 - ); - - // An event with only Text falls back to the generic shape. - let text_event = Event { - id: "evt-text-1".to_string(), - kind: EventKind::Message, - role: Role::User, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![Block::Text { - text: "hello world".to_string(), - }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }; - let text_item = deepseek_item_json(&text_event); - assert!(text_item.get("tool_name").is_none()); - assert!(text_item.get("output").is_none()); - assert_eq!(text_item["source"], "memorph-canonical"); - } - - #[test] - fn deepseek_export_content_excludes_tool_text_from_multi_block_events() { - // An assistant event with [Text, ToolCall] should export content as - // just the text, not the full visible text blob that includes the - // stringified tool call. - let event = Event { - id: "multi-block".to_string(), - kind: EventKind::Action, - role: Role::Assistant, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![ - Block::Text { - text: "I'll check that".to_string(), - }, - Block::ToolCall { - tool_call_id: "call-mb".to_string(), - name: "read_file".to_string(), - input: Some(json!({"path": "src/lib.rs"})), - }, - ], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }; - - let content = deepseek_message_content(&event).expect("content for multi-block event"); - // Content should be just the text block, not include tool representation. - assert_eq!(content, "I'll check that"); - assert!(!content.contains("[Tool use:")); - assert!(!content.contains("read_file")); - } -} diff --git a/rust/crates/memorph/src/providers/deepseek/backup.rs b/rust/crates/memorph/src/providers/deepseek/backup.rs deleted file mode 100644 index 5a08f06..0000000 --- a/rust/crates/memorph/src/providers/deepseek/backup.rs +++ /dev/null @@ -1,988 +0,0 @@ -use super::{get_session_index_path, get_state_db_path, PROVIDER_ID}; -use crate::provider::{ProviderSessionBackup, ProviderSourceMutation}; -use anyhow::{Context as _, Result}; -use rusqlite::{Connection, OptionalExtension, Transaction}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -const DEEPSEEK_BACKUP_FORMAT: &str = "deepseek-session-backup-v1"; -const DEEPSEEK_BACKUP_MIME: &str = "application/vnd.memorph.deepseek-session-backup"; -const DEEPSEEK_BACKUP_DB_PATH: &str = "sqlite/deepseek-session.db"; -const DEEPSEEK_INDEX_BACKUP_PATH: &str = "files/session_index.jsonl"; -const DEEPSEEK_TABLES: [&str; 4] = ["threads", "messages", "checkpoints", "thread_dynamic_tools"]; - -#[cfg(test)] -static TEST_DEEPSEEK_BACKUP_FAILURE: std::sync::OnceLock> = - std::sync::OnceLock::new(); - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DeepseekSessionBackupMetadata { - version: u32, - provider_id: String, - mutation: ProviderSourceMutation, - operation_id: String, - provider_session_id: String, - db_path: PathBuf, - sqlite_tables: Vec, - session_index: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DeepseekSqliteTableManifest { - table: String, - columns: Vec, - row_count: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct DeepseekFileManifest { - source_path: PathBuf, - relative_path: PathBuf, - present: bool, - byte_len: u64, - sha256: String, -} - -enum SessionIndexRestore { - Noop, - Remove(PathBuf), - Write(PathBuf, Vec), -} - -pub(super) fn create_session_backup( - mutation: ProviderSourceMutation, - operation_id: &str, - session_id: &str, - backup_root: &Path, -) -> Result { - let db_path = get_state_db_path(); - let source_path = db_path.canonicalize().with_context(|| { - format!( - "Failed to resolve DeepSeek state database: {}", - db_path.display() - ) - })?; - let mut conn = Connection::open(&source_path).with_context(|| { - format!( - "Failed to open DeepSeek state database: {}", - source_path.display() - ) - })?; - validate_mutation_source(&conn, mutation, session_id)?; - - let provider_backup_root = backup_root.join(PROVIDER_ID); - std::fs::create_dir_all(&provider_backup_root).with_context(|| { - format!( - "Failed to create DeepSeek backup root: {}", - provider_backup_root.display() - ) - })?; - let backup_path = provider_backup_root.join(operation_id); - std::fs::create_dir(&backup_path).with_context(|| { - format!( - "Failed to create DeepSeek session backup: {}", - backup_path.display() - ) - })?; - - let create_result = (|| -> Result { - std::fs::create_dir(backup_path.join("sqlite"))?; - let sqlite_tables = capture_sqlite_backup( - &mut conn, - mutation, - session_id, - &backup_path.join(DEEPSEEK_BACKUP_DB_PATH), - )?; - let session_index = if mutation == ProviderSourceMutation::Rename { - std::fs::create_dir(backup_path.join("files"))?; - Some(capture_session_index(&source_path, &backup_path)?) - } else { - None - }; - fail_deepseek_backup_after_capture()?; - - let metadata = DeepseekSessionBackupMetadata { - version: 1, - provider_id: PROVIDER_ID.to_string(), - mutation, - operation_id: operation_id.to_string(), - provider_session_id: session_id.to_string(), - db_path: source_path.clone(), - sqlite_tables, - session_index, - }; - std::fs::write( - backup_path.join("metadata.json"), - serde_json::to_vec_pretty(&metadata)?, - )?; - - Ok(ProviderSessionBackup { - mutation, - operation_id: operation_id.to_string(), - provider_session_id: session_id.to_string(), - source_path, - backup_path: backup_path.clone(), - restore_hint: - "Restore this backup with memorph's DeepSeek native session restore flow before reopening DeepSeek." - .to_string(), - mime_type: DEEPSEEK_BACKUP_MIME.to_string(), - format: DEEPSEEK_BACKUP_FORMAT.to_string(), - artifact_metadata: serde_json::json!({ - "role": "deepseek_prewrite_session_backup", - "mutation": mutation, - "sqlite_table_count": metadata.sqlite_tables.len(), - "session_index_captured": metadata.session_index.is_some(), - }), - restore_metadata: serde_json::json!({ - "restore_mode": "deepseek_session_restore", - "metadata_file": "metadata.json", - "mutation": mutation, - }), - }) - })(); - if create_result.is_err() { - let _ = std::fs::remove_dir_all(&backup_path); - } - create_result -} - -pub(super) fn restore_session_backup(backup: &ProviderSessionBackup) -> Result<()> { - if backup.format != DEEPSEEK_BACKUP_FORMAT { - anyhow::bail!( - "Unsupported DeepSeek session backup format: {}", - backup.format - ); - } - if backup.mime_type != DEEPSEEK_BACKUP_MIME { - anyhow::bail!( - "Unsupported DeepSeek session backup MIME type: {}", - backup.mime_type - ); - } - - let metadata_path = backup.backup_path.join("metadata.json"); - let metadata: DeepseekSessionBackupMetadata = - serde_json::from_slice(&std::fs::read(&metadata_path).with_context(|| { - format!( - "Failed to read DeepSeek backup metadata: {}", - metadata_path.display() - ) - })?)?; - if metadata.version != 1 - || metadata.provider_id != PROVIDER_ID - || metadata.operation_id != backup.operation_id - || metadata.provider_session_id != backup.provider_session_id - || metadata.mutation != backup.mutation - || metadata.db_path != backup.source_path - { - anyhow::bail!( - "DeepSeek backup metadata does not match the registered restore context: {}", - backup.backup_path.display() - ); - } - if (metadata.mutation == ProviderSourceMutation::Rename) != metadata.session_index.is_some() { - anyhow::bail!("DeepSeek backup has an invalid session index restore contract"); - } - let current_source_path = get_state_db_path().canonicalize().with_context(|| { - format!( - "Failed to resolve current DeepSeek state database: {}", - get_state_db_path().display() - ) - })?; - if current_source_path != metadata.db_path { - anyhow::bail!( - "DeepSeek backup source database does not match the current state database: captured={} current={}", - metadata.db_path.display(), - current_source_path.display() - ); - } - - let backup_db_path = backup.backup_path.join(DEEPSEEK_BACKUP_DB_PATH); - validate_sqlite_backup( - &metadata.db_path, - &backup_db_path, - metadata.mutation, - &metadata.provider_session_id, - &metadata.sqlite_tables, - )?; - let index_restore = metadata - .session_index - .as_ref() - .map(|index| { - let expected_source_path = metadata - .db_path - .parent() - .context("DeepSeek database path has no parent directory")? - .join("session_index.jsonl"); - if index.source_path != expected_source_path { - anyhow::bail!("DeepSeek session index source path is invalid"); - } - prepare_session_index_restore(&backup.backup_path, index, &metadata.provider_session_id) - }) - .transpose()? - .unwrap_or(SessionIndexRestore::Noop); - - restore_sqlite_backup( - &metadata.db_path, - &backup_db_path, - metadata.mutation, - &metadata.provider_session_id, - &metadata.sqlite_tables, - )?; - apply_session_index_restore(index_restore)?; - Ok(()) -} - -pub(super) fn validate_mutation_source( - conn: &Connection, - mutation: ProviderSourceMutation, - session_id: &str, -) -> Result<()> { - validate_source_schema(conn)?; - let thread_count: i64 = conn.query_row( - "SELECT COUNT(*) FROM threads WHERE id = ?1", - [session_id], - |row| row.get(0), - )?; - if thread_count != 1 { - anyhow::bail!("DeepSeek thread not found or ambiguous: {session_id}"); - } - if mutation == ProviderSourceMutation::Rename { - validate_live_session_index()?; - } - Ok(()) -} - -fn validate_source_schema(conn: &Connection) -> Result<()> { - for (table, selection_column) in [ - ("threads", "id"), - ("messages", "thread_id"), - ("checkpoints", "thread_id"), - ("thread_dynamic_tools", "thread_id"), - ] { - let columns = table_columns(conn, "main", table)?; - if !columns.iter().any(|column| column == selection_column) { - anyhow::bail!( - "DeepSeek table {table} must contain selection column {selection_column}" - ); - } - } - for column in ["title", "preview", "updated_at"] { - if !table_columns(conn, "main", "threads")? - .iter() - .any(|candidate| candidate == column) - { - anyhow::bail!("DeepSeek threads table must contain rename column {column}"); - } - } - if !table_has_unique_column(conn, "threads", "id")? { - anyhow::bail!("DeepSeek threads table does not enforce a unique id"); - } - validate_no_mutating_triggers(conn)?; - validate_foreign_key_delete_effects(conn)?; - Ok(()) -} - -fn validate_no_mutating_triggers(conn: &Connection) -> Result<()> { - let count: i64 = conn.query_row( - "SELECT COUNT(*) - FROM sqlite_schema - WHERE type = 'trigger' AND tbl_name IN ( - 'threads', 'messages', 'checkpoints', 'thread_dynamic_tools' - )", - [], - |row| row.get(0), - )?; - if count != 0 { - anyhow::bail!("DeepSeek managed tables contain unsupported mutation triggers"); - } - Ok(()) -} - -fn validate_foreign_key_delete_effects(conn: &Connection) -> Result<()> { - let mut stmt = conn.prepare( - "SELECT name FROM sqlite_schema - WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", - )?; - let tables = stmt - .query_map([], |row| row.get::<_, String>(0))? - .collect::>>()?; - for child_table in tables { - let mut foreign_key_stmt = conn.prepare(&format!( - "PRAGMA foreign_key_list({})", - quote_identifier(&child_table) - ))?; - let foreign_keys = foreign_key_stmt - .query_map([], |row| { - Ok(( - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, String>(6)?, - )) - })? - .collect::>>()?; - for (parent_table, child_column, parent_column, on_delete) in foreign_keys { - if !DEEPSEEK_TABLES.contains(&parent_table.as_str()) - || matches!(on_delete.as_str(), "NO ACTION" | "RESTRICT") - { - continue; - } - let managed_thread_cascade = matches!( - child_table.as_str(), - "messages" | "checkpoints" | "thread_dynamic_tools" - ) && parent_table == "threads" - && child_column == "thread_id" - && parent_column.as_deref().is_none_or(|column| column == "id") - && on_delete == "CASCADE"; - if !managed_thread_cascade { - anyhow::bail!( - "DeepSeek table {child_table} has unsupported ON DELETE {on_delete} behavior referencing {parent_table}" - ); - } - } - } - Ok(()) -} - -fn capture_sqlite_backup( - conn: &mut Connection, - mutation: ProviderSourceMutation, - session_id: &str, - backup_db_path: &Path, -) -> Result> { - if backup_db_path.exists() { - anyhow::bail!( - "DeepSeek SQLite backup already exists: {}", - backup_db_path.display() - ); - } - let backup_db_path_text = backup_db_path.to_str().with_context(|| { - format!( - "DeepSeek SQLite backup path is not valid UTF-8: {}", - backup_db_path.display() - ) - })?; - conn.execute( - "ATTACH DATABASE ?1 AS memorph_backup", - [backup_db_path_text], - )?; - let capture_result = (|| -> Result> { - let tx = conn.transaction()?; - match mutation { - ProviderSourceMutation::Delete | ProviderSourceMutation::Replace => { - tx.execute( - "CREATE TABLE memorph_backup.threads AS - SELECT * FROM main.threads WHERE id = ?1", - [session_id], - )?; - for table in ["messages", "checkpoints", "thread_dynamic_tools"] { - tx.execute( - &format!( - "CREATE TABLE memorph_backup.{} AS - SELECT * FROM main.{} WHERE thread_id = ?1", - quote_identifier(table), - quote_identifier(table) - ), - [session_id], - )?; - } - } - ProviderSourceMutation::Rename => { - tx.execute( - "CREATE TABLE memorph_backup.threads AS - SELECT * FROM main.threads WHERE id = ?1", - [session_id], - )?; - } - } - let tables: &[&str] = match mutation { - ProviderSourceMutation::Delete | ProviderSourceMutation::Replace => &DEEPSEEK_TABLES, - ProviderSourceMutation::Rename => &["threads"], - }; - let manifests = tables - .iter() - .map(|table| table_manifest(&tx, "memorph_backup", table)) - .collect::>>()?; - validate_backup_selection(&tx, mutation, session_id, &manifests)?; - tx.commit()?; - Ok(manifests) - })(); - let detach_result = conn.execute_batch("DETACH DATABASE memorph_backup;"); - match capture_result { - Ok(manifests) => { - detach_result?; - Ok(manifests) - } - Err(error) => { - let _ = detach_result; - let _ = std::fs::remove_file(backup_db_path); - Err(error) - } - } -} - -fn restore_sqlite_backup( - db_path: &Path, - backup_db_path: &Path, - mutation: ProviderSourceMutation, - session_id: &str, - manifests: &[DeepseekSqliteTableManifest], -) -> Result<()> { - if !backup_db_path.is_file() { - anyhow::bail!( - "DeepSeek SQLite backup does not exist: {}", - backup_db_path.display() - ); - } - let mut conn = Connection::open(db_path).with_context(|| { - format!( - "Failed to open DeepSeek state database: {}", - db_path.display() - ) - })?; - validate_source_schema(&conn)?; - let backup_db_path_text = backup_db_path.to_str().with_context(|| { - format!( - "DeepSeek SQLite backup path is not valid UTF-8: {}", - backup_db_path.display() - ) - })?; - conn.execute( - "ATTACH DATABASE ?1 AS memorph_backup", - [backup_db_path_text], - )?; - let restore_result = (|| -> Result<()> { - validate_manifest_schemas(&conn, mutation, manifests)?; - validate_backup_selection(&conn, mutation, session_id, manifests)?; - let tx = conn.transaction()?; - match mutation { - ProviderSourceMutation::Delete | ProviderSourceMutation::Replace => { - restore_deleted_rows(&tx, session_id, manifests)? - } - ProviderSourceMutation::Rename => restore_renamed_fields(&tx, session_id)?, - } - tx.commit()?; - Ok(()) - })(); - let detach_result = conn.execute_batch("DETACH DATABASE memorph_backup;"); - match restore_result { - Ok(()) => { - detach_result?; - Ok(()) - } - Err(error) => { - let _ = detach_result; - Err(error) - } - } -} - -fn validate_sqlite_backup( - db_path: &Path, - backup_db_path: &Path, - mutation: ProviderSourceMutation, - session_id: &str, - manifests: &[DeepseekSqliteTableManifest], -) -> Result<()> { - if !backup_db_path.is_file() { - anyhow::bail!( - "DeepSeek SQLite backup does not exist: {}", - backup_db_path.display() - ); - } - let conn = Connection::open(db_path).with_context(|| { - format!( - "Failed to open DeepSeek state database: {}", - db_path.display() - ) - })?; - validate_source_schema(&conn)?; - let backup_db_path_text = backup_db_path.to_str().with_context(|| { - format!( - "DeepSeek SQLite backup path is not valid UTF-8: {}", - backup_db_path.display() - ) - })?; - conn.execute( - "ATTACH DATABASE ?1 AS memorph_backup", - [backup_db_path_text], - )?; - let validate_result = (|| -> Result<()> { - validate_manifest_schemas(&conn, mutation, manifests)?; - validate_backup_selection(&conn, mutation, session_id, manifests) - })(); - let detach_result = conn.execute_batch("DETACH DATABASE memorph_backup;"); - match validate_result { - Ok(()) => { - detach_result?; - Ok(()) - } - Err(error) => { - let _ = detach_result; - Err(error) - } - } -} - -fn restore_deleted_rows( - tx: &Transaction<'_>, - session_id: &str, - manifests: &[DeepseekSqliteTableManifest], -) -> Result<()> { - for table in ["messages", "checkpoints", "thread_dynamic_tools"] { - tx.execute( - &format!( - "DELETE FROM main.{} WHERE thread_id = ?1", - quote_identifier(table) - ), - [session_id], - )?; - } - tx.execute("DELETE FROM main.threads WHERE id = ?1", [session_id])?; - insert_all_backup_rows(tx, manifest_for_table(manifests, "threads")?)?; - for table in ["messages", "checkpoints", "thread_dynamic_tools"] { - insert_all_backup_rows(tx, manifest_for_table(manifests, table)?)?; - } - Ok(()) -} - -fn restore_renamed_fields(tx: &Transaction<'_>, session_id: &str) -> Result<()> { - let exists = tx - .query_row( - "SELECT 1 FROM main.threads WHERE id = ?1", - [session_id], - |_| Ok(()), - ) - .optional()? - .is_some(); - if !exists { - return Ok(()); - } - let updated = tx.execute( - "UPDATE main.threads - SET title = ( - SELECT backup.title FROM memorph_backup.threads AS backup - WHERE backup.id = main.threads.id - ), - preview = ( - SELECT backup.preview FROM memorph_backup.threads AS backup - WHERE backup.id = main.threads.id - ), - updated_at = ( - SELECT backup.updated_at FROM memorph_backup.threads AS backup - WHERE backup.id = main.threads.id - ) - WHERE id = ?1 - AND EXISTS ( - SELECT 1 FROM memorph_backup.threads AS backup - WHERE backup.id = main.threads.id - )", - [session_id], - )?; - if updated != 1 { - anyhow::bail!("DeepSeek rename restore could not update the captured thread"); - } - Ok(()) -} - -fn capture_session_index(db_path: &Path, backup_path: &Path) -> Result { - let source_path = db_path - .parent() - .context("DeepSeek database path has no parent directory")? - .join("session_index.jsonl"); - let relative_path = PathBuf::from(DEEPSEEK_INDEX_BACKUP_PATH); - if !source_path.exists() { - return Ok(DeepseekFileManifest { - source_path, - relative_path, - present: false, - byte_len: 0, - sha256: sha256_bytes(&[]), - }); - } - if !source_path.is_file() { - anyhow::bail!( - "DeepSeek session index is not a file: {}", - source_path.display() - ); - } - let bytes = std::fs::read(&source_path)?; - validate_index_prefix(&bytes)?; - std::fs::write(backup_path.join(&relative_path), &bytes)?; - Ok(DeepseekFileManifest { - source_path, - relative_path, - present: true, - byte_len: u64::try_from(bytes.len()).context("DeepSeek index is too large")?, - sha256: sha256_bytes(&bytes), - }) -} - -fn validate_live_session_index() -> Result<()> { - let path = get_session_index_path(); - if !path.exists() { - return Ok(()); - } - if !path.is_file() { - anyhow::bail!("DeepSeek session index is not a file: {}", path.display()); - } - validate_index_prefix(&std::fs::read(path)?) -} - -fn validate_index_prefix(bytes: &[u8]) -> Result<()> { - if !bytes.is_empty() && !bytes.ends_with(b"\n") { - anyhow::bail!("DeepSeek session index does not end at a complete JSONL record"); - } - for line in bytes.split(|byte| *byte == b'\n') { - let line = line.strip_suffix(b"\r").unwrap_or(line); - if line.is_empty() { - continue; - } - let value: Value = serde_json::from_slice(line) - .context("DeepSeek session index contains invalid JSONL")?; - if !value.is_object() { - anyhow::bail!("DeepSeek session index contains a non-object record"); - } - } - Ok(()) -} - -fn prepare_session_index_restore( - backup_path: &Path, - manifest: &DeepseekFileManifest, - session_id: &str, -) -> Result { - if manifest.relative_path != Path::new(DEEPSEEK_INDEX_BACKUP_PATH) { - anyhow::bail!("DeepSeek session index backup path is invalid"); - } - let original = if manifest.present { - let bytes = std::fs::read(backup_path.join(&manifest.relative_path))?; - validate_file_manifest(manifest, &bytes)?; - validate_index_prefix(&bytes)?; - bytes - } else { - if manifest.byte_len != 0 || manifest.sha256 != sha256_bytes(&[]) { - anyhow::bail!("DeepSeek absent session index manifest is invalid"); - } - Vec::new() - }; - - let current = match std::fs::read(&manifest.source_path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound && !manifest.present => { - return Ok(SessionIndexRestore::Noop) - } - Err(error) => return Err(error.into()), - }; - if !current.starts_with(&original) { - anyhow::bail!("DeepSeek session index no longer has the captured byte prefix"); - } - let suffix = ¤t[original.len()..]; - if suffix.is_empty() { - return Ok(SessionIndexRestore::Noop); - } - if !suffix.ends_with(b"\n") { - anyhow::bail!("DeepSeek session index suffix contains an incomplete JSONL record"); - } - - let mut target_count = 0; - let mut kept_suffix = Vec::with_capacity(suffix.len()); - for raw_line in suffix.split_inclusive(|byte| *byte == b'\n') { - let content = raw_line.strip_suffix(b"\n").unwrap_or(raw_line); - let content = content.strip_suffix(b"\r").unwrap_or(content); - if content.is_empty() { - kept_suffix.extend_from_slice(raw_line); - continue; - } - let value: Value = serde_json::from_slice(content) - .context("DeepSeek session index suffix contains invalid JSONL")?; - let record_thread_id = value - .get("thread_id") - .and_then(Value::as_str) - .context("DeepSeek session index suffix record has no thread_id")?; - if record_thread_id == session_id { - target_count += 1; - } else { - kept_suffix.extend_from_slice(raw_line); - } - } - if target_count > 1 { - anyhow::bail!( - "DeepSeek session index suffix contains multiple target records for {session_id}" - ); - } - if target_count == 0 { - return Ok(SessionIndexRestore::Noop); - } - - let mut restored = original; - restored.extend_from_slice(&kept_suffix); - if restored.is_empty() && !manifest.present { - Ok(SessionIndexRestore::Remove(manifest.source_path.clone())) - } else { - Ok(SessionIndexRestore::Write( - manifest.source_path.clone(), - restored, - )) - } -} - -fn apply_session_index_restore(restore: SessionIndexRestore) -> Result<()> { - match restore { - SessionIndexRestore::Noop => {} - SessionIndexRestore::Remove(path) => match std::fs::remove_file(path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - }, - SessionIndexRestore::Write(path, bytes) => std::fs::write(path, bytes)?, - } - Ok(()) -} - -fn validate_file_manifest(manifest: &DeepseekFileManifest, bytes: &[u8]) -> Result<()> { - if u64::try_from(bytes.len()).ok() != Some(manifest.byte_len) - || sha256_bytes(bytes) != manifest.sha256 - { - anyhow::bail!("DeepSeek session index backup does not match its manifest"); - } - Ok(()) -} - -fn validate_manifest_schemas( - conn: &Connection, - mutation: ProviderSourceMutation, - manifests: &[DeepseekSqliteTableManifest], -) -> Result<()> { - let expected: HashSet<&str> = match mutation { - ProviderSourceMutation::Delete | ProviderSourceMutation::Replace => { - DEEPSEEK_TABLES.into_iter().collect() - } - ProviderSourceMutation::Rename => HashSet::from(["threads"]), - }; - let actual = manifests - .iter() - .map(|manifest| manifest.table.as_str()) - .collect::>(); - if manifests.len() != expected.len() || actual != expected { - anyhow::bail!("DeepSeek backup table manifest is incomplete or duplicated"); - } - for manifest in manifests { - let source_columns = table_columns(conn, "main", &manifest.table)?; - let backup_columns = table_columns(conn, "memorph_backup", &manifest.table)?; - if source_columns != manifest.columns || backup_columns != manifest.columns { - anyhow::bail!( - "DeepSeek backup schema does not match source table {}", - manifest.table - ); - } - if table_row_count(conn, "memorph_backup", &manifest.table)? != manifest.row_count { - anyhow::bail!( - "DeepSeek backup row count does not match manifest for {}", - manifest.table - ); - } - } - Ok(()) -} - -fn validate_backup_selection( - conn: &Connection, - mutation: ProviderSourceMutation, - session_id: &str, - manifests: &[DeepseekSqliteTableManifest], -) -> Result<()> { - if manifest_for_table(manifests, "threads")?.row_count != 1 { - anyhow::bail!("DeepSeek backup does not contain exactly one thread row"); - } - let thread_ids = selected_values(conn, "threads", "id")?; - if thread_ids != vec![session_id.to_string()] { - anyhow::bail!("DeepSeek backup contains a thread outside the target session"); - } - if matches!( - mutation, - ProviderSourceMutation::Delete | ProviderSourceMutation::Replace - ) { - for table in ["messages", "checkpoints", "thread_dynamic_tools"] { - if selected_values(conn, table, "thread_id")? - .iter() - .any(|value| value != session_id) - { - anyhow::bail!( - "DeepSeek backup table {table} contains rows outside the target session" - ); - } - } - } - Ok(()) -} - -fn selected_values(conn: &Connection, table: &str, column: &str) -> Result> { - let mut stmt = conn.prepare(&format!( - "SELECT {} FROM memorph_backup.{}", - quote_identifier(column), - quote_identifier(table) - ))?; - let values = stmt - .query_map([], |row| row.get(0))? - .collect::>>() - .map_err(anyhow::Error::from)?; - Ok(values) -} - -fn table_manifest( - conn: &Connection, - schema: &str, - table: &str, -) -> Result { - Ok(DeepseekSqliteTableManifest { - table: table.to_string(), - columns: table_columns(conn, schema, table)?, - row_count: table_row_count(conn, schema, table)?, - }) -} - -fn manifest_for_table<'a>( - manifests: &'a [DeepseekSqliteTableManifest], - table: &str, -) -> Result<&'a DeepseekSqliteTableManifest> { - manifests - .iter() - .find(|manifest| manifest.table == table) - .with_context(|| format!("DeepSeek backup manifest is missing table {table}")) -} - -fn table_columns(conn: &Connection, schema: &str, table: &str) -> Result> { - let mut stmt = conn.prepare(&format!( - "PRAGMA {}.table_info({})", - quote_identifier(schema), - quote_identifier(table) - ))?; - let columns = stmt - .query_map([], |row| row.get::<_, String>(1))? - .collect::>>()?; - if columns.is_empty() { - anyhow::bail!("DeepSeek SQLite table does not exist: {schema}.{table}"); - } - Ok(columns) -} - -fn table_row_count(conn: &Connection, schema: &str, table: &str) -> Result { - let count: i64 = conn.query_row( - &format!( - "SELECT COUNT(*) FROM {}.{}", - quote_identifier(schema), - quote_identifier(table) - ), - [], - |row| row.get(0), - )?; - usize::try_from(count).context("DeepSeek backup row count is negative") -} - -fn table_has_unique_column(conn: &Connection, table: &str, column: &str) -> Result { - let mut table_info = conn.prepare(&format!( - "PRAGMA main.table_info({})", - quote_identifier(table) - ))?; - let primary_key_columns = table_info - .query_map([], |row| { - Ok((row.get::<_, String>(1)?, row.get::<_, i64>(5)?)) - })? - .filter_map(|row| match row { - Ok((name, position)) if position > 0 => Some(Ok((name, position))), - Ok(_) => None, - Err(error) => Some(Err(error)), - }) - .collect::>>()?; - if primary_key_columns == vec![(column.to_string(), 1)] { - return Ok(true); - } - - let mut indexes = conn.prepare(&format!( - "PRAGMA main.index_list({})", - quote_identifier(table) - ))?; - let indexes = indexes - .query_map([], |row| { - Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0)) - })? - .collect::>>()?; - for (index, unique) in indexes { - if !unique { - continue; - } - let mut index_info = conn.prepare(&format!( - "PRAGMA main.index_info({})", - quote_identifier(&index) - ))?; - let columns = index_info - .query_map([], |row| row.get::<_, Option>(2))? - .collect::>>()?; - if columns == vec![Some(column.to_string())] { - return Ok(true); - } - } - Ok(false) -} - -fn insert_all_backup_rows( - tx: &Transaction<'_>, - manifest: &DeepseekSqliteTableManifest, -) -> Result<()> { - let columns = manifest - .columns - .iter() - .map(|column| quote_identifier(column)) - .collect::>() - .join(", "); - tx.execute( - &format!( - "INSERT INTO main.{table} ({columns}) - SELECT {columns} FROM memorph_backup.{table}", - table = quote_identifier(&manifest.table) - ), - [], - )?; - Ok(()) -} - -fn quote_identifier(identifier: &str) -> String { - format!("\"{}\"", identifier.replace('"', "\"\"")) -} - -fn sha256_bytes(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - format!("{:x}", hasher.finalize()) -} - -#[cfg(test)] -pub(super) fn set_test_backup_failure(enabled: bool) { - *TEST_DEEPSEEK_BACKUP_FAILURE - .get_or_init(|| std::sync::Mutex::new(false)) - .lock() - .expect("test DeepSeek backup failure lock") = enabled; -} - -#[cfg(test)] -fn fail_deepseek_backup_after_capture() -> Result<()> { - let mut enabled = TEST_DEEPSEEK_BACKUP_FAILURE - .get_or_init(|| std::sync::Mutex::new(false)) - .lock() - .expect("test DeepSeek backup failure lock"); - if *enabled { - *enabled = false; - anyhow::bail!("injected DeepSeek backup failure after native capture"); - } - Ok(()) -} - -#[cfg(not(test))] -fn fail_deepseek_backup_after_capture() -> Result<()> { - Ok(()) -} diff --git a/rust/crates/memorph/src/providers/droid/adapter.rs b/rust/crates/memorph/src/providers/droid/adapter.rs deleted file mode 100644 index 338d033..0000000 --- a/rust/crates/memorph/src/providers/droid/adapter.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Factory hook adapter. -//! -//! This adapter is intentionally registered as its own provider module even -//! when the current payload shape is Claude-like. Keeping it separate makes -//! future provider-specific hook changes local to this file. - -use anyhow::Result; -use chrono::{DateTime, TimeZone, Utc}; -use serde_json::Value; -use std::path::PathBuf; - -use crate::hooks::contract::HookAdapter; -use crate::hooks::model::{ - HookEvent, HookEventType, HookMessage, HookToolCall, PermissionRequest, QuestionRequest, -}; -use crate::hooks::protocol::{HookIngestRequest, HookIngestResponse}; - -pub struct DroidHookAdapter; - -impl HookAdapter for DroidHookAdapter { - fn provider_id(&self) -> &'static str { - "droid" - } - - fn blocking_response_json( - &self, - event_name: &str, - response: &HookIngestResponse, - ) -> Option { - crate::hooks::contract::hook_specific_output_response_json(event_name, response) - } - - fn normalize(&self, request: &HookIngestRequest) -> Result> { - let raw = request.raw.clone(); - let event_name = string_at( - &raw, - &[ - "hook_event_name", - "hookEventName", - "event_name", - "eventName", - "event", - "type", - ], - ) - .unwrap_or_else(|| request.event_name.clone()); - let mut event = HookEvent::new("droid", provider_event_type(&event_name), raw.clone()); - - event.event_id = string_at(&raw, &["event_id", "eventId", "id"]).unwrap_or(event.event_id); - event.provider_session_id = string_at( - &raw, - &[ - "session_id", - "sessionId", - "conversation_id", - "conversationId", - "thread_id", - "threadId", - ], - ); - event.run_id = string_at( - &raw, - &[ - "run_id", - "runId", - "request_id", - "requestId", - "call_id", - "callId", - ], - ) - .or_else(|| Some(request.request_id.clone())); - event.cwd = path_at( - &raw, - &[ - "cwd", - "workspace", - "workspace_dir", - "workspaceDir", - "project_dir", - "projectDir", - ], - ) - .or_else(|| request.environment.cwd.clone()); - event.pid = u32_at(&raw, &["pid"]).or(request.environment.pid); - event.parent_pid = - u32_at(&raw, &["parent_pid", "parentPid", "ppid"]).or(request.environment.parent_pid); - event.tty = string_at(&raw, &["tty", "terminal_tty", "terminalTty"]) - .or_else(|| request.environment.tty.clone()); - event.timestamp = timestamp_at(&raw).unwrap_or(request.received_at); - - let tool = tool_at(&raw); - event.tool = tool.clone(); - event.message = message_at(&raw); - event.permission = match event.event_type { - HookEventType::PermissionRequested => Some(permission_at(&raw, tool)), - _ => None, - }; - event.question = match event.event_type { - HookEventType::QuestionRequested => question_at(&raw), - _ => None, - }; - - Ok(vec![event]) - } -} - -fn provider_event_type(event_name: &str) -> HookEventType { - match normalize_name(event_name).as_str() { - "sessionstart" | "session_start" | "taskstart" => HookEventType::SessionStarted, - "sessionend" | "session_end" | "taskcomplete" | "stop" => HookEventType::SessionCompleted, - "taskcancel" | "erroroccurred" => HookEventType::SessionFailed, - "userpromptsubmit" | "user_prompt_submit" | "userpromptsubmitted" | "notification" => { - HookEventType::MessageCreated - } - "pretooluse" | "pre_tool_use" => HookEventType::ToolStarted, - "posttooluse" | "post_tool_use" | "posttoolusefailure" | "post_tool_use_failure" => { - HookEventType::ToolFinished - } - "permissionrequest" | "permission_request" => HookEventType::PermissionRequested, - "askuserquestion" | "questionrequest" | "question_request" => { - HookEventType::QuestionRequested - } - "subagentstart" | "subagent_start" | "subagentstop" | "subagent_stop" | "precompact" - | "pre_compact" | "postcompact" | "post_compact" | "taskresume" => HookEventType::Heartbeat, - _ => HookEventType::from_provider_name(event_name), - } -} - -fn normalize_name(value: &str) -> String { - value - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn tool_at(value: &Value) -> Option { - if let Some(tool) = value.get("tool").filter(|tool| tool.is_object()) { - let name = string_at(tool, &["name", "tool_name", "toolName"])?; - return Some(HookToolCall { - id: string_at( - tool, - &["id", "tool_use_id", "toolUseId", "call_id", "callId"], - ), - name, - input: value_at(tool, &["input", "arguments", "args", "params"]).unwrap_or(Value::Null), - }); - } - - let name = string_at(value, &["tool_name", "toolName", "tool", "name"])?; - Some(HookToolCall { - id: string_at( - value, - &["tool_use_id", "toolUseId", "call_id", "callId", "id"], - ), - name, - input: value_at( - value, - &[ - "tool_input", - "toolInput", - "input", - "arguments", - "args", - "params", - ], - ) - .unwrap_or(Value::Null), - }) -} - -fn permission_at(value: &Value, tool: Option) -> PermissionRequest { - PermissionRequest { - request_id: string_at( - value, - &[ - "permission_id", - "permissionId", - "request_id", - "requestId", - "tool_use_id", - "toolUseId", - "call_id", - "callId", - ], - ) - .or_else(|| tool.as_ref().and_then(|tool| tool.id.clone())), - tool, - prompt: string_at( - value, - &[ - "permission_prompt", - "permissionPrompt", - "prompt", - "reason", - "message", - ], - ), - } -} - -fn question_at(value: &Value) -> Option { - let prompt = string_at( - value, - &[ - "question", - "question_prompt", - "questionPrompt", - "prompt", - "message", - ], - )?; - Some(QuestionRequest { - request_id: string_at( - value, - &["question_id", "questionId", "request_id", "requestId"], - ), - prompt, - }) -} - -fn message_at(value: &Value) -> Option { - let text = string_at( - value, - &[ - "message", - "text", - "content", - "prompt", - "user_prompt", - "userPrompt", - ], - )?; - Some(HookMessage { - role: string_at(value, &["role"]), - text, - }) -} - -fn string_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::String(s)) if !s.trim().is_empty() => Some(s.to_string()), - Some(Value::Number(n)) => Some(n.to_string()), - Some(Value::Bool(b)) => Some(b.to_string()), - _ => None, - }) -} - -fn u32_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()), - Some(Value::String(s)) => s.parse().ok(), - _ => None, - }) -} - -fn path_at(value: &Value, keys: &[&str]) -> Option { - string_at(value, keys).map(PathBuf::from) -} - -fn value_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| value.get(*key).cloned()) -} - -fn timestamp_at(value: &Value) -> Option> { - let raw = value - .get("timestamp") - .or_else(|| value.get("created_at")) - .or_else(|| value.get("createdAt"))?; - match raw { - Value::String(s) => DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|| s.parse::().ok().and_then(unix_timestamp)), - Value::Number(n) => n.as_i64().and_then(unix_timestamp), - _ => None, - } -} - -fn unix_timestamp(value: i64) -> Option> { - Utc.timestamp_opt(value, 0).single() -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn maps_pre_tool_use() { - let request = HookIngestRequest::new( - "droid", - "PreToolUse", - json!({"session_id": "s1", "tool_name": "Bash"}), - ); - let event = DroidHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.provider, "droid"); - assert_eq!(event.event_type, HookEventType::ToolStarted); - } -} diff --git a/rust/crates/memorph/src/providers/droid/hook.rs b/rust/crates/memorph/src/providers/droid/hook.rs deleted file mode 100644 index 2289508..0000000 --- a/rust/crates/memorph/src/providers/droid/hook.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::path::PathBuf; - -use anyhow::Result; - -use crate::hooks::contract::ProviderHook; -use crate::hooks::json_settings_hook::{event, JsonSettingsHookEvent, JsonSettingsHookSpec}; -use crate::hooks::model::{HookHealthStatus, HookInstallStatus, HookOperationReport}; - -pub struct DroidHook; - -pub static DROID_HOOK: DroidHook = DroidHook; - -const EVENTS: &[JsonSettingsHookEvent] = &[ - event("UserPromptSubmit", 5, true), - event("PreToolUse", 5, false), - event("PostToolUse", 5, true), - event("SessionStart", 5, false), - event("SessionEnd", 5, true), - event("Stop", 5, true), - event("SubagentStart", 5, true), - event("SubagentStop", 5, true), - event("Notification", 86400, false), - event("PreCompact", 5, true), -]; - -impl ProviderHook for DroidHook { - fn provider_id(&self) -> &'static str { - "droid" - } - - fn status(&self) -> Result { - crate::hooks::json_settings_hook::status(spec()) - } - - fn install(&self) -> Result { - crate::hooks::json_settings_hook::install(spec()) - } - - fn verify(&self) -> Result { - let status = self.status()?; - Ok(HookOperationReport { - provider: self.provider_id().to_string(), - operation: "verify".to_string(), - changed: false, - backup_path: None, - message: status.message.clone(), - status, - }) - } - - fn repair(&self) -> Result { - let before = self.status()?; - let mut report = self.install()?; - report.operation = "repair".to_string(); - report.changed = before.status != HookHealthStatus::InstalledOk; - Ok(report) - } - - fn uninstall(&self) -> Result { - crate::hooks::json_settings_hook::uninstall(spec()) - } -} - -pub(crate) fn settings_path() -> PathBuf { - crate::hooks::shared::hook_home_dir() - .join(".factory") - .join("settings.json") -} - -fn spec() -> JsonSettingsHookSpec { - JsonSettingsHookSpec { - provider: "droid", - display_name: "Factory", - settings_path, - events: EVENTS, - missing_config_message: "Factory settings.json does not exist.", - install_message: "Factory hook entries installed.", - uninstall_missing_message: "Factory settings file does not exist.", - uninstall_message: "Factory memorph hook entries removed.", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn descriptor_matches_hook_registry() { - let descriptor = DROID_HOOK.descriptor().expect("droid descriptor"); - assert_eq!(descriptor.provider(), DROID_HOOK.provider_id()); - } -} diff --git a/rust/crates/memorph/src/providers/droid/mod.rs b/rust/crates/memorph/src/providers/droid/mod.rs deleted file mode 100644 index 582f059..0000000 --- a/rust/crates/memorph/src/providers/droid/mod.rs +++ /dev/null @@ -1,384 +0,0 @@ -pub mod adapter; -pub mod hook; - -use crate::provider::{ - PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, - ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, - ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; -use walkdir::WalkDir; - -pub struct DroidProvider; -const PROVIDER_ID: &str = "droid"; - -impl Provider for DroidProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "Factory" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - storage_shape: StorageShape::Directory, - scan_strategy: ScanStrategy::FullScan, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - resume_quality: ResumeQuality::None, - write_risk: ProviderWriteRisk { - level: WriteRiskLevel::High, - multiple_files: true, - sqlite: false, - sidecar_files: true, - index_repair: false, - }, - backup_support: ProviderBackupSupport { - before_write: false, - restore: false, - sync_only: false, - }, - activity_support: ProviderActivitySupport { - hook_events: false, - runtime_endpoint: false, - session_activity: false, - }, - ..ProviderCapabilities::default() - } - } - fn scan_sessions(&self) -> Result> { - let mut sessions = Vec::new(); - for root in roots() { - if !root.exists() { - continue; - } - for entry in WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { - continue; - } - let Some(id) = session_id(path) else { - continue; - }; - let events = read_events(path).unwrap_or_default(); - let title = events - .iter() - .find_map(|e| text_for(e).filter(|s| !s.trim().is_empty())); - sessions.push(ProviderSessionSummary { - archived: false, - session_id: id, - title, - project_dir: events.iter().find_map(workspace), - created_at: None, - last_active_at: modified_ms(path), - source_path: Some(path.to_string_lossy().into_owned()), - }); - } - } - sessions.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); - sessions.dedup_by(|a, b| a.session_id == b.session_id); - Ok(sessions) - } - fn import_session(&self, source_path: &str) -> Result { - let path = std::fs::canonicalize(source_path) - .with_context(|| format!("Droid source does not exist: {source_path}"))?; - let id = session_id(&path) - .context("Droid source must be .factory/sessions//.jsonl")?; - let raw = read_events(&path)?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events: Vec = raw - .iter() - .enumerate() - .filter_map(|(i, e)| map_event(e, i, &mut report)) - .collect(); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - let mut extensions = BTreeMap::new(); - extensions.insert("droid_session_jsonl".into(), Value::Array(raw.clone())); - let created = raw - .iter() - .find_map(timestamp) - .or_else(|| modified_datetime(&path)) - .unwrap_or_else(Utc::now); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.clone(), - title: raw.iter().find_map(text_for), - }, - context: Context { - workspace: raw.iter().find_map(workspace), - created_at: Some(created), - last_active_at: modified_datetime(&path), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: id, - source_path: Some(path.to_string_lossy().into_owned()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - let path = Path::new(source_path); - if !path.exists() { - return Ok(None); - } - let digest = Sha256::digest(std::fs::read(path)?); - let size = std::fs::metadata(path)?.len(); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: modified_ms(path).unwrap_or(0), - size_bytes: size.min(i64::MAX as u64) as i64, - value: format!( - "droid-session-v1:{}:{digest:x}", - session_id(path).unwrap_or_default() - ), - })) - } - fn data_source_paths(&self) -> Vec { - roots() - } -} -fn roots() -> Vec { - dirs::home_dir() - .into_iter() - .map(|h| h.join(".factory/sessions")) - .collect() -} -fn session_id(path: &Path) -> Option { - path.file_stem()? - .to_str() - .filter(|s| !s.is_empty()) - .map(str::to_string) -} -fn read_events(path: &Path) -> Result> { - std::fs::read_to_string(path)? - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| { - serde_json::from_str(l) - .with_context(|| format!("invalid Droid event in {}", path.display())) - }) - .collect() -} -fn modified_ms(path: &Path) -> Option { - std::fs::metadata(path) - .ok()? - .modified() - .ok()? - .duration_since(UNIX_EPOCH) - .ok() - .map(|d| d.as_millis().min(i64::MAX as u128) as i64) -} -fn modified_datetime(path: &Path) -> Option> { - modified_ms(path).and_then(DateTime::::from_timestamp_millis) -} -fn timestamp(v: &Value) -> Option> { - v.get("timestamp") - .or_else(|| v.get("createdAt")) - .and_then(|x| { - x.as_str() - .and_then(|s| { - DateTime::parse_from_rfc3339(s) - .ok() - .map(|d| d.with_timezone(&Utc)) - }) - .or_else(|| x.as_i64().and_then(DateTime::::from_timestamp_millis)) - }) -} -fn workspace(v: &Value) -> Option { - [ - "cwd", - "workspace", - "workspaceDirectory", - "projectDir", - "rootPath", - ] - .iter() - .find_map(|k| v.get(*k).and_then(Value::as_str).map(str::to_string)) - .or_else(|| v.get("message").and_then(workspace)) -} -fn text_for(v: &Value) -> Option { - let m = v.get("message").unwrap_or(v); - let c = m.get("content").unwrap_or(m); - c.as_str().map(str::to_string).or_else(|| { - c.as_array()? - .iter() - .find_map(|b| b.get("text").and_then(Value::as_str).map(str::to_string)) - }) -} -fn blocks(v: &Value) -> Vec { - let m = v.get("message").unwrap_or(v); - let c = m.get("content").unwrap_or(m); - if let Some(s) = c.as_str() { - return vec![Block::Text { text: s.into() }]; - }; - c.as_array() - .into_iter() - .flatten() - .filter_map( - |b| match b.get("type").and_then(Value::as_str).unwrap_or("") { - "text" | "input_text" | "output_text" => b - .get("text") - .and_then(Value::as_str) - .map(|s| Block::Text { text: s.into() }), - "thinking" => b - .get("thinking") - .and_then(Value::as_str) - .map(|s| Block::Thinking { - text: s.into(), - signature: None, - }), - "tool_use" | "toolCall" => Some(Block::ToolCall { - tool_call_id: b - .get("id") - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - name: b - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .into(), - input: b.get("input").or_else(|| b.get("arguments")).cloned(), - }), - "tool_result" | "toolResult" => Some(Block::ToolResult { - tool_call_id: b - .get("tool_use_id") - .or_else(|| b.get("toolCallId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - content: b.get("content").map(|x| x.to_string()).unwrap_or_default(), - outcome: crate::session::execution_outcome( - b.get("is_error").and_then(Value::as_bool).unwrap_or(false), - ), - }), - _ => None, - }, - ) - .collect() -} -fn map_event(v: &Value, i: usize, r: &mut MappingReport) -> Option { - let m = v.get("message").unwrap_or(v); - let role_raw = m - .get("role") - .or_else(|| v.get("role")) - .or_else(|| v.get("type"))? - .as_str()?; - let role = match role_raw.to_ascii_lowercase().as_str() { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - _ => Role::Other, - }; - let bs = blocks(v); - if bs.is_empty() { - return None; - }; - let kind = if bs.iter().any(|b| matches!(b, Block::ToolResult { .. })) { - EventKind::Observation - } else if bs.iter().any(|b| matches!(b, Block::ToolCall { .. })) { - EventKind::Action - } else { - EventKind::Message - }; - r.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "droid-native-event".into(), - message: "Mapped Factory/Droid JSONL event".into(), - path: Some(format!("events[{i}]")), - raw: None, - }); - Some(Event { - id: format!("droid:event:{i}"), - kind, - role, - timestamp: timestamp(v).unwrap_or_else(Utc::now), - links: Links::default(), - blocks: bs, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn capabilities_match_native_read_only_contract() { - let capabilities = DroidProvider.capabilities(); - assert!(capabilities.scan); - assert!(capabilities.import); - assert!(!capabilities.export); - assert!(!capabilities.delete); - assert!(!capabilities.rename); - assert!(!capabilities.resume); - assert_eq!(capabilities.storage_shape, StorageShape::Directory); - assert_eq!(capabilities.scan_strategy, ScanStrategy::FullScan); - assert_eq!(capabilities.page_strategy, PageStrategy::FullImport); - assert_eq!(capabilities.resume_quality, ResumeQuality::None); - assert!(!capabilities.backup_support.before_write); - assert!(!capabilities.backup_support.restore); - } - #[test] - fn identity_comes_from_transcript_filename() { - assert_eq!( - session_id(Path::new("/tmp/.factory/sessions/project/abc.jsonl")).as_deref(), - Some("abc") - ); - } - #[test] - fn maps_claude_style_droid_event() { - let v = serde_json::json!({"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}}); - let mut r = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let e = map_event(&v, 0, &mut r).unwrap(); - assert!(matches!(e.blocks[0],Block::Text{ref text} if text=="ok")); - } -} diff --git a/rust/crates/memorph/src/providers/emerging.rs b/rust/crates/memorph/src/providers/emerging.rs deleted file mode 100644 index b5e51e4..0000000 --- a/rust/crates/memorph/src/providers/emerging.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::provider::{ - Provider, ProviderCapabilities, ProviderContentFidelity, ProviderSessionSummary, -}; -use crate::providers::generic_json::{self, JsonProviderSpec}; -use crate::session::Fidelity; -use crate::session::ImportedSession; -use anyhow::Result; -use std::path::PathBuf; - -fn spec(provider_id: &'static str, roots: fn() -> Vec) -> JsonProviderSpec { - JsonProviderSpec { - provider_id, - extension_key: "provider_session", - roots, - } -} - -fn home_join(relative: &str) -> Option { - dirs::home_dir().map(|home| home.join(relative)) -} - -fn mac_app_support(app: &str) -> Option { - home_join(&format!("Library/Application Support/{app}")) -} - -fn vscode_global_storage(app: &str) -> Vec { - let mut roots = Vec::new(); - - #[cfg(target_os = "macos")] - if let Some(root) = mac_app_support(app) { - roots.push(root.join("User").join("globalStorage")); - roots.push(root.join("User").join("workspaceStorage")); - } - - #[cfg(target_os = "windows")] - if let Ok(appdata) = std::env::var("APPDATA") { - let root = PathBuf::from(appdata).join(app); - roots.push(root.join("User").join("globalStorage")); - roots.push(root.join("User").join("workspaceStorage")); - } - - #[cfg(target_os = "linux")] - if let Some(root) = home_join(&format!(".config/{app}")) { - roots.push(root.join("User").join("globalStorage")); - roots.push(root.join("User").join("workspaceStorage")); - } - - roots -} - -pub struct AugmentProvider; - -fn augment_roots() -> Vec { - let mut roots = Vec::new(); - if let Some(home) = home_join(".augment") { - roots.push(home); - } - roots.extend( - vscode_global_storage("Code") - .into_iter() - .map(|root| root.join("augmentcode.augment")), - ); - roots -} - -impl Provider for AugmentProvider { - fn id(&self) -> &'static str { - "augment" - } - fn name(&self) -> &'static str { - "Augment" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - ..ProviderCapabilities::default() - } - } - fn scan_sessions(&self) -> Result> { - generic_json::scan_sessions(spec("augment", augment_roots)) - } - fn import_session(&self, source_path: &str) -> Result { - generic_json::import_session(spec("augment", augment_roots), source_path) - } -} diff --git a/rust/crates/memorph/src/providers/environment_profiles.rs b/rust/crates/memorph/src/providers/environment_profiles.rs index 8bad5a5..bfb65c0 100644 --- a/rust/crates/memorph/src/providers/environment_profiles.rs +++ b/rust/crates/memorph/src/providers/environment_profiles.rs @@ -3,29 +3,18 @@ use std::path::PathBuf; pub(crate) fn executable_candidates(provider_id: &str) -> &'static [&'static str] { let provider_id = crate::providers::canonical_provider_id(provider_id); match provider_id.as_str() { - "augment" => &["auggie"], "antigravity" => &["antigravity"], - "amazonq" => &["q"], "claude" => &["claude"], "codex" => &["codex"], - "cline" => &["cline"], "copilot" => &["gh"], "cursor" => &["cursor-agent", "cursor"], - "deepseek" => &["deepseek"], - "codebuddy" => &["codebuddy"], - "droid" => &["factory", "droid"], "gemini" => &["gemini"], - "hermes" => &["hermes"], "kiro" => &["kiro"], "kimi" => &["kimi"], "opencode" => &["opencode"], - "openclaw" => &["openclaw"], "pi" => &["pi"], "qoder" => &["qoder"], - "qwen" => &["qwen"], - "trae" => &["trae"], "workbuddy" => &["workbuddy"], - "windsurf" => &["windsurf"], _ => &[], } } @@ -33,33 +22,20 @@ pub(crate) fn executable_candidates(provider_id: &str) -> &'static [&'static str pub(crate) fn config_path(provider_id: &str) -> PathBuf { let provider_id = crate::providers::canonical_provider_id(provider_id); match provider_id.as_str() { - "augment" => home_join(".augment"), "antigravity" => home_join(".antigravity"), - "amazonq" => dirs::data_local_dir() - .map(|dir| dir.join("amazon-q")) - .unwrap_or_else(|| PathBuf::from("amazon-q")), "claude" => home_join(".claude"), "codex" => home_join(".codex"), - "cline" => home_join("Documents/Cline"), "copilot" => app_config_dir("Code", ".config/Code") .join("User") .join("globalStorage"), "cursor" => cursor_config_dir(), - "deepseek" => home_join(".deepseek"), - "codebuddy" => home_join(".codebuddy"), - "droid" => home_join(".factory"), "gemini" => home_join(".gemini"), - "hermes" => home_join(".hermes"), "kiro" => kiro_config_dir(), "kimi" => home_join(".kimi"), "opencode" => home_join(".config/opencode"), - "openclaw" => home_join(".openclaw"), "pi" => home_join(".pi/agent"), "qoder" => home_join(".qoder"), - "qwen" => home_join(".qwen"), - "trae" => home_join(".trae"), "workbuddy" => home_join(".workbuddy"), - "windsurf" => app_config_dir("Windsurf", ".config/Windsurf"), other => PathBuf::from(other), } } @@ -149,24 +125,13 @@ mod tests { assert_eq!(config_path("opencode"), home_join(".config/opencode")); assert_eq!(config_path("gemini"), home_join(".gemini")); assert_eq!(config_path("kimi"), home_join(".kimi")); - assert_eq!(config_path("qwen"), home_join(".qwen")); - assert_eq!( - config_path("amazonq"), - dirs::data_local_dir() - .map(|dir| dir.join("amazon-q")) - .unwrap_or_else(|| PathBuf::from("amazon-q")) - ); } #[test] fn config_path_maps_codeisland_hook_roots() { assert_eq!(config_path("qoder"), home_join(".qoder")); - assert_eq!(config_path("droid"), home_join(".factory")); - assert_eq!(config_path("factory"), home_join(".factory")); - assert_eq!(config_path("codebuddy"), home_join(".codebuddy")); assert_eq!(config_path("antigravity"), home_join(".antigravity")); assert_eq!(config_path("workbuddy"), home_join(".workbuddy")); - assert_eq!(config_path("hermes"), home_join(".hermes")); assert_eq!(config_path("pi"), home_join(".pi/agent")); } @@ -174,14 +139,9 @@ mod tests { fn executable_candidates_cover_codeisland_hook_providers() { for provider in [ "qoder", - "droid", - "codebuddy", "antigravity", "workbuddy", - "hermes", "pi", - "amazonq", - "qwen", ] { assert!( !executable_candidates(provider).is_empty(), diff --git a/rust/crates/memorph/src/providers/generic_json.rs b/rust/crates/memorph/src/providers/generic_json.rs deleted file mode 100644 index c4dff84..0000000 --- a/rust/crates/memorph/src/providers/generic_json.rs +++ /dev/null @@ -1,769 +0,0 @@ -use crate::provider::ProviderSessionSummary; -use crate::session::{ - Block, Context, Event, EventKind, EventMeta, EventSource, Fidelity, Identity, ImportedSession, - Links, MappingDirection, MappingIssue, MappingIssueLevel, MappingReport, Metadata, Provenance, - ProviderRef, Role, Schema, Session, Usage, -}; -use crate::utils::{extract_text, parse_timestamp_to_ms, truncate_summary}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use serde_json::Value; -use std::collections::{BTreeMap, HashSet}; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -const TITLE_MAX_CHARS: usize = 80; -const MAX_SCAN_DEPTH: usize = 8; - -#[derive(Debug, Clone, Copy)] -pub struct JsonProviderSpec { - pub provider_id: &'static str, - pub extension_key: &'static str, - pub roots: fn() -> Vec, -} - -pub fn scan_sessions(spec: JsonProviderSpec) -> Result> { - let mut sessions = Vec::new(); - let mut seen = HashSet::new(); - - for root in (spec.roots)() { - if !root.exists() { - continue; - } - - for entry in WalkDir::new(&root) - .max_depth(MAX_SCAN_DEPTH) - .into_iter() - .filter_map(|entry| entry.ok()) - { - let path = entry.path(); - if !is_json_session_candidate(path) { - continue; - } - let Ok(value) = read_session_value(path) else { - continue; - }; - if !looks_like_session(&value) { - continue; - } - let summary = session_summary_from_value(path, &value); - if seen.insert(summary.session_id.clone()) { - sessions.push(summary); - } - } - } - - sessions.sort_by_key(|session| std::cmp::Reverse(session.last_active_at.unwrap_or(0))); - Ok(sessions) -} - -pub fn import_session(spec: JsonProviderSpec, source_path: &str) -> Result { - let path = Path::new(source_path); - let value = read_session_value(path).with_context(|| { - format!( - "Failed to read {} session: {}", - spec.provider_id, source_path - ) - })?; - import_session_from_value(spec, path, value) -} - -pub fn import_session_from_value( - spec: JsonProviderSpec, - path: &Path, - value: Value, -) -> Result { - let mut report = MappingReport::new(spec.provider_id, MappingDirection::Import); - let session_id = session_id_from_value(path, &value); - let title = title_from_value(&value); - let workspace_dir = workspace_from_value(&value); - let created_at = datetime_from_value_keys(&value, &["startTime", "createdAt", "created_at"]) - .or_else(|| path_mtime_datetime(path)) - .unwrap_or_else(Utc::now); - let last_active_at = datetime_from_value_keys( - &value, - &["lastUpdated", "updatedAt", "updated_at", "timestamp"], - ) - .or_else(|| path_mtime_datetime(path)) - .unwrap_or(created_at); - - let (events, event_meta): (Vec, Vec) = extract_message_items(&value) - .into_iter() - .enumerate() - .filter_map(|(index, item)| event_from_message(spec.provider_id, index, item, &mut report)) - .collect::>() - .into_iter() - .unzip(); - - let mut extensions = BTreeMap::new(); - extensions.insert(spec.extension_key.to_string(), value); - - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: session_id.clone(), - title, - }, - context: Context { - workspace: workspace_dir, - created_at: Some(created_at), - last_active_at: Some(last_active_at), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".to_string()), - primary_source: ProviderRef { - provider_id: spec.provider_id.to_string(), - session_id, - source_path: Some(path.to_string_lossy().to_string()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) -} - -fn read_json(path: &Path) -> Result { - let raw = std::fs::read_to_string(path)?; - serde_json::from_str(&raw).with_context(|| format!("Failed to parse JSON: {}", path.display())) -} - -/// Read a session file that may be either a single JSON object or JSONL. -/// -/// For `.jsonl`, each non-empty line is parsed independently. Lines that look like -/// session metadata (carry `sessionId`/`session_id`/`cwd`/timestamp fields) are -/// merged into the returned object's top level; all other lines are collected -/// under a synthesized `messages` array so the downstream `extract_message_items` -/// pipeline can process them uniformly. -fn read_session_value(path: &Path) -> Result { - let is_jsonl = path - .extension() - .and_then(|value| value.to_str()) - .map(|ext| ext == "jsonl") - .unwrap_or(false); - if !is_jsonl { - return read_json(path); - } - read_jsonl_as_session_value(path) -} - -fn read_jsonl_as_session_value(path: &Path) -> Result { - let raw = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read JSONL: {}", path.display()))?; - let mut map = serde_json::Map::new(); - let mut messages = Vec::::new(); - let metadata_keys = [ - "sessionId", - "session_id", - "id", - "conversationId", - "conversation_id", - "cwd", - "workspace", - "project", - "projectDir", - "model", - "startTime", - "createdAt", - "created_at", - "lastUpdated", - "updatedAt", - "updated_at", - ]; - for (line_index, raw_line) in raw.lines().enumerate() { - let trimmed = raw_line.trim(); - if trimmed.is_empty() { - continue; - } - let Ok(line) = serde_json::from_str::(trimmed) else { - continue; - }; - - let is_meta_line = line - .get("type") - .and_then(value_to_string) - .is_some_and(|ty| { - matches!( - ty.as_str(), - "session_meta" | "sessionMeta" | "metadata" | "meta" - ) - }) - || line - .get("isMeta") - .and_then(Value::as_bool) - .is_some_and(|v| v); - - if is_meta_line { - if let Some(obj) = line.as_object() { - for (key, value) in obj { - if key == "type" || key == "isMeta" { - continue; - } - if metadata_keys.contains(&key.as_str()) { - map.entry(key.clone()).or_insert(value.clone()); - } - } - } - // Prefer the nested payload block if present (codex-style {type:"session_meta", payload:{...}}). - if let Some(payload) = line.get("payload") { - if let Some(obj) = payload.as_object() { - for (key, value) in obj { - if metadata_keys.contains(&key.as_str()) { - map.entry(key.clone()).or_insert(value.clone()); - } - } - } - } - continue; - } - - // Promote metadata-looking keys from any line into the top-level map, but only - // when they haven't been seen yet. This lets a Claude-style jsonl where every - // row carries `sessionId`/`cwd` still surface those fields. - if let Some(obj) = line.as_object() { - for key in metadata_keys { - if let Some(value) = obj.get(key) { - map.entry(key.to_string()).or_insert(value.clone()); - } - } - } - - // Anything that isn't a pure metadata marker becomes a message candidate. - // Skip lines that are obviously control records without message content. - let looks_like_control = line - .get("type") - .and_then(value_to_string) - .is_some_and(|ty| { - matches!( - ty.as_str(), - "turn_context" | "compacted" | "summary" | "custom-title" | "ai-title" | "tag" - ) - }); - if looks_like_control { - continue; - } - - let mut entry = line.clone(); - if let Some(obj) = entry.as_object_mut() { - obj.insert("__line__".to_string(), Value::Number(line_index.into())); - } - messages.push(entry); - } - - map.insert("messages".to_string(), Value::Array(messages)); - Ok(Value::Object(map)) -} - -fn is_json_session_candidate(path: &Path) -> bool { - path.is_file() - && path - .extension() - .and_then(|value| value.to_str()) - .map(|ext| matches!(ext, "json" | "jsonl")) - .unwrap_or(false) - && path - .file_name() - .and_then(|value| value.to_str()) - .map(|name| { - let name = name.to_ascii_lowercase(); - name.starts_with("session") - || name.contains("chat") - || name.contains("conversation") - || name.contains("history") - || name.contains("thread") - || name.contains("rollout") - || name.contains("transcript") - || name.contains("events") - }) - .unwrap_or(false) -} - -fn looks_like_session(value: &Value) -> bool { - !extract_message_items(value).is_empty() - || value.get("sessionId").is_some() - || value.get("session_id").is_some() - || value.get("conversationId").is_some() -} - -fn session_summary_from_value(path: &Path, value: &Value) -> ProviderSessionSummary { - let session_id = session_id_from_value(path, value); - let title = title_from_value(value).or_else(|| first_message_title(value)); - let project_dir = workspace_from_value(value); - let created_at = timestamp_from_value_keys( - value, - &[ - "createdAt", - "created_at", - "timeCreated", - "time_created", - "startTime", - "start_time", - ], - ); - let last_active_at = timestamp_from_value_keys( - value, - &["lastUpdated", "updatedAt", "updated_at", "timestamp"], - ) - .or_else(|| path_mtime_ms(path)); - - ProviderSessionSummary { - archived: false, - session_id, - title, - project_dir, - created_at, - last_active_at, - source_path: Some(path.to_string_lossy().to_string()), - } -} - -fn session_id_from_value(path: &Path, value: &Value) -> String { - for key in [ - "sessionId", - "session_id", - "id", - "conversationId", - "conversation_id", - "threadId", - "thread_id", - "chatId", - "chat_id", - ] { - if let Some(raw) = value.get(key).and_then(value_to_string) { - if !raw.trim().is_empty() { - return raw; - } - } - } - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown-session") - .to_string() -} - -fn title_from_value(value: &Value) -> Option { - for key in ["title", "name", "summary", "preview"] { - let Some(raw) = value.get(key).and_then(value_to_string) else { - continue; - }; - let title = truncate_summary(&raw, TITLE_MAX_CHARS); - if !title.is_empty() { - return Some(title); - } - } - None -} - -fn first_message_title(value: &Value) -> Option { - extract_message_items(value).into_iter().find_map(|item| { - let text = message_text(item); - let title = truncate_summary(&text, TITLE_MAX_CHARS); - (!title.is_empty()).then_some(title) - }) -} - -fn workspace_from_value(value: &Value) -> Option { - for key in [ - "workspaceDirectory", - "workspaceDir", - "workspace_dir", - "projectDir", - "project_dir", - "cwd", - "rootPath", - ] { - let Some(raw) = value.get(key).and_then(value_to_string) else { - continue; - }; - let raw = raw.trim(); - if !raw.is_empty() { - return Some(raw.to_string()); - } - } - value - .get("context") - .and_then(workspace_from_value) - .or_else(|| value.get("workspace").and_then(workspace_from_value)) -} - -fn extract_message_items(value: &Value) -> Vec<&Value> { - if let Some(items) = value.get("messages").and_then(|value| value.as_array()) { - return items.iter().collect(); - } - if let Some(items) = value.get("history").and_then(|value| value.as_array()) { - return items.iter().collect(); - } - if let Some(items) = value.get("entries").and_then(|value| value.as_array()) { - return items.iter().collect(); - } - if let Some(items) = value.get("turns").and_then(|value| value.as_array()) { - return items.iter().collect(); - } - if let Some(conversation) = value.get("conversation") { - let nested = extract_message_items(conversation); - if !nested.is_empty() { - return nested; - } - } - Vec::new() -} - -fn event_from_message( - provider_id: &str, - index: usize, - item: &Value, - report: &mut MappingReport, -) -> Option<(Event, EventMeta)> { - let message = item.get("message").unwrap_or(item); - let blocks = message_blocks(message, item, index, report); - if blocks.is_empty() { - return None; - } - let role_raw = role_string(message); - let role = role_from_string(&role_raw); - let timestamp = datetime_from_value_keys(message, &["timestamp", "createdAt", "created_at"]) - .unwrap_or_else(Utc::now); - let original_id = message_id(message); - - let mut provider_ext = BTreeMap::new(); - provider_ext.insert("raw_message".to_string(), item.clone()); - Some(( - Event { - id: original_id - .clone() - .unwrap_or_else(|| format!("{}:message:{}", provider_id, index)), - kind: event_kind(&blocks), - role, - timestamp, - links: Links { - parent_event_id: None, - turn_id: Some(index.to_string()), - turn_outcome: None, - related_event_ids: Vec::new(), - }, - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: message.get("model").and_then(value_to_string), - usage: usage_from_message(message), - }, - }, - EventMeta { - source: EventSource { - provider_id: provider_id.to_string(), - original_id, - original_role: Some(role_raw), - phase: None, - }, - fidelity: Fidelity::Normalized, - provider_ext, - }, - )) -} - -fn message_blocks( - message: &Value, - raw_item: &Value, - index: usize, - report: &mut MappingReport, -) -> Vec { - let mut blocks = Vec::new(); - let text = message_text(message); - if !text.trim().is_empty() { - blocks.push(Block::Text { text }); - } - - if let Some(thoughts) = message.get("thoughts").and_then(|value| value.as_array()) { - for thought in thoughts { - let text = thought - .get("subject") - .and_then(|value| value.as_str()) - .or_else(|| thought.get("description").and_then(|value| value.as_str())) - .or_else(|| thought.get("text").and_then(|value| value.as_str())) - .unwrap_or(""); - if !text.trim().is_empty() { - blocks.push(Block::Thinking { - text: text.to_string(), - signature: None, - }); - } - } - } - - if let Some(tool_calls) = message.get("toolCalls").and_then(|value| value.as_array()) { - for tool_call in tool_calls { - let id = tool_call - .get("id") - .and_then(value_to_string) - .unwrap_or_else(|| format!("tool-call-{}", blocks.len())); - let name = tool_call - .get("name") - .or_else(|| tool_call.get("displayName")) - .and_then(value_to_string) - .unwrap_or_else(|| "tool".to_string()); - blocks.push(Block::ToolCall { - tool_call_id: id.clone(), - name, - input: tool_call.get("args").cloned(), - }); - if let Some(result) = tool_call.get("result") { - let content = extract_text(result); - blocks.push(Block::ToolResult { - tool_call_id: id, - content, - outcome: crate::session::execution_outcome( - tool_call - .get("status") - .and_then(|value| value.as_str()) - .map(|status| status.eq_ignore_ascii_case("error")) - .unwrap_or(false), - ), - }); - } - } - } - - if blocks.is_empty() && looks_like_non_text_message(message) { - report.push_issue(MappingIssue { - level: MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "provider_message_preserved".to_string(), - message: "Preserved provider message with unsupported block structure".to_string(), - path: Some(format!("messages:{}", index)), - raw: Some(raw_item.clone()), - }); - blocks.push(Block::Other { - raw: message.clone(), - }); - } - - blocks -} - -fn message_text(message: &Value) -> String { - for key in ["content", "displayContent", "text", "prompt", "response"] { - let Some(value) = message.get(key) else { - continue; - }; - let text = extract_text(value); - if !text.trim().is_empty() { - return text; - } - } - String::new() -} - -fn looks_like_non_text_message(message: &Value) -> bool { - message.get("toolCalls").is_some() - || message.get("parts").is_some() - || message.get("content").is_some() - || message.get("message").is_some() -} - -fn role_string(message: &Value) -> String { - for key in ["role", "type", "author", "sender"] { - if let Some(role) = message.get(key).and_then(value_to_string) { - return role; - } - } - "unknown".to_string() -} - -fn role_from_string(role: &str) -> Role { - match role.to_ascii_lowercase().as_str() { - "user" | "human" => Role::User, - "assistant" | "ai" | "agent" | "bot" | "gemini" | "copilot" => Role::Assistant, - "tool" | "tool_result" | "tool-call" => Role::Tool, - "system" => Role::System, - "developer" => Role::Developer, - _ => Role::Other, - } -} - -fn message_id(message: &Value) -> Option { - for key in ["id", "messageId", "message_id"] { - if let Some(id) = message.get(key).and_then(value_to_string) { - return Some(id); - } - } - None -} - -fn event_kind(blocks: &[Block]) -> EventKind { - if blocks - .iter() - .any(|block| matches!(block, Block::ToolResult { .. })) - { - EventKind::Observation - } else if blocks - .iter() - .any(|block| matches!(block, Block::ToolCall { .. })) - { - EventKind::Action - } else if blocks - .iter() - .all(|block| matches!(block, Block::Other { .. })) - { - EventKind::Other - } else { - EventKind::Message - } -} - -fn usage_from_message(message: &Value) -> Option { - let tokens = message.get("tokens").or_else(|| message.get("usage"))?; - Some(Usage { - input_tokens: tokens.get("input").and_then(Value::as_u64), - output_tokens: tokens - .get("output") - .or_else(|| tokens.get("candidates")) - .and_then(Value::as_u64), - cache_read_tokens: None, - cache_write_tokens: None, - reasoning_tokens: None, - }) -} - -fn datetime_from_value_keys(value: &Value, keys: &[&str]) -> Option> { - timestamp_from_value_keys(value, keys).and_then(DateTime::::from_timestamp_millis) -} - -fn timestamp_from_value_keys(value: &Value, keys: &[&str]) -> Option { - keys.iter() - .find_map(|key| value.get(*key).and_then(parse_timestamp_to_ms)) -} - -fn value_to_string(value: &Value) -> Option { - match value { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - } -} - -fn path_mtime_datetime(path: &Path) -> Option> { - path_mtime_ms(path).and_then(DateTime::::from_timestamp_millis) -} - -fn path_mtime_ms(path: &Path) -> Option { - std::fs::metadata(path) - .ok()? - .modified() - .ok() - .map(DateTime::::from) - .map(|dt| dt.timestamp_millis()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - const JSONL_WITH_SESSION_META: &str = r#"{"type":"session_meta","payload":{"id":"sess-1","cwd":"/tmp/proj","model_provider":"codex"}} -{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}} -{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}} -"#; - - const JSONL_CLAUDE_STYLE: &str = r#"{"sessionId":"abc","cwd":"/work","type":"user","message":{"role":"user","content":"ping"}} -{"sessionId":"abc","cwd":"/work","type":"assistant","message":{"role":"assistant","content":"pong"}} -"#; - - const JSONL_WITH_CONTROL_LINES: &str = r#"{"sessionId":"ctrl","type":"session_meta","payload":{"id":"ctrl","cwd":"/x"}} -{"type":"turn_context","payload":{"turn_id":"t1"}} -{"type":"compacted","message":"summarized"} -{"sessionId":"ctrl","type":"user","message":{"role":"user","content":"hi"}} -"#; - - fn spec() -> JsonProviderSpec { - JsonProviderSpec { - provider_id: "example", - extension_key: "example_session", - roots: Vec::new, - } - } - - #[test] - fn imports_gemini_style_conversation_record() { - let value = json!({ - "sessionId": "abc", - "projectHash": "proj", - "startTime": "2026-06-08T01:00:00Z", - "lastUpdated": "2026-06-08T01:02:00Z", - "messages": [ - {"id": "u1", "type": "user", "timestamp": "2026-06-08T01:00:00Z", "content": "hello"}, - {"id": "g1", "type": "gemini", "timestamp": "2026-06-08T01:01:00Z", "content": [{"text": "hi"}], "tokens": {"input": 1, "output": 2, "total": 3}} - ] - }); - - let imported = import_session_from_value(spec(), Path::new("session-abc.json"), value) - .expect("import session"); - - assert_eq!(imported.session.identity.id, "abc"); - assert_eq!(imported.session.events.len(), 2); - assert_eq!(imported.session.events[1].role, Role::Assistant); - assert_eq!( - imported.session.events[1] - .metadata - .usage - .as_ref() - .and_then(|usage| usage.output_tokens), - Some(2) - ); - } - - #[test] - fn ignores_plain_config_json_without_messages() { - let value = json!({"theme": "dark", "enabled": true}); - assert!(!looks_like_session(&value)); - } - - #[test] - fn read_jsonl_merges_session_meta_and_messages() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("rollout-test.jsonl"); - std::fs::write(&file, JSONL_WITH_SESSION_META).unwrap(); - - let value = read_session_value(&file).expect("read jsonl"); - assert_eq!(value.get("id").and_then(|v| v.as_str()), Some("sess-1")); - assert_eq!(value.get("cwd").and_then(|v| v.as_str()), Some("/tmp/proj")); - let messages = value.get("messages").and_then(|v| v.as_array()).unwrap(); - assert_eq!(messages.len(), 2, "two message lines preserved"); - } - - #[test] - fn read_jsonl_promotes_metadata_from_repeated_rows() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("session-abc.jsonl"); - std::fs::write(&file, JSONL_CLAUDE_STYLE).unwrap(); - - let value = read_session_value(&file).expect("read"); - assert_eq!(value.get("sessionId").and_then(|v| v.as_str()), Some("abc")); - assert_eq!(value.get("cwd").and_then(|v| v.as_str()), Some("/work")); - assert_eq!( - value - .get("messages") - .and_then(|v| v.as_array()) - .unwrap() - .len(), - 2, - ); - } - - #[test] - fn read_jsonl_skips_control_lines() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("session-ctrl.jsonl"); - std::fs::write(&file, JSONL_WITH_CONTROL_LINES).unwrap(); - - let value = read_session_value(&file).expect("read"); - let messages = value.get("messages").and_then(|v| v.as_array()).unwrap(); - assert_eq!(messages.len(), 1, "only the user message survives"); - } -} diff --git a/rust/crates/memorph/src/providers/hermes/adapter.rs b/rust/crates/memorph/src/providers/hermes/adapter.rs deleted file mode 100644 index ab1fe12..0000000 --- a/rust/crates/memorph/src/providers/hermes/adapter.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Hermes hook adapter. -//! -//! This adapter is intentionally registered as its own provider module even -//! when the current payload shape is Claude-like. Keeping it separate makes -//! future provider-specific hook changes local to this file. - -use anyhow::Result; -use chrono::{DateTime, TimeZone, Utc}; -use serde_json::Value; -use std::path::PathBuf; - -use crate::hooks::contract::HookAdapter; -use crate::hooks::model::{ - HookEvent, HookEventType, HookMessage, HookToolCall, PermissionRequest, QuestionRequest, -}; -use crate::hooks::protocol::{HookIngestRequest, HookIngestResponse}; - -pub struct HermesHookAdapter; - -impl HookAdapter for HermesHookAdapter { - fn provider_id(&self) -> &'static str { - "hermes" - } - - fn blocking_response_json( - &self, - event_name: &str, - response: &HookIngestResponse, - ) -> Option { - crate::hooks::contract::hook_specific_output_response_json(event_name, response) - } - - fn normalize(&self, request: &HookIngestRequest) -> Result> { - let raw = request.raw.clone(); - let event_name = string_at( - &raw, - &[ - "hook_event_name", - "hookEventName", - "event_name", - "eventName", - "event", - "type", - ], - ) - .unwrap_or_else(|| request.event_name.clone()); - let mut event = HookEvent::new("hermes", provider_event_type(&event_name), raw.clone()); - - event.event_id = string_at(&raw, &["event_id", "eventId", "id"]).unwrap_or(event.event_id); - event.provider_session_id = string_at( - &raw, - &[ - "session_id", - "sessionId", - "conversation_id", - "conversationId", - "thread_id", - "threadId", - ], - ); - event.run_id = string_at( - &raw, - &[ - "run_id", - "runId", - "request_id", - "requestId", - "call_id", - "callId", - ], - ) - .or_else(|| Some(request.request_id.clone())); - event.cwd = path_at( - &raw, - &[ - "cwd", - "workspace", - "workspace_dir", - "workspaceDir", - "project_dir", - "projectDir", - ], - ) - .or_else(|| request.environment.cwd.clone()); - event.pid = u32_at(&raw, &["pid"]).or(request.environment.pid); - event.parent_pid = - u32_at(&raw, &["parent_pid", "parentPid", "ppid"]).or(request.environment.parent_pid); - event.tty = string_at(&raw, &["tty", "terminal_tty", "terminalTty"]) - .or_else(|| request.environment.tty.clone()); - event.timestamp = timestamp_at(&raw).unwrap_or(request.received_at); - - let tool = tool_at(&raw); - event.tool = tool.clone(); - event.message = message_at(&raw); - event.permission = match event.event_type { - HookEventType::PermissionRequested => Some(permission_at(&raw, tool)), - _ => None, - }; - event.question = match event.event_type { - HookEventType::QuestionRequested => question_at(&raw), - _ => None, - }; - - Ok(vec![event]) - } -} - -fn provider_event_type(event_name: &str) -> HookEventType { - match normalize_name(event_name).as_str() { - "sessionstart" | "session_start" | "taskstart" => HookEventType::SessionStarted, - "sessionend" | "session_end" | "taskcomplete" | "stop" => HookEventType::SessionCompleted, - "taskcancel" | "erroroccurred" => HookEventType::SessionFailed, - "userpromptsubmit" | "user_prompt_submit" | "userpromptsubmitted" | "notification" => { - HookEventType::MessageCreated - } - "pretooluse" | "pre_tool_use" => HookEventType::ToolStarted, - "posttooluse" | "post_tool_use" | "posttoolusefailure" | "post_tool_use_failure" => { - HookEventType::ToolFinished - } - "permissionrequest" | "permission_request" => HookEventType::PermissionRequested, - "askuserquestion" | "questionrequest" | "question_request" => { - HookEventType::QuestionRequested - } - "subagentstart" | "subagent_start" | "subagentstop" | "subagent_stop" | "precompact" - | "pre_compact" | "postcompact" | "post_compact" | "taskresume" => HookEventType::Heartbeat, - _ => HookEventType::from_provider_name(event_name), - } -} - -fn normalize_name(value: &str) -> String { - value - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn tool_at(value: &Value) -> Option { - if let Some(tool) = value.get("tool").filter(|tool| tool.is_object()) { - let name = string_at(tool, &["name", "tool_name", "toolName"])?; - return Some(HookToolCall { - id: string_at( - tool, - &["id", "tool_use_id", "toolUseId", "call_id", "callId"], - ), - name, - input: value_at(tool, &["input", "arguments", "args", "params"]).unwrap_or(Value::Null), - }); - } - - let name = string_at(value, &["tool_name", "toolName", "tool", "name"])?; - Some(HookToolCall { - id: string_at( - value, - &["tool_use_id", "toolUseId", "call_id", "callId", "id"], - ), - name, - input: value_at( - value, - &[ - "tool_input", - "toolInput", - "input", - "arguments", - "args", - "params", - ], - ) - .unwrap_or(Value::Null), - }) -} - -fn permission_at(value: &Value, tool: Option) -> PermissionRequest { - PermissionRequest { - request_id: string_at( - value, - &[ - "permission_id", - "permissionId", - "request_id", - "requestId", - "tool_use_id", - "toolUseId", - "call_id", - "callId", - ], - ) - .or_else(|| tool.as_ref().and_then(|tool| tool.id.clone())), - tool, - prompt: string_at( - value, - &[ - "permission_prompt", - "permissionPrompt", - "prompt", - "reason", - "message", - ], - ), - } -} - -fn question_at(value: &Value) -> Option { - let prompt = string_at( - value, - &[ - "question", - "question_prompt", - "questionPrompt", - "prompt", - "message", - ], - )?; - Some(QuestionRequest { - request_id: string_at( - value, - &["question_id", "questionId", "request_id", "requestId"], - ), - prompt, - }) -} - -fn message_at(value: &Value) -> Option { - let text = string_at( - value, - &[ - "message", - "text", - "content", - "prompt", - "user_prompt", - "userPrompt", - ], - )?; - Some(HookMessage { - role: string_at(value, &["role"]), - text, - }) -} - -fn string_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::String(s)) if !s.trim().is_empty() => Some(s.to_string()), - Some(Value::Number(n)) => Some(n.to_string()), - Some(Value::Bool(b)) => Some(b.to_string()), - _ => None, - }) -} - -fn u32_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()), - Some(Value::String(s)) => s.parse().ok(), - _ => None, - }) -} - -fn path_at(value: &Value, keys: &[&str]) -> Option { - string_at(value, keys).map(PathBuf::from) -} - -fn value_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| value.get(*key).cloned()) -} - -fn timestamp_at(value: &Value) -> Option> { - let raw = value - .get("timestamp") - .or_else(|| value.get("created_at")) - .or_else(|| value.get("createdAt"))?; - match raw { - Value::String(s) => DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|| s.parse::().ok().and_then(unix_timestamp)), - Value::Number(n) => n.as_i64().and_then(unix_timestamp), - _ => None, - } -} - -fn unix_timestamp(value: i64) -> Option> { - Utc.timestamp_opt(value, 0).single() -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn maps_pre_tool_use() { - let request = HookIngestRequest::new( - "hermes", - "PreToolUse", - json!({"session_id": "s1", "tool_name": "Bash"}), - ); - let event = HermesHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.provider, "hermes"); - assert_eq!(event.event_type, HookEventType::ToolStarted); - } -} diff --git a/rust/crates/memorph/src/providers/hermes/hook.rs b/rust/crates/memorph/src/providers/hermes/hook.rs deleted file mode 100644 index 978d915..0000000 --- a/rust/crates/memorph/src/providers/hermes/hook.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::path::PathBuf; - -use anyhow::Result; - -use crate::hooks::contract::ProviderHook; -use crate::hooks::json_settings_hook::{event, JsonSettingsHookEvent, JsonSettingsHookSpec}; -use crate::hooks::model::{HookHealthStatus, HookInstallStatus, HookOperationReport}; - -pub struct HermesHook; - -pub static HERMES_HOOK: HermesHook = HermesHook; - -const EVENTS: &[JsonSettingsHookEvent] = &[ - event("UserPromptSubmit", 5, true), - event("PreToolUse", 5, false), - event("PostToolUse", 5, true), - event("SessionStart", 5, false), - event("SessionEnd", 5, true), - event("Stop", 5, true), - event("SubagentStart", 5, true), - event("SubagentStop", 5, true), - event("Notification", 86400, false), - event("PreCompact", 5, true), -]; - -impl ProviderHook for HermesHook { - fn provider_id(&self) -> &'static str { - "hermes" - } - - fn status(&self) -> Result { - crate::hooks::json_settings_hook::status(spec()) - } - - fn install(&self) -> Result { - crate::hooks::json_settings_hook::install(spec()) - } - - fn verify(&self) -> Result { - let status = self.status()?; - Ok(HookOperationReport { - provider: self.provider_id().to_string(), - operation: "verify".to_string(), - changed: false, - backup_path: None, - message: status.message.clone(), - status, - }) - } - - fn repair(&self) -> Result { - let before = self.status()?; - let mut report = self.install()?; - report.operation = "repair".to_string(); - report.changed = before.status != HookHealthStatus::InstalledOk; - Ok(report) - } - - fn uninstall(&self) -> Result { - crate::hooks::json_settings_hook::uninstall(spec()) - } -} - -pub(crate) fn settings_path() -> PathBuf { - crate::hooks::shared::hook_home_dir() - .join(".hermes") - .join("settings.json") -} - -fn spec() -> JsonSettingsHookSpec { - JsonSettingsHookSpec { - provider: "hermes", - display_name: "Hermes", - settings_path, - events: EVENTS, - missing_config_message: "Hermes settings.json does not exist.", - install_message: "Hermes hook entries installed.", - uninstall_missing_message: "Hermes settings file does not exist.", - uninstall_message: "Hermes memorph hook entries removed.", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn descriptor_matches_hook_registry() { - let descriptor = HERMES_HOOK.descriptor().expect("hermes descriptor"); - assert_eq!(descriptor.provider(), HERMES_HOOK.provider_id()); - } -} diff --git a/rust/crates/memorph/src/providers/hermes/mod.rs b/rust/crates/memorph/src/providers/hermes/mod.rs deleted file mode 100644 index bb40cb1..0000000 --- a/rust/crates/memorph/src/providers/hermes/mod.rs +++ /dev/null @@ -1,488 +0,0 @@ -pub mod adapter; -pub mod hook; -use crate::provider::{ - PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, - ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, - ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{Connection, OpenFlags, OptionalExtension}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::{Duration, UNIX_EPOCH}; - -pub struct HermesProvider; -const PROVIDER_ID: &str = "hermes"; - -impl Provider for HermesProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "Hermes" - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - resume: true, - storage_shape: StorageShape::Sqlite, - scan_strategy: ScanStrategy::Indexed, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - resume_quality: ResumeQuality::Native, - write_risk: ProviderWriteRisk { - level: WriteRiskLevel::High, - multiple_files: false, - sqlite: true, - sidecar_files: true, - index_repair: false, - }, - backup_support: ProviderBackupSupport { - before_write: false, - restore: false, - sync_only: false, - }, - activity_support: ProviderActivitySupport { - hook_events: false, - runtime_endpoint: false, - session_activity: false, - }, - ..ProviderCapabilities::default() - } - } - - fn scan_sessions(&self) -> Result> { - scan_sessions_from_db(&state_db_path()) - } - - fn import_session(&self, source_path: &str) -> Result { - let (db, id) = parse_source_locator(source_path)?; - let conn = open_read_only(&db)?; - let meta = conn - .query_row( - "SELECT id, title, cwd, model, started_at FROM sessions WHERE id = ?1", - [&id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, f64>(4)?, - )) - }, - ) - .with_context(|| format!("Hermes session not found: {id}"))?; - let mut stmt = conn.prepare( - "SELECT id, role, content, tool_call_id, tool_calls, tool_name, effect_disposition, timestamp, reasoning, reasoning_content, reasoning_details, compacted, active, api_content FROM messages WHERE session_id = ?1 ORDER BY id", - )?; - let mut events = Vec::new(); - let mut event_meta: Vec = Vec::new(); - let mut rows = stmt.query([&id])?; - while let Some(row) = rows.next()? { - let active: i64 = row.get(12)?; - if active == 0 { - continue; - } - let message_id: i64 = row.get(0)?; - let role: String = row.get(1)?; - let content: Option = row.get(2)?; - let tool_call_id: Option = row.get(3)?; - let tool_calls: Option = row.get(4)?; - let tool_name: Option = row.get(5)?; - let effect_disposition: Option = row.get(6)?; - let timestamp: f64 = row.get(7)?; - let reasoning: Option = row.get(8)?; - let reasoning_content: Option = row.get(9)?; - let reasoning_details: Option = row.get(10)?; - let compacted: i64 = row.get(11)?; - let api_content: Option = row.get(13)?; - let raw_message = serde_json::json!({ - "id": message_id, - "role": role, - "content": content, - "tool_call_id": tool_call_id, - "tool_calls": tool_calls.as_deref().and_then(|raw| serde_json::from_str::(raw).ok()), - "tool_name": tool_name, - "effect_disposition": effect_disposition, - "timestamp": timestamp, - "reasoning": reasoning, - "reasoning_content": reasoning_content, - "reasoning_details": reasoning_details, - "compacted": compacted != 0, - "active": active != 0, - "api_content": api_content, - }); - let event_role = match role.as_str() { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - _ => Role::Other, - }; - let mut blocks = Vec::new(); - if tool_name.is_none() { - if let Some(text) = content.clone().filter(|v| !v.trim().is_empty()) { - blocks.push(Block::Text { text }); - } - } - if let Some(text) = reasoning - .clone() - .or(reasoning_content.clone()) - .or(reasoning_details.clone()) - .filter(|v| !v.trim().is_empty()) - { - blocks.push(Block::Thinking { - text, - signature: None, - }); - } - if let Some(name) = tool_name.clone() { - blocks.push(Block::ToolResult { - tool_call_id: tool_call_id - .clone() - .unwrap_or_else(|| message_id.to_string()), - content: content.clone().unwrap_or_default(), - outcome: crate::session::execution_outcome(false), - }); - let _ = name; - } - if let Some(raw) = tool_calls.as_deref() { - if let Ok(value) = serde_json::from_str::(raw) { - if let Some(items) = value.as_array() { - for item in items { - let function = item.get("function").unwrap_or(item); - let name = function - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .to_string(); - let id = item - .get("id") - .and_then(Value::as_str) - .or(tool_call_id.as_deref()) - .unwrap_or("unknown") - .to_string(); - let input = function - .get("arguments") - .cloned() - .or_else(|| function.get("input").cloned()) - .and_then(|value| match value { - Value::String(raw) => { - serde_json::from_str(&raw).ok().or(Some(Value::String(raw))) - } - value => Some(value), - }); - blocks.push(Block::ToolCall { - tool_call_id: id, - name, - input, - }); - } - } - } - } - let kind = if tool_name.is_some() { - EventKind::Observation - } else if tool_calls.is_some() { - EventKind::Action - } else { - EventKind::Message - }; - events.push(Event { - id: message_id.to_string(), - kind, - role: event_role, - timestamp: timestamp_ms(timestamp) - .map(datetime_from_ms) - .unwrap_or_else(Utc::now), - links: Links::default(), - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: meta.3.clone(), - usage: None, - }, - }); - let mut provider_ext = BTreeMap::new(); - provider_ext.insert("hermes_message".into(), raw_message); - event_meta.push(crate::session::EventMeta { - source: crate::session::EventSource { - provider_id: PROVIDER_ID.to_string(), - original_id: Some(message_id.to_string()), - original_role: Some(role.clone()), - phase: None, - }, - fidelity: Fidelity::Preserved, - provider_ext, - }); - } - let created = timestamp_ms(meta.4).map(datetime_from_ms); - let last = events.last().map(|event| event.timestamp).or(created); - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - report.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "hermes-sqlite-source".into(), - message: "Imported from Hermes state.db sessions/messages tables".into(), - path: Some("source".into()), - raw: None, - }); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: meta.0.clone(), - title: meta.1, - }, - context: Context { - workspace: meta.2, - created_at: created, - last_active_at: last, - tags: Vec::new(), - }, - events, - extensions: BTreeMap::new(), - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: meta.0, - source_path: Some(source_path.into()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - source_fingerprint(source_path) - } - fn resume_command(&self, session_id: &str) -> Option { - Some(format!("hermes --resume {session_id}")) - } - fn data_source_paths(&self) -> Vec { - vec![state_db_path()] - } -} - -fn state_db_path() -> PathBuf { - std::env::var_os("HERMES_HOME") - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|home| home.join(".hermes"))) - .unwrap_or_else(|| PathBuf::from(".hermes")) - .join("state.db") -} -fn source_locator_for(db: &Path, id: &str) -> String { - format!("{}#session={id}", db.display()) -} -#[cfg(test)] -fn source_locator(id: &str) -> String { - source_locator_for(&state_db_path(), id) -} -fn scan_sessions_from_db(db: &Path) -> Result> { - if !db.exists() { - return Ok(Vec::new()); - } - let conn = open_read_only(db)?; - let mut stmt = conn.prepare( - "SELECT id, title, cwd, started_at, archived FROM sessions ORDER BY started_at DESC", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, f64>(3)?, - row.get::<_, i64>(4)?, - )) - })?; - rows.map(|row| { - let (id, title, cwd, started, archived) = row?; - Ok(ProviderSessionSummary { - archived: archived != 0, - session_id: id.clone(), - title, - project_dir: cwd, - created_at: None, - last_active_at: timestamp_ms(started), - source_path: Some(source_locator_for(db, &id)), - }) - }) - .collect() -} -fn parse_source_locator(source: &str) -> Result<(PathBuf, String)> { - let (path, fragment) = source - .split_once('#') - .context("Hermes source must be state.db#session=")?; - let id = fragment - .strip_prefix("session=") - .filter(|id| !id.is_empty()) - .context("Hermes source must contain session id")?; - Ok((PathBuf::from(path), id.to_string())) -} -fn open_read_only(path: &Path) -> Result { - Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("failed to open Hermes state db at {}", path.display())) -} -fn timestamp_ms(value: f64) -> Option { - if value.is_finite() { - Some((value * 1000.0).round() as i64) - } else { - None - } -} -fn datetime_from_ms(value: i64) -> DateTime { - DateTime::::from(UNIX_EPOCH + Duration::from_millis(value.max(0) as u64)) -} -fn source_fingerprint(source: &str) -> Result> { - let (path, id) = parse_source_locator(source)?; - let metadata = match std::fs::metadata(&path) { - Ok(value) => value, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - let conn = open_read_only(&path)?; - let session = conn - .query_row( - "SELECT CAST(started_at AS TEXT), CAST(ended_at AS TEXT), CAST(message_count AS TEXT), CAST(tool_call_count AS TEXT), CAST(archived AS TEXT), title, cwd, model FROM sessions WHERE id = ?1", - [&id], - |row| (0..8).map(|index| row.get::<_, Option>(index)).collect::>>(), - ) - .optional()?; - let Some(session) = session else { - return Ok(None); - }; - let mut digest = Sha256::new(); - for value in session { - digest.update(value.unwrap_or_default()); - digest.update([0]); - } - let mut stmt = conn.prepare( - "SELECT CAST(id AS TEXT), role, content, tool_call_id, tool_calls, tool_name, effect_disposition, CAST(timestamp AS TEXT), reasoning, reasoning_content, reasoning_details, CAST(compacted AS TEXT), CAST(active AS TEXT), api_content FROM messages WHERE session_id = ?1 ORDER BY id", - )?; - let rows = stmt.query_map([&id], |row| { - (0..14) - .map(|index| row.get::<_, Option>(index)) - .collect::>>() - })?; - for row in rows { - for value in row? { - digest.update(value.unwrap_or_default()); - digest.update([0]); - } - } - let modified_at_ms = metadata - .modified() - .ok() - .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) - .map(|value| value.as_millis().min(i64::MAX as u128) as i64) - .unwrap_or(0); - let digest = digest.finalize(); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms, - size_bytes: metadata.len().min(i64::MAX as u64) as i64, - value: format!("hermes-sqlite-v1:{id}:{digest:x}"), - })) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_db() -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - let conn = Connection::open(dir.path().join("state.db")).unwrap(); - conn.execute_batch( - "CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, cwd TEXT, model TEXT, started_at REAL NOT NULL, ended_at REAL, message_count INTEGER, tool_call_count INTEGER, archived INTEGER NOT NULL DEFAULT 0); - CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, effect_disposition TEXT, timestamp REAL NOT NULL, reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, compacted INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, api_content TEXT); - INSERT INTO sessions VALUES ('active','Fixture','/tmp/project','model-x',1000,NULL,3,1,0); - INSERT INTO sessions VALUES ('archived','Hidden',NULL,NULL,900,NULL,0,0,1); - INSERT INTO messages (id,session_id,role,content,tool_call_id,tool_calls,tool_name,effect_disposition,timestamp,reasoning,reasoning_content,reasoning_details,compacted,active,api_content) VALUES (1,'active','user','hello',NULL,NULL,NULL,NULL,1000,NULL,NULL,NULL,0,1,NULL); - INSERT INTO messages (id,session_id,role,content,tool_call_id,tool_calls,tool_name,effect_disposition,timestamp,reasoning,reasoning_content,reasoning_details,compacted,active,api_content) VALUES (2,'active','assistant',NULL,NULL,'[{\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"terminal\",\"arguments\":\"{\\\"cmd\\\":\\\"pwd\\\"}\"}}]',NULL,NULL,1001,'thinking',NULL,NULL,0,1,'wire-content'); - INSERT INTO messages (id,session_id,role,content,tool_call_id,tool_calls,tool_name,effect_disposition,timestamp,reasoning,reasoning_content,reasoning_details,compacted,active,api_content) VALUES (3,'active','tool','done','call-1',NULL,'terminal',NULL,1002,NULL,NULL,NULL,0,1,NULL); - INSERT INTO messages (id,session_id,role,content,tool_call_id,tool_calls,tool_name,effect_disposition,timestamp,reasoning,reasoning_content,reasoning_details,compacted,active,api_content) VALUES (4,'active','assistant','inactive',NULL,NULL,NULL,NULL,1003,NULL,NULL,NULL,0,0,NULL);" - ).unwrap(); - dir - } - - #[test] - fn native_sqlite_scan_import_and_fingerprint() { - let dir = fixture_db(); - let db = dir.path().join("state.db"); - let sessions = scan_sessions_from_db(&db).unwrap(); - assert_eq!(sessions.len(), 2); - assert_eq!(sessions[0].session_id, "active"); - assert!(!sessions[0].archived); - assert_eq!(sessions[0].last_active_at, Some(1_000_000)); - assert_eq!(sessions[1].session_id, "archived"); - assert!(sessions[1].archived); - - let source = source_locator_for(&db, "active"); - let imported = HermesProvider.import_session(&source).unwrap(); - assert_eq!(imported.session.events.len(), 3); - assert!(matches!( - imported.session.events[1].blocks[0], - Block::Thinking { .. } - )); - assert!( - matches!(imported.session.events[1].blocks[1], Block::ToolCall { ref name, .. } if name == "terminal") - ); - assert!( - matches!(imported.session.events[2].blocks[0], Block::ToolResult { ref content, .. } if content == "done") - ); - assert_eq!( - imported.event_meta[1].provider_ext["hermes_message"]["tool_calls"], - serde_json::json!([{ - "id": "call-1", - "type": "function", - "function": { - "name": "terminal", - "arguments": "{\"cmd\":\"pwd\"}" - } - }]) - ); - assert_eq!( - imported.event_meta[1].provider_ext["hermes_message"]["api_content"], - "wire-content" - ); - assert!(source_fingerprint(&source).unwrap().is_some()); - assert!(source_fingerprint(&source_locator_for(&db, "missing")) - .unwrap() - .is_none()); - } - - #[test] - fn source_locator_round_trips() { - let source = source_locator("abc"); - let (path, id) = parse_source_locator(&source).unwrap(); - assert_eq!(path, state_db_path()); - assert_eq!(id, "abc"); - } -} diff --git a/rust/crates/memorph/src/providers/hook_profiles.rs b/rust/crates/memorph/src/providers/hook_profiles.rs index e9c0884..8f4bacf 100644 --- a/rust/crates/memorph/src/providers/hook_profiles.rs +++ b/rust/crates/memorph/src/providers/hook_profiles.rs @@ -22,16 +22,6 @@ const CLAUDE_EVENTS: &[HookProviderEventProfile] = &[ event("PreCompact", false), ]; -const CLINE_EVENTS: &[HookProviderEventProfile] = &[ - event("UserPromptSubmit", false), - event("PreToolUse", true), - event("PostToolUse", false), - event("TaskStart", false), - event("TaskResume", false), - event("TaskCancel", false), - event("TaskComplete", false), -]; - const CODEX_EVENTS: &[HookProviderEventProfile] = &[ event("SessionStart", false), event("SessionEnd", false), @@ -107,30 +97,6 @@ const QODER_EVENTS: &[HookProviderEventProfile] = &[ event("Notification", false), event("PreCompact", true), ]; -const FACTORY_EVENTS: &[HookProviderEventProfile] = &[ - event("UserPromptSubmit", true), - event("PreToolUse", false), - event("PostToolUse", true), - event("SessionStart", false), - event("SessionEnd", true), - event("Stop", true), - event("SubagentStart", true), - event("SubagentStop", true), - event("Notification", false), - event("PreCompact", true), -]; -const CODEBUDDY_EVENTS: &[HookProviderEventProfile] = &[ - event("UserPromptSubmit", true), - event("PreToolUse", false), - event("PostToolUse", true), - event("SessionStart", false), - event("SessionEnd", true), - event("Stop", true), - event("SubagentStart", true), - event("SubagentStop", true), - event("Notification", false), - event("PreCompact", true), -]; const ANTIGRAVITY_EVENTS: &[HookProviderEventProfile] = &[ event("UserPromptSubmit", true), event("PreToolUse", false), @@ -155,34 +121,6 @@ const WORKBUDDY_EVENTS: &[HookProviderEventProfile] = &[ event("Notification", false), event("PreCompact", true), ]; -const HERMES_EVENTS: &[HookProviderEventProfile] = &[ - event("UserPromptSubmit", true), - event("PreToolUse", false), - event("PostToolUse", true), - event("SessionStart", false), - event("SessionEnd", true), - event("Stop", true), - event("SubagentStart", true), - event("SubagentStop", true), - event("Notification", false), - event("PreCompact", true), -]; -const TRAE_EVENTS: &[HookProviderEventProfile] = &[ - event("session_start", false), - event("session_end", false), - event("user_prompt_submit", false), - event("pre_tool_use", false), - event("post_tool_use", false), - event("post_tool_use_failure", false), - event("permission_request", true), - event("notification", false), - event("subagent_start", false), - event("subagent_stop", false), - event("stop", false), - event("pre_compact", false), - event("post_compact", false), -]; - const PI_EVENTS: &[HookProviderEventProfile] = &[ event("SessionStart", false), event("SessionEnd", true), @@ -213,14 +151,6 @@ const PROFILES: &[HookProviderProfile] = &[ "~/.claude/settings.json", CLAUDE_EVENTS, ), - profile( - "cline", - "Cline", - HookFormat::ClineFiles, - HookConfigStrategyKind::ClineFiles, - "~/Documents/Cline/Rules/Hooks or ~/Documents/Cline/Hooks", - CLINE_EVENTS, - ), profile( "codex", "Codex", @@ -285,22 +215,6 @@ const PROFILES: &[HookProviderProfile] = &[ "~/.qoder/settings.json", QODER_EVENTS, ), - profile( - "droid", - "Factory", - HookFormat::FactoryClaudeJson, - HookConfigStrategyKind::ClaudeLikeJson, - "~/.factory/settings.json", - FACTORY_EVENTS, - ), - profile( - "codebuddy", - "CodeBuddy", - HookFormat::CodeBuddyClaudeJson, - HookConfigStrategyKind::ClaudeLikeJson, - "~/.codebuddy/settings.json", - CODEBUDDY_EVENTS, - ), profile( "antigravity", "AntiGravity", @@ -317,14 +231,6 @@ const PROFILES: &[HookProviderProfile] = &[ "~/.workbuddy/settings.json", WORKBUDDY_EVENTS, ), - profile( - "hermes", - "Hermes", - HookFormat::HermesClaudeJson, - HookConfigStrategyKind::ClaudeLikeJson, - "~/.hermes/settings.json", - HERMES_EVENTS, - ), profile( "pi", "pi", @@ -333,14 +239,6 @@ const PROFILES: &[HookProviderProfile] = &[ "~/.pi/agent/extensions/memorph.ts", PI_EVENTS, ), - profile( - "trae", - "TraeCli", - HookFormat::TraeYaml, - HookConfigStrategyKind::TraeYaml, - "~/.trae/traecli.yaml", - TRAE_EVENTS, - ), ]; const fn event(name: &'static str, blocking: bool) -> HookProviderEventProfile { @@ -381,10 +279,8 @@ pub(crate) fn find(provider: &str) -> Option<&'static HookProviderProfile> { fn normalize_provider_id(provider: &str) -> String { let provider = provider.trim().to_ascii_lowercase(); match provider.as_str() { - "traecli" | "trae-cli" | "trae_cli" => "trae".to_string(), "claude-code" | "claude_code" => "claude".to_string(), "gemini-cli" | "gemini_cli" => "gemini".to_string(), - "cline-agent" | "cline_agent" => "cline".to_string(), other => crate::providers::canonical_provider_id(other), } } @@ -405,16 +301,11 @@ mod tests { fn profile_registry_exposes_supported_hook_providers() { for provider in [ "claude", - "cline", "codex", "opencode", - "trae", "qoder", - "droid", - "codebuddy", "antigravity", "workbuddy", - "hermes", "pi", ] { assert!(supports_provider(provider), "missing profile: {provider}"); @@ -436,8 +327,6 @@ mod tests { #[test] fn accepts_provider_aliases() { assert_eq!(find("claude-code").unwrap().provider, "claude"); - assert_eq!(find("traecli").unwrap().provider, "trae"); - assert_eq!(find("factory").unwrap().provider, "droid"); } #[test] @@ -447,18 +336,12 @@ mod tests { "codex", "gemini", "cursor", - "trae", - "traecli", "qoder", - "droid", - "codebuddy", "antigravity", "workbuddy", - "hermes", "copilot", "kimi", "kiro", - "cline", "pi", ]; @@ -466,13 +349,9 @@ mod tests { if source.starts_with("codeisland-") { continue; } - let provider = match source { - "traecli" => "trae", - other => other, - }; assert!( - supports_provider(provider), - "CodeIsland provider source is not registered in memorph: {source} -> {provider}" + supports_provider(source), + "CodeIsland provider source is not registered in memorph: {source}" ); } assert!(supports_provider("opencode")); diff --git a/rust/crates/memorph/src/providers/hook_registry.rs b/rust/crates/memorph/src/providers/hook_registry.rs index 1730bde..745330b 100644 --- a/rust/crates/memorph/src/providers/hook_registry.rs +++ b/rust/crates/memorph/src/providers/hook_registry.rs @@ -11,22 +11,14 @@ static ANTIGRAVITY_ADAPTER: super::antigravity::adapter::AntiGravityHookAdapter super::antigravity::adapter::AntiGravityHookAdapter; static CLAUDE_ADAPTER: super::claude::adapter::ClaudeHookAdapter = super::claude::adapter::ClaudeHookAdapter; -static CLINE_ADAPTER: super::cline::adapter::ClineHookAdapter = - super::cline::adapter::ClineHookAdapter; -static CODEBUDDY_ADAPTER: super::codebuddy::adapter::CodeBuddyHookAdapter = - super::codebuddy::adapter::CodeBuddyHookAdapter; static CODEX_ADAPTER: super::codex::adapter::CodexHookAdapter = super::codex::adapter::CodexHookAdapter; static COPILOT_ADAPTER: super::copilot::adapter::CopilotHookAdapter = super::copilot::adapter::CopilotHookAdapter; static CURSOR_ADAPTER: super::cursor::adapter::CursorHookAdapter = super::cursor::adapter::CursorHookAdapter; -static DROID_ADAPTER: super::droid::adapter::DroidHookAdapter = - super::droid::adapter::DroidHookAdapter; static GEMINI_ADAPTER: super::gemini::adapter::GeminiHookAdapter = super::gemini::adapter::GeminiHookAdapter; -static HERMES_ADAPTER: super::hermes::adapter::HermesHookAdapter = - super::hermes::adapter::HermesHookAdapter; static KIMI_ADAPTER: super::kimi::adapter::KimiHookAdapter = super::kimi::adapter::KimiHookAdapter; static KIRO_ADAPTER: super::kiro::adapter::KiroHookAdapter = super::kiro::adapter::KiroHookAdapter; static OPENCODE_ADAPTER: super::opencode::adapter::OpenCodeHookAdapter = @@ -34,7 +26,6 @@ static OPENCODE_ADAPTER: super::opencode::adapter::OpenCodeHookAdapter = static PI_ADAPTER: super::pi::adapter::PiHookAdapter = super::pi::adapter::PiHookAdapter; static QODER_ADAPTER: super::qoder::adapter::QoderHookAdapter = super::qoder::adapter::QoderHookAdapter; -static TRAE_ADAPTER: super::trae::adapter::TraeHookAdapter = super::trae::adapter::TraeHookAdapter; static WORKBUDDY_ADAPTER: super::workbuddy::adapter::WorkBuddyHookAdapter = super::workbuddy::adapter::WorkBuddyHookAdapter; @@ -42,22 +33,17 @@ pub fn find_provider_hook(provider: &str) -> Option<&'static dyn ProviderHook> { let provider = crate::hooks::profiles::find(provider)?.provider; match provider { "claude" => Some(&super::claude::hook::CLAUDE_HOOK), - "cline" => Some(&super::cline::hook::CLINE_HOOK), "codex" => Some(&super::codex::hook::CODEX_HOOK), "copilot" => Some(&super::copilot::hook::COPILOT_HOOK), "cursor" => Some(&super::cursor::hook::CURSOR_HOOK), - "droid" => Some(&super::droid::hook::DROID_HOOK), - "codebuddy" => Some(&super::codebuddy::hook::CODEBUDDY_HOOK), "antigravity" => Some(&super::antigravity::hook::ANTIGRAVITY_HOOK), "workbuddy" => Some(&super::workbuddy::hook::WORKBUDDY_HOOK), - "hermes" => Some(&super::hermes::hook::HERMES_HOOK), "gemini" => Some(&super::gemini::hook::GEMINI_HOOK), "kiro" => Some(&super::kiro::hook::KIRO_HOOK), "kimi" => Some(&super::kimi::hook::KIMI_HOOK), "opencode" => Some(&super::opencode::hook::OPENCODE_HOOK), "pi" => Some(&super::pi::hook::PI_HOOK), "qoder" => Some(&super::qoder::hook::QODER_HOOK), - "trae" => Some(&super::trae::hook::TRAE_HOOK), _ => None, } } @@ -66,22 +52,17 @@ pub fn find_hook_adapter(provider: &str) -> Option<&'static dyn HookAdapter> { let provider = crate::hooks::profiles::find(provider)?.provider; match provider { "claude" => Some(&CLAUDE_ADAPTER), - "cline" => Some(&CLINE_ADAPTER), "codex" => Some(&CODEX_ADAPTER), "copilot" => Some(&COPILOT_ADAPTER), "cursor" => Some(&CURSOR_ADAPTER), - "droid" => Some(&DROID_ADAPTER), - "codebuddy" => Some(&CODEBUDDY_ADAPTER), "antigravity" => Some(&ANTIGRAVITY_ADAPTER), "workbuddy" => Some(&WORKBUDDY_ADAPTER), - "hermes" => Some(&HERMES_ADAPTER), "gemini" => Some(&GEMINI_ADAPTER), "kiro" => Some(&KIRO_ADAPTER), "kimi" => Some(&KIMI_ADAPTER), "opencode" => Some(&OPENCODE_ADAPTER), "pi" => Some(&PI_ADAPTER), "qoder" => Some(&QODER_ADAPTER), - "trae" => Some(&TRAE_ADAPTER), _ => None, } } @@ -96,10 +77,6 @@ mod tests { fn basic_tool_started_payload(provider: &str) -> (&'static str, Value) { match provider { - "cline" => ( - "PreToolUse", - json!({"task_id": "task-1", "tool_name": "execute_command"}), - ), "codex" => ( "PreToolUse", json!({"session_id": "s1", "tool_name": "shell"}), @@ -116,10 +93,6 @@ mod tests { "BeforeTool", json!({"session_id": "s1", "tool_name": "run_shell_command"}), ), - "trae" => ( - "pre_tool_use", - json!({"session_id": "s1", "tool_name": "Bash"}), - ), _ => ( "PreToolUse", json!({"session_id": "s1", "tool_name": "Bash"}), @@ -135,12 +108,6 @@ mod tests { .provider_id(), "claude" ); - assert_eq!( - find_provider_hook("factory") - .expect("factory hook") - .provider_id(), - "droid" - ); } #[test] @@ -151,12 +118,6 @@ mod tests { .provider_id(), "claude" ); - assert_eq!( - find_hook_adapter("factory") - .expect("factory adapter") - .provider_id(), - "droid" - ); } #[test] @@ -183,11 +144,8 @@ mod tests { let _home = TestHookHomeGuard::new(); for provider in [ "qoder", - "droid", - "codebuddy", "antigravity", "workbuddy", - "hermes", ] { assert_eq!( find_provider_hook(provider) diff --git a/rust/crates/memorph/src/providers/mod.rs b/rust/crates/memorph/src/providers/mod.rs index d63e295..ae76c0f 100644 --- a/rust/crates/memorph/src/providers/mod.rs +++ b/rust/crates/memorph/src/providers/mod.rs @@ -2,30 +2,18 @@ mod aliases; pub mod antigravity; pub mod catalog; pub mod claude; -pub mod cline; -pub mod codebuddy; pub mod codex; pub mod copilot; pub mod cursor; -pub mod deepseek; -pub mod droid; -pub mod emerging; pub(crate) mod environment_profiles; pub mod gemini; -pub(crate) mod generic_json; -pub mod hermes; pub mod kimi; pub mod kiro; -pub mod openclaw; pub mod opencode; pub mod pi; pub mod qoder; -pub mod qwen; -pub mod trae; -pub mod windsurf; pub mod workbuddy; -pub mod amazonq; pub(crate) mod hook_profiles; mod hook_registry; pub(crate) mod q_conversation; @@ -35,26 +23,15 @@ use crate::provider::Provider; const PROVIDER_IDS: &[&str] = &[ "claude", "codex", - "cline", "cursor", "opencode", - "openclaw", - "augment", "kiro", - "deepseek", "kimi", "gemini", "antigravity", "copilot", - "windsurf", - "codebuddy", "qoder", - "trae", - "droid", "workbuddy", - "hermes", - "amazonq", - "qwen", "pi", ]; @@ -70,27 +47,16 @@ impl ProviderRegistry { match id.as_str() { "claude" => Some(Box::new(claude::ClaudeProvider)), "codex" => Some(Box::new(codex::CodexProvider)), - "cline" => Some(Box::new(cline::ClineProvider)), "cursor" => Some(Box::new(cursor::CursorProvider)), - "deepseek" => Some(Box::new(deepseek::DeepseekProvider)), "antigravity" => Some(Box::new(antigravity::AntigravityProvider)), "copilot" => Some(Box::new(copilot::CopilotProvider)), - "windsurf" => Some(Box::new(windsurf::WindsurfProvider)), - "codebuddy" => Some(Box::new(codebuddy::CodeBuddyProvider)), "qoder" => Some(Box::new(qoder::QoderProvider)), - "trae" => Some(Box::new(trae::TraeProvider)), - "droid" => Some(Box::new(droid::DroidProvider)), "workbuddy" => Some(Box::new(workbuddy::WorkBuddyProvider)), - "hermes" => Some(Box::new(hermes::HermesProvider)), - "amazonq" => Some(Box::new(amazonq::AmazonQProvider)), - "qwen" => Some(Box::new(qwen::QwenProvider)), "pi" => Some(Box::new(pi::PiProvider)), "gemini" => Some(Box::new(gemini::GeminiProvider)), "kiro" => Some(Box::new(kiro::KiroProvider)), "kimi" => Some(Box::new(kimi::KimiProvider)), "opencode" => Some(Box::new(opencode::OpenCodeProvider)), - "openclaw" => Some(Box::new(openclaw::OpenClawProvider)), - "augment" => Some(Box::new(emerging::AugmentProvider)), _ => None, } } @@ -146,20 +112,10 @@ mod tests { for id in [ "gemini", "antigravity", - "cline", "copilot", - "windsurf", - "codebuddy", "qoder", - "trae", - "droid", "workbuddy", - "hermes", "pi", - "amazonq", - "qwen", - "openclaw", - "augment", ] { assert!( all_provider_ids().contains(&id), @@ -169,30 +125,6 @@ mod tests { } } - #[test] - fn registry_uses_native_hermes_sqlite_provider() { - let provider = find_provider("hermes").expect("hermes provider"); - let capabilities = provider.capabilities(); - assert_eq!( - capabilities.storage_shape, - crate::provider::StorageShape::Sqlite - ); - assert_eq!( - capabilities.page_strategy, - crate::provider::PageStrategy::FullImport - ); - assert!(capabilities.resume); - assert!(!capabilities.delete); - assert!(!capabilities.rename); - assert!(!capabilities.export); - } - - #[test] - fn factory_alias_resolves_to_droid_provider() { - let provider = find_provider("factory").expect("factory alias"); - assert_eq!(provider.id(), "droid"); - } - /// Providers without export must not claim meaningful export fidelity: /// if export=false, every export_fidelity field must be None or Unsupported. /// Providers with export must declare all 9 export_fidelity fields. diff --git a/rust/crates/memorph/src/providers/openclaw/mod.rs b/rust/crates/memorph/src/providers/openclaw/mod.rs deleted file mode 100644 index 69673bb..0000000 --- a/rust/crates/memorph/src/providers/openclaw/mod.rs +++ /dev/null @@ -1,447 +0,0 @@ -use crate::provider::{ - PageStrategy, Provider, ProviderCapabilities, ProviderContentFidelity, ProviderSessionSummary, - ProviderSourceFingerprint, ScanStrategy, StorageShape, TurnQuality, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingIssue, MappingIssueLevel, MappingReport, Metadata, Provenance, ProviderRef, Role, - Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{Connection, OpenFlags, OptionalExtension}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::{Duration, UNIX_EPOCH}; - -pub struct OpenClawProvider; -const PROVIDER_ID: &str = "openclaw"; -const DATABASE_NAME: &str = "openclaw-agent.sqlite"; - -impl Provider for OpenClawProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "OpenClaw" - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - storage_shape: StorageShape::Sqlite, - scan_strategy: ScanStrategy::Indexed, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - ..ProviderCapabilities::default() - } - } - - fn scan_sessions(&self) -> Result> { - scan_root(&openclaw_agents_dir()) - } - - fn import_session(&self, source_path: &str) -> Result { - let (db, agent_id, session_id) = parse_locator(source_path)?; - let conn = open_read_only(&db)?; - let metadata = conn.query_row( - "SELECT session_key, display_name, model, created_at, updated_at FROM sessions WHERE session_id = ?1", - [&session_id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, row.get::<_, i64>(3)?, row.get::<_, i64>(4)?)), - ).with_context(|| format!("OpenClaw session not found: {session_id}"))?; - - let mut stmt = conn.prepare( - "SELECT seq, event_json, created_at FROM transcript_events WHERE session_id = ?1 ORDER BY seq", - )?; - let rows = stmt.query_map([&session_id], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - )) - })?; - let mut events = Vec::new(); - for row in rows { - let (seq, raw, created_at) = row?; - let value: Value = serde_json::from_str(&raw) - .with_context(|| format!("invalid OpenClaw transcript event at sequence {seq}"))?; - if let Some(event) = event_from_value(seq, created_at, value) { - events.push(event); - } - } - - let title = metadata.1.or_else(|| first_user_text(&events)); - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - report.push_issue(MappingIssue { - level: MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "openclaw-sqlite-source".into(), - message: "Imported from OpenClaw sessions and transcript_events tables".into(), - path: Some("source".into()), - raw: None, - }); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: session_id.clone(), - title, - }, - context: Context { - workspace: None, - created_at: Some(datetime_from_ms(metadata.3)), - last_active_at: Some(datetime_from_ms(metadata.4)), - tags: vec![format!("agent:{agent_id}")], - }, - events, - extensions: BTreeMap::new(), - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: session_id.clone(), - source_path: Some(source_path.into()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - fingerprint(source_path) - } -} - -fn scan_root(root: &Path) -> Result> { - let mut sessions = Vec::new(); - let Ok(entries) = std::fs::read_dir(root) else { - return Ok(sessions); - }; - for entry in entries.flatten() { - let agent_id = entry.file_name().to_string_lossy().into_owned(); - let db = entry.path().join("agent").join(DATABASE_NAME); - if !db.is_file() { - continue; - } - let conn = match open_read_only(&db) { - Ok(conn) => conn, - Err(_) => continue, - }; - let mut stmt = match conn.prepare("SELECT session_id, session_key, display_name, updated_at FROM sessions ORDER BY updated_at DESC") { - Ok(stmt) => stmt, - Err(_) => continue, - }; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, i64>(3)?, - )) - })?; - for row in rows { - let (session_id, _session_key, title, updated_at) = row?; - sessions.push(ProviderSessionSummary { - archived: false, - source_path: Some(locator(&db, &agent_id, &session_id)), - session_id, - title, - project_dir: None, - created_at: None, - last_active_at: Some(updated_at), - }); - } - } - sessions.sort_by_key(|session| std::cmp::Reverse(session.last_active_at.unwrap_or(0))); - Ok(sessions) -} - -fn event_from_value(seq: i64, created_at: i64, value: Value) -> Option { - if value.get("type").and_then(Value::as_str) != Some("message") { - return None; - } - let message = value.get("message")?; - let role_text = message - .get("role") - .and_then(Value::as_str) - .unwrap_or("unknown"); - let role = match role_text { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - _ => Role::Other, - }; - let mut blocks = Vec::new(); - match message.get("content") { - Some(Value::String(text)) if !text.is_empty() => { - blocks.push(Block::Text { text: text.clone() }) - } - Some(Value::Array(items)) => { - for item in items { - match item.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(text) = item.get("text").and_then(Value::as_str) { - blocks.push(Block::Text { text: text.into() }); - } - } - Some("thinking") => { - if let Some(text) = item - .get("thinking") - .or_else(|| item.get("text")) - .and_then(Value::as_str) - { - blocks.push(Block::Thinking { - text: text.into(), - signature: item - .get("signature") - .and_then(Value::as_str) - .map(str::to_owned), - }); - } - } - Some("tool_use") => blocks.push(Block::ToolCall { - tool_call_id: item - .get("id") - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - name: item - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .into(), - input: item.get("input").cloned(), - }), - Some("tool_result") => blocks.push(Block::ToolResult { - tool_call_id: item - .get("tool_use_id") - .or_else(|| item.get("toolCallId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - content: text_content(item.get("content")), - outcome: crate::session::execution_outcome( - item.get("is_error") - .or_else(|| item.get("isError")) - .and_then(Value::as_bool) - .unwrap_or(false), - ), - }), - _ => blocks.push(Block::Other { raw: item.clone() }), - } - } - } - _ => {} - } - if blocks.is_empty() { - blocks.push(Block::Other { - raw: message.clone(), - }); - } - let kind = if blocks - .iter() - .any(|block| matches!(block, Block::ToolResult { .. })) - { - EventKind::Observation - } else if blocks - .iter() - .any(|block| matches!(block, Block::ToolCall { .. })) - { - EventKind::Action - } else { - EventKind::Message - }; - let id = value - .get("id") - .and_then(Value::as_str) - .map(str::to_owned) - .unwrap_or_else(|| seq.to_string()); - Some(Event { - id: id.clone(), - kind, - role, - timestamp: datetime_from_ms(created_at), - links: Links::default(), - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: message - .get("model") - .and_then(Value::as_str) - .map(str::to_owned), - usage: None, - }, - }) -} - -fn text_content(value: Option<&Value>) -> String { - match value { - Some(Value::String(text)) => text.clone(), - Some(value) => serde_json::to_string(value).unwrap_or_default(), - None => String::new(), - } -} -fn first_user_text(events: &[Event]) -> Option { - events - .iter() - .find(|event| event.role == Role::User) - .and_then(|event| { - event.blocks.iter().find_map(|block| match block { - Block::Text { text } => Some(text.chars().take(80).collect()), - _ => None, - }) - }) -} -fn openclaw_agents_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".openclaw/agents") -} -fn locator(db: &Path, agent_id: &str, session_id: &str) -> String { - format!( - "{}#agent={agent_id}&session={session_id}", - db.to_string_lossy() - ) -} -fn parse_locator(source: &str) -> Result<(PathBuf, String, String)> { - let (path, fragment) = source - .split_once('#') - .context("OpenClaw source must contain agent and session ids")?; - let mut agent = None; - let mut session = None; - for item in fragment.split('&') { - if let Some(value) = item.strip_prefix("agent=") { - agent = Some(value.to_string()); - } - if let Some(value) = item.strip_prefix("session=") { - session = Some(value.to_string()); - } - } - Ok(( - PathBuf::from(path), - agent - .filter(|v| !v.is_empty()) - .context("OpenClaw source agent id is empty")?, - session - .filter(|v| !v.is_empty()) - .context("OpenClaw source session id is empty")?, - )) -} -fn open_read_only(path: &Path) -> Result { - Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("failed to open OpenClaw database at {}", path.display())) -} -fn datetime_from_ms(value: i64) -> DateTime { - DateTime::::from(UNIX_EPOCH + Duration::from_millis(value.max(0) as u64)) -} -fn fingerprint(source: &str) -> Result> { - let (db, _, session_id) = parse_locator(source)?; - let metadata = match std::fs::metadata(&db) { - Ok(value) => value, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - let conn = open_read_only(&db)?; - let updated = conn - .query_row( - "SELECT updated_at FROM sessions WHERE session_id = ?1", - [&session_id], - |row| row.get::<_, i64>(0), - ) - .optional()?; - let Some(updated) = updated else { - return Ok(None); - }; - let mut digest = Sha256::new(); - digest.update(updated.to_le_bytes()); - let mut stmt = conn.prepare("SELECT seq, event_json, created_at FROM transcript_events WHERE session_id = ?1 ORDER BY seq")?; - let rows = stmt.query_map([&session_id], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - )) - })?; - for row in rows { - let (seq, event, created) = row?; - digest.update(seq.to_le_bytes()); - digest.update(event); - digest.update(created.to_le_bytes()); - } - let modified_at_ms = metadata - .modified() - .ok() - .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) - .map(|value| value.as_millis().min(i64::MAX as u128) as i64) - .unwrap_or(0); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms, - size_bytes: metadata.len().min(i64::MAX as u64) as i64, - value: format!("openclaw-sqlite-v1:{session_id}:{:x}", digest.finalize()), - })) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn native_sqlite_scan_import_and_fingerprint() { - let root = tempfile::tempdir().unwrap(); - let agent_dir = root.path().join("main/agent"); - std::fs::create_dir_all(&agent_dir).unwrap(); - let db = agent_dir.join(DATABASE_NAME); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch("CREATE TABLE sessions (session_id TEXT PRIMARY KEY, session_key TEXT NOT NULL, display_name TEXT, model TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); CREATE TABLE transcript_events (session_id TEXT NOT NULL, seq INTEGER NOT NULL, event_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(session_id, seq)); INSERT INTO sessions VALUES ('s1','agent:main:main','Fixture','model-x',1000,3000); INSERT INTO transcript_events VALUES ('s1',1,'{\"type\":\"message\",\"id\":\"m1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}',1000); INSERT INTO transcript_events VALUES ('s1',2,'{\"type\":\"message\",\"id\":\"m2\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"c1\",\"name\":\"exec\",\"input\":{\"cmd\":\"pwd\"}}]}}',2000);").unwrap(); - drop(conn); - let sessions = scan_root(root.path()).unwrap(); - assert_eq!(sessions.len(), 1); - let imported = OpenClawProvider - .import_session(sessions[0].source_path.as_deref().unwrap()) - .unwrap(); - assert_eq!(imported.session.events.len(), 2); - assert!( - matches!(imported.session.events[1].blocks[0], Block::ToolCall { ref name, .. } if name == "exec") - ); - let source = sessions[0].source_path.as_deref().unwrap(); - let before = fingerprint(source).unwrap().unwrap(); - let conn = Connection::open(&db).unwrap(); - conn.execute( - "UPDATE sessions SET updated_at = 4000, display_name = 'Changed' WHERE session_id = 's1'", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO transcript_events VALUES ('s1', 3, '{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"changed\"}}', 4000)", - [], - ) - .unwrap(); - let after = fingerprint(source).unwrap().unwrap(); - assert_ne!(before.value, after.value); - } -} diff --git a/rust/crates/memorph/src/providers/qwen.rs b/rust/crates/memorph/src/providers/qwen.rs deleted file mode 100644 index 863bd0d..0000000 --- a/rust/crates/memorph/src/providers/qwen.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Qwen CLI sessions, whose JSONL format follows Gemini CLI. -use crate::provider::{ - Provider, ProviderCapabilities, ProviderContentFidelity, ProviderSessionSummary, ScanStrategy, - StorageShape, TurnQuality, -}; -use crate::session::{ - Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::Result; -use chrono::Utc; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -pub struct QwenProvider; -const ID: &str = "qwen"; -impl Provider for QwenProvider { - fn id(&self) -> &'static str { - ID - } - fn name(&self) -> &'static str { - "Qwen" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - scan_strategy: ScanStrategy::FullScan, - storage_shape: StorageShape::Jsonl, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - ..ProviderCapabilities::default() - } - } - fn scan_sessions(&self) -> Result> { - let Some(home) = dirs::home_dir() else { - return Ok(Vec::new()); - }; - let root = home.join(".qwen").join("projects"); - let mut out = Vec::new(); - if !root.is_dir() { - return Ok(out); - } - for e in WalkDir::new(root) - .min_depth(1) - .max_depth(4) - .into_iter() - .filter_map(Result::ok) - .filter(|e| { - e.file_type().is_file() - && e.path().extension().and_then(|x| x.to_str()) == Some("jsonl") - }) - { - let path = e.path(); - let records = read_records(path)?; - let Some(id) = records.iter().find_map(|v| { - v.get("sessionId") - .or_else(|| v.get("session_id")) - .and_then(Value::as_str) - }) else { - continue; - }; - let title = records - .iter() - .find_map(|v| v.get("content").and_then(Value::as_str)) - .map(|s| s.chars().take(100).collect()); - out.push(ProviderSessionSummary { - archived: false, - session_id: id.into(), - title, - project_dir: path.parent().map(|p| p.to_string_lossy().into()), - created_at: None, - last_active_at: None, - source_path: Some(path.to_string_lossy().into()), - }); - } - Ok(out) - } - fn import_session(&self, source_path: &str) -> Result { - let path = Path::new(source_path); - let records = read_records(path)?; - let id = records - .iter() - .find_map(|v| { - v.get("sessionId") - .or_else(|| v.get("session_id")) - .and_then(Value::as_str) - }) - .unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("qwen")) - .to_string(); - let report = MappingReport::new(ID, MappingDirection::Import); - let events = records - .into_iter() - .enumerate() - .filter_map(|(i, v)| event_from_record(&v, i)) - .collect::>(); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(ID)) - .collect(); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.clone(), - title: None, - }, - context: Context { - workspace: path.parent().map(|p| p.to_string_lossy().into()), - created_at: None, - last_active_at: None, - tags: Vec::new(), - }, - events, - extensions: Default::default(), - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: ID.into(), - session_id: id, - source_path: Some(source_path.into()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - fn session_source_fingerprint( - &self, - p: &str, - ) -> Result> { - let m = std::fs::metadata(p)?; - let d = Sha256::digest(std::fs::read(p)?); - Ok(Some(crate::provider::ProviderSourceFingerprint { - modified_at_ms: 0, - size_bytes: m.len() as i64, - value: format!("qwen-jsonl-v1:{d:x}"), - })) - } - fn data_source_paths(&self) -> Vec { - dirs::home_dir() - .into_iter() - .map(|h| h.join(".qwen").join("projects")) - .collect() - } -} -fn read_records(p: &Path) -> Result> { - Ok(std::fs::read_to_string(p)? - .lines() - .filter_map(|l| serde_json::from_str(l).ok()) - .collect()) -} -fn event_from_record(v: &Value, i: usize) -> Option { - let role = match v.get("type").and_then(Value::as_str).unwrap_or("") { - "user" => Role::User, - "assistant" | "gemini" => Role::Assistant, - _ => return None, - }; - let text = v.get("content").and_then(Value::as_str).or_else(|| { - v.get("message") - .and_then(|m| m.get("content")) - .and_then(Value::as_str) - })?; - Some(Event { - id: format!("qwen:{i}"), - kind: EventKind::Message, - role, - timestamp: Utc::now(), - links: Links::default(), - blocks: vec![crate::session::Block::Text { text: text.into() }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) -} diff --git a/rust/crates/memorph/src/providers/trae/adapter.rs b/rust/crates/memorph/src/providers/trae/adapter.rs deleted file mode 100644 index 7489dcd..0000000 --- a/rust/crates/memorph/src/providers/trae/adapter.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! Trae / TraeCli hook adapter. -//! -//! TraeCli uses snake_case hook names in `~/.trae/traecli.yaml`. This adapter -//! maps those events into memorph's canonical runtime model while keeping the -//! provider-specific payload parsing isolated from the hook runtime. - -use anyhow::Result; -use chrono::{DateTime, TimeZone, Utc}; -use serde_json::Value; -use std::path::PathBuf; - -use crate::hooks::contract::HookAdapter; -use crate::hooks::model::{ - HookEvent, HookEventType, HookMessage, HookToolCall, PermissionRequest, QuestionRequest, -}; -use crate::hooks::protocol::HookIngestRequest; - -pub struct TraeHookAdapter; - -impl HookAdapter for TraeHookAdapter { - fn provider_id(&self) -> &'static str { - "trae" - } - - fn normalize(&self, request: &HookIngestRequest) -> Result> { - let raw = request.raw.clone(); - let event_name = string_at( - &raw, - &[ - "hook_event_name", - "hookEventName", - "event_name", - "eventName", - "event", - "type", - ], - ) - .unwrap_or_else(|| request.event_name.clone()); - let mut event = HookEvent::new("trae", trae_event_type(&event_name), raw.clone()); - - event.event_id = string_at(&raw, &["event_id", "eventId", "id"]).unwrap_or(event.event_id); - event.provider_session_id = string_at( - &raw, - &[ - "session_id", - "sessionId", - "conversation_id", - "conversationId", - "thread_id", - "threadId", - ], - ); - event.run_id = string_at( - &raw, - &[ - "run_id", - "runId", - "request_id", - "requestId", - "call_id", - "callId", - ], - ) - .or_else(|| Some(request.request_id.clone())); - event.cwd = path_at( - &raw, - &[ - "cwd", - "workspace", - "workspace_dir", - "workspaceDir", - "project_dir", - "projectDir", - ], - ) - .or_else(|| request.environment.cwd.clone()); - event.pid = u32_at(&raw, &["pid"]).or(request.environment.pid); - event.parent_pid = - u32_at(&raw, &["parent_pid", "parentPid", "ppid"]).or(request.environment.parent_pid); - event.tty = string_at(&raw, &["tty", "terminal_tty", "terminalTty"]) - .or_else(|| request.environment.tty.clone()); - event.timestamp = timestamp_at(&raw).unwrap_or(request.received_at); - - let tool = tool_at(&raw); - event.tool = tool.clone(); - event.message = message_at(&raw); - event.permission = match event.event_type { - HookEventType::PermissionRequested => Some(permission_at(&raw, tool)), - _ => None, - }; - event.question = match event.event_type { - HookEventType::QuestionRequested => question_at(&raw), - _ => None, - }; - - Ok(vec![event]) - } -} - -fn trae_event_type(event_name: &str) -> HookEventType { - match normalize_name(event_name).as_str() { - "sessionstart" => HookEventType::SessionStarted, - "sessionend" | "stop" => HookEventType::SessionCompleted, - "userpromptsubmit" | "notification" => HookEventType::MessageCreated, - "pretooluse" => HookEventType::ToolStarted, - "posttooluse" | "posttoolusefailure" => HookEventType::ToolFinished, - "permissionrequest" => HookEventType::PermissionRequested, - "askuserquestion" | "questionrequest" => HookEventType::QuestionRequested, - "subagentstart" | "subagentstop" | "precompact" | "postcompact" => HookEventType::Heartbeat, - _ => HookEventType::from_provider_name(event_name), - } -} - -fn normalize_name(value: &str) -> String { - value - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn tool_at(value: &Value) -> Option { - if let Some(tool) = value.get("tool").filter(|tool| tool.is_object()) { - let name = string_at(tool, &["name", "tool_name", "toolName"])?; - return Some(HookToolCall { - id: string_at( - tool, - &["id", "tool_use_id", "toolUseId", "call_id", "callId"], - ), - name, - input: value_at(tool, &["input", "arguments", "args", "params"]).unwrap_or(Value::Null), - }); - } - - let name = string_at(value, &["tool_name", "toolName", "tool", "name"])?; - Some(HookToolCall { - id: string_at( - value, - &["tool_use_id", "toolUseId", "call_id", "callId", "id"], - ), - name, - input: value_at( - value, - &[ - "tool_input", - "toolInput", - "input", - "arguments", - "args", - "params", - ], - ) - .unwrap_or(Value::Null), - }) -} - -fn permission_at(value: &Value, tool: Option) -> PermissionRequest { - PermissionRequest { - request_id: string_at( - value, - &[ - "permission_id", - "permissionId", - "request_id", - "requestId", - "tool_use_id", - "toolUseId", - "call_id", - "callId", - ], - ) - .or_else(|| tool.as_ref().and_then(|tool| tool.id.clone())), - tool, - prompt: string_at( - value, - &[ - "permission_prompt", - "permissionPrompt", - "prompt", - "reason", - "message", - ], - ), - } -} - -fn question_at(value: &Value) -> Option { - let prompt = string_at( - value, - &[ - "question", - "question_prompt", - "questionPrompt", - "prompt", - "message", - ], - )?; - Some(QuestionRequest { - request_id: string_at( - value, - &["question_id", "questionId", "request_id", "requestId"], - ), - prompt, - }) -} - -fn message_at(value: &Value) -> Option { - let text = string_at( - value, - &[ - "message", - "text", - "content", - "prompt", - "user_prompt", - "userPrompt", - ], - )?; - Some(HookMessage { - role: string_at(value, &["role"]), - text, - }) -} - -fn string_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::String(s)) if !s.trim().is_empty() => Some(s.to_string()), - Some(Value::Number(n)) => Some(n.to_string()), - Some(Value::Bool(b)) => Some(b.to_string()), - _ => None, - }) -} - -fn u32_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| match value.get(*key) { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()), - Some(Value::String(s)) => s.parse().ok(), - _ => None, - }) -} - -fn path_at(value: &Value, keys: &[&str]) -> Option { - string_at(value, keys).map(PathBuf::from) -} - -fn value_at(value: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| value.get(*key).cloned()) -} - -fn timestamp_at(value: &Value) -> Option> { - let raw = value - .get("timestamp") - .or_else(|| value.get("created_at")) - .or_else(|| value.get("createdAt"))?; - match raw { - Value::String(s) => DateTime::parse_from_rfc3339(s) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|| s.parse::().ok().and_then(unix_timestamp)), - Value::Number(n) => n.as_i64().and_then(unix_timestamp), - _ => None, - } -} - -fn unix_timestamp(value: i64) -> Option> { - Utc.timestamp_opt(value, 0).single() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::protocol::HookIngestRequest; - use serde_json::json; - - #[test] - fn maps_traecli_permission_request() { - let request = HookIngestRequest::new( - "trae", - "permission_request", - json!({ - "event": "permission_request", - "session_id": "s1", - "tool_name": "Bash", - "tool_input": {"command": "cargo test"}, - "request_id": "r1", - "prompt": "Allow command?" - }), - ); - let event = TraeHookAdapter.normalize(&request).unwrap().remove(0); - assert_eq!(event.provider, "trae"); - assert_eq!(event.event_type, HookEventType::PermissionRequested); - assert_eq!(event.provider_session_id.as_deref(), Some("s1")); - assert_eq!(event.permission.unwrap().request_id.as_deref(), Some("r1")); - } -} diff --git a/rust/crates/memorph/src/providers/trae/hook.rs b/rust/crates/memorph/src/providers/trae/hook.rs deleted file mode 100644 index eb3a446..0000000 --- a/rust/crates/memorph/src/providers/trae/hook.rs +++ /dev/null @@ -1,530 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -use anyhow::{Context as _, Result}; - -use crate::hooks::contract::ProviderHook; -use crate::hooks::model::{HookHealthStatus, HookInstallStatus, HookOperationReport}; -use crate::storage::atomic_write; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct TraeHookEvent { - name: &'static str, - timeout_sec: u64, - blocking: bool, -} - -const EVENTS: &[TraeHookEvent] = &[ - event("session_start", 5, false), - event("session_end", 5, false), - event("user_prompt_submit", 5, false), - event("pre_tool_use", 5, false), - event("post_tool_use", 5, false), - event("post_tool_use_failure", 5, false), - event("permission_request", 86400, true), - event("notification", 86400, false), - event("subagent_start", 5, false), - event("subagent_stop", 5, false), - event("stop", 5, false), - event("pre_compact", 5, false), - event("post_compact", 5, false), -]; - -const fn event(name: &'static str, timeout_sec: u64, blocking: bool) -> TraeHookEvent { - TraeHookEvent { - name, - timeout_sec, - blocking, - } -} - -pub struct TraeHook; - -pub static TRAE_HOOK: TraeHook = TraeHook; - -impl ProviderHook for TraeHook { - fn provider_id(&self) -> &'static str { - "trae" - } - - fn status(&self) -> Result { - status() - } - - fn install(&self) -> Result { - install() - } - - fn verify(&self) -> Result { - let status = self.status()?; - Ok(HookOperationReport { - provider: self.provider_id().to_string(), - operation: "verify".to_string(), - changed: false, - backup_path: None, - message: status.message.clone(), - status, - }) - } - - fn repair(&self) -> Result { - let before = self.status()?; - let mut report = self.install()?; - report.operation = "repair".to_string(); - report.changed = before.status != HookHealthStatus::InstalledOk; - Ok(report) - } - - fn uninstall(&self) -> Result { - uninstall() - } -} - -pub(crate) fn config_path() -> PathBuf { - crate::hooks::shared::hook_home_dir() - .join(".trae") - .join("traecli.yaml") -} - -fn required_events() -> impl Iterator { - EVENTS.iter().map(|event| event.name) -} - -fn status() -> Result { - let path = config_path(); - let config_path = Some(path.display().to_string()); - if !path.exists() { - return Ok(HookInstallStatus { - provider: "trae".to_string(), - status: HookHealthStatus::NotInstalled, - config_path, - installed_version: None, - current_version: Some(crate::hooks::shared::current_hook_managed_version().to_string()), - message: Some("TraeCli traecli.yaml does not exist.".to_string()), - last_event_at: crate::hooks::health::last_event_at("trae"), - }); - } - - let contents = fs::read_to_string(&path)?; - let missing: Vec<&str> = required_events() - .filter(|event| !contents_contains_memorph_hook(&contents, event)) - .collect(); - let installed_version = crate::hooks::health::summarize_versions( - required_events().filter_map(|event| event_memorph_hook_version(&contents, event)), - ); - let current_version = Some(crate::hooks::shared::current_hook_managed_version().to_string()); - let stale = missing.is_empty() - && installed_version.as_deref() - != Some(crate::hooks::shared::current_hook_managed_version()); - let health = if missing.is_empty() && !stale { - HookHealthStatus::InstalledOk - } else if missing.len() == EVENTS.len() { - HookHealthStatus::NotInstalled - } else if stale { - HookHealthStatus::InstalledStaleBinary - } else { - HookHealthStatus::Repairable - }; - let message = if stale { - Some(format!( - "TraeCli memorph hooks are installed but stale: installed {}, current {}.", - installed_version.as_deref().unwrap_or("unknown"), - crate::hooks::shared::current_hook_managed_version() - )) - } else if missing.is_empty() { - Some("TraeCli memorph hooks are installed.".to_string()) - } else { - Some(format!( - "Missing memorph hook events: {}", - missing.join(", ") - )) - }; - - Ok(HookInstallStatus { - provider: "trae".to_string(), - status: health, - config_path, - installed_version, - current_version, - message, - last_event_at: crate::hooks::health::last_event_at("trae"), - }) -} - -fn install() -> Result { - let path = config_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create TraeCli config directory: {}", - parent.display() - ) - })?; - } - - let original = fs::read_to_string(&path).unwrap_or_default(); - let backup_path = crate::hooks::shared::backup_if_exists(&path)?; - let command_base = crate::hooks::shared::bridge_command_base()?; - let updated = merge_traecli_hooks(&original, &command_base)?; - let changed = updated != original; - if changed { - atomic_write::write_string_atomic(&path, &updated)?; - } - let status = status()?; - Ok(HookOperationReport { - provider: "trae".to_string(), - operation: "install".to_string(), - changed, - backup_path: backup_path.map(|path| path.display().to_string()), - message: Some("TraeCli hook entries installed.".to_string()), - status, - }) -} - -fn uninstall() -> Result { - let path = config_path(); - if !path.exists() { - let status = status()?; - return Ok(HookOperationReport { - provider: "trae".to_string(), - operation: "uninstall".to_string(), - changed: false, - status, - backup_path: None, - message: Some("TraeCli config file does not exist.".to_string()), - }); - } - - let original = fs::read_to_string(&path).unwrap_or_default(); - let backup_path = crate::hooks::shared::backup_if_exists(&path)?; - let updated = remove_traecli_hooks(&original); - let changed = updated != original; - if changed { - atomic_write::write_string_atomic(&path, &updated)?; - } - let status = status()?; - Ok(HookOperationReport { - provider: "trae".to_string(), - operation: "uninstall".to_string(), - changed, - status, - backup_path: backup_path.map(|path| path.display().to_string()), - message: Some("TraeCli memorph hook entries removed.".to_string()), - }) -} - -pub(crate) fn contents_contains_memorph_hook(contents: &str, event: &str) -> bool { - hook_blocks_from_contents(contents) - .into_iter() - .any(|block| { - block_event(&block).as_deref() == Some(event) && block_contains_memorph_command(&block) - }) -} - -pub(crate) fn event_memorph_hook_version(contents: &str, event: &str) -> Option> { - hook_blocks_from_contents(contents) - .into_iter() - .find(|block| { - block_event(block).as_deref() == Some(event) && block_contains_memorph_command(block) - }) - .map(|block| block_managed_version(&block)) -} - -pub(crate) fn merge_traecli_hooks(contents: &str, command_base: &str) -> Result { - let cleaned = remove_traecli_hooks(contents); - let mut lines: Vec = cleaned - .replace("\r\n", "\n") - .split('\n') - .map(ToString::to_string) - .collect(); - - while lines.last().is_some_and(|line| line.trim().is_empty()) { - lines.pop(); - } - - let rendered = render_hooks(command_base)?; - if let Some(hooks_index) = lines.iter().position(|line| { - let trimmed = line.trim(); - line == trimmed - && (trimmed == "hooks:" - || trimmed == "hooks: []" - || trimmed == "hooks: null" - || trimmed == "hooks: ~") - }) { - lines[hooks_index] = "hooks:".to_string(); - let mut rendered_lines: Vec = rendered.lines().map(ToString::to_string).collect(); - lines.splice(hooks_index + 1..hooks_index + 1, rendered_lines.drain(..)); - } else { - if !lines.is_empty() { - lines.push(String::new()); - } - lines.push("hooks:".to_string()); - lines.extend(rendered.lines().map(ToString::to_string)); - } - - let mut output = lines.join("\n"); - output.push('\n'); - Ok(output) -} - -fn render_hooks(command_base: &str) -> Result { - let mut blocks = Vec::new(); - for event in EVENTS { - let command = format!( - "{} --managed-version {} --provider trae --event {}{}", - command_base, - crate::hooks::shared::current_hook_managed_version(), - event.name, - if event.blocking { " --blocking" } else { "" } - ); - blocks.push(format!( - " - type: command\n command: '{}'\n timeout: '{}s'\n matchers:\n - event: {}", - yaml_single_quote(&command), - event.timeout_sec, - event.name - )); - } - Ok(blocks.join("\n")) -} - -pub(crate) fn remove_traecli_hooks(contents: &str) -> String { - let normalized = contents.replace("\r\n", "\n"); - let had_trailing_newline = normalized.ends_with('\n'); - let lines: Vec = normalized.lines().map(ToString::to_string).collect(); - let mut result = Vec::new(); - let mut idx = 0; - while idx < lines.len() { - let line = &lines[idx]; - let trimmed = line.trim_start(); - if is_hook_item_start(trimmed) { - let indent = line.len() - trimmed.len(); - let mut next = idx + 1; - while next < lines.len() { - let candidate = &lines[next]; - let candidate_trimmed = candidate.trim_start(); - let candidate_indent = candidate.len() - candidate_trimmed.len(); - if candidate_indent == indent && candidate_trimmed.starts_with("- ") { - break; - } - if candidate_indent < indent && !candidate_trimmed.trim().is_empty() { - break; - } - next += 1; - } - if block_contains_memorph_command(&lines[idx..next]) { - idx = next; - continue; - } - result.extend(lines[idx..next].iter().cloned()); - idx = next; - continue; - } - result.push(line.clone()); - idx += 1; - } - while result.last().is_some_and(|line| line.trim().is_empty()) { - result.pop(); - } - join_lines(result, had_trailing_newline) -} - -fn hook_blocks_from_contents(contents: &str) -> Vec> { - let lines: Vec = contents - .replace("\r\n", "\n") - .lines() - .map(ToString::to_string) - .collect(); - let mut blocks = Vec::new(); - let mut idx = 0; - while idx < lines.len() { - let line = &lines[idx]; - let trimmed = line.trim_start(); - if is_hook_item_start(trimmed) { - let indent = line.len() - trimmed.len(); - let mut next = idx + 1; - while next < lines.len() { - let candidate = &lines[next]; - let candidate_trimmed = candidate.trim_start(); - let candidate_indent = candidate.len() - candidate_trimmed.len(); - if candidate_indent == indent && candidate_trimmed.starts_with("- ") { - break; - } - if candidate_indent < indent && !candidate_trimmed.trim().is_empty() { - break; - } - next += 1; - } - blocks.push(lines[idx..next].to_vec()); - idx = next; - } else { - idx += 1; - } - } - blocks -} - -fn is_hook_item_start(trimmed: &str) -> bool { - trimmed == "- type: command" - || trimmed.starts_with("- type: command ") - || trimmed.starts_with("- type: command #") -} - -fn block_event(block: &[String]) -> Option { - for (idx, line) in block.iter().enumerate() { - let trimmed = line.trim(); - if let Some(raw) = trimmed.strip_prefix("- event:") { - return Some(parse_yaml_scalar(raw)); - } - if trimmed == "matchers:" { - for matcher_line in block.iter().skip(idx + 1) { - let matcher_trimmed = matcher_line.trim(); - if let Some(raw) = matcher_trimmed.strip_prefix("- event:") { - return Some(parse_yaml_scalar(raw)); - } - if matcher_trimmed.starts_with("- ") && !matcher_trimmed.starts_with("- event:") { - break; - } - } - } - } - None -} - -fn block_contains_memorph_command(block: &[String]) -> bool { - block - .iter() - .filter_map(|line| yaml_assignment_value(line.trim(), "command")) - .any(|command| crate::hooks::shared::command_contains_memorph_hook(&command)) -} - -fn block_managed_version(block: &[String]) -> Option { - block - .iter() - .filter_map(|line| yaml_assignment_value(line.trim(), "command")) - .find_map(|command| { - crate::hooks::shared::command_contains_memorph_hook(&command) - .then(|| command_managed_version(&command)) - }) - .flatten() -} - -fn command_managed_version(command: &str) -> Option { - let mut parts = command.split_whitespace(); - while let Some(part) = parts.next() { - if part == "--managed-version" { - return parts.next().map(ToString::to_string); - } - if let Some(value) = part.strip_prefix("--managed-version=") { - return Some(value.to_string()); - } - } - None -} - -fn yaml_assignment_value(line: &str, key: &str) -> Option { - let raw = line.strip_prefix(key)?.strip_prefix(':')?; - Some(parse_yaml_scalar(raw)) -} - -fn parse_yaml_scalar(raw: &str) -> String { - let value = raw.split('#').next().unwrap_or_default().trim(); - if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { - return value[1..value.len() - 1].replace("''", "'"); - } - if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { - return serde_json::from_str(value) - .unwrap_or_else(|_| value[1..value.len() - 1].to_string()); - } - value.to_string() -} - -fn yaml_single_quote(value: &str) -> String { - value.replace('\'', "''") -} - -fn join_lines(lines: Vec, trailing_newline: bool) -> String { - let mut output = lines.join("\n"); - if trailing_newline && !output.ends_with('\n') { - output.push('\n'); - } - output -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::hooks::test_support::TestHookHomeGuard; - - #[test] - fn descriptor_matches_hook_registry() { - let descriptor = TRAE_HOOK.descriptor().expect("trae descriptor"); - assert_eq!(descriptor.provider(), TRAE_HOOK.provider_id()); - } - #[test] - fn detects_and_removes_traecli_yaml_hook_blocks() { - let contents = r#"model: trae -hooks: - - type: command - command: 'memorph __hook-bridge --managed-version hook-v1 --provider trae --event pre_tool_use' - timeout: '5s' - matchers: - - event: pre_tool_use - - type: command - command: 'echo keep' - timeout: '5s' - matchers: - - event: session_start -"#; - assert!(contents_contains_memorph_hook(contents, "pre_tool_use")); - assert_eq!( - event_memorph_hook_version(contents, "pre_tool_use") - .flatten() - .as_deref(), - Some(crate::hooks::shared::HOOK_MANAGED_VERSION) - ); - let cleaned = remove_traecli_hooks(contents); - assert!(!cleaned.contains("__hook-bridge")); - assert!(cleaned.contains("echo keep")); - } - - #[test] - fn merges_traecli_yaml_hooks_under_existing_hooks_key() { - let merged = - merge_traecli_hooks("model: trae\nhooks: []\n", "memorph __hook-bridge").unwrap(); - assert!(merged.contains("hooks:\n - type: command")); - assert!(merged.contains("--provider trae --event permission_request --blocking")); - assert!(contents_contains_memorph_hook(&merged, "session_start")); - assert!(contents_contains_memorph_hook( - &merged, - "permission_request" - )); - } - #[test] - fn traecli_install_uninstall_preserves_foreign_yaml_hook_blocks() { - let _home = TestHookHomeGuard::new(); - let path = config_path(); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let original = r#"model: trae -hooks: - - type: command - command: 'echo keep' - timeout: '5s' - matchers: - - event: session_start -workspace: keep -"#; - crate::storage::atomic_write::write_string_atomic(&path, original).unwrap(); - - TRAE_HOOK.install().unwrap(); - let installed = std::fs::read_to_string(&path).unwrap(); - assert!(installed.contains("command: 'echo keep'")); - assert!(installed.contains("workspace: keep")); - assert!(contents_contains_memorph_hook(&installed, "session_start")); - - TRAE_HOOK.uninstall().unwrap(); - let removed = std::fs::read_to_string(&path).unwrap(); - assert!(removed.contains("command: 'echo keep'")); - assert!(removed.contains("workspace: keep")); - assert!(!contents_contains_memorph_hook(&removed, "session_start")); - } -} diff --git a/rust/crates/memorph/src/providers/trae/mod.rs b/rust/crates/memorph/src/providers/trae/mod.rs deleted file mode 100644 index 6f5dc14..0000000 --- a/rust/crates/memorph/src/providers/trae/mod.rs +++ /dev/null @@ -1,404 +0,0 @@ -pub mod adapter; -pub mod hook; - -use crate::provider::{ - PageStrategy, Provider, ProviderCapabilities, ProviderContentFidelity, ProviderSessionSummary, - ProviderSourceFingerprint, ResumeQuality, ScanStrategy, StorageShape, TurnQuality, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{bail, Context as _, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{Connection, OptionalExtension}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -pub struct TraeProvider; - -const PROVIDER_ID: &str = "trae"; -const STORAGE_KEY: &str = "memento/icube-ai-agent-storage"; - -#[cfg(test)] -static TEST_TRAE_CONFIG_DIR: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - -impl Provider for TraeProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "Trae" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - storage_shape: StorageShape::Sqlite, - scan_strategy: ScanStrategy::FullScan, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Unsupported), - tool_result: Some(Fidelity::Unsupported), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - resume_quality: ResumeQuality::None, - ..ProviderCapabilities::default() - } - } - - fn scan_sessions(&self) -> Result> { - let mut sessions = Vec::new(); - for root in state_paths() { - for db in db_paths(&root) { - if !db.exists() { - continue; - } - let conn = Connection::open(&db) - .with_context(|| format!("failed to open Trae state db at {}", db.display()))?; - let Some(data) = read_storage(&conn)? else { - continue; - }; - for session in session_list(&data) { - let Some(id) = session_id(session) else { - continue; - }; - sessions.push(ProviderSessionSummary { - archived: false, - session_id: id, - title: session_title(session), - project_dir: workspace_dir(&db), - created_at: None, - last_active_at: session_updated_at(session), - source_path: Some(locator( - &db, - session_id(session).as_deref().unwrap_or_default(), - )), - }); - } - } - } - sessions.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); - sessions.dedup_by(|a, b| a.session_id == b.session_id); - Ok(sessions) - } - - fn import_session(&self, source_path: &str) -> Result { - let (db, id) = parse_locator(source_path)?; - let conn = Connection::open(&db)?; - let data = read_storage(&conn)?.context("Trae storage key is missing")?; - let session = session_list(&data) - .into_iter() - .find(|session| session_id(session).as_deref() == Some(id.as_str())) - .context("Trae session was not found")?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events: Vec = session_messages(session) - .iter() - .enumerate() - .filter_map(|(index, message)| map_message(message, index, &mut report)) - .collect(); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - let title = session_title(session); - let now = session_updated_at(session) - .and_then(datetime_from_ms) - .unwrap_or_else(Utc::now); - let mut extensions = BTreeMap::new(); - extensions.insert("trae_storage_key".into(), Value::String(STORAGE_KEY.into())); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.clone(), - title, - }, - context: Context { - workspace: workspace_dir(&db), - created_at: None, - last_active_at: Some(now), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: id, - source_path: Some(source_path.into()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - let (db, id) = parse_locator(source_path)?; - if !db.exists() { - return Ok(None); - } - let conn = Connection::open(&db)?; - let Some(data) = read_storage(&conn)? else { - return Ok(None); - }; - let Some(session) = session_list(&data) - .into_iter() - .find(|s| session_id(s).as_deref() == Some(id.as_str())) - else { - return Ok(None); - }; - let bytes = serde_json::to_vec(session)?; - let digest = Sha256::digest(&bytes); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: session_updated_at(session).unwrap_or_else(|| file_modified_ms(&db)), - size_bytes: bytes.len().min(i64::MAX as usize) as i64, - value: format!("trae-workspace-v1:{digest:x}"), - })) - } - - fn data_source_paths(&self) -> Vec { - state_paths() - } -} - -fn trae_config_dir() -> Option { - #[cfg(test)] - if let Some(dir) = TEST_TRAE_CONFIG_DIR - .get() - .and_then(|v| v.lock().ok()) - .and_then(|v| v.clone()) - { - return Some(dir); - } - dirs::config_dir() -} - -fn state_paths() -> Vec { - let Some(cfg) = trae_config_dir() else { - return Vec::new(); - }; - vec![cfg.join("Trae").join("User").join("workspaceStorage")] -} - -fn workspace_dir(db: &Path) -> Option { - std::fs::read_to_string(db.parent()?.join("workspace.json")) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .and_then(|v| v.get("folder").and_then(Value::as_str).map(str::to_owned)) -} - -fn db_paths(root: &Path) -> impl Iterator + '_ { - std::fs::read_dir(root) - .into_iter() - .flatten() - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path().join("state.vscdb")) -} - -fn read_storage(conn: &Connection) -> Result> { - let raw: Option = conn - .query_row( - "SELECT value FROM ItemTable WHERE key = ?1", - [STORAGE_KEY], - |row| row.get(0), - ) - .optional()?; - raw.map(|value| serde_json::from_str(&value).context("Trae storage JSON is invalid")) - .transpose() -} - -fn session_list(data: &Value) -> Vec<&Value> { - data.get("list") - .and_then(Value::as_array) - .map(|v| v.iter().collect()) - .unwrap_or_default() -} -fn session_id(session: &Value) -> Option { - session - .get("sessionId") - .or_else(|| session.get("id")) - .and_then(Value::as_str) - .map(str::to_owned) -} -fn session_title(session: &Value) -> Option { - session - .get("title") - .or_else(|| session.get("name")) - .and_then(Value::as_str) - .map(str::to_owned) -} -fn session_messages(session: &Value) -> &[Value] { - session - .get("messages") - .and_then(Value::as_array) - .map(Vec::as_slice) - .unwrap_or(&[]) -} -fn session_updated_at(session: &Value) -> Option { - session - .get("updatedAt") - .or_else(|| session.get("createdAt")) - .and_then(Value::as_i64) -} -fn datetime_from_ms(ms: i64) -> Option> { - DateTime::from_timestamp_millis(ms) -} -fn file_modified_ms(path: &Path) -> i64 { - std::fs::metadata(path) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis().min(i64::MAX as u128) as i64) - .unwrap_or(0) -} - -fn locator(db: &Path, session_id: &str) -> String { - format!("{}#session={session_id}", db.display()) -} -fn parse_locator(source: &str) -> Result<(PathBuf, String)> { - let (path, fragment) = source - .split_once("#session=") - .context("invalid Trae locator")?; - if path.is_empty() || fragment.is_empty() { - bail!("invalid Trae locator") - } - Ok((PathBuf::from(path), fragment.to_owned())) -} - -fn map_message(message: &Value, index: usize, report: &mut MappingReport) -> Option { - let role = match message.get("role").and_then(Value::as_str) { - Some("user") => Role::User, - Some("assistant") | Some("ai") => Role::Assistant, - Some("tool") => Role::Tool, - _ => { - report.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Warning, - disposition: Fidelity::Unsupported, - code: "trae-unknown-role".into(), - message: "Dropped Trae message with unknown role".into(), - path: Some(format!("messages[{index}]")), - raw: Some(message.clone()), - }); - return None; - } - }; - let text = message - .get("content") - .or_else(|| message.get("text")) - .or_else(|| message.get("message")) - .and_then(Value::as_str) - .map(str::to_owned); - let mut blocks = text - .map(|text| vec![Block::Text { text }]) - .unwrap_or_default(); - if let Some(task) = message.get("agentTaskContent") { - blocks.push(Block::Other { raw: task.clone() }); - } - if blocks.is_empty() { - return None; - } - report.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "trae-native-message".into(), - message: "Mapped Trae workspaceStorage message".into(), - path: Some(format!("messages[{index}]")), - raw: None, - }); - Some(Event { - id: format!("trae:event:{index}"), - kind: EventKind::Message, - role, - timestamp: message - .get("timestamp") - .and_then(Value::as_i64) - .and_then(datetime_from_ms) - .unwrap_or_else(Utc::now), - links: Links::default(), - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use rusqlite::params; - use tempfile::tempdir; - - struct ConfigDirGuard; - - impl Drop for ConfigDirGuard { - fn drop(&mut self) { - *TEST_TRAE_CONFIG_DIR.get().unwrap().lock().unwrap() = None; - } - } - - fn config_dir_guard(dir: &Path) -> ConfigDirGuard { - *TEST_TRAE_CONFIG_DIR - .get_or_init(|| std::sync::Mutex::new(None)) - .lock() - .unwrap() = Some(dir.to_path_buf()); - ConfigDirGuard - } - - #[test] - fn scans_and_imports_macos_workspace_storage_session() { - let root = tempdir().unwrap(); - let workspace = root.path().join("Trae/User/workspaceStorage/abc"); - std::fs::create_dir_all(&workspace).unwrap(); - std::fs::write( - workspace.join("workspace.json"), - r#"{"folder":"file:///tmp/demo"}"#, - ) - .unwrap(); - let db = workspace.join("state.vscdb"); - let conn = Connection::open(&db).unwrap(); - conn.execute( - "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)", - [], - ) - .unwrap(); - let data = serde_json::json!({"list":[{"sessionId":"trae-1","title":"Fix bug","createdAt":1000,"updatedAt":2000,"messages":[{"id":"m1","role":"user","content":"hello","timestamp":1000},{"id":"m2","role":"assistant","content":"world","timestamp":2000}]}]}); - conn.execute( - "INSERT INTO ItemTable(key,value) VALUES(?1,?2)", - params![STORAGE_KEY, data.to_string()], - ) - .unwrap(); - let _guard = config_dir_guard(root.path()); - let provider = TraeProvider; - let sessions = provider.scan_sessions().unwrap(); - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].session_id, "trae-1"); - let imported = provider - .import_session(sessions[0].source_path.as_deref().unwrap()) - .unwrap(); - assert_eq!(imported.session.events.len(), 2); - assert_eq!(imported.session.identity.id, "trae-1"); - assert!(provider - .session_source_fingerprint(sessions[0].source_path.as_deref().unwrap()) - .unwrap() - .is_some()); - } -} diff --git a/rust/crates/memorph/src/providers/windsurf/mod.rs b/rust/crates/memorph/src/providers/windsurf/mod.rs deleted file mode 100644 index 35913dc..0000000 --- a/rust/crates/memorph/src/providers/windsurf/mod.rs +++ /dev/null @@ -1,795 +0,0 @@ -use crate::provider::{ - PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, - ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, - ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, -}; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use base64::{engine::general_purpose::STANDARD, Engine}; -use chrono::{DateTime, Utc}; -use rusqlite::Connection; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; - -pub struct WindsurfProvider; -const PROVIDER_ID: &str = "windsurf"; -const ACTIVE_PREFIX: &str = "windsurf.state.cachedActiveTrajectory:"; - -impl Provider for WindsurfProvider { - fn id(&self) -> &'static str { - PROVIDER_ID - } - fn name(&self) -> &'static str { - "Windsurf" - } - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - scan: true, - import: true, - storage_shape: StorageShape::Mixed, - scan_strategy: ScanStrategy::Indexed, - page_strategy: PageStrategy::FullImport, - turn_quality: TurnQuality::Inferred, - import_fidelity: ProviderContentFidelity { - text: Some(Fidelity::Preserved), - thinking: Some(Fidelity::Preserved), - tool_call: Some(Fidelity::Preserved), - tool_result: Some(Fidelity::Preserved), - provider_payload: Some(Fidelity::Preserved), - ..ProviderContentFidelity::unknown() - }, - resume_quality: ResumeQuality::None, - write_risk: ProviderWriteRisk { - level: WriteRiskLevel::High, - multiple_files: true, - sqlite: true, - sidecar_files: true, - index_repair: true, - }, - backup_support: ProviderBackupSupport { - before_write: false, - restore: false, - sync_only: false, - }, - activity_support: ProviderActivitySupport { - hook_events: false, - runtime_endpoint: false, - session_activity: false, - }, - ..ProviderCapabilities::default() - } - } - fn scan_sessions(&self) -> Result> { - let mut out = Vec::new(); - for db in state_paths() { - if !db.exists() { - continue; - } - let conn = Connection::open(&db)?; - let mut stmt = conn.prepare("SELECT key, value FROM ItemTable WHERE key LIKE ?1")?; - let rows = stmt.query_map([format!("{ACTIVE_PREFIX}%")], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - for row in rows.flatten() { - let (key, encoded) = row; - let workspace = key.strip_prefix(ACTIVE_PREFIX).unwrap_or_default(); - let blob = match decode(&encoded) { - Ok(v) => v, - Err(_) => continue, - }; - let trajectory = match trajectory_id(&blob) { - Some(v) => v, - None => continue, - }; - let steps = decode_steps(&blob); - let source = locator(&db, workspace); - out.push(ProviderSessionSummary { - archived: false, - session_id: trajectory, - title: steps - .iter() - .find_map(|s| s.user_text.clone().or(s.visible.clone())), - project_dir: workspace_dir(&db, workspace), - created_at: None, - last_active_at: modified_ms(&db), - source_path: Some(source), - }); - } - } - for root in legacy_paths() { - let Ok(files) = std::fs::read_dir(root) else { - continue; - }; - for path in files - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("pbtxt")) - { - for (id, title, last_active_at) in legacy_sessions(&path)? { - out.push(ProviderSessionSummary { - archived: false, - session_id: id.clone(), - title, - project_dir: None, - created_at: None, - last_active_at, - source_path: Some(legacy_locator(&path, &id)), - }); - } - } - } - out.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); - out.dedup_by(|a, b| a.session_id == b.session_id); - Ok(out) - } - fn import_session(&self, source_path: &str) -> Result { - if source_path.contains("#conversation=") { - return import_legacy_session(source_path); - } - let (db, workspace) = parse_locator(source_path)?; - let encoded = read_active_value(&db, workspace)?; - let blob = decode(&encoded)?; - let id = trajectory_id(&blob).context("Windsurf trajectory has no UUID")?; - let steps = decode_steps(&blob); - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events: Vec = steps - .iter() - .enumerate() - .filter_map(|(i, step)| map_step(step, i, &mut report)) - .collect(); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - let mut extensions = BTreeMap::new(); - extensions.insert( - "windsurf_trajectory_protobuf".into(), - Value::String(encoded), - ); - let now = modified_datetime(&db).unwrap_or_else(Utc::now); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.clone(), - title: steps - .iter() - .find_map(|s| s.user_text.clone().or(s.visible.clone())), - }, - context: Context { - workspace: workspace_dir(&db, workspace), - created_at: Some(now), - last_active_at: Some(now), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: id, - source_path: Some(source_path.into()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) - } - fn session_source_fingerprint( - &self, - source_path: &str, - ) -> Result> { - if source_path.contains("#conversation=") { - return legacy_fingerprint(source_path); - } - let (db, workspace) = parse_locator(source_path)?; - if !db.exists() { - return Ok(None); - } - let encoded = read_active_value(&db, workspace)?; - let digest = Sha256::digest(encoded.as_bytes()); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: modified_ms(&db).unwrap_or(0), - size_bytes: encoded.len().min(i64::MAX as usize) as i64, - value: format!("windsurf-trajectory-v1:{digest:x}"), - })) - } - fn data_source_paths(&self) -> Vec { - state_paths().into_iter().chain(legacy_paths()).collect() - } -} - -fn legacy_paths() -> Vec { - dirs::home_dir() - .into_iter() - .map(|home| home.join(".codeium/chat_state")) - .collect() -} -fn legacy_locator(path: &Path, id: &str) -> String { - format!("{}#conversation={id}", path.display()) -} -fn parse_legacy_locator(source: &str) -> Result<(PathBuf, &str)> { - let (path, id) = source - .rsplit_once("#conversation=") - .context("Windsurf legacy source locator must be chat_state file#conversation=")?; - Ok((PathBuf::from(path), id)) -} -fn legacy_blocks<'a>(raw: &'a str, field: &str) -> Vec<&'a str> { - let mut out = Vec::new(); - let mut start = 0; - while let Some(offset) = raw[start..].find(field) { - let field_end = start + offset + field.len(); - let rest = &raw[field_end..]; - let Some(after_colon) = rest.trim_start().strip_prefix(':') else { - start = field_end; - continue; - }; - let whitespace = after_colon.len() - after_colon.trim_start().len(); - if !after_colon[whitespace..].starts_with('{') { - start = field_end; - continue; - } - let colon_offset = rest.len() - rest.trim_start().len(); - let open = field_end + colon_offset + 1 + whitespace; - let mut depth = 0; - let mut quoted = false; - let bytes = raw.as_bytes(); - let mut end = None; - for i in open..raw.len() { - match bytes[i] as char { - '"' if i == 0 || bytes[i - 1] != b'\\' => quoted = !quoted, - '{' if !quoted => depth += 1, - '}' if !quoted => { - depth -= 1; - if depth == 0 { - end = Some(i); - break; - } - } - _ => {} - } - } - let Some(end) = end else { break }; - out.push(&raw[open + 1..end]); - start = end + 1; - } - out -} -fn legacy_string(block: &str, field: &str) -> Option { - let prefix = format!("{field}:"); - let start = block.find(&prefix)? + prefix.len(); - let rest = block[start..].trim_start(); - if !rest.starts_with('"') { - return None; - } - let mut escaped = false; - for (i, c) in rest[1..].char_indices() { - if c == '"' && !escaped { - return Some(rest[1..i + 1].replace("\\n", "\n").replace("\\\"", "\"")); - } - escaped = c == '\\' && !escaped; - if c != '\\' { - escaped = false; - } - } - None -} -type LegacySession = (String, Option, Option); - -fn legacy_sessions(path: &Path) -> Result> { - let raw = std::fs::read_to_string(path)?; - let mut out = BTreeMap::new(); - for message in legacy_blocks(&raw, "message") { - if !message.contains("CHAT_MESSAGE_SOURCE_USER") { - continue; - } - let Some(id) = legacy_string(message, "conversation_id") else { - continue; - }; - let title = legacy_blocks(message, "intent") - .into_iter() - .find_map(|b| legacy_string(b, "text")); - out.entry(id).or_insert((title, modified_ms(path))); - } - Ok(out - .into_iter() - .map(|(id, (title, modified))| (id, title, modified)) - .collect()) -} -fn import_legacy_session(source: &str) -> Result { - let (path, id) = parse_legacy_locator(source)?; - let raw = std::fs::read_to_string(&path)?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let mut events = Vec::new(); - for (i, message) in legacy_blocks(&raw, "message").into_iter().enumerate() { - if !message.contains("CHAT_MESSAGE_SOURCE_USER") - || legacy_string(message, "conversation_id").as_deref() != Some(id) - { - continue; - } - let Some(text) = legacy_blocks(message, "intent") - .into_iter() - .find_map(|b| legacy_string(b, "text")) - else { - continue; - }; - report.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "windsurf-legacy-pbtxt".into(), - message: "Mapped Windsurf legacy chat_state user message".into(), - path: Some(format!("message[{i}]")), - raw: None, - }); - events.push(Event { - id: format!("windsurf:legacy:{i}"), - kind: EventKind::Message, - role: Role::User, - timestamp: modified_datetime(&path).unwrap_or_else(Utc::now), - links: Links::default(), - blocks: vec![Block::Text { text }], - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }); - } - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.into(), - title: None, - }, - context: Context { - workspace: None, - created_at: modified_datetime(&path), - last_active_at: modified_datetime(&path), - tags: Vec::new(), - }, - events, - extensions: BTreeMap::new(), - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: id.into(), - source_path: Some(source.into()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) -} -fn legacy_fingerprint(source: &str) -> Result> { - let (path, id) = parse_legacy_locator(source)?; - if !path.exists() { - return Ok(None); - } - let raw = std::fs::read(&path)?; - let digest = Sha256::digest(&raw); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: modified_ms(&path).unwrap_or(0), - size_bytes: raw.len().min(i64::MAX as usize) as i64, - value: format!("windsurf-legacy-pbtxt-v1:{id}:{digest:x}"), - })) -} - -#[derive(Default)] -struct Step { - id: Option, - timestamp: Option>, - user_text: Option, - visible: Option, - thinking: Option, - tools: Vec, - provider: Option, -} -struct Tool { - id: String, - name: String, - input: Option, - result: Option, -} - -fn state_paths() -> Vec { - let Some(home) = dirs::home_dir() else { - return Vec::new(); - }; - ["Windsurf - Next", "Windsurf"] - .into_iter() - .map(|name| { - home.join("Library/Application Support") - .join(name) - .join("User/globalStorage/state.vscdb") - }) - .collect() -} -fn locator(db: &Path, workspace: &str) -> String { - format!("{}#workspace={}", db.display(), workspace) -} -fn parse_locator(source: &str) -> Result<(PathBuf, &str)> { - let (path, workspace) = source - .rsplit_once("#workspace=") - .context("Windsurf source locator must be state.vscdb#workspace=")?; - Ok((PathBuf::from(path), workspace)) -} -fn read_active_value(db: &Path, workspace: &str) -> Result { - let conn = Connection::open(db)?; - conn.query_row( - "SELECT value FROM ItemTable WHERE key = ?1", - [format!("{ACTIVE_PREFIX}{workspace}")], - |r| r.get(0), - ) - .with_context(|| format!("Windsurf active trajectory not found for workspace {workspace}")) -} -fn decode(encoded: &str) -> Result> { - STANDARD - .decode(encoded) - .context("invalid Windsurf trajectory base64") -} -fn workspace_dir(db: &Path, workspace: &str) -> Option { - let p = db - .parent()? - .parent()? - .join("workspaceStorage") - .join(workspace) - .join("workspace.json"); - let raw = std::fs::read_to_string(p).ok()?; - let v: Value = serde_json::from_str(&raw).ok()?; - v.get("folder") - .or_else(|| v.get("workspace")) - .and_then(Value::as_str) - .map(str::to_string) -} -fn modified_ms(path: &Path) -> Option { - std::fs::metadata(path) - .ok()? - .modified() - .ok()? - .duration_since(UNIX_EPOCH) - .ok() - .map(|d| d.as_millis().min(i64::MAX as u128) as i64) -} -fn modified_datetime(path: &Path) -> Option> { - modified_ms(path).and_then(DateTime::::from_timestamp_millis) -} - -fn read_varint(bytes: &[u8], pos: &mut usize) -> Option { - let mut out = 0; - for shift in (0..70).step_by(7) { - let b = *bytes.get(*pos)?; - *pos += 1; - out |= ((b & 0x7f) as u64) << shift; - if b & 0x80 == 0 { - return Some(out); - } - } - None -} -fn fields(bytes: &[u8]) -> Vec<(u32, u8, Vec, u64)> { - let mut p = 0; - let mut out = Vec::new(); - while p < bytes.len() { - let tag = match read_varint(bytes, &mut p) { - Some(v) => v, - None => break, - }; - let no = (tag >> 3) as u32; - let wt = (tag & 7) as u8; - match wt { - 0 => { - if let Some(v) = read_varint(bytes, &mut p) { - out.push((no, wt, Vec::new(), v)); - } - } - 2 => { - let n = match read_varint(bytes, &mut p) { - Some(v) => v as usize, - None => break, - }; - if p + n > bytes.len() { - break; - } - out.push((no, wt, bytes[p..p + n].to_vec(), 0)); - p += n; - } - _ => break, - } - } - out -} -fn text(bytes: &[u8]) -> Option { - String::from_utf8(bytes.to_vec()) - .ok() - .filter(|s| !s.is_empty()) -} -fn trajectory_id(blob: &[u8]) -> Option { - fields(blob) - .into_iter() - .find(|(n, w, _, _)| *n == 1 && *w == 2) - .and_then(|(_, _, b, _)| text(&b)) -} -fn decode_steps(blob: &[u8]) -> Vec { - let top = fields(blob); - let container = top - .iter() - .find(|(n, w, _, _)| *n == 2 && *w == 2) - .map(|x| x.2.as_slice()) - .unwrap_or(&[]); - fields(container) - .into_iter() - .filter(|(_, w, _, _)| *w == 2) - .filter_map(|(_, _, b, _)| decode_step(&b)) - .collect() -} -fn decode_step(blob: &[u8]) -> Option { - let mut step = Step::default(); - for (n, w, b, v) in fields(blob) { - if n == 1 && w == 0 { - step.id = Some(v as i64) - } else if n == 5 && w == 2 { - step.timestamp = decode_timestamp(&b) - } else if n == 19 && w == 2 { - step.user_text = decode_text_message(&b) - } else if n == 20 && w == 2 { - decode_ai(&b, &mut step) - } else if n == 28 && w == 2 { - step.visible = step.visible.or_else(|| text(&b)); - } - } - Some(step) -} -fn decode_timestamp(blob: &[u8]) -> Option> { - let f = fields(blob) - .into_iter() - .find(|(n, w, _, _)| *n == 1 && *w == 2)? - .2; - let fs = fields(&f); - let sec = fs.iter().find(|(n, w, _, _)| *n == 1 && *w == 0)?.3 as i64; - let nano = fs - .iter() - .find(|(n, w, _, _)| *n == 2 && *w == 0) - .map(|x| x.3 as u32) - .unwrap_or(0); - DateTime::from_timestamp(sec, nano) -} -fn decode_text_message(blob: &[u8]) -> Option { - fields(blob) - .into_iter() - .filter(|(_, w, _, _)| *w == 2) - .find_map(|(_, _, b, _)| text(&b)) -} -fn decode_ai(blob: &[u8], step: &mut Step) { - for (n, w, b, _) in fields(blob) { - if w != 2 { - continue; - } - if n == 3 { - step.thinking = text(&b) - } else if n == 8 { - step.visible = text(&b) - } else if n == 12 { - step.provider = text(&b) - } else if n == 7 { - let fs = fields(&b); - let id = fs - .iter() - .find(|(n, w, _, _)| *n == 1 && *w == 2) - .and_then(|x| text(&x.2)) - .unwrap_or_else(|| "unknown".into()); - let name = fs - .iter() - .find(|(n, w, _, _)| *n == 2 && *w == 2) - .and_then(|x| text(&x.2)) - .unwrap_or_else(|| "tool".into()); - let input = fs - .iter() - .find(|(n, w, _, _)| *n == 3 && *w == 2) - .and_then(|x| text(&x.2)) - .and_then(|s| serde_json::from_str(&s).ok()); - step.tools.push(Tool { - id, - name, - input, - result: None, - }); - } - } -} -fn map_step(step: &Step, i: usize, r: &mut MappingReport) -> Option { - let mut blocks = Vec::new(); - if let Some(t) = &step.user_text { - blocks.push(Block::Text { text: t.clone() }) - } - if let Some(t) = &step.thinking { - blocks.push(Block::Thinking { - text: t.clone(), - signature: None, - }) - } - if let Some(t) = &step.visible { - blocks.push(Block::Text { text: t.clone() }) - } - for tool in &step.tools { - blocks.push(Block::ToolCall { - tool_call_id: tool.id.clone(), - name: tool.name.clone(), - input: tool.input.clone(), - }); - if let Some(result) = &tool.result { - blocks.push(Block::ToolResult { - tool_call_id: tool.id.clone(), - content: result.clone(), - outcome: crate::session::execution_outcome(false), - }) - } - } - if blocks.is_empty() { - return None; - } - let role = if step.user_text.is_some() { - Role::User - } else { - Role::Assistant - }; - let kind = if blocks.iter().any(|b| matches!(b, Block::ToolResult { .. })) { - EventKind::Observation - } else if blocks.iter().any(|b| matches!(b, Block::ToolCall { .. })) { - EventKind::Action - } else { - EventKind::Message - }; - r.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "windsurf-native-step".into(), - message: "Mapped Windsurf trajectory step".into(), - path: Some(format!("steps[{i}]")), - raw: None, - }); - Some(Event { - id: format!("windsurf:event:{i}"), - kind, - role, - timestamp: step.timestamp.unwrap_or_else(Utc::now), - links: Links::default(), - blocks, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn field(no: u8, payload: &[u8]) -> Vec { - let mut out = Vec::new(); - let mut tag = ((no as u64) << 3) | 2; - while tag >= 0x80 { - out.push((tag as u8) | 0x80); - tag >>= 7; - } - out.push(tag as u8); - out.push(payload.len() as u8); - out.extend_from_slice(payload); - out - } - - #[test] - fn capabilities_match_read_only_source_boundary() { - let capabilities = WindsurfProvider.capabilities(); - assert!(capabilities.scan); - assert!(capabilities.import); - assert!(!capabilities.export); - assert!(!capabilities.delete); - assert!(!capabilities.rename); - assert!(!capabilities.resume); - assert!(!capabilities.backup_support.before_write); - assert!(!capabilities.backup_support.restore); - } - - #[test] - fn trajectory_identity_comes_from_protobuf_field_one() { - let blob = field(1, b"trajectory-id"); - assert_eq!(trajectory_id(&blob).as_deref(), Some("trajectory-id")); - } - - #[test] - fn locator_keeps_workspace_identity_separate_from_database_path() { - let (path, workspace) = parse_locator("/tmp/state.vscdb#workspace=abc").unwrap(); - assert_eq!(path, PathBuf::from("/tmp/state.vscdb")); - assert_eq!(workspace, "abc"); - } - - #[test] - fn imports_legacy_chat_state_user_message() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("chat.pbtxt"); - std::fs::write( - &path, - r#"message: {source: CHAT_MESSAGE_SOURCE_USER conversation_id: "legacy-1" timestamp: {seconds: 10} intent: {text: "hello"}} -message:{source: CHAT_MESSAGE_SOURCE_USER conversation_id: "other" intent:{text: "skip"}} -"#, - ) - .unwrap(); - let sessions = legacy_sessions(&path).unwrap(); - assert_eq!(sessions.len(), 2); - assert!(sessions.iter().any(|session| session.0 == "legacy-1")); - let imported = import_legacy_session(&legacy_locator(&path, "legacy-1")).unwrap(); - assert_eq!(imported.session.events.len(), 1); - assert!(matches!( - imported.session.events[0].blocks[0], - Block::Text { ref text } if text == "hello" - )); - assert!(legacy_fingerprint(&legacy_locator(&path, "legacy-1")) - .unwrap() - .is_some()); - } - - #[test] - fn decodes_native_ai_text_thinking_and_tool_call() { - let tool = [ - field(1, b"call-1"), - field(2, b"read"), - field(3, br#"{"path":"x"}"#), - ] - .concat(); - let ai = [field(3, b"thinking"), field(7, &tool), field(8, b"answer")].concat(); - let step = [field(1, b"\x01"), field(20, &ai)].concat(); - let decoded = decode_step(&step).unwrap(); - assert_eq!(decoded.thinking.as_deref(), Some("thinking")); - assert_eq!(decoded.visible.as_deref(), Some("answer")); - assert_eq!(decoded.tools[0].name, "read"); - assert_eq!( - decoded.tools[0].input, - Some(serde_json::json!({"path":"x"})) - ); - } - #[test] - fn step_with_tool_result_is_observation() { - let mut report = MappingReport::new("windsurf", crate::session::MappingDirection::Import); - let step = Step { - id: Some(1), - timestamp: None, - user_text: None, - visible: None, - thinking: None, - tools: vec![Tool { - id: "t1".into(), - name: "read".into(), - input: Some(serde_json::json!({})), - result: Some("file contents".into()), - }], - provider: None, - }; - let event = map_step(&step, 0, &mut report).expect("event"); - assert_eq!(event.kind, crate::session::EventKind::Observation); - } -} diff --git a/rust/crates/memorph/src/skills/discovery.rs b/rust/crates/memorph/src/skills/discovery.rs index c199646..e9ee2a2 100644 --- a/rust/crates/memorph/src/skills/discovery.rs +++ b/rust/crates/memorph/src/skills/discovery.rs @@ -7,7 +7,7 @@ use super::inspection::{ SkillsOverview, MANAGED_MARKER, }; -pub const SKILL_AGENTS: [(&str, &str, &str, &str); 6] = [ +pub const SKILL_AGENTS: [(&str, &str, &str, &str); 5] = [ ("claude", "Claude Code", ".claude/skills", ".claude/skills"), ("codex", "Codex", ".codex/skills", ".codex/skills"), ("gemini", "Gemini CLI", ".gemini/skills", ".gemini/skills"), @@ -17,7 +17,6 @@ pub const SKILL_AGENTS: [(&str, &str, &str, &str); 6] = [ ".config/opencode/skills", ".opencode/skills", ), - ("hermes", "Hermes", ".hermes/skills", ".hermes/skills"), ("agents-shared", "Shared Agents", ".agents/skills", ""), ]; diff --git a/rust/crates/memorph/src/skills/repository.rs b/rust/crates/memorph/src/skills/repository.rs index 29b41b5..0492647 100644 --- a/rust/crates/memorph/src/skills/repository.rs +++ b/rust/crates/memorph/src/skills/repository.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use std::collections::BTreeSet; -const ALL_SKILL_AGENTS: [&str; 5] = ["claude", "codex", "gemini", "opencode", "hermes"]; +const ALL_SKILL_AGENTS: [&str; 4] = ["claude", "codex", "gemini", "opencode"]; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CatalogRecord { @@ -901,7 +901,7 @@ mod catalog_tests { assert_eq!(page.items[0].installations[0].used_by, "all"); assert_eq!( page.items[0].used_by, - vec!["claude", "codex", "gemini", "hermes", "opencode"] + vec!["claude", "codex", "gemini", "opencode"] ); assert_eq!(page.used_by, page.items[0].used_by); } From b0eeaebd4fea3510472357dddfe51c8350c81e0f Mon Sep 17 00:00:00 2001 From: ip2a Date: Mon, 19 Oct 2026 16:10:16 +0800 Subject: [PATCH 4/6] feat(providers): add grok, omp, and zcode; drop retired agents --- README.md | 13 +- README_en.md | 13 +- README_zh.md | 13 +- .../web/src/assets/provider-logos/alibaba.svg | 3 - .../assets/provider-logos/amazon-bedrock.svg | 3 - .../web/src/assets/provider-logos/augment.svg | 10 - apps/web/src/assets/provider-logos/cline.svg | 3 - .../src/assets/provider-logos/codebuddy.svg | 1 - .../src/assets/provider-logos/deepseek.svg | 3 - apps/web/src/assets/provider-logos/hermes.svg | 1 - .../src/assets/provider-logos/manifest.json | 27 +- .../src/assets/provider-logos/openclaw.svg | 22 - .../shared/provider-logo-map.test.ts | 4 - .../features/skills/skill-detail-panel.tsx | 2 +- rust/apps/memorph-cli/src/api/tests.rs | 14 +- rust/apps/memorph-cli/src/skills/server.rs | 2 +- rust/crates/memorph/src/core.rs | 3 + rust/crates/memorph/src/providers/aliases.rs | 2 + rust/crates/memorph/src/providers/catalog.rs | 3 + .../src/providers/environment_profiles.rs | 6 + rust/crates/memorph/src/providers/grok.rs | 569 ++++++++++++++++++ rust/crates/memorph/src/providers/mod.rs | 13 + rust/crates/memorph/src/providers/omp.rs | 118 ++++ .../memorph/src/providers/opencode/load.rs | 2 +- rust/crates/memorph/src/providers/pi/mod.rs | 327 +--------- .../crates/memorph/src/providers/pi_format.rs | 338 +++++++++++ rust/crates/memorph/src/providers/zcode.rs | 431 +++++++++++++ 27 files changed, 1522 insertions(+), 424 deletions(-) delete mode 100644 apps/web/src/assets/provider-logos/alibaba.svg delete mode 100644 apps/web/src/assets/provider-logos/amazon-bedrock.svg delete mode 100644 apps/web/src/assets/provider-logos/augment.svg delete mode 100644 apps/web/src/assets/provider-logos/cline.svg delete mode 100644 apps/web/src/assets/provider-logos/codebuddy.svg delete mode 100644 apps/web/src/assets/provider-logos/deepseek.svg delete mode 100644 apps/web/src/assets/provider-logos/hermes.svg delete mode 100644 apps/web/src/assets/provider-logos/openclaw.svg create mode 100644 rust/crates/memorph/src/providers/grok.rs create mode 100644 rust/crates/memorph/src/providers/omp.rs create mode 100644 rust/crates/memorph/src/providers/pi_format.rs create mode 100644 rust/crates/memorph/src/providers/zcode.rs diff --git a/README.md b/README.md index bbacbfd..518ef29 100644 --- a/README.md +++ b/README.md @@ -187,23 +187,18 @@ memorph 支持 23 个 AI 编程 Agent 的会话管理。各 Provider 的能力 | Claude | ✅ | ✅ | ✅ | | Codex | ✅ | ✅ | ✅ | | OpenCode | ✅ | ✅ | ✅ | -| DeepSeek | ✅ | ✅ | ✅ | | Kimi | ✅ | ✅ | ✅ | | Cursor | ✅ | ✅ | ✅ | | Gemini CLI | ✅ | ❌ | ✅ | | Kiro | ✅ | ❌ | ✅ | -| Hermes | ✅ | ❌ | ✅ | -| Cline | ✅ | ❌ | ❌ | | Copilot | ✅ | ❌ | ❌ | -| CodeBuddy | ✅ | ❌ | ❌ | | Qoder | ✅ | ❌ | ❌ | | WorkBuddy | ✅ | ❌ | ❌ | | Pi | ✅ | ❌ | ❌ | +| Oh My Pi | ✅ | ❌ | ❌ | | Antigravity | ✅ | ❌ | ❌ | -| OpenClaw | ✅ | ❌ | ❌ | -| Augment | ✅ | ❌ | ❌ | -| Amazon Q | ✅ | ❌ | ❌ | -| Qwen | ✅ | ❌ | ❌ | +| Grok Build | ✅ | ❌ | ❌ | +| ZCode | ✅ | ❌ | ❌ | > **导出**:将 OASF canonical Session 回写为 Provider 原生格式。 > **管理/恢复**:支持 delete / rename / resume 中至少一项。 @@ -215,7 +210,7 @@ memorph 基于 [OASF (Open Agent Session Format)](https://crates.io/crates/oasf) - 所有会话文件(`.morph`、`.json`、`.md`、`.html`)在 meta 行携带 schema name/version,导入时校验,拒绝不兼容版本 - 导入侧:23 个 Provider 的原生格式统一转换为 OASF canonical Session -- 导出侧:6 个 Provider 支持将 canonical Session 回写为原生格式(Claude、Codex、OpenCode、DeepSeek、Kimi、Cursor) +- 导出侧:5 个 Provider 支持将 canonical Session 回写为原生格式(Claude、Codex、OpenCode、Kimi、Cursor) - 保真度模型:每个 Provider × Block 类型声明 `Preserved` / `Normalized` / `Downgraded` / `Dropped` / `Unsupported`,通过 `export_report` 在导出时生成损失报告 --- diff --git a/README_en.md b/README_en.md index d81c1af..1b32d1d 100644 --- a/README_en.md +++ b/README_en.md @@ -171,23 +171,18 @@ memorph supports session management for 23 AI coding agents. Capabilities differ | Claude | ✅ | ✅ | ✅ | | Codex | ✅ | ✅ | ✅ | | OpenCode | ✅ | ✅ | ✅ | -| DeepSeek | ✅ | ✅ | ✅ | | Kimi | ✅ | ✅ | ✅ | | Cursor | ✅ | ✅ | ✅ | | Gemini CLI | ✅ | ❌ | ✅ | | Kiro | ✅ | ❌ | ✅ | -| Hermes | ✅ | ❌ | ✅ | -| Cline | ✅ | ❌ | ❌ | | Copilot | ✅ | ❌ | ❌ | -| CodeBuddy | ✅ | ❌ | ❌ | | Qoder | ✅ | ❌ | ❌ | | WorkBuddy | ✅ | ❌ | ❌ | | Pi | ✅ | ❌ | ❌ | +| Oh My Pi | ✅ | ❌ | ❌ | | Antigravity | ✅ | ❌ | ❌ | -| OpenClaw | ✅ | ❌ | ❌ | -| Augment | ✅ | ❌ | ❌ | -| Amazon Q | ✅ | ❌ | ❌ | -| Qwen | ✅ | ❌ | ❌ | +| Grok Build | ✅ | ❌ | ❌ | +| ZCode | ✅ | ❌ | ❌ | > **Export**: write OASF canonical Session back as provider-native format. > **Manage/Resume**: supports at least one of delete / rename / resume. @@ -199,7 +194,7 @@ memorph is built on [OASF (Open Agent Session Format)](https://crates.io/crates/ - All session files (`.morph`, `.json`, `.md`, `.html`) carry schema name/version in the meta line; validated on import, rejecting incompatible versions - Import: 23 providers' native formats are unified into OASF canonical Session -- Export: 6 providers support writing canonical Session back to native format (Claude, Codex, OpenCode, DeepSeek, Kimi, Cursor) +- Export: 5 providers support writing canonical Session back to native format (Claude, Codex, OpenCode, Kimi, Cursor) - Fidelity model: each Provider × Block type declares `Preserved` / `Normalized` / `Downgraded` / `Dropped` / `Unsupported`, with loss reports generated at export time via `export_report` --- diff --git a/README_zh.md b/README_zh.md index bbacbfd..518ef29 100644 --- a/README_zh.md +++ b/README_zh.md @@ -187,23 +187,18 @@ memorph 支持 23 个 AI 编程 Agent 的会话管理。各 Provider 的能力 | Claude | ✅ | ✅ | ✅ | | Codex | ✅ | ✅ | ✅ | | OpenCode | ✅ | ✅ | ✅ | -| DeepSeek | ✅ | ✅ | ✅ | | Kimi | ✅ | ✅ | ✅ | | Cursor | ✅ | ✅ | ✅ | | Gemini CLI | ✅ | ❌ | ✅ | | Kiro | ✅ | ❌ | ✅ | -| Hermes | ✅ | ❌ | ✅ | -| Cline | ✅ | ❌ | ❌ | | Copilot | ✅ | ❌ | ❌ | -| CodeBuddy | ✅ | ❌ | ❌ | | Qoder | ✅ | ❌ | ❌ | | WorkBuddy | ✅ | ❌ | ❌ | | Pi | ✅ | ❌ | ❌ | +| Oh My Pi | ✅ | ❌ | ❌ | | Antigravity | ✅ | ❌ | ❌ | -| OpenClaw | ✅ | ❌ | ❌ | -| Augment | ✅ | ❌ | ❌ | -| Amazon Q | ✅ | ❌ | ❌ | -| Qwen | ✅ | ❌ | ❌ | +| Grok Build | ✅ | ❌ | ❌ | +| ZCode | ✅ | ❌ | ❌ | > **导出**:将 OASF canonical Session 回写为 Provider 原生格式。 > **管理/恢复**:支持 delete / rename / resume 中至少一项。 @@ -215,7 +210,7 @@ memorph 基于 [OASF (Open Agent Session Format)](https://crates.io/crates/oasf) - 所有会话文件(`.morph`、`.json`、`.md`、`.html`)在 meta 行携带 schema name/version,导入时校验,拒绝不兼容版本 - 导入侧:23 个 Provider 的原生格式统一转换为 OASF canonical Session -- 导出侧:6 个 Provider 支持将 canonical Session 回写为原生格式(Claude、Codex、OpenCode、DeepSeek、Kimi、Cursor) +- 导出侧:5 个 Provider 支持将 canonical Session 回写为原生格式(Claude、Codex、OpenCode、Kimi、Cursor) - 保真度模型:每个 Provider × Block 类型声明 `Preserved` / `Normalized` / `Downgraded` / `Dropped` / `Unsupported`,通过 `export_report` 在导出时生成损失报告 --- diff --git a/apps/web/src/assets/provider-logos/alibaba.svg b/apps/web/src/assets/provider-logos/alibaba.svg deleted file mode 100644 index b3a2edc..0000000 --- a/apps/web/src/assets/provider-logos/alibaba.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/provider-logos/amazon-bedrock.svg b/apps/web/src/assets/provider-logos/amazon-bedrock.svg deleted file mode 100644 index 1f185ef..0000000 --- a/apps/web/src/assets/provider-logos/amazon-bedrock.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/provider-logos/augment.svg b/apps/web/src/assets/provider-logos/augment.svg deleted file mode 100644 index 2438545..0000000 --- a/apps/web/src/assets/provider-logos/augment.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/apps/web/src/assets/provider-logos/cline.svg b/apps/web/src/assets/provider-logos/cline.svg deleted file mode 100644 index 9488305..0000000 --- a/apps/web/src/assets/provider-logos/cline.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/apps/web/src/assets/provider-logos/codebuddy.svg b/apps/web/src/assets/provider-logos/codebuddy.svg deleted file mode 100644 index ec353e2..0000000 --- a/apps/web/src/assets/provider-logos/codebuddy.svg +++ /dev/null @@ -1 +0,0 @@ -CodeBuddy \ No newline at end of file diff --git a/apps/web/src/assets/provider-logos/deepseek.svg b/apps/web/src/assets/provider-logos/deepseek.svg deleted file mode 100644 index 5d6efa9..0000000 --- a/apps/web/src/assets/provider-logos/deepseek.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/web/src/assets/provider-logos/hermes.svg b/apps/web/src/assets/provider-logos/hermes.svg deleted file mode 100644 index 2521a10..0000000 --- a/apps/web/src/assets/provider-logos/hermes.svg +++ /dev/null @@ -1 +0,0 @@ -Hermes Agent \ No newline at end of file diff --git a/apps/web/src/assets/provider-logos/manifest.json b/apps/web/src/assets/provider-logos/manifest.json index 2c40a03..caff6cc 100644 --- a/apps/web/src/assets/provider-logos/manifest.json +++ b/apps/web/src/assets/provider-logos/manifest.json @@ -1,27 +1,19 @@ { "source": "https://models.dev", "note": "Subset aligned to memorph provider IDs; agent-specific logos sourced from official/lobe-icons/ACP where models.dev has no match.", - "provider_count": 24, + "provider_count": 16, "providers": [ - "alibaba", - "amazon-bedrock", "anthropic", "antigravity", - "augment", "claude", - "cline", - "codebuddy", "codex", "cursor", - "deepseek", "gemini", "github-copilot", "google", - "hermes", "kimi-for-coding", "kiro", "openai", - "openclaw", "opencode", "pi", "qoder", @@ -33,22 +25,14 @@ "codex": "codex", "opencode": "opencode", "kiro": "kiro", - "deepseek": "deepseek", "kimi": "kimi-for-coding", "gemini": "gemini", "antigravity": "antigravity", "copilot": "github-copilot", "cursor": "cursor", - "cline": "cline", "qoder": "qoder", - "codebuddy": "codebuddy", - "hermes": "hermes", "pi": "pi", - "workbuddy": "workbuddy", - "augment": "augment", - "amazonq": "amazon-bedrock", - "qwen": "alibaba", - "openclaw": "openclaw" + "workbuddy": "workbuddy" }, "unmapped_memorph_providers": [], "external_sources": { @@ -57,14 +41,9 @@ "antigravity": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/antigravity.svg", "codex": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/codex.svg", "gemini": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/gemini-color.svg", - "cline": "https://raw.githubusercontent.com/cline/cline/main/assets/icons/icon.png", "qoder": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/qoder-color.svg", - "codebuddy": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/codebuddy-color.svg", - "hermes": "https://raw.githubusercontent.com/lobehub/lobe-icons/master/packages/static-svg/icons/hermesagent.svg", "kiro": "https://kiro.dev/icon.svg", "pi": "https://pi.dev/logo-auto.svg", - "workbuddy": "https://codebuddy-1328495429.cos.accelerate.myqcloud.com/web/workbuddy/d1d0b9329b771f020a1025d11661a33296390d7c/assets/logo.svg", - "augment": "https://www.augmentcode.com/favicon.svg", - "openclaw": "https://openclaw.ai/favicon.svg" + "workbuddy": "https://codebuddy-1328495429.cos.accelerate.myqcloud.com/web/workbuddy/d1d0b9329b771f020a1025d11661a33296390d7c/assets/logo.svg" } } diff --git a/apps/web/src/assets/provider-logos/openclaw.svg b/apps/web/src/assets/provider-logos/openclaw.svg deleted file mode 100644 index bcbc1e1..0000000 --- a/apps/web/src/assets/provider-logos/openclaw.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/src/components/shared/provider-logo-map.test.ts b/apps/web/src/components/shared/provider-logo-map.test.ts index a3f1985..25208ef 100644 --- a/apps/web/src/components/shared/provider-logo-map.test.ts +++ b/apps/web/src/components/shared/provider-logo-map.test.ts @@ -10,12 +10,9 @@ describe("resolveProviderLogoAssetId", () => { expect(resolveProviderLogoAssetId("codex")).toBe("codex"); expect(resolveProviderLogoAssetId("kimi")).toBe("kimi-for-coding"); expect(resolveProviderLogoAssetId("copilot")).toBe("github-copilot"); - expect(resolveProviderLogoAssetId("openclaw")).toBe("openclaw"); - expect(resolveProviderLogoAssetId("augment")).toBe("augment"); }); it("accepts direct asset ids", () => { - expect(resolveProviderLogoAssetId("deepseek")).toBe("deepseek"); expect(resolveProviderLogoAssetId("opencode")).toBe("opencode"); }); @@ -29,7 +26,6 @@ describe("resolveProviderLogoAssetId", () => { expect(resolveProviderLogoAssetId("kiro")).toBe("kiro"); expect(resolveProviderLogoAssetId("gemini")).toBe("gemini"); expect(resolveProviderLogoAssetId("antigravity")).toBe("antigravity"); - expect(resolveProviderLogoAssetId("cline")).toBe("cline"); }); }); diff --git a/apps/web/src/features/skills/skill-detail-panel.tsx b/apps/web/src/features/skills/skill-detail-panel.tsx index 6bc06ef..d4de637 100644 --- a/apps/web/src/features/skills/skill-detail-panel.tsx +++ b/apps/web/src/features/skills/skill-detail-panel.tsx @@ -37,7 +37,7 @@ import { SectionHeading } from "@/components/shared/section-heading"; import type { I18nKey } from "@/lib/i18n-core"; import type { SkillAsset, SkillCatalogItem, SkillDetail, SkillFilePreview } from "@/lib/types"; -const AGENTS = ["claude", "codex", "gemini", "opencode", "hermes"] as const; +const AGENTS = ["claude", "codex", "gemini", "opencode"] as const; type DetailTab = "bundle" | "coverage" | "installations" | "consolidate"; diff --git a/rust/apps/memorph-cli/src/api/tests.rs b/rust/apps/memorph-cli/src/api/tests.rs index 9d3b99b..b12d5bf 100644 --- a/rust/apps/memorph-cli/src/api/tests.rs +++ b/rust/apps/memorph-cli/src/api/tests.rs @@ -1797,8 +1797,20 @@ async fn catalog_route_returns_classified_providers() { gemini_capabilities["activity_support"]["session_activity"], false ); +} + +#[tokio::test] +async fn agents_summary_route_includes_settings_and_environment_without_features() { + let request = Request::builder() + .uri("/api/v1/agents/summary") + .body(Body::empty()) + .unwrap(); + let (status, value) = read_json(router(), request).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(value["ok"], true); -value["data"]["providers"].as_array().unwrap(); + let providers = value["data"]["providers"].as_array().unwrap(); let codex = providers .iter() .find(|provider| provider["provider_id"] == "codex") diff --git a/rust/apps/memorph-cli/src/skills/server.rs b/rust/apps/memorph-cli/src/skills/server.rs index e66dda4..64905bf 100644 --- a/rust/apps/memorph-cli/src/skills/server.rs +++ b/rust/apps/memorph-cli/src/skills/server.rs @@ -1587,7 +1587,7 @@ mod tests { ) .await; assert_eq!(status, StatusCode::OK); - assert_eq!(scanned["data"]["roots_scanned"], 8); + assert_eq!(scanned["data"]["roots_scanned"], 7); let listed = wait_for_skills(app, 2000).await; assert_eq!(listed["data"]["total"], 1); diff --git a/rust/crates/memorph/src/core.rs b/rust/crates/memorph/src/core.rs index 257c8ec..49b622c 100644 --- a/rust/crates/memorph/src/core.rs +++ b/rust/crates/memorph/src/core.rs @@ -59,6 +59,9 @@ const PROJECTED_SESSION_PROVIDER_IDS: &[&str] = &[ "qoder", "workbuddy", "pi", + "omp", + "grok", + "zcode", "antigravity", ]; diff --git a/rust/crates/memorph/src/providers/aliases.rs b/rust/crates/memorph/src/providers/aliases.rs index 2d90d11..31e4784 100644 --- a/rust/crates/memorph/src/providers/aliases.rs +++ b/rust/crates/memorph/src/providers/aliases.rs @@ -1,6 +1,7 @@ pub(crate) fn canonical_provider_id(provider_id: &str) -> String { match provider_id.trim().to_ascii_lowercase().as_str() { "work-buddy" | "work_buddy" => "workbuddy".to_string(), + "oh-my-pi" | "oh_my_pi" => "omp".to_string(), other => other.to_string(), } } @@ -10,5 +11,6 @@ mod tests { #[test] fn canonicalizes_known_provider_aliases() { assert_eq!(super::canonical_provider_id("work-buddy"), "workbuddy"); + assert_eq!(super::canonical_provider_id("oh-my-pi"), "omp"); } } diff --git a/rust/crates/memorph/src/providers/catalog.rs b/rust/crates/memorph/src/providers/catalog.rs index acb073c..abc8549 100644 --- a/rust/crates/memorph/src/providers/catalog.rs +++ b/rust/crates/memorph/src/providers/catalog.rs @@ -93,6 +93,9 @@ fn curated_display_name(canonical_id: &str) -> Option<&'static str> { "qoder" => "Qoder", "workbuddy" => "WorkBuddy", "pi" => "Pi", + "omp" => "Oh My Pi", + "grok" => "Grok Build", + "zcode" => "ZCode", _ => return None, }) } diff --git a/rust/crates/memorph/src/providers/environment_profiles.rs b/rust/crates/memorph/src/providers/environment_profiles.rs index bfb65c0..5daebc1 100644 --- a/rust/crates/memorph/src/providers/environment_profiles.rs +++ b/rust/crates/memorph/src/providers/environment_profiles.rs @@ -4,6 +4,9 @@ pub(crate) fn executable_candidates(provider_id: &str) -> &'static [&'static str let provider_id = crate::providers::canonical_provider_id(provider_id); match provider_id.as_str() { "antigravity" => &["antigravity"], + "grok" => &["grok"], + "omp" => &["omp"], + "zcode" => &["zcode"], "claude" => &["claude"], "codex" => &["codex"], "copilot" => &["gh"], @@ -23,6 +26,9 @@ pub(crate) fn config_path(provider_id: &str) -> PathBuf { let provider_id = crate::providers::canonical_provider_id(provider_id); match provider_id.as_str() { "antigravity" => home_join(".antigravity"), + "grok" => home_join(".grok"), + "omp" => home_join(".omp"), + "zcode" => home_join(".zcode"), "claude" => home_join(".claude"), "codex" => home_join(".codex"), "copilot" => app_config_dir("Code", ".config/Code") diff --git a/rust/crates/memorph/src/providers/grok.rs b/rust/crates/memorph/src/providers/grok.rs new file mode 100644 index 0000000..8b561eb --- /dev/null +++ b/rust/crates/memorph/src/providers/grok.rs @@ -0,0 +1,569 @@ +//! Grok Build (xAI coding CLI). +//! +//! Layout: `~/.grok/sessions///updates.jsonl` plus a +//! `summary.json` sidecar (`info.cwd`, `generated_title` / `session_summary`, +//! `created_at` / `updated_at` ISO, `current_model_id`). `updates.jsonl` is an +//! ACP-style stream — `{timestamp(unix sec), method:"session/update", +//! params:{update:{sessionUpdate:…}}}` — where text chunks are incremental +//! fragments that must be concatenated per role segment, never trimmed/joined +//! (token-boundary spaces would be eaten). `chat_history.jsonl` is a +//! compaction snapshot, not full history; `session_search.sqlite` and +//! `prompt_history.jsonl` are Grok's own index — none of these are session +//! transcripts. +//! +//! Semantics ported from the Wake reference implementation (real-machine +//! verified there). Known non-content updates (task_backgrounded, task_completed, +//! auto_compact_*, compaction_checkpoint, plan, current_mode_update) are skipped. +//! +//! ponytail: subagent sessions are imported standalone with their own summary +//! cwd; Wake additionally re-parents grok subagents via a worktree registry +//! (grok_group) — add if workspace attribution of child sessions ever matters. + +use crate::provider::{ + PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, + ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, + ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, +}; +use crate::session::{ + Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, + MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, +}; +use anyhow::{Context as _, Result}; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use walkdir::WalkDir; + +pub struct GrokProvider; +const PROVIDER_ID: &str = "grok"; + +impl Provider for GrokProvider { + fn id(&self) -> &'static str { + PROVIDER_ID + } + fn name(&self) -> &'static str { + "Grok Build" + } + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + scan: true, + import: true, + storage_shape: StorageShape::Directory, + scan_strategy: ScanStrategy::FullScan, + page_strategy: PageStrategy::FullImport, + turn_quality: TurnQuality::Inferred, + import_fidelity: ProviderContentFidelity { + text: Some(Fidelity::Preserved), + thinking: Some(Fidelity::Preserved), + tool_call: Some(Fidelity::Preserved), + tool_result: Some(Fidelity::Preserved), + provider_payload: Some(Fidelity::Dropped), + ..ProviderContentFidelity::unknown() + }, + resume_quality: ResumeQuality::None, + write_risk: ProviderWriteRisk { + level: WriteRiskLevel::High, + multiple_files: true, + sqlite: false, + sidecar_files: true, + index_repair: false, + }, + backup_support: ProviderBackupSupport { + before_write: false, + restore: false, + sync_only: false, + }, + activity_support: ProviderActivitySupport { + hook_events: false, + runtime_endpoint: false, + session_activity: false, + }, + ..ProviderCapabilities::default() + } + } + fn scan_sessions(&self) -> Result> { + let mut sessions = Vec::new(); + for root in roots() { + if !root.exists() { + continue; + } + for entry in WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()) != Some("updates.jsonl") { + continue; + } + let Some(id) = path.parent().and_then(|p| p.file_name()) else { + continue; + }; + let summary = read_summary(path); + let id = id.to_string_lossy().into_owned(); + sessions.push(ProviderSessionSummary { + archived: false, + session_id: id.clone(), + title: summary.title.filter(|t| !t.trim().is_empty()), + project_dir: summary.cwd, + created_at: summary.created_ms, + last_active_at: summary + .updated_ms + .or_else(|| modified_ms(path)), + source_path: Some(path.to_string_lossy().into_owned()), + }); + } + } + sessions.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); + Ok(sessions) + } + fn import_session(&self, source_path: &str) -> Result { + let path = Path::new(source_path); + anyhow::ensure!( + path.file_name().and_then(|n| n.to_str()) == Some("updates.jsonl"), + "Grok source must be an updates.jsonl transcript: {source_path}" + ); + let id = path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .context("Grok source must live inside a session directory")?; + let summary = read_summary(path); + let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); + let mut events = parse_updates(path, &mut report)?; + if let Some(model) = &summary.model { + for event in events.iter_mut() { + event.metadata.model = Some(model.clone()); + } + } + let title = summary + .title + .filter(|t| !t.trim().is_empty()) + .or_else(|| { + events + .iter() + .find(|e| e.role == Role::User) + .and_then(|e| first_text(e)) + }); + let created = summary + .created_ms + .and_then(DateTime::::from_timestamp_millis) + .or_else(|| modified_datetime(path)) + .unwrap_or_else(Utc::now); + let mut imported = ImportedSession { + session: Session { + lineage: Vec::new(), + schema: Schema::default(), + identity: Identity { id: id.clone(), title }, + context: Context { + workspace: summary.cwd, + created_at: Some(created), + last_active_at: summary + .updated_ms + .and_then(DateTime::::from_timestamp_millis) + .or_else(|| modified_datetime(path)), + tags: Vec::new(), + }, + events, + extensions: BTreeMap::new(), + }, + provenance: Provenance { + imported_at: Utc::now(), + imported_by: Some("memorph-cli".into()), + primary_source: ProviderRef { + provider_id: PROVIDER_ID.into(), + session_id: id, + source_path: Some(path.to_string_lossy().into_owned()), + }, + aliases: Vec::new(), + }, + event_meta: Vec::new(), + report, + }; + imported.event_meta = (0..imported.session.events.len()) + .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) + .collect(); + Ok(imported) + } + fn session_source_fingerprint( + &self, + source_path: &str, + ) -> Result> { + let path = Path::new(source_path); + if !path.exists() { + return Ok(None); + } + let digest = Sha256::digest(std::fs::read(path)?); + let size = std::fs::metadata(path)?.len(); + Ok(Some(ProviderSourceFingerprint { + modified_at_ms: modified_ms(path).unwrap_or(0), + size_bytes: size.min(i64::MAX as u64) as i64, + value: format!("{PROVIDER_ID}-session-v1:{digest:x}"), + })) + } + fn data_source_paths(&self) -> Vec { + roots() + } +} + +fn roots() -> Vec { + dirs::home_dir() + .into_iter() + .map(|h| h.join(".grok/sessions")) + .collect() +} + +struct Summary { + cwd: Option, + title: Option, + model: Option, + created_ms: Option, + updated_ms: Option, +} + +fn read_summary(updates_path: &Path) -> Summary { + let mut s = Summary { + cwd: None, + title: None, + model: None, + created_ms: None, + updated_ms: None, + }; + let path = updates_path.with_file_name("summary.json"); + let Ok(raw) = std::fs::read_to_string(&path) else { + return s; + }; + let Ok(v) = serde_json::from_str::(&raw) else { + return s; + }; + s.cwd = v + .pointer("/info/cwd") + .and_then(Value::as_str) + .filter(|c| !c.is_empty()) + .map(str::to_string); + for key in ["generated_title", "session_summary"] { + if let Some(t) = v.get(key).and_then(Value::as_str) { + if !t.trim().is_empty() { + s.title = Some(t.to_string()); + break; + } + } + } + s.model = v + .get("current_model_id") + .and_then(Value::as_str) + .filter(|m| !m.is_empty()) + .map(str::to_string); + s.created_ms = v + .get("created_at") + .and_then(Value::as_str) + .and_then(iso_ms); + s.updated_ms = v + .get("updated_at") + .and_then(Value::as_str) + .and_then(iso_ms); + s +} + +/// Replay state: one segment per role turn; assistant segments accumulate +/// interleaved thought/message/tool chunks into a single event. +struct Replay { + events: Vec, + cur_role: Option, + cur_ts: Option>, + cur_blocks: Vec, +} + +impl Replay { + fn new() -> Self { + Self { + events: Vec::new(), + cur_role: None, + cur_ts: None, + cur_blocks: Vec::new(), + } + } + + fn flush(&mut self) { + let Some(role) = self.cur_role.take() else { + return; + }; + let blocks = std::mem::take(&mut self.cur_blocks); + if blocks.is_empty() { + return; + } + let kind = if blocks.iter().any(|b| matches!(b, Block::ToolResult { .. })) { + EventKind::Observation + } else if blocks.iter().any(|b| matches!(b, Block::ToolCall { .. })) { + EventKind::Action + } else { + EventKind::Message + }; + self.events.push(Event { + id: format!("grok:event:{}", self.events.len()), + kind, + role, + timestamp: self.cur_ts.unwrap_or_else(Utc::now), + links: Links::default(), + blocks, + tags: Vec::new(), + extensions: Default::default(), + metadata: Metadata { + model: None, + usage: None, + }, + }); + self.cur_ts = None; + } + + /// Enter (or continue) a role segment. + fn ensure(&mut self, role: Role, ts: DateTime) { + if self.cur_role != Some(role) { + self.flush(); + self.cur_role = Some(role); + self.cur_ts = Some(ts); + } + } + + /// Append incremental text to the current segment's trailing Text block. + fn append_text(&mut self, text: &str) { + if let Some(Block::Text { text: acc }) = self.cur_blocks.last_mut() { + acc.push_str(text); + } else { + self.cur_blocks.push(Block::Text { + text: text.to_string(), + }); + } + } + + /// Append incremental thinking to the current segment's trailing Thinking block. + fn append_thinking(&mut self, text: &str) { + if let Some(Block::Thinking { text: acc, .. }) = self.cur_blocks.last_mut() { + acc.push_str(text); + } else { + self.cur_blocks.push(Block::Thinking { + text: text.to_string(), + signature: None, + }); + } + } +} + +fn chunk_text(update: &Value) -> &str { + update + .pointer("/content/text") + .and_then(Value::as_str) + .unwrap_or_default() +} + +/// Parse `updates.jsonl` into canonical events. +fn parse_updates( + path: &Path, + report: &mut MappingReport, +) -> Result> { + let file = std::fs::File::open(path)?; + let reader = BufReader::with_capacity(1 << 20, file); + let mut rp = Replay::new(); + let mut unknown = 0u32; + + for line in reader.lines() { + let Ok(line) = line else { + unknown += 1; + continue; + }; + if line.trim().is_empty() { + continue; + } + let Ok(row) = serde_json::from_str::(&line) else { + unknown += 1; + continue; + }; + let ts = row + .get("timestamp") + .and_then(|v| v.as_i64()) + .and_then(|secs| DateTime::::from_timestamp(secs, 0)) + .unwrap_or_else(Utc::now); + let Some(update) = row.pointer("/params/update") else { + unknown += 1; + continue; + }; + match update.get("sessionUpdate").and_then(Value::as_str) { + Some("user_message_chunk") => { + rp.ensure(Role::User, ts); + rp.append_text(chunk_text(update)); + } + Some("agent_message_chunk") => { + rp.ensure(Role::Assistant, ts); + rp.append_text(chunk_text(update)); + } + Some("agent_thought_chunk") => { + rp.ensure(Role::Assistant, ts); + rp.append_thinking(chunk_text(update)); + } + Some("tool_call") => { + rp.ensure(Role::Assistant, ts); + rp.cur_blocks.push(Block::ToolCall { + tool_call_id: update + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or("unknown") + .into(), + name: update + .get("title") + .and_then(Value::as_str) + .unwrap_or("tool") + .into(), + input: update.get("rawInput").filter(|r| !r.is_null()).cloned(), + }); + } + Some("tool_call_update") => { + rp.ensure(Role::Assistant, ts); + let id = update + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let content = update + .get("content") + .and_then(|c| c.pointer("/text")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + let failed = + update.get("status").and_then(Value::as_str) == Some("failed"); + rp.cur_blocks.push(Block::ToolResult { + tool_call_id: id.into(), + content, + outcome: crate::session::execution_outcome(failed), + }); + } + // Known non-content updates. + Some( + "task_backgrounded" + | "task_completed" + | "auto_compact_started" + | "auto_compact_completed" + | "compaction_checkpoint" + | "plan" + | "current_mode_update" + ) => {} + _ => { + unknown += 1; + } + } + } + rp.flush(); + if unknown > 0 { + report.push_issue(crate::session::MappingIssue { + level: crate::session::MappingIssueLevel::Info, + disposition: Fidelity::Preserved, + code: "grok-unknown-updates".into(), + message: format!("{unknown} updates.jsonl rows carried no sessionUpdate payload"), + path: None, + raw: None, + }); + } + Ok(rp.events) +} + +fn first_text(event: &Event) -> Option { + event.blocks.iter().find_map(|b| match b { + Block::Text { text } => Some(text.clone()), + _ => None, + }) +} + +fn iso_ms(s: &str) -> Option { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc).timestamp_millis()) +} + +fn modified_ms(path: &Path) -> Option { + std::fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) +} + +fn modified_datetime(path: &Path) -> Option> { + modified_ms(path).and_then(DateTime::::from_timestamp_millis) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_session(dir: &Path, rows: &[&str]) -> PathBuf { + let session_dir = dir.join("9f2c1a44-1111-2222-3333-444455556666"); + std::fs::create_dir_all(&session_dir).unwrap(); + let updates = session_dir.join("updates.jsonl"); + std::fs::write(&updates, rows.join("\n") + "\n").unwrap(); + std::fs::write( + session_dir.join("summary.json"), + r#"{"info":{"cwd":"/tmp/grok-project"},"generated_title":"Refactor auth","created_at":"2026-09-20T10:00:00Z","updated_at":"2026-09-20T11:30:00Z"}"#, + ) + .unwrap(); + updates + } + + #[test] + fn imports_segmented_transcript_with_tools_and_thinking() { + let dir = tempfile::tempdir().unwrap(); + let updates = write_session( + dir.path(), + &[ + r#"{"timestamp":1789908000,"method":"session/update","params":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"run the "}}}}"#, + r#"{"timestamp":1789908001,"method":"session/update","params":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"tests"}}}}"#, + r#"{"timestamp":1789908002,"method":"session/update","params":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"checking deps"}}}}"#, + r#"{"timestamp":1789908003,"method":"session/update","params":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"on it"}}}}"#, + r#"{"timestamp":1789908004,"method":"session/update","params":{"update":{"sessionUpdate":"tool_call","toolCallId":"tc-1","title":"shell","rawInput":{"command":"pytest"}}}}"#, + r#"{"timestamp":1789908009,"method":"session/update","params":{"update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-1","content":{"type":"text","text":"3 passed"},"status":"completed"}}}"#, + r#"{"timestamp":1789908010,"method":"session/update","params":{"update":{"sessionUpdate":"task_completed"}}}"#, + ], + ); + + let imported = GrokProvider + .import_session(updates.to_str().unwrap()) + .unwrap(); + assert_eq!(imported.session.identity.id, "9f2c1a44-1111-2222-3333-444455556666"); + assert_eq!(imported.session.identity.title.as_deref(), Some("Refactor auth")); + assert_eq!(imported.session.context.workspace.as_deref(), Some("/tmp/grok-project")); + assert_eq!(imported.session.events.len(), 2); + + let user = &imported.session.events[0]; + assert_eq!(user.role, Role::User); + assert!(matches!(&user.blocks[0], Block::Text { text } if text == "run the tests")); + + let assistant = &imported.session.events[1]; + assert_eq!(assistant.role, Role::Assistant); + // thinking + text + tool_call + tool_result all inside one segment + assert_eq!(assistant.blocks.len(), 4); + assert!(matches!(&assistant.blocks[0], Block::Thinking { text, .. } if text == "checking deps")); + assert!(matches!(&assistant.blocks[2], Block::ToolCall { tool_call_id, name, .. } if tool_call_id == "tc-1" && name == "shell")); + assert!(matches!(&assistant.blocks[3], Block::ToolResult { content, outcome, .. } if content == "3 passed" && !crate::session::execution_outcome_is_error(*outcome))); + } + + #[test] + fn scan_finds_updates_jsonl_with_sidecar_metadata() { + let dir = tempfile::tempdir().unwrap(); + let _ = write_session( + dir.path(), + &[r#"{"timestamp":1789908000,"method":"session/update","params":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}}}"#], + ); + + // scan reads the real root; emulate by checking the summary parser instead + let updates = dir + .path() + .join("9f2c1a44-1111-2222-3333-444455556666/updates.jsonl"); + let summary = read_summary(&updates); + assert_eq!(summary.cwd.as_deref(), Some("/tmp/grok-project")); + assert_eq!(summary.created_ms, Some(1789898400000)); + } +} diff --git a/rust/crates/memorph/src/providers/mod.rs b/rust/crates/memorph/src/providers/mod.rs index ae76c0f..b15f5b5 100644 --- a/rust/crates/memorph/src/providers/mod.rs +++ b/rust/crates/memorph/src/providers/mod.rs @@ -1,5 +1,6 @@ mod aliases; pub mod antigravity; +pub mod grok; pub mod catalog; pub mod claude; pub mod codex; @@ -13,9 +14,12 @@ pub mod opencode; pub mod pi; pub mod qoder; pub mod workbuddy; +pub mod zcode; pub(crate) mod hook_profiles; mod hook_registry; +pub mod omp; +pub(crate) mod pi_format; pub(crate) mod q_conversation; use crate::provider::Provider; @@ -33,6 +37,9 @@ const PROVIDER_IDS: &[&str] = &[ "qoder", "workbuddy", "pi", + "omp", + "grok", + "zcode", ]; pub struct ProviderRegistry; @@ -57,6 +64,9 @@ impl ProviderRegistry { "kiro" => Some(Box::new(kiro::KiroProvider)), "kimi" => Some(Box::new(kimi::KimiProvider)), "opencode" => Some(Box::new(opencode::OpenCodeProvider)), + "omp" => Some(Box::new(omp::OmpProvider)), + "grok" => Some(Box::new(grok::GrokProvider)), + "zcode" => Some(Box::new(zcode::ZcodeProvider)), _ => None, } } @@ -116,6 +126,9 @@ mod tests { "qoder", "workbuddy", "pi", + "omp", + "grok", + "zcode", ] { assert!( all_provider_ids().contains(&id), diff --git a/rust/crates/memorph/src/providers/omp.rs b/rust/crates/memorph/src/providers/omp.rs new file mode 100644 index 0000000..cf3f50c --- /dev/null +++ b/rust/crates/memorph/src/providers/omp.rs @@ -0,0 +1,118 @@ +//! Oh My Pi (`omp`): same pi-ai JSONL family as Pi, sessions under +//! `~/.omp/agent/sessions//_.jsonl`. Files may carry a +//! leading `{"type":"title"}` row; the shared parser locates the session +//! header by type. Parsing lives in [`crate::providers::pi_format`]. + +use crate::provider::{ + PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, + ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, + ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, +}; +use crate::providers::pi_format; +use crate::session::{Fidelity, ImportedSession}; +use anyhow::Result; +use std::path::PathBuf; + +pub struct OmpProvider; +const PROVIDER_ID: &str = "omp"; + +impl Provider for OmpProvider { + fn id(&self) -> &'static str { + PROVIDER_ID + } + fn name(&self) -> &'static str { + "Oh My Pi" + } + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + scan: true, + import: true, + storage_shape: StorageShape::Directory, + scan_strategy: ScanStrategy::FullScan, + page_strategy: PageStrategy::FullImport, + turn_quality: TurnQuality::Inferred, + import_fidelity: ProviderContentFidelity { + text: Some(Fidelity::Preserved), + thinking: Some(Fidelity::Preserved), + tool_call: Some(Fidelity::Preserved), + tool_result: Some(Fidelity::Preserved), + provider_payload: Some(Fidelity::Preserved), + ..ProviderContentFidelity::unknown() + }, + resume_quality: ResumeQuality::None, + write_risk: ProviderWriteRisk { + level: WriteRiskLevel::High, + multiple_files: true, + sqlite: false, + sidecar_files: true, + index_repair: false, + }, + backup_support: ProviderBackupSupport { + before_write: false, + restore: false, + sync_only: false, + }, + activity_support: ProviderActivitySupport { + hook_events: false, + runtime_endpoint: false, + session_activity: false, + }, + ..ProviderCapabilities::default() + } + } + fn scan_sessions(&self) -> Result> { + pi_format::scan(PROVIDER_ID, &roots()) + } + fn import_session(&self, source_path: &str) -> Result { + pi_format::import(PROVIDER_ID, source_path) + } + fn session_source_fingerprint( + &self, + source_path: &str, + ) -> Result> { + pi_format::fingerprint(PROVIDER_ID, source_path) + } + fn data_source_paths(&self) -> Vec { + roots() + } +} + +fn roots() -> Vec { + dirs::home_dir() + .into_iter() + .map(|h| h.join(".omp/agent/sessions")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn imports_omp_transcript_with_leading_title_row() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("2026-09-24T08-20-25-247Z_01a0d280.jsonl"); + std::fs::write( + &file, + concat!( + r#"{"type":"title","v":1,"title":"","updatedAt":"2026-09-24T08:20:25.247Z"}"#, + "\n", + r#"{"type":"session","version":3,"id":"01a0d280","timestamp":"2026-09-24T08:20:25.247Z","cwd":"/tmp/omp-project"}"#, + "\n", + r#"{"type":"message","message":{"role":"user","content":"hello"},"timestamp":"2026-09-24T08:20:26.000Z"}"#, + "\n", + r#"{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"timestamp":"2026-09-24T08:20:27.000Z"}"#, + "\n", + ), + ) + .unwrap(); + + let imported = pi_format::import(PROVIDER_ID, file.to_str().unwrap()).unwrap(); + assert_eq!(imported.session.identity.id, "01a0d280"); + assert_eq!(imported.session.context.workspace.as_deref(), Some("/tmp/omp-project")); + assert_eq!(imported.session.events.len(), 2); + assert_eq!( + imported.provenance.primary_source.provider_id, PROVIDER_ID + ); + } +} diff --git a/rust/crates/memorph/src/providers/opencode/load.rs b/rust/crates/memorph/src/providers/opencode/load.rs index 170e6dd..ac7faba 100644 --- a/rust/crates/memorph/src/providers/opencode/load.rs +++ b/rust/crates/memorph/src/providers/opencode/load.rs @@ -146,7 +146,7 @@ pub(super) fn opencode_database_source(source_locator: &str) -> Option<(&str, &s (!database_path.is_empty() && !session_id.is_empty()).then_some((database_path, session_id)) } -pub(super) fn imported_session_from_data( +pub(crate) fn imported_session_from_data( session_id: &str, (session_json, messages, parts): OpenCodeSessionData, ) -> Result { diff --git a/rust/crates/memorph/src/providers/pi/mod.rs b/rust/crates/memorph/src/providers/pi/mod.rs index 510f7b9..97b576b 100644 --- a/rust/crates/memorph/src/providers/pi/mod.rs +++ b/rust/crates/memorph/src/providers/pi/mod.rs @@ -6,18 +6,10 @@ use crate::provider::{ ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, }; -use crate::session::{ - Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, - MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, -}; -use anyhow::{Context as _, Result}; -use chrono::{DateTime, Utc}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; -use walkdir::WalkDir; +use crate::providers::pi_format; +use crate::session::{Fidelity, ImportedSession}; +use anyhow::Result; +use std::path::PathBuf; pub struct PiProvider; const PROVIDER_ID: &str = "pi"; @@ -67,328 +59,25 @@ impl Provider for PiProvider { } } fn scan_sessions(&self) -> Result> { - let mut sessions = Vec::new(); - for root in roots() { - if !root.exists() { - continue; - } - for entry in WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_map(Result::ok) - { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { - continue; - } - let Ok(records) = read_records(path) else { - continue; - }; - let Some(header) = records - .first() - .filter(|v| v.get("type").and_then(Value::as_str) == Some("session")) - else { - continue; - }; - let Some(id) = session_id(header) else { - continue; - }; - let events: Vec<&Value> = records - .iter() - .skip(1) - .filter(|v| v.get("type").and_then(Value::as_str) == Some("message")) - .collect(); - let title = events - .iter() - .find_map(|e| text_for(e).filter(|s| !s.trim().is_empty())); - sessions.push(ProviderSessionSummary { - archived: false, - session_id: id, - title, - project_dir: header - .get("cwd") - .and_then(Value::as_str) - .map(str::to_string), - created_at: None, - last_active_at: modified_ms(path), - source_path: Some(path.to_string_lossy().into_owned()), - }); - } - } - sessions.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); - sessions.dedup_by(|a, b| a.session_id == b.session_id); - Ok(sessions) + pi_format::scan(PROVIDER_ID, &roots()) } fn import_session(&self, source_path: &str) -> Result { - let path = std::fs::canonicalize(source_path) - .with_context(|| format!("Pi source does not exist: {source_path}"))?; - let raw = read_records(&path)?; - let header = raw - .first() - .filter(|v| v.get("type").and_then(Value::as_str) == Some("session")) - .context("Pi source must begin with a session header")?; - let id = session_id(header).context("Pi session header must contain id")?; - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let events: Vec = raw - .iter() - .enumerate() - .filter_map(|(i, e)| map_event(e, i, &mut report)) - .collect(); - let event_meta = events - .iter() - .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) - .collect::>(); - let mut extensions = BTreeMap::new(); - extensions.insert("pi_session_jsonl".into(), Value::Array(raw.clone())); - let created = raw - .iter() - .find_map(timestamp) - .or_else(|| modified_datetime(&path)) - .unwrap_or_else(Utc::now); - Ok(ImportedSession { - session: Session { - lineage: Vec::new(), - schema: Schema::default(), - identity: Identity { - id: id.clone(), - title: raw.iter().find_map(text_for), - }, - context: Context { - workspace: header - .get("cwd") - .and_then(Value::as_str) - .map(str::to_string), - created_at: Some(created), - last_active_at: modified_datetime(&path), - tags: Vec::new(), - }, - events, - extensions, - }, - provenance: Provenance { - imported_at: Utc::now(), - imported_by: Some("memorph-cli".into()), - primary_source: ProviderRef { - provider_id: PROVIDER_ID.into(), - session_id: id, - source_path: Some(path.to_string_lossy().into_owned()), - }, - aliases: Vec::new(), - }, - event_meta, - report, - }) + pi_format::import(PROVIDER_ID, source_path) } fn session_source_fingerprint( &self, source_path: &str, ) -> Result> { - let path = Path::new(source_path); - if !path.exists() { - return Ok(None); - } - let digest = Sha256::digest(std::fs::read(path)?); - let size = std::fs::metadata(path)?.len(); - Ok(Some(ProviderSourceFingerprint { - modified_at_ms: modified_ms(path).unwrap_or(0), - size_bytes: size.min(i64::MAX as u64) as i64, - value: format!( - "pi-session-v1:{}:{digest:x}", - read_records(path)? - .first() - .and_then(session_id) - .unwrap_or_default() - ), - })) + pi_format::fingerprint(PROVIDER_ID, source_path) } fn data_source_paths(&self) -> Vec { roots() } } + fn roots() -> Vec { dirs::home_dir() .into_iter() .map(|h| h.join(".pi/agent/sessions")) .collect() } -fn session_id(header: &Value) -> Option { - header - .get("id") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} -fn read_records(path: &Path) -> Result> { - std::fs::read_to_string(path)? - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| { - serde_json::from_str(l) - .with_context(|| format!("invalid Pi record in {}", path.display())) - }) - .collect() -} -fn modified_ms(path: &Path) -> Option { - std::fs::metadata(path) - .ok()? - .modified() - .ok()? - .duration_since(UNIX_EPOCH) - .ok() - .map(|d| d.as_millis().min(i64::MAX as u128) as i64) -} -fn modified_datetime(path: &Path) -> Option> { - modified_ms(path).and_then(DateTime::::from_timestamp_millis) -} -fn timestamp(v: &Value) -> Option> { - v.get("timestamp") - .or_else(|| v.get("createdAt")) - .and_then(|x| { - x.as_str() - .and_then(|s| { - DateTime::parse_from_rfc3339(s) - .ok() - .map(|d| d.with_timezone(&Utc)) - }) - .or_else(|| x.as_i64().and_then(DateTime::::from_timestamp_millis)) - }) -} -fn text_for(v: &Value) -> Option { - let m = v.get("message").unwrap_or(v); - let c = m.get("content").unwrap_or(m); - c.as_str().map(str::to_string).or_else(|| { - c.as_array()? - .iter() - .find_map(|b| b.get("text").and_then(Value::as_str).map(str::to_string)) - }) -} -fn blocks(v: &Value) -> Vec { - let m = v.get("message").unwrap_or(v); - let c = m.get("content").unwrap_or(m); - if let Some(s) = c.as_str() { - return vec![Block::Text { text: s.into() }]; - }; - c.as_array() - .into_iter() - .flatten() - .filter_map( - |b| match b.get("type").and_then(Value::as_str).unwrap_or("") { - "text" | "input_text" | "output_text" => b - .get("text") - .and_then(Value::as_str) - .map(|s| Block::Text { text: s.into() }), - "thinking" => b - .get("thinking") - .and_then(Value::as_str) - .map(|s| Block::Thinking { - text: s.into(), - signature: None, - }), - "tool_use" | "toolCall" => Some(Block::ToolCall { - tool_call_id: b - .get("id") - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - name: b - .get("name") - .and_then(Value::as_str) - .unwrap_or("tool") - .into(), - input: b.get("input").or_else(|| b.get("arguments")).cloned(), - }), - "tool_result" | "toolResult" => Some(Block::ToolResult { - tool_call_id: b - .get("tool_use_id") - .or_else(|| b.get("toolCallId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .into(), - content: b.get("content").map(|x| x.to_string()).unwrap_or_default(), - outcome: crate::session::execution_outcome( - b.get("is_error").and_then(Value::as_bool).unwrap_or(false), - ), - }), - _ => None, - }, - ) - .collect() -} -fn map_event(v: &Value, i: usize, r: &mut MappingReport) -> Option { - if v.get("type").and_then(Value::as_str) != Some("message") { - return None; - } - let m = v.get("message").unwrap_or(v); - let role_raw = m - .get("role") - .or_else(|| v.get("role")) - .or_else(|| v.get("type"))? - .as_str()?; - let role = match role_raw.to_ascii_lowercase().as_str() { - "user" => Role::User, - "assistant" => Role::Assistant, - "tool" => Role::Tool, - "system" => Role::System, - _ => Role::Other, - }; - let bs = blocks(v); - if bs.is_empty() { - return None; - }; - let kind = if bs.iter().any(|b| matches!(b, Block::ToolResult { .. })) { - EventKind::Observation - } else if bs.iter().any(|b| matches!(b, Block::ToolCall { .. })) { - EventKind::Action - } else { - EventKind::Message - }; - r.push_issue(crate::session::MappingIssue { - level: crate::session::MappingIssueLevel::Info, - disposition: Fidelity::Preserved, - code: "pi-native-event".into(), - message: "Mapped Factory/Droid JSONL event".into(), - path: Some(format!("events[{i}]")), - raw: None, - }); - Some(Event { - id: format!("pi:event:{i}"), - kind, - role, - timestamp: timestamp(v).unwrap_or_else(Utc::now), - links: Links::default(), - blocks: bs, - tags: Vec::new(), - extensions: Default::default(), - metadata: Metadata { - model: None, - usage: None, - }, - }) -} -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn identity_and_workspace_come_from_session_header() { - let header = serde_json::json!({"type":"session","id":"header-id","cwd":"/tmp/project"}); - assert_eq!(session_id(&header).as_deref(), Some("header-id")); - assert_eq!( - header.get("cwd").and_then(Value::as_str), - Some("/tmp/project") - ); - } - #[test] - fn maps_pi_content_blocks() { - let v = serde_json::json!({"type":"message","message":{"role":"assistant","content":[{"type":"thinking","thinking":"reason"},{"type":"text","text":"answer"},{"type":"toolCall","id":"call-1","name":"read","arguments":{"path":"x"}},{"type":"toolResult","toolCallId":"call-1","content":"ok"}]}}); - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - let event = map_event(&v, 1, &mut report).unwrap(); - assert_eq!(event.blocks.len(), 4); - assert!(matches!(event.kind, EventKind::Observation)); - } - #[test] - fn branch_and_compaction_rows_are_not_messages() { - let mut report = MappingReport::new(PROVIDER_ID, MappingDirection::Import); - assert!(map_event(&serde_json::json!({"type":"branch"}), 1, &mut report).is_none()); - assert!(map_event(&serde_json::json!({"type":"compaction"}), 2, &mut report).is_none()); - } -} diff --git a/rust/crates/memorph/src/providers/pi_format.rs b/rust/crates/memorph/src/providers/pi_format.rs new file mode 100644 index 0000000..c56ef73 --- /dev/null +++ b/rust/crates/memorph/src/providers/pi_format.rs @@ -0,0 +1,338 @@ +//! Shared parsing for the pi-ai JSONL session family (Pi, Oh My Pi). +//! +//! Both agents write `_.jsonl` transcripts under +//! `//agent/sessions//`. Oh My Pi files may carry a +//! leading `{"type":"title"}` row, so the session header is located by type, +//! not by position. + +use crate::provider::{ProviderSessionSummary, ProviderSourceFingerprint}; +use crate::session::{ + Block, Context, Event, EventKind, Fidelity, Identity, ImportedSession, Links, MappingDirection, + MappingReport, Metadata, Provenance, ProviderRef, Role, Schema, Session, +}; +use anyhow::{Context as _, Result}; +use chrono::{DateTime, Utc}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use walkdir::WalkDir; + +pub(crate) fn scan( + provider_id: &str, + roots: &[PathBuf], +) -> Result> { + let mut sessions = Vec::new(); + for root in roots { + if !root.exists() { + continue; + } + for entry in WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let Ok(records) = read_records(path) else { + continue; + }; + let Some(header) = records + .iter() + .find(|v| v.get("type").and_then(Value::as_str) == Some("session")) + else { + continue; + }; + let Some(id) = session_id(header) else { + continue; + }; + let events: Vec<&Value> = records + .iter() + .filter(|v| v.get("type").and_then(Value::as_str) == Some("message")) + .collect(); + let title = events + .iter() + .find_map(|e| text_for(e).filter(|s| !s.trim().is_empty())); + sessions.push(ProviderSessionSummary { + archived: false, + session_id: id, + title, + project_dir: header + .get("cwd") + .and_then(Value::as_str) + .map(str::to_string), + created_at: timestamp(header), + last_active_at: modified_ms(path), + source_path: Some(path.to_string_lossy().into_owned()), + }); + } + } + sessions.sort_by_key(|s| std::cmp::Reverse(s.last_active_at.unwrap_or(0))); + sessions.dedup_by(|a, b| a.session_id == b.session_id); + Ok(sessions) +} + +pub(crate) fn import(provider_id: &str, source_path: &str) -> Result { + let path = std::fs::canonicalize(source_path) + .with_context(|| format!("{provider_id} source does not exist: {source_path}"))?; + let raw = read_records(&path)?; + let header = raw + .iter() + .find(|v| v.get("type").and_then(Value::as_str) == Some("session")) + .context("source must contain a session header")?; + let id = session_id(header).context("session header must contain id")?; + let mut report = MappingReport::new(provider_id, MappingDirection::Import); + let events: Vec = raw + .iter() + .enumerate() + .filter_map(|(i, e)| map_event(provider_id, e, i, &mut report)) + .collect(); + let event_meta = events + .iter() + .map(|_| crate::session::EventMeta::preserved(provider_id)) + .collect::>(); + let mut extensions = std::collections::BTreeMap::new(); + extensions.insert("pi_session_jsonl".into(), Value::Array(raw.clone())); + let created = raw + .iter() + .find_map(rfc3339_ms) + .or_else(|| modified_datetime(&path)) + .unwrap_or_else(Utc::now); + Ok(ImportedSession { + session: Session { + lineage: Vec::new(), + schema: Schema::default(), + identity: Identity { + id: id.clone(), + title: raw.iter().find_map(text_for), + }, + context: Context { + workspace: header + .get("cwd") + .and_then(Value::as_str) + .map(str::to_string), + created_at: Some(created), + last_active_at: modified_datetime(&path), + tags: Vec::new(), + }, + events, + extensions, + }, + provenance: Provenance { + imported_at: Utc::now(), + imported_by: Some("memorph-cli".into()), + primary_source: ProviderRef { + provider_id: provider_id.into(), + session_id: id.clone(), + source_path: Some(path.to_string_lossy().into_owned()), + }, + aliases: Vec::new(), + }, + event_meta, + report, + }) +} + +pub(crate) fn fingerprint( + provider_id: &str, + source_path: &str, +) -> Result> { + let path = Path::new(source_path); + if !path.exists() { + return Ok(None); + } + let digest = Sha256::digest(std::fs::read(path)?); + let size = std::fs::metadata(path)?.len(); + Ok(Some(ProviderSourceFingerprint { + modified_at_ms: modified_ms(path).unwrap_or(0), + size_bytes: size.min(i64::MAX as u64) as i64, + value: format!( + "{provider_id}-session-v1:{}:{digest:x}", + read_records(path)? + .iter() + .find(|v| v.get("type").and_then(Value::as_str) == Some("session")) + .and_then(session_id) + .unwrap_or_default() + ), + })) +} + +fn session_id(header: &Value) -> Option { + header + .get("id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn read_records(path: &Path) -> Result> { + std::fs::read_to_string(path)? + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + serde_json::from_str(l).with_context(|| { + format!("invalid session record in {}", path.display()) + }) + }) + .collect() +} + +fn modified_ms(path: &Path) -> Option { + std::fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) +} + +fn modified_datetime(path: &Path) -> Option> { + modified_ms(path).and_then(DateTime::::from_timestamp_millis) +} + +fn timestamp(v: &Value) -> Option { + v.get("timestamp") + .or_else(|| v.get("createdAt")) + .and_then(|x| { + x.as_str() + .and_then(|s| { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc)) + }) + .or_else(|| x.as_i64().and_then(DateTime::::from_timestamp_millis)) + }) + .map(|d| d.timestamp_millis()) +} + +fn text_for(v: &Value) -> Option { + let m = v.get("message").unwrap_or(v); + let c = m.get("content").unwrap_or(m); + c.as_str().map(str::to_string).or_else(|| { + c.as_array()? + .iter() + .find_map(|b| b.get("text").and_then(Value::as_str).map(str::to_string)) + }) +} + +fn blocks(v: &Value) -> Vec { + let m = v.get("message").unwrap_or(v); + let c = m.get("content").unwrap_or(m); + if let Some(s) = c.as_str() { + return vec![Block::Text { text: s.into() }]; + }; + c.as_array() + .into_iter() + .flatten() + .filter_map( + |b| match b.get("type").and_then(Value::as_str).unwrap_or("") { + "text" | "input_text" | "output_text" => b + .get("text") + .and_then(Value::as_str) + .map(|s| Block::Text { text: s.into() }), + "thinking" => b + .get("thinking") + .and_then(Value::as_str) + .map(|s| Block::Thinking { + text: s.into(), + signature: None, + }), + "tool_use" | "toolCall" => Some(Block::ToolCall { + tool_call_id: b + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown") + .into(), + name: b + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .into(), + input: b.get("input").or_else(|| b.get("arguments")).cloned(), + }), + "tool_result" | "toolResult" => Some(Block::ToolResult { + tool_call_id: b + .get("tool_use_id") + .or_else(|| b.get("toolCallId")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .into(), + content: b.get("content").map(|x| x.to_string()).unwrap_or_default(), + outcome: crate::session::execution_outcome( + b.get("is_error").and_then(Value::as_bool).unwrap_or(false), + ), + }), + _ => None, + }, + ) + .collect() +} + +fn map_event(provider_id: &str, v: &Value, i: usize, r: &mut MappingReport) -> Option { + if v.get("type").and_then(Value::as_str) != Some("message") { + return None; + } + let m = v.get("message").unwrap_or(v); + let role_raw = m + .get("role") + .or_else(|| v.get("role")) + .or_else(|| v.get("type"))? + .as_str()?; + let role = match role_raw.to_ascii_lowercase().as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + "tool" => Role::Tool, + "system" => Role::System, + _ => Role::Other, + }; + let bs = blocks(v); + if bs.is_empty() { + return None; + }; + let kind = if bs.iter().any(|b| matches!(b, Block::ToolResult { .. })) { + EventKind::Observation + } else if bs.iter().any(|b| matches!(b, Block::ToolCall { .. })) { + EventKind::Action + } else { + EventKind::Message + }; + r.push_issue(crate::session::MappingIssue { + level: crate::session::MappingIssueLevel::Info, + disposition: Fidelity::Preserved, + code: "pi-native-event".into(), + message: "Mapped pi-ai JSONL event".into(), + path: Some(format!("events[{i}]")), + raw: None, + }); + Some(Event { + id: format!("{provider_id}:event:{i}"), + kind, + role, + timestamp: rfc3339_ms(v).unwrap_or_else(Utc::now), + links: Links::default(), + blocks: bs, + tags: Vec::new(), + extensions: Default::default(), + metadata: Metadata { + model: None, + usage: None, + }, + }) +} + +fn rfc3339_ms(v: &Value) -> Option> { + v.get("timestamp") + .or_else(|| v.get("createdAt")) + .and_then(|x| { + x.as_str() + .and_then(|s| { + DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.with_timezone(&Utc)) + }) + .or_else(|| x.as_i64().and_then(DateTime::::from_timestamp_millis)) + }) +} diff --git a/rust/crates/memorph/src/providers/zcode.rs b/rust/crates/memorph/src/providers/zcode.rs new file mode 100644 index 0000000..ad32129 --- /dev/null +++ b/rust/crates/memorph/src/providers/zcode.rs @@ -0,0 +1,431 @@ +//! ZCode (Z.ai's GLM harness, desktop app). +//! +//! The agent runtime `~/.zcode/cli/` is an OpenCode derivative: `cli/db/db.sqlite` +//! has the same session / message / part tables, and part JSON (text / reasoning / +//! tool{callID,tool,state} / step-start / step-finish) is field-compatible — +//! event conversion is reused from the OpenCode provider +//! ([`crate::providers::opencode::load::imported_session_from_data`]). +//! +//! Differences handled here (semantics verified against the zcode open-source +//! tree by the Wake reference implementation): +//! - session table has no model / token columns; model lives per-message in +//! `message.data.modelId` (note the lowercase `d`) and is normalized to the +//! OpenCode `modelID` shape before conversion. +//! - `task_type` is ALTER-added: PRAGMA-probe per open; when present, only +//! interactive / fork / selection_side_chat are user sessions +//! (subagent_child / workflow_child are runtime noise); when absent, fall +//! back to `parent_id IS NULL` (OpenCode semantics). +//! - `v2/tasks-index.sqlite` (`tasks.task_id` = `session.id`) supplies two +//! filters: `deleted` (desktop soft-delete) and `migration_source` +//! (first-run import from Claude Code — already listed once there). Read +//! failure degrades to no filtering. +//! - message ordering prefers the ALTER-added `sequence` column, falling back +//! to OpenCode's `time_created, id`. +//! +//! Import-only: the desktop app holds a write lock on the runtime, so +//! resume / export are not offered (`resume` is only a CLI affordance in +//! ZCode's case anyway — there is no `zcode` CLI on PATH). + +use crate::provider::{ + PageStrategy, Provider, ProviderActivitySupport, ProviderBackupSupport, ProviderCapabilities, + ProviderContentFidelity, ProviderSessionSummary, ProviderSourceFingerprint, ProviderWriteRisk, + ResumeQuality, ScanStrategy, StorageShape, TurnQuality, WriteRiskLevel, +}; +use crate::providers::opencode::load::imported_session_from_data; +use crate::session::{Fidelity, ImportedSession}; +use anyhow::{Context as _, Result}; +use rusqlite::Connection; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +pub struct ZcodeProvider; +const PROVIDER_ID: &str = "zcode"; + +#[cfg(test)] +static TEST_ZCODE_DIR: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +impl Provider for ZcodeProvider { + fn id(&self) -> &'static str { + PROVIDER_ID + } + fn name(&self) -> &'static str { + "ZCode" + } + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + scan: true, + import: true, + storage_shape: StorageShape::Sqlite, + scan_strategy: ScanStrategy::Indexed, + page_strategy: PageStrategy::FullImport, + turn_quality: TurnQuality::Inferred, + import_fidelity: ProviderContentFidelity { + text: Some(Fidelity::Preserved), + thinking: Some(Fidelity::Preserved), + tool_call: Some(Fidelity::Preserved), + tool_result: Some(Fidelity::Preserved), + provider_payload: Some(Fidelity::Dropped), + ..ProviderContentFidelity::unknown() + }, + resume_quality: ResumeQuality::None, + write_risk: ProviderWriteRisk { + level: WriteRiskLevel::High, + multiple_files: true, + sqlite: true, + sidecar_files: false, + index_repair: true, + }, + backup_support: ProviderBackupSupport { + before_write: false, + restore: false, + sync_only: false, + }, + activity_support: ProviderActivitySupport { + hook_events: false, + runtime_endpoint: false, + session_activity: false, + }, + ..ProviderCapabilities::default() + } + } + fn scan_sessions(&self) -> Result> { + let Some(db) = db_path() else { + return Ok(Vec::new()); + }; + if !db.is_file() { + return Ok(Vec::new()); + } + let conn = Connection::open(&db) + .with_context(|| format!("failed to open ZCode db at {}", db.display()))?; + let has_task_type = table_has_column(&conn, "session", "task_type"); + let listed = if has_task_type { + "task_type IN ('interactive', 'fork', 'selection_side_chat')" + } else { + "parent_id IS NULL" + }; + let mut stmt = conn.prepare(&format!( + "SELECT id, directory, title, time_created, time_updated, + (SELECT COALESCE(SUM(LENGTH(p.data)), 0) FROM part p WHERE p.session_id = session.id) + FROM session WHERE {listed} ORDER BY time_updated DESC" + ))?; + let hidden = hidden_session_ids(); + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + )) + })?; + let mut sessions = Vec::new(); + for row in rows.flatten() { + let (id, directory, title, created, updated, content_len) = row; + if content_len == 0 || hidden.contains(&id) { + continue; + } + sessions.push(ProviderSessionSummary { + archived: false, + session_id: id.clone(), + title: Some(title), + project_dir: Some(directory), + created_at: Some(created), + last_active_at: Some(updated), + source_path: Some(source_locator(&db, &id)), + }); + } + Ok(sessions) + } + fn import_session(&self, source_path: &str) -> Result { + let (db, session_id) = parse_source_locator(source_path)?; + let conn = Connection::open(&db) + .with_context(|| format!("failed to open ZCode db at {}", db.display()))?; + + let mut session_stmt = conn.prepare( + "SELECT id, directory, title, time_created, time_updated FROM session WHERE id = ?1", + )?; + let (id, directory, title, created, updated) = session_stmt.query_row( + [&session_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }, + )?; + let session_json = serde_json::json!({ + "id": id, + "directory": directory, + "title": title, + "time": { "created": created, "updated": updated }, + }); + + let msg_order = if table_has_column(&conn, "message", "sequence") { + "sequence, id" + } else { + "time_created, id" + }; + let mut msg_stmt = conn.prepare(&format!( + "SELECT id, time_created, data FROM message WHERE session_id = ?1 ORDER BY {msg_order}" + ))?; + let msg_rows = msg_stmt.query_map([&session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut messages = Vec::new(); + for row in msg_rows.flatten() { + let (msg_id, time_created, data_str) = row; + let mut data: Value = + serde_json::from_str(&data_str).unwrap_or_else(|_| Value::Object(Default::default())); + if let Value::Object(map) = &mut data { + map.insert("id".to_string(), Value::String(msg_id)); + // ZCode writes `modelId`; the OpenCode converter reads `modelID`. + if let Some(model) = map.get("modelId").cloned() { + map.insert("modelID".to_string(), model); + } + } + messages.push((Some(time_created), data)); + } + + let part_order = if table_has_column(&conn, "part", "sequence") { + "message_id, sequence, id" + } else { + "message_id, time_created, id" + }; + let mut part_stmt = conn.prepare(&format!( + "SELECT id, message_id, data FROM part WHERE session_id = ?1 ORDER BY {part_order}" + ))?; + let part_rows = part_stmt.query_map([&session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + let mut parts_map: HashMap> = HashMap::new(); + for row in part_rows.flatten() { + let (part_id, message_id, data_str) = row; + let mut data: Value = + serde_json::from_str(&data_str).unwrap_or_else(|_| Value::Object(Default::default())); + if let Value::Object(map) = &mut data { + map.insert("id".to_string(), Value::String(part_id)); + } + parts_map.entry(message_id).or_default().push(data); + } + + let mut imported = imported_session_from_data( + &session_id, + (session_json, messages, parts_map), + )?; + imported.provenance.primary_source = crate::session::ProviderRef { + provider_id: PROVIDER_ID.into(), + session_id: session_id.clone(), + source_path: Some(source_path.to_string()), + }; + imported.event_meta = (0..imported.session.events.len()) + .map(|_| crate::session::EventMeta::preserved(PROVIDER_ID)) + .collect(); + Ok(imported) + } + fn session_source_fingerprint( + &self, + source_path: &str, + ) -> Result> { + let (db, session_id) = parse_source_locator(source_path)?; + if !db.is_file() { + return Ok(None); + } + let meta = std::fs::metadata(&db)?; + let modified = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0); + Ok(Some(ProviderSourceFingerprint { + modified_at_ms: modified, + size_bytes: meta.len().min(i64::MAX as u64) as i64, + value: format!("{PROVIDER_ID}-session-v1:{session_id}"), + })) + } + fn data_source_paths(&self) -> Vec { + db_path().into_iter().collect() + } +} + +fn zcode_home() -> Option { + #[cfg(test)] + if let Some(lock) = TEST_ZCODE_DIR.get_or_init(|| std::sync::Mutex::new(None)).lock().ok() { + if let Some(dir) = lock.as_ref() { + return Some(dir.clone()); + } + } + std::env::var_os("ZCODE_STORAGE_DIR") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|h| h.join(".zcode"))) +} + +fn db_path() -> Option { + zcode_home().map(|home| home.join("cli/db/db.sqlite")) +} + +fn tasks_db_path() -> Option { + zcode_home().map(|home| home.join("v2/tasks-index.sqlite")) +} + +fn source_locator(db: &Path, session_id: &str) -> String { + format!("{}#session={session_id}", db.to_string_lossy()) +} + +fn parse_source_locator(source_path: &str) -> Result<(PathBuf, String)> { + let (db, session_id) = source_path + .rsplit_once("#session=") + .context("ZCode source must be a `#session=` locator")?; + Ok((PathBuf::from(db), session_id.to_string())) +} + +fn table_has_column(conn: &Connection, table: &str, column: &str) -> bool { + let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({table})")) else { + return false; + }; + let found = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map(|rows| rows.flatten().any(|name| name == column)) + .unwrap_or(false); + found +} + +/// tasks-index soft-deleted or first-run Claude imports; read failure degrades +/// to an empty set (those sessions stay listed rather than disappearing). +fn hidden_session_ids() -> std::collections::HashSet { + let mut hidden = std::collections::HashSet::new(); + let Some(tasks_db) = tasks_db_path() else { + return hidden; + }; + if !tasks_db.is_file() { + return hidden; + } + let Ok(conn) = Connection::open(&tasks_db) else { + return hidden; + }; + let Ok(mut stmt) = + conn.prepare("SELECT task_id FROM tasks WHERE deleted = 1 OR migration_source IS NOT NULL") + else { + return hidden; + }; + if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) { + hidden.extend(rows.flatten()); + } + hidden +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ZcodeDirGuard; + + impl ZcodeDirGuard { + fn new(dir: PathBuf) -> Self { + *TEST_ZCODE_DIR + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .unwrap() = Some(dir); + ZcodeDirGuard + } + } + + impl Drop for ZcodeDirGuard { + fn drop(&mut self) { + *TEST_ZCODE_DIR + .get_or_init(|| std::sync::Mutex::new(None)) + .lock() + .unwrap() = None; + } + } + + fn create_zcode_db(dir: &Path) -> PathBuf { + let db = dir.join("cli/db/db.sqlite"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, title TEXT, task_type TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, data TEXT NOT NULL); + INSERT INTO session VALUES ('sess-1', NULL, '/tmp/zc-project', 'Fix login flow', 'interactive', 1000, 2000, NULL); + INSERT INTO session VALUES ('sess-child', 'sess-1', '/tmp/zc-project', 'subagent', 'subagent_child', 1000, 2000, NULL); + INSERT INTO message VALUES ('msg-1', 'sess-1', 'user', 1100, '{\"role\":\"user\"}'); + INSERT INTO message VALUES ('msg-2', 'sess-1', 'assistant', 1200, '{\"role\":\"assistant\",\"modelId\":\"glm-5.3\"}'); + INSERT INTO part VALUES ('p-1', 'msg-1', 'sess-1', 1100, '{\"type\":\"text\",\"text\":\"hello zcode\"}'); + INSERT INTO part VALUES ('p-2', 'msg-2', 'sess-1', 1200, '{\"type\":\"text\",\"text\":\"hi back\"}');", + ) + .unwrap(); + drop(conn); + db + } + + #[test] + fn scans_only_user_task_types_and_imports_via_opencode_converter() { + let dir = tempfile::tempdir().unwrap(); + let _guard = ZcodeDirGuard::new(dir.path().to_path_buf()); + let db = create_zcode_db(dir.path()); + + let conn = Connection::open(&db).unwrap(); + let has_task_type = table_has_column(&conn, "session", "task_type"); + drop(conn); + assert!(has_task_type); + + let sessions = ZcodeProvider.scan_sessions().unwrap(); + assert_eq!(sessions.len(), 1, "subagent_child must not be listed"); + assert_eq!(sessions[0].session_id, "sess-1"); + assert_eq!(sessions[0].project_dir.as_deref(), Some("/tmp/zc-project")); + + let imported = ZcodeProvider + .import_session(&sessions[0].source_path.clone().unwrap()) + .unwrap(); + assert_eq!(imported.session.identity.title.as_deref(), Some("Fix login flow")); + assert_eq!(imported.session.context.workspace.as_deref(), Some("/tmp/zc-project")); + assert_eq!(imported.session.events.len(), 2); + assert_eq!( + imported.session.events[1].metadata.model.as_deref(), + Some("glm-5.3"), + "modelId must be normalized to modelID" + ); + assert_eq!( + imported.provenance.primary_source.provider_id, PROVIDER_ID + ); + } + + #[test] + fn old_schema_without_task_type_falls_back_to_parent_id() { + let dir = tempfile::tempdir().unwrap(); + let _guard = ZcodeDirGuard::new(dir.path().to_path_buf()); + let db = dir.path().join("cli/db/db.sqlite"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, title TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT); + INSERT INTO session VALUES ('old-1', NULL, '/tmp/old', 'Old session', 1, 2, NULL); + INSERT INTO message VALUES ('old-msg-1', 'old-1', 'user', 1, '{}'); + INSERT INTO part VALUES ('old-p-1', 'old-msg-1', 'old-1', 1, '{\"type\":\"text\",\"text\":\"old body\"}');", + ) + .unwrap(); + drop(conn); + + let sessions = ZcodeProvider.scan_sessions().unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "old-1"); + } +} From cc665773b3032f9455a82ed1174604b432c386da Mon Sep 17 00:00:00 2001 From: ip2a Date: Mon, 19 Oct 2026 16:16:15 +0800 Subject: [PATCH 5/6] test(readiness): pin cursor db path so scoped reconcile test stays hermetic --- rust/crates/memorph/src/core/readiness.rs | 6 ++++++ rust/crates/memorph/src/providers/cursor/mod.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/crates/memorph/src/core/readiness.rs b/rust/crates/memorph/src/core/readiness.rs index 92ac654..af551a0 100644 --- a/rust/crates/memorph/src/core/readiness.rs +++ b/rust/crates/memorph/src/core/readiness.rs @@ -1361,6 +1361,11 @@ mod tests { let home = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap(); crate::config::set_test_home_dir(home.path().to_path_buf()); + // cursor_data_dir() reads the real HOME on macOS, bypassing the test + // home; pin the state db to a missing path so the scan stays hermetic. + crate::providers::cursor::db::set_test_cursor_db_path(Some( + home.path().join("missing/state.vscdb"), + )); let workspace = workspace.path().canonicalize().unwrap(); let report = projection::reconcile_workspace_session_projections_for( @@ -1372,6 +1377,7 @@ mod tests { let latest = HashMap::from([("cursor".to_string(), ActivityStatus::Success)]); let readiness = assess_expected_workspace_checkpoints(&["cursor"], &latest); + crate::providers::cursor::db::set_test_cursor_db_path(None); crate::config::reset_test_home_dir(); assert_eq!(report.discovered_sessions, 0); assert_eq!(report.scanned_providers, 1); diff --git a/rust/crates/memorph/src/providers/cursor/mod.rs b/rust/crates/memorph/src/providers/cursor/mod.rs index cc9eb93..3cd78c0 100644 --- a/rust/crates/memorph/src/providers/cursor/mod.rs +++ b/rust/crates/memorph/src/providers/cursor/mod.rs @@ -1,6 +1,6 @@ pub mod adapter; mod backup; -mod db; +pub(crate) mod db; pub mod hook; mod load; mod scan; From 6529d0f0969c1a3e7e3fcde20d3907afb57b7b48 Mon Sep 17 00:00:00 2001 From: ip2a Date: Mon, 19 Oct 2026 16:16:21 +0800 Subject: [PATCH 6/6] feat(providers): add grok, oh my pi, and zcode integrations Shared pi-ai JSONL parsing extracted into pi_format; zcode reuses the opencode event conversion; omp gains an oh-my-pi alias. --- rust/crates/memorph/src/providers/omp.rs | 2 +- rust/crates/memorph/src/providers/pi/mod.rs | 2 +- rust/crates/memorph/src/providers/pi_format.rs | 5 +---- rust/crates/memorph/src/providers/zcode.rs | 14 ++++++++++++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/rust/crates/memorph/src/providers/omp.rs b/rust/crates/memorph/src/providers/omp.rs index cf3f50c..8181bd5 100644 --- a/rust/crates/memorph/src/providers/omp.rs +++ b/rust/crates/memorph/src/providers/omp.rs @@ -61,7 +61,7 @@ impl Provider for OmpProvider { } } fn scan_sessions(&self) -> Result> { - pi_format::scan(PROVIDER_ID, &roots()) + pi_format::scan(&roots()) } fn import_session(&self, source_path: &str) -> Result { pi_format::import(PROVIDER_ID, source_path) diff --git a/rust/crates/memorph/src/providers/pi/mod.rs b/rust/crates/memorph/src/providers/pi/mod.rs index 97b576b..e9e7fc5 100644 --- a/rust/crates/memorph/src/providers/pi/mod.rs +++ b/rust/crates/memorph/src/providers/pi/mod.rs @@ -59,7 +59,7 @@ impl Provider for PiProvider { } } fn scan_sessions(&self) -> Result> { - pi_format::scan(PROVIDER_ID, &roots()) + pi_format::scan(&roots()) } fn import_session(&self, source_path: &str) -> Result { pi_format::import(PROVIDER_ID, source_path) diff --git a/rust/crates/memorph/src/providers/pi_format.rs b/rust/crates/memorph/src/providers/pi_format.rs index c56ef73..7bab60a 100644 --- a/rust/crates/memorph/src/providers/pi_format.rs +++ b/rust/crates/memorph/src/providers/pi_format.rs @@ -18,10 +18,7 @@ use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use walkdir::WalkDir; -pub(crate) fn scan( - provider_id: &str, - roots: &[PathBuf], -) -> Result> { +pub(crate) fn scan(roots: &[PathBuf]) -> Result> { let mut sessions = Vec::new(); for root in roots { if !root.exists() { diff --git a/rust/crates/memorph/src/providers/zcode.rs b/rust/crates/memorph/src/providers/zcode.rs index ad32129..6e69c6f 100644 --- a/rust/crates/memorph/src/providers/zcode.rs +++ b/rust/crates/memorph/src/providers/zcode.rs @@ -46,6 +46,7 @@ const PROVIDER_ID: &str = "zcode"; #[cfg(test)] static TEST_ZCODE_DIR: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static TEST_ZCODE_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); impl Provider for ZcodeProvider { fn id(&self) -> &'static str { @@ -333,15 +334,24 @@ fn hidden_session_ids() -> std::collections::HashSet { mod tests { use super::*; - struct ZcodeDirGuard; + // Single TEST_ZCODE_DIR slot is process-global; hold a dedicated mutex for + // the test duration so parallel zcode tests can't overwrite each other's + // dir (runtime reads take the slot lock only briefly, so it stays free). + struct ZcodeDirGuard { + _serial: std::sync::MutexGuard<'static, ()>, + } impl ZcodeDirGuard { fn new(dir: PathBuf) -> Self { + let _serial = TEST_ZCODE_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); *TEST_ZCODE_DIR .get_or_init(|| std::sync::Mutex::new(None)) .lock() .unwrap() = Some(dir); - ZcodeDirGuard + ZcodeDirGuard { _serial } } }