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:
+15
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""CAM (Cursor Agents Manager) version."""
|
||||||
|
|
||||||
|
__version__ = "0.0.1"
|
||||||
|
__status__ = "in development"
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Cursor Agents Manager — export and import Cursor agent transcripts."""
|
||||||
|
|
||||||
|
from __version__ import __status__, __version__
|
||||||
|
|
||||||
|
__all__ = ["__version__", "__status__"]
|
||||||
+124
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PyYAML>=6.0
|
||||||
Reference in New Issue
Block a user