commit 2be7da127c286a8eb49dba7b95fe7f2029c9a106 Author: Michael BAG Date: Fri Jul 10 10:50:51 2026 +0300 Initial release v0.0.1 — Cursor Agents Manager. Export/import Cursor agent transcripts between workstations. YAML per-workstation config, Markdown archive, MIT license. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4577fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Local workstation configs (copy from *.example.yml) +config/workstations/*.yml +!config/workstations/*.example.yml + +# Optional local override +config/config.yml + +__pycache__/ +*.py[cod] +.Python +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..779fd9e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to **CAM (Cursor Agents Manager)** are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [0.0.1] - 2026-07-10 + +### Status + +Initial release. **In development.** + +### Added + +- CLI `cam.py` with subcommands: `export`, `import`, `list`, `paths` +- Export Cursor `agent-transcripts` to a project archive: + - `raw/` — original `.jsonl` files + - `markdown/` — readable session exports + - `manifest.json` — machine-readable registry + - `INDEX.md` — session index with links +- Import archived `raw/` transcripts back into Cursor `agent-transcripts` folder +- YAML workstation configuration (`config/config.example.yml`, OS-specific examples) +- Auto-detection of Cursor transcripts path from project root +- `export.index_language`: `en` or `ru` for generated Markdown +- `import.overwrite_existing` safety flag (default: `false`) +- `--dry-run` for import + +[0.0.1]: https://git.p7net.ru/tools/cam/-/tags/v0.0.1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a3cfa9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RHANA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b970527 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# CAM — Cursor Agents Manager + +**Version:** 0.0.1 +**Status:** in development +**License:** [MIT](LICENSE) + +CAM exports and imports [Cursor](https://cursor.com) agent conversation transcripts so you can version them in a project repository and move context between workstations. + +Repository: + +## Why + +Cursor stores agent history locally under `~/.cursor/projects//agent-transcripts/`. That data does not travel with your git project. CAM: + +1. **Export** — copy transcripts into `docs/cursor_agents/` (or another folder) as JSONL + Markdown + index +2. **Commit** — push the archive with your project +3. **Import** (optional) — restore `.jsonl` files into Cursor on another machine +4. **Continue** — open `@docs/cursor_agents/INDEX.md` in a new Cursor chat for agent context + +## Requirements + +- Python 3.10+ +- PyYAML + +```bash +pip install -r requirements.txt +``` + +## Quick start + +### 1. Clone CAM into your project + +```bash +git clone https://git.p7net.ru/tools/cam.git tools/cam +cd tools/cam +pip install -r requirements.txt +``` + +### 2. Create a workstation config + +Each machine needs its own YAML file (not committed to the public CAM repo): + +```bash +cp config/workstations/macos.example.yml config/workstations/local.yml +# edit project.root and other paths +``` + +Or copy a project-specific example if your project provides one (e.g. `nt-041.example.yml`). + +Config search order (first existing file wins): + +1. `--config` argument +2. `$CAM_CONFIG` environment variable +3. `config/workstations/.yml` +4. `config/workstations/local.yml` +5. `config/config.yml` + +### 3. Export + +```bash +python cam.py -c config/workstations/local.yml export +``` + +### 4. Commit archive (in your project repo) + +```bash +git add docs/cursor_agents/ +git commit -m "Export Cursor agent history" +``` + +### 5. Import on another workstation (optional) + +Close Cursor first, then: + +```bash +python cam.py -c config/workstations/local.yml import --dry-run # preview +python cam.py -c config/workstations/local.yml import +``` + +Re-open Cursor. Chat history may appear in the sidebar depending on Cursor version (undocumented behaviour). + +**Recommended:** use the Markdown archive and `@docs/cursor_agents/INDEX.md` in a new agent chat instead of relying on UI restore. + +## Commands + +| Command | Description | +|---------|-------------| +| `paths` | Show resolved paths (project, transcripts, export dir) | +| `export` | Export Cursor transcripts to project archive | +| `import` | Copy archive `raw/` back to Cursor transcripts folder | +| `list` | List sessions in the export archive | + +```bash +python cam.py --version +python cam.py -c config/workstations/local.yml paths +python cam.py -c config/workstations/local.yml export +python cam.py -c config/workstations/local.yml import --dry-run +python cam.py -c config/workstations/local.yml list +``` + +## Configuration + +See [`config/config.example.yml`](config/config.example.yml). + +| Key | Description | +|-----|-------------| +| `workstation.id` | Short ID stored in `manifest.json` (no secrets) | +| `workstation.label` | Human-readable workstation name | +| `project.root` | Absolute path to project root opened in Cursor | +| `project.name` | Display name | +| `cursor.transcripts_dir` | Override transcripts path (optional) | +| `cursor.home` | Override `~/.cursor` (optional) | +| `export.output_dir` | Archive directory relative to `project.root` | +| `export.index_language` | `en` or `ru` for generated Markdown | +| `import.overwrite_existing` | Replace existing Cursor transcript files | + +**Security:** do not put passwords, tokens, or private hostnames into configs committed to the open CAM repository. Keep real workstation configs local (`*.yml` is gitignored; only `*.example.yml` templates are tracked). + +## Archive layout + +``` +docs/cursor_agents/ +├── INDEX.md +├── manifest.json +├── raw/ +│ ├── .jsonl +│ └── /subagents/.jsonl +└── markdown/ + ├── .md + └── subagents/.md +``` + +## Cursor project slug + +If `cursor.transcripts_dir` is omitted, CAM derives: + +``` +~/.cursor/projects//agent-transcripts/ +``` + +where `` is built from the absolute `project.root` path (slashes → dashes, drive letter lowercased on Windows). Example: + +| Project root | Cursor slug | +|--------------|-------------| +| `/Users/you/projects/my-app` | `Users-you-projects-my-app` | +| `D:/work/my-app` | `d-work-my-app` | + +Use `python cam.py paths` to verify the resolved path on your machine. + +## Integration with consumer projects + +Consumer projects (e.g. `crm3-migration`) typically: + +1. Clone CAM into `tools/cam/` (separate git repo) +2. Keep a project-specific `config/workstations/.example.yml` template in the consumer repo or local docs +3. Store exports in `docs/cursor_agents/` + +## Development + +```bash +python cam.py paths -c config/config.example.yml # fails until project.root exists +``` + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). diff --git a/__version__.py b/__version__.py new file mode 100644 index 0000000..0866eba --- /dev/null +++ b/__version__.py @@ -0,0 +1,4 @@ +"""CAM (Cursor Agents Manager) version.""" + +__version__ = "0.0.1" +__status__ = "in development" diff --git a/cam.py b/cam.py new file mode 100644 index 0000000..96d37ba --- /dev/null +++ b/cam.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""CAM CLI — Cursor Agents Manager.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +CAM_ROOT = Path(__file__).resolve().parent +if str(CAM_ROOT) not in sys.path: + sys.path.insert(0, str(CAM_ROOT)) + +from __version__ import __status__, __version__ # noqa: E402 +from cam.config import load_config, resolve_config_path # noqa: E402 +from cam.export import export_transcripts # noqa: E402 +from cam.import_transcripts import import_transcripts, list_archive_sessions # noqa: E402 + + +def _default_cam_command(config_path: Path | None) -> str: + if config_path is not None: + rel = config_path + try: + rel = config_path.relative_to(CAM_ROOT) + except ValueError: + rel = config_path + return f"python tools/cam/cam.py -c {rel} export" + return "python tools/cam/cam.py export" + + +def cmd_paths(config_path: Path) -> int: + config = load_config(config_path) + print(f"workstation_id: {config.workstation_id}") + print(f"workstation_label: {config.workstation_label}") + print(f"project_root: {config.project_root}") + print(f"cursor_project_slug:{config.cursor_project_slug}") + print(f"transcripts_dir: {config.transcripts_dir}") + print(f"export_dir: {config.export_dir}") + print(f"index_language: {config.index_language}") + print(f"config_file: {config.source_path}") + return 0 + + +def cmd_export(config_path: Path) -> int: + config = load_config(config_path) + main_count, sub_count = export_transcripts(config, _default_cam_command(config_path)) + print( + f"Exported {main_count} main sessions and {sub_count} subagents " + f"to {config.export_dir}" + ) + return 0 + + +def cmd_import(config_path: Path, dry_run: bool, source: Path | None) -> int: + config = load_config(config_path) + result = import_transcripts(config, dry_run=dry_run, source=source) + mode = "Dry-run:" if dry_run else "Imported:" + print( + f"{mode} {result.copied} file(s) to {config.transcripts_dir}; " + f"skipped {result.skipped} existing" + ) + return 0 + + +def cmd_list(config_path: Path) -> int: + config = load_config(config_path) + sessions = list_archive_sessions(config.export_dir) + if not sessions: + print(f"No sessions in {config.export_dir / 'raw'}") + return 0 + for session_id, parent_id, path in sessions: + parent = parent_id or "-" + print(f"{session_id}\tparent={parent}\t{path}") + print(f"Total: {len(sessions)}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cam", + description="Cursor Agents Manager — export and import agent transcripts", + ) + parser.add_argument( + "-V", + "--version", + action="version", + version=f"%(prog)s {__version__} ({__status__})", + ) + parser.add_argument( + "-c", + "--config", + type=Path, + help="Path to workstation YAML config (default: auto-detect)", + ) + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("paths", help="Show resolved paths from config").set_defaults( + handler="paths" + ) + sub.add_parser("export", help="Export Cursor transcripts to project archive").set_defaults( + handler="export" + ) + sub.add_parser("list", help="List sessions in export archive").set_defaults(handler="list") + + import_parser = sub.add_parser( + "import", + help="Import archived transcripts into Cursor agent-transcripts folder", + ) + import_parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be copied without writing files", + ) + import_parser.add_argument( + "--source", + type=Path, + help="Archive root (default: export.output_dir from config)", + ) + import_parser.set_defaults(handler="import") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + config_path = resolve_config_path(CAM_ROOT, args.config) + except FileNotFoundError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + try: + if args.handler == "paths": + return cmd_paths(config_path) + if args.handler == "export": + return cmd_export(config_path) + if args.handler == "list": + return cmd_list(config_path) + if args.handler == "import": + return cmd_import(config_path, args.dry_run, args.source) + except (FileNotFoundError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + parser.error(f"Unknown command: {args.handler}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cam/__init__.py b/cam/__init__.py new file mode 100644 index 0000000..2673d4b --- /dev/null +++ b/cam/__init__.py @@ -0,0 +1,5 @@ +"""Cursor Agents Manager — export and import Cursor agent transcripts.""" + +from __version__ import __status__, __version__ + +__all__ = ["__version__", "__status__"] diff --git a/cam/config.py b/cam/config.py new file mode 100644 index 0000000..4573f18 --- /dev/null +++ b/cam/config.py @@ -0,0 +1,124 @@ +"""Load and validate CAM YAML configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from cam.paths import resolve_export_dir, resolve_transcripts_dir + + +@dataclass(frozen=True) +class CamConfig: + workstation_id: str + workstation_label: str + project_root: Path + project_name: str + transcripts_dir: Path + export_dir: Path + index_language: str + overwrite_existing: bool + source_path: Path + + @property + def cursor_project_slug(self) -> str: + from cam.paths import cursor_project_slug + + return cursor_project_slug(self.project_root) + + +def _require_mapping(data: dict[str, Any], key: str) -> dict[str, Any]: + value = data.get(key) + if not isinstance(value, dict): + raise ValueError(f"Config section '{key}' must be a mapping") + return value + + +def load_config(path: Path) -> CamConfig: + path = path.expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(f"Config file not found: {path}") + + with path.open(encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + + if not isinstance(raw, dict): + raise ValueError("Config root must be a mapping") + + workstation = _require_mapping(raw, "workstation") + project = _require_mapping(raw, "project") + cursor = raw.get("cursor") or {} + export = raw.get("export") or {} + import_cfg = raw.get("import") or {} + + if not isinstance(cursor, dict) or not isinstance(export, dict) or not isinstance(import_cfg, dict): + raise ValueError("Sections 'cursor', 'export', and 'import' must be mappings when present") + + project_root = Path(str(project.get("root", ""))).expanduser().resolve() + if not project_root.is_dir(): + raise FileNotFoundError(f"Project root does not exist: {project_root}") + + export_rel = str(export.get("output_dir", "docs/cursor_agents")) + index_language = str(export.get("index_language", "en")).lower() + if index_language not in {"en", "ru"}: + raise ValueError("export.index_language must be 'en' or 'ru'") + + return CamConfig( + workstation_id=str(workstation.get("id", "unknown")), + workstation_label=str(workstation.get("label", workstation.get("id", "unknown"))), + project_root=project_root, + project_name=str(project.get("name", project_root.name)), + transcripts_dir=resolve_transcripts_dir( + project_root, + cursor.get("transcripts_dir"), + cursor.get("home"), + ), + export_dir=resolve_export_dir(project_root, export_rel), + index_language=index_language, + overwrite_existing=bool(import_cfg.get("overwrite_existing", False)), + source_path=path, + ) + + +def default_config_candidates(cam_root: Path) -> list[Path]: + import os + import socket + + names = [ + os.environ.get("CAM_CONFIG"), + f"config/workstations/{socket.gethostname()}.yml", + "config/workstations/local.yml", + "config/config.yml", + ] + out: list[Path] = [] + for name in names: + if not name: + continue + candidate = Path(name) + if not candidate.is_absolute(): + candidate = cam_root / candidate + out.append(candidate) + return out + + +def resolve_config_path(cam_root: Path, explicit: Path | None) -> Path: + if explicit is not None: + path = explicit.expanduser() + if not path.is_absolute(): + path = (cam_root / path).resolve() + return path + + for candidate in default_config_candidates(cam_root): + if candidate.is_file(): + return candidate.resolve() + + example = cam_root / "config" / "config.example.yml" + raise FileNotFoundError( + "No CAM config found. Copy an example workstation config, e.g.\n" + f" cp config/workstations/example.yml config/workstations/local.yml\n" + f"or pass --config explicitly.\n" + f"Template: {example}" + ) diff --git a/cam/export.py b/cam/export.py new file mode 100644 index 0000000..1a04b16 --- /dev/null +++ b/cam/export.py @@ -0,0 +1,196 @@ +"""Export Cursor agent transcripts into a project archive.""" + +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from cam.config import CamConfig +from cam.markdown import records_to_markdown +from cam.transcripts import SessionInfo, iter_transcript_files, parse_transcript + + +def _write_index( + sessions: list[SessionInfo], + output_root: Path, + exported_at: str, + transcripts_root: Path, + language: str, + cam_command: str, +) -> None: + main_sessions = [s for s in sessions if not s.is_subagent] + subagents = [s for s in sessions if s.is_subagent] + main_sessions.sort(key=lambda s: s.modified_at, reverse=True) + + if language == "ru": + title = "История сессий Cursor Agent" + intro = f"Экспорт от **{exported_at}**. Источник: `{transcripts_root}`." + structure = "Структура каталога:" + raw_desc = "`raw/` — оригинальные `.jsonl` транскрипты" + md_desc = "`markdown/` — читаемые версии диалогов" + manifest_desc = "`manifest.json` — машиночитаемый реестр сессий" + reexport = "Повторный экспорт:" + sessions_heading = "Основные сессии" + subagents_heading = "Subagents" + parent_label = "Родитель" + date_col = "Дата (UTC)" + title_col = "Заголовок" + else: + title = "Cursor Agent session history" + intro = f"Exported at **{exported_at}**. Source: `{transcripts_root}`." + structure = "Directory layout:" + raw_desc = "`raw/` — original `.jsonl` transcripts" + md_desc = "`markdown/` — readable conversation exports" + manifest_desc = "`manifest.json` — machine-readable session registry" + reexport = "Re-export:" + sessions_heading = "Main sessions" + subagents_heading = "Subagents" + parent_label = "Parent" + date_col = "Date (UTC)" + title_col = "Title" + + lines = [ + "---", + f"title: {title}", + f"exported_at: {exported_at}", + f"sessions_total: {len(main_sessions)}", + f"subagents_total: {len(subagents)}", + "---", + "", + f"# {title}", + "", + intro, + "", + structure, + "", + f"- {raw_desc}", + f"- {md_desc}", + f"- {manifest_desc}", + "", + reexport, + "", + "```bash", + cam_command, + "```", + "", + f"## {sessions_heading}", + "", + f"| {date_col} | {title_col} | ID | Msg | Subagents |", + "|---|---|---|---:|---:|", + ] + + subagents_by_parent: dict[str, list[SessionInfo]] = {} + for sub in subagents: + key = sub.parent_id or "unknown" + subagents_by_parent.setdefault(key, []).append(sub) + + for session in main_sessions: + sub_count = len(subagents_by_parent.get(session.session_id, [])) + rel_md = f"markdown/{session.session_id}.md" + lines.append( + f"| {session.modified_at} | [{session.title}]({rel_md}) | `{session.session_id}` | " + f"{session.message_count} | {sub_count} |" + ) + + lines.extend(["", f"## {subagents_heading}", ""]) + for parent_id, subs in sorted(subagents_by_parent.items()): + lines.append(f"### {parent_label} `{parent_id}`") + lines.append("") + for sub in sorted(subs, key=lambda s: s.modified_at, reverse=True): + rel_md = f"markdown/subagents/{sub.session_id}.md" + lines.append( + f"- [{sub.title}]({rel_md}) — `{sub.session_id}` ({sub.message_count} msg)" + ) + lines.append("") + + (output_root / "INDEX.md").write_text("\n".join(lines), encoding="utf-8") + + +def export_transcripts(config: CamConfig, cam_command: str) -> tuple[int, int]: + transcripts_root = config.transcripts_dir + output_root = config.export_dir + + if not transcripts_root.exists(): + raise FileNotFoundError(f"Transcripts folder not found: {transcripts_root}") + + exported_at = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + raw_root = output_root / "raw" + md_root = output_root / "markdown" + md_sub_root = md_root / "subagents" + + for folder in (raw_root, md_root, md_sub_root): + folder.mkdir(parents=True, exist_ok=True) + + sessions: list[SessionInfo] = [] + manifest: list[dict[str, Any]] = [] + + for path in iter_transcript_files(transcripts_root): + records, info = parse_transcript(path) + sessions.append(info) + + if info.is_subagent: + raw_dest = raw_root / info.parent_id / "subagents" / path.name + md_dest = md_sub_root / f"{info.session_id}.md" + else: + raw_dest = raw_root / path.name + md_dest = md_root / f"{info.session_id}.md" + + raw_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, raw_dest) + md_dest.write_text( + records_to_markdown(info, records, config.index_language), + encoding="utf-8", + ) + + manifest.append( + { + "session_id": info.session_id, + "parent_id": info.parent_id, + "is_subagent": info.is_subagent, + "title": info.title, + "first_user_query": info.first_user_query, + "modified_at": info.modified_at, + "message_count": info.message_count, + "user_messages": info.user_messages, + "assistant_messages": info.assistant_messages, + "tool_uses": info.tool_uses, + "source_path": str(path), + "raw_export": str(raw_dest.relative_to(output_root)), + "markdown_export": str(md_dest.relative_to(output_root)), + "workstation_id": config.workstation_id, + } + ) + + manifest.sort(key=lambda item: item["modified_at"], reverse=True) + (output_root / "manifest.json").write_text( + json.dumps( + { + "exported_at": exported_at, + "transcripts_source": str(transcripts_root), + "workstation_id": config.workstation_id, + "workstation_label": config.workstation_label, + "project_root": str(config.project_root), + "sessions_total": len([s for s in sessions if not s.is_subagent]), + "subagents_total": len([s for s in sessions if s.is_subagent]), + "sessions": manifest, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + _write_index( + sessions, + output_root, + exported_at, + transcripts_root, + config.index_language, + cam_command, + ) + + main_count = len([s for s in sessions if not s.is_subagent]) + sub_count = len([s for s in sessions if s.is_subagent]) + return main_count, sub_count diff --git a/cam/import_transcripts.py b/cam/import_transcripts.py new file mode 100644 index 0000000..eefe9a3 --- /dev/null +++ b/cam/import_transcripts.py @@ -0,0 +1,91 @@ +"""Import archived transcripts back into Cursor agent-transcripts folder.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +from cam.config import CamConfig +from cam.paths import cursor_transcript_path, raw_archive_path + + +@dataclass +class ImportResult: + copied: int = 0 + skipped: int = 0 + paths: list[str] | None = None + + def __post_init__(self) -> None: + if self.paths is None: + self.paths = [] + + +def import_transcripts( + config: CamConfig, + *, + dry_run: bool = False, + source: Path | None = None, +) -> ImportResult: + archive_root = (source or config.export_dir).resolve() + raw_root = archive_root / "raw" + transcripts_root = config.transcripts_dir + + if not raw_root.is_dir(): + raise FileNotFoundError(f"Archive raw/ folder not found: {raw_root}") + + result = ImportResult() + + for path in sorted(raw_root.rglob("*.jsonl")): + rel = path.relative_to(raw_root) + parts = rel.parts + if len(parts) == 1: + session_id = path.stem + parent_id = None + elif len(parts) == 3 and parts[1] == "subagents": + parent_id = parts[0] + session_id = path.stem + else: + continue + + dest = cursor_transcript_path(transcripts_root, session_id, parent_id) + result.paths.append(str(dest)) + + if dest.exists() and not config.overwrite_existing: + result.skipped += 1 + continue + + if dry_run: + result.copied += 1 + continue + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, dest) + result.copied += 1 + + return result + + +def list_archive_sessions(archive_root: Path) -> list[tuple[str, str | None, Path]]: + raw_root = archive_root / "raw" + sessions: list[tuple[str, str | None, Path]] = [] + if not raw_root.is_dir(): + return sessions + + for path in sorted(raw_root.rglob("*.jsonl")): + rel = path.relative_to(raw_root) + parts = rel.parts + if len(parts) == 1: + sessions.append((path.stem, None, path)) + elif len(parts) == 3 and parts[1] == "subagents": + sessions.append((path.stem, parts[0], path)) + return sessions + + +def validate_archive_layout(archive_root: Path) -> list[str]: + issues: list[str] = [] + for session_id, parent_id, path in list_archive_sessions(archive_root): + expected = raw_archive_path(archive_root, session_id, parent_id, path.name) + if path != expected: + issues.append(f"Unexpected layout for {path}") + return issues diff --git a/cam/markdown.py b/cam/markdown.py new file mode 100644 index 0000000..6765428 --- /dev/null +++ b/cam/markdown.py @@ -0,0 +1,101 @@ +"""Render transcript records as Markdown.""" + +from __future__ import annotations + +import json +from typing import Any + +from cam.transcripts import SessionInfo + + +def block_to_markdown(block: dict[str, Any], language: str) -> str: + block_type = block.get("type") + if block_type == "text": + text = block.get("text", "") + if text == "[REDACTED]": + hidden = "content hidden by Cursor" if language == "en" else "содержимое скрыто Cursor" + return f"_[{hidden}]_\n" + return text + "\n" + if block_type == "tool_use": + name = block.get("name", "tool") + tool_input = block.get("input", {}) + payload = json.dumps(tool_input, ensure_ascii=False, indent=2) + if len(payload) > 4000: + truncated = "truncated" if language == "en" else "обрезано" + payload = payload[:4000] + f"\n... [{truncated}] ..." + return f"**Tool:** `{name}`\n\n```json\n{payload}\n```\n" + return f"_{block_type or 'unknown'} block_\n" + + +def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], language: str) -> str: + if language == "ru": + session_label = "Subagent" if info.is_subagent else "Сессия" + parent_label = "Родительская сессия" + updated_label = "Обновлено" + messages_label = "Сообщений" + tools_heading = "Использованные инструменты" + history_heading = "История" + role_labels = {"user": "Пользователь", "assistant": "Агент"} + else: + session_label = "Subagent" if info.is_subagent else "Session" + parent_label = "Parent session" + updated_label = "Updated" + messages_label = "Messages" + tools_heading = "Tools used" + history_heading = "History" + role_labels = {"user": "User", "assistant": "Agent"} + + lines = [ + "---", + f"session_id: {info.session_id}", + f"type: {'subagent' if info.is_subagent else 'main'}", + ] + if info.parent_id: + lines.append(f"parent_id: {info.parent_id}") + lines.extend( + [ + f"modified_at: {info.modified_at}", + f"messages: {info.message_count}", + "---", + "", + f"# {session_label}: {info.title}", + "", + f"- **ID:** `{info.session_id}`", + ] + ) + if info.parent_id: + lines.append(f"- **{parent_label}:** `{info.parent_id}`") + lines.extend( + [ + f"- **{updated_label}:** {info.modified_at}", + ( + f"- **{messages_label}:** {info.message_count} " + f"(user: {info.user_messages}, assistant: {info.assistant_messages})" + ), + "", + ] + ) + if info.tool_uses: + lines.append(f"## {tools_heading}") + lines.append("") + lines.append(", ".join(f"`{t}`" for t in info.tool_uses)) + lines.append("") + + lines.append(f"## {history_heading}") + lines.append("") + + for idx, record in enumerate(records, start=1): + role = record.get("role", "unknown") + role_label = role_labels.get(role, role) + lines.append(f"### {idx}. {role_label}") + lines.append("") + content = record.get("message", {}).get("content", []) + if isinstance(content, str): + lines.append(content) + lines.append("") + continue + for block in content: + lines.append(block_to_markdown(block, language)) + lines.append("") + + return "\n".join(lines) diff --git a/cam/paths.py b/cam/paths.py new file mode 100644 index 0000000..acdcf04 --- /dev/null +++ b/cam/paths.py @@ -0,0 +1,57 @@ +"""Resolve Cursor transcript paths from project and workstation settings.""" + +from __future__ import annotations + +from pathlib import Path + + +def cursor_project_slug(project_root: Path) -> str: + """Derive Cursor project folder name from an absolute workspace path.""" + path = project_root.resolve() + text = str(path) + if len(text) >= 2 and text[1] == ":": + text = text[0].lower() + text[2:] + text = text.replace("\\", "/") + if text.startswith("/"): + text = text[1:] + return text.replace("/", "-") + + +def default_transcripts_dir(project_root: Path, cursor_home: Path | None = None) -> Path: + """Return default Cursor agent-transcripts directory for a workspace.""" + base = (cursor_home or Path.home() / ".cursor").expanduser() + slug = cursor_project_slug(project_root) + return base / "projects" / slug / "agent-transcripts" + + +def resolve_transcripts_dir( + project_root: Path, + configured: str | None, + cursor_home: str | None, +) -> Path: + if configured: + path = Path(configured).expanduser() + if not path.is_absolute(): + path = (project_root / path).resolve() + return path + cursor_base = Path(cursor_home).expanduser() if cursor_home else None + return default_transcripts_dir(project_root, cursor_base) + + +def resolve_export_dir(project_root: Path, configured: str) -> Path: + path = Path(configured).expanduser() + if path.is_absolute(): + return path + return (project_root / path).resolve() + + +def raw_archive_path(export_root: Path, session_id: str, parent_id: str | None, filename: str) -> Path: + if parent_id: + return export_root / "raw" / parent_id / "subagents" / filename + return export_root / "raw" / filename + + +def cursor_transcript_path(transcripts_root: Path, session_id: str, parent_id: str | None) -> Path: + if parent_id: + return transcripts_root / parent_id / "subagents" / f"{session_id}.jsonl" + return transcripts_root / session_id / f"{session_id}.jsonl" diff --git a/cam/transcripts.py b/cam/transcripts.py new file mode 100644 index 0000000..ab2d7ad --- /dev/null +++ b/cam/transcripts.py @@ -0,0 +1,96 @@ +"""Parse Cursor agent transcript JSONL files.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +@dataclass +class SessionInfo: + session_id: str + is_subagent: bool + parent_id: str | None + source_path: Path + first_user_query: str = "" + title: str = "" + message_count: int = 0 + user_messages: int = 0 + assistant_messages: int = 0 + tool_uses: list[str] = field(default_factory=list) + modified_at: str = "" + + +def extract_user_query(text: str) -> str: + match = re.search(r"\s*(.*?)\s*", text, re.DOTALL) + if match: + return match.group(1).strip() + return text.strip() + + +def make_title(query: str, max_len: int = 80) -> str: + one_line = " ".join(query.split()) + if len(one_line) <= max_len: + return one_line + return one_line[: max_len - 1].rstrip() + "…" + + +def parse_transcript(path: Path) -> tuple[list[dict[str, Any]], SessionInfo]: + session_id = path.stem + parent_id = None + is_subagent = "subagents" in path.parts + if is_subagent: + parent_id = path.parent.parent.name + + info = SessionInfo( + session_id=session_id, + is_subagent=is_subagent, + parent_id=parent_id, + source_path=path, + modified_at=datetime.fromtimestamp( + path.stat().st_mtime, tz=timezone.utc + ).strftime("%Y-%m-%d %H:%M UTC"), + ) + + records: list[dict[str, Any]] = [] + with path.open(encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + records.append(record) + role = record.get("role") + info.message_count += 1 + if role == "user": + info.user_messages += 1 + if not info.first_user_query: + content = record.get("message", {}).get("content", []) + for block in content: + if block.get("type") == "text": + info.first_user_query = extract_user_query(block.get("text", "")) + break + elif role == "assistant": + info.assistant_messages += 1 + content = record.get("message", {}).get("content", []) + for block in content: + if block.get("type") == "tool_use": + tool_name = block.get("name", "unknown") + if tool_name not in info.tool_uses: + info.tool_uses.append(tool_name) + + info.title = make_title(info.first_user_query) if info.first_user_query else session_id + return records, info + + +def iter_transcript_files(root: Path) -> list[Path]: + if not root.is_dir(): + return [] + return sorted(root.rglob("*.jsonl")) diff --git a/config/config.example.yml b/config/config.example.yml new file mode 100644 index 0000000..0310537 --- /dev/null +++ b/config/config.example.yml @@ -0,0 +1,31 @@ +# CAM configuration example +# Version: 0.0.1 +# +# Copy to config/workstations/.yml or config/workstations/local.yml +# Do not commit workstation-specific files with real paths to the public CAM repo. + +workstation: + # Short identifier for manifest metadata (no secrets) + id: "example" + label: "Example workstation" + +project: + # Absolute path to the project root opened in Cursor + root: "/path/to/your/project" + name: "your-project" + +cursor: + # Optional override for ~/.cursor (default: user home) + # home: "~/.cursor" + # Optional explicit transcripts path; if omitted, derived from project.root: + # transcripts_dir: "~/.cursor/projects//agent-transcripts" + +export: + # Relative to project.root unless absolute + output_dir: "docs/cursor_agents" + # INDEX.md language: en | ru + index_language: "en" + +import: + # When false, existing Cursor transcript files are not overwritten + overwrite_existing: false diff --git a/config/workstations/linux.example.yml b/config/workstations/linux.example.yml new file mode 100644 index 0000000..4e17abe --- /dev/null +++ b/config/workstations/linux.example.yml @@ -0,0 +1,20 @@ +# Linux workstation example for CAM +# Copy: cp config/workstations/linux.example.yml config/workstations/local.yml + +workstation: + id: "linux-dev" + label: "Linux development machine" + +project: + root: "/home/you/projects/your-project" + name: "your-project" + +cursor: + # home: "~/.cursor" + +export: + output_dir: "docs/cursor_agents" + index_language: "en" + +import: + overwrite_existing: false diff --git a/config/workstations/macos.example.yml b/config/workstations/macos.example.yml new file mode 100644 index 0000000..0963cfb --- /dev/null +++ b/config/workstations/macos.example.yml @@ -0,0 +1,20 @@ +# macOS workstation example for CAM +# Copy: cp config/workstations/macos.example.yml config/workstations/local.yml + +workstation: + id: "macos-dev" + label: "macOS development machine" + +project: + root: "/Users/you/projects/your-project" + name: "your-project" + +cursor: + # home: "~/.cursor" + +export: + output_dir: "docs/cursor_agents" + index_language: "en" + +import: + overwrite_existing: false diff --git a/config/workstations/nt-041.example.yml b/config/workstations/nt-041.example.yml new file mode 100644 index 0000000..c4161e3 --- /dev/null +++ b/config/workstations/nt-041.example.yml @@ -0,0 +1,18 @@ +# CRM3-26 project — macOS workstation nt-041 +# Copy: cp config/workstations/nt-041.example.yml config/workstations/nt-041.yml +# File nt-041.yml is gitignored (local paths only). + +workstation: + id: "nt-041" + label: "nt-041 macOS" + +project: + root: "/Users/you/projects/crm3-26" + name: "crm3-26" + +export: + output_dir: "docs/cursor_agents" + index_language: "ru" + +import: + overwrite_existing: false diff --git a/config/workstations/windows.example.yml b/config/workstations/windows.example.yml new file mode 100644 index 0000000..586decb --- /dev/null +++ b/config/workstations/windows.example.yml @@ -0,0 +1,20 @@ +# Windows workstation example for CAM +# Copy: cp config/workstations/windows.example.yml config/workstations/local.yml + +workstation: + id: "windows-dev" + label: "Windows development machine" + +project: + root: "D:/projects/your-project" + name: "your-project" + +cursor: + # home: "%USERPROFILE%/.cursor" + +export: + output_dir: "docs/cursor_agents" + index_language: "en" + +import: + overwrite_existing: false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c1a201d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML>=6.0