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 <cursoragent@cursor.com>
This commit is contained in:
19 changed files with 1167 additions and 0 deletions
+15
View File
@@ -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/
+28
View File
@@ -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
+21
View File
@@ -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.
+166
View File
@@ -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: <https://git.p7net.ru/tools/cam.git>
## Why
Cursor stores agent history locally under `~/.cursor/projects/<workspace-slug>/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/<hostname>.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/
│ ├── <session-id>.jsonl
│ └── <parent-id>/subagents/<subagent-id>.jsonl
└── markdown/
├── <session-id>.md
└── subagents/<subagent-id>.md
```
## Cursor project slug
If `cursor.transcripts_dir` is omitted, CAM derives:
```
~/.cursor/projects/<slug>/agent-transcripts/
```
where `<slug>` 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/<host>.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).
+4
View File
@@ -0,0 +1,4 @@
"""CAM (Cursor Agents Manager) version."""
__version__ = "0.0.1"
__status__ = "in development"
+153
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
"""Cursor Agents Manager — export and import Cursor agent transcripts."""
from __version__ import __status__, __version__
__all__ = ["__version__", "__status__"]
+124
View File
@@ -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}"
)
+196
View File
@@ -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
+91
View File
@@ -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
+101
View File
@@ -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)
+57
View File
@@ -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"
+96
View File
@@ -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"<user_query>\s*(.*?)\s*</user_query>", 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"))
+31
View File
@@ -0,0 +1,31 @@
# CAM configuration example
# Version: 0.0.1
#
# Copy to config/workstations/<your-hostname>.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/<cursor-project-slug>/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
+20
View File
@@ -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
+20
View File
@@ -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
+18
View File
@@ -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
+20
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
PyYAML>=6.0