Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da351ba770 | |||
| 2cab3c2b02 | |||
| ae61eb9c9d |
+22
-16
@@ -2,27 +2,33 @@
|
||||
|
||||
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
|
||||
## [0.1.0] - 2026-07-13
|
||||
|
||||
### Status
|
||||
|
||||
Initial release. **In development.**
|
||||
**In development.** Context registry and readable file names.
|
||||
|
||||
### 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
|
||||
- **Context codes** (`w1-1`, `w2-110`, manual `w1.git.001`, …) stored in `registry.yaml`
|
||||
- **Readable markdown files** for Cursor `@` picker: `{code}--{topic-slug}.md`
|
||||
- **Stable paths** — re-export updates the same file for a given context code
|
||||
- **Workstation index** (`workstation.index: w1`) in config; optional `prefix_workstation: false`
|
||||
- **Selective export/import** via `--context CODE` and `--mask PATTERN` (e.g. `w1-*`, `w1.git.*`)
|
||||
- **`cam code`** subcommands: `list`, `set`, `show` for manual code assignment
|
||||
- Auto-migration from legacy UUID-based `manifest.json` on first export
|
||||
- Legacy UUID markdown cleanup (`context.cleanup_legacy_files`)
|
||||
- Subagent codes: `{parent}.{seq}` (e.g. `w1-11.1`)
|
||||
|
||||
### Changed
|
||||
|
||||
- `INDEX.md` shows context code column and workstation of origin
|
||||
- `manifest.json` includes `context_code`, `created_workstation`, `manual_code`
|
||||
- Markdown front matter includes `context_code` and `created_workstation`
|
||||
|
||||
## [0.0.1] - 2026-07-10
|
||||
|
||||
Initial release: export/import all transcripts, workstation YAML config, `USAGE.md`.
|
||||
|
||||
[0.1.0]: https://git.p7net.ru/tools/cam/-/tags/v0.1.0
|
||||
[0.0.1]: https://git.p7net.ru/tools/cam/-/tags/v0.0.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CAM — Cursor Agents Manager
|
||||
|
||||
**Version:** 0.0.1
|
||||
**Version:** 0.1.0
|
||||
**Status:** in development
|
||||
**License:** [MIT](LICENSE)
|
||||
|
||||
@@ -15,7 +15,19 @@ Cursor stores agent history locally under `~/.cursor/projects/<workspace-slug>/a
|
||||
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
|
||||
4. **Continue** — `@docs/cursor_agents/markdown/w1-11--topic.md` (readable context code files)
|
||||
|
||||
## Context codes (0.1.0)
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Auto codes | `w1-1`, `w2-110` from `workstation.index` + sequence |
|
||||
| Manual codes | `cam code set w1-11 w1.git.001` |
|
||||
| Readable files | `w1-11--topic-slug.md` for Cursor `@` picker |
|
||||
| Stable export | Re-export overwrites the same file for a code |
|
||||
| Selective sync | `--context w1-11` or `--mask 'w1-*'` on export/import |
|
||||
|
||||
Registry: `docs/cursor_agents/registry.yaml`. See **[USAGE.md](USAGE.md)**.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -28,6 +40,8 @@ pip install -r requirements.txt
|
||||
|
||||
## Quick start
|
||||
|
||||
See **[USAGE.md](USAGE.md)** for the full step-by-step workflow (setup, export before leaving a workstation, import on a new machine).
|
||||
|
||||
### 1. Clone CAM into your project
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# CAM Usage Workflow
|
||||
|
||||
Step-by-step order for moving Cursor agent context between workstations.
|
||||
|
||||
**Repository:** <https://git.p7net.ru/tools/cam.git>
|
||||
**Version:** 0.1.0 (in development)
|
||||
|
||||
---
|
||||
|
||||
## Context codes (readable names)
|
||||
|
||||
Each agent session gets a **context code** stored in `registry.yaml`:
|
||||
|
||||
| Code type | Example | Meaning |
|
||||
|-----------|---------|---------|
|
||||
| Auto main | `w1-1`, `w2-110` | `{workstation.index}-{seq}` |
|
||||
| Auto subagent | `w1-11.1` | `{parent}.{seq}` |
|
||||
| Manual | `w1.git.001`, `sync-crm3` | Assigned by operator |
|
||||
|
||||
Markdown files use readable names for Cursor `@`:
|
||||
|
||||
```
|
||||
docs/cursor_agents/markdown/w1-11--Из-исходной-базы-в-целевую.md
|
||||
docs/cursor_agents/markdown/w1.git.001--sync-analysis.md
|
||||
```
|
||||
|
||||
**Re-export updates the same file** for a given code (consolidated/enriched context overwrites in place).
|
||||
|
||||
In a new Cursor chat:
|
||||
|
||||
```
|
||||
@docs/cursor_agents/markdown/w1-11--Из-исходной-базы-в-целевую.md
|
||||
```
|
||||
|
||||
or reference by code:
|
||||
|
||||
```
|
||||
Context w1-11 — continue transfer of customer orders.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (each workstation)
|
||||
|
||||
### 1. Clone CAM
|
||||
|
||||
```bash
|
||||
git clone https://git.p7net.ru/tools/cam.git tools/cam
|
||||
pip install -r tools/cam/requirements.txt
|
||||
```
|
||||
|
||||
### 2. Workstation config
|
||||
|
||||
```bash
|
||||
cp tools/cam/config/workstations/macos.example.yml tools/cam/config/workstations/local.yml
|
||||
```
|
||||
|
||||
Required fields:
|
||||
|
||||
```yaml
|
||||
workstation:
|
||||
id: "nt-041"
|
||||
index: "w1" # w1, w2, … — prefix for auto codes
|
||||
|
||||
context:
|
||||
prefix_workstation: true # w1-1 (true) or 1 (false)
|
||||
auto_format: "{ws}-{seq}"
|
||||
subagent_format: "{parent}.{seq}"
|
||||
|
||||
registry:
|
||||
file: "registry.yaml"
|
||||
```
|
||||
|
||||
### 3. Verify
|
||||
|
||||
```bash
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml paths
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Before leaving a workstation
|
||||
|
||||
```bash
|
||||
# all contexts
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml export
|
||||
|
||||
# one context
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml export --context w1-11
|
||||
|
||||
# by mask
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml export --mask 'w1-*'
|
||||
```
|
||||
|
||||
Commit project archive:
|
||||
|
||||
```bash
|
||||
git add docs/cursor_agents/
|
||||
git commit -m "Export Cursor agent contexts (w1)"
|
||||
git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## On a new workstation
|
||||
|
||||
```bash
|
||||
git pull
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml import --dry-run
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml import
|
||||
# or selective:
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml import --mask 'w1-*'
|
||||
```
|
||||
|
||||
Close Cursor before import. For new agent work, prefer `@markdown/w1-11--….md`.
|
||||
|
||||
---
|
||||
|
||||
## Manual context codes
|
||||
|
||||
```bash
|
||||
# list all codes
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml code list
|
||||
|
||||
# rename w1-11 → thematic code
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml code set w1-11 w1.git.zakaz
|
||||
|
||||
# show details
|
||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml code show w1.git.zakaz
|
||||
```
|
||||
|
||||
Rules for manual codes: letters, digits, `.`, `-`, `_` (e.g. `w1.git.001`, `sync-crm3`).
|
||||
|
||||
To drop workstation prefix in **new** auto codes only:
|
||||
|
||||
```yaml
|
||||
context:
|
||||
prefix_workstation: false
|
||||
auto_format: "{seq}"
|
||||
```
|
||||
|
||||
Existing codes in `registry.yaml` are not renamed automatically.
|
||||
|
||||
---
|
||||
|
||||
## Command reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `export` | Cursor → project archive |
|
||||
| `import` | project archive → Cursor |
|
||||
| `list` | List contexts in archive |
|
||||
| `code list` | List codes from registry |
|
||||
| `code set TARGET NEW` | Assign/rename code |
|
||||
| `code show CODE` | Show context record |
|
||||
| `paths` | Resolved paths |
|
||||
|
||||
Flags: `--context CODE`, `--mask PATTERN` (glob: `w1-*`, `*.git.*`).
|
||||
|
||||
---
|
||||
|
||||
## Archive layout (0.1.0)
|
||||
|
||||
```
|
||||
docs/cursor_agents/
|
||||
├── registry.yaml # codes ↔ session UUID ↔ file paths
|
||||
├── INDEX.md
|
||||
├── manifest.json
|
||||
├── raw/ # JSONL by session UUID (for Cursor import)
|
||||
└── markdown/
|
||||
├── w1-1--topic.md # @-friendly names
|
||||
└── subagents/
|
||||
└── w1-1.1--topic.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
Do not commit workstation `local.yml` with secrets to public repos.
|
||||
Agent transcripts may contain business data — keep `docs/cursor_agents/` in your private project repo.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"""CAM (Cursor Agents Manager) version."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
__version__ = "0.1.0"
|
||||
__status__ = "in development"
|
||||
|
||||
@@ -14,7 +14,8 @@ if str(CAM_ROOT) not in sys.path:
|
||||
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
|
||||
from cam.import_transcripts import import_transcripts, list_archive_contexts # noqa: E402
|
||||
from cam.registry import ContextRegistry # noqa: E402
|
||||
|
||||
|
||||
def _default_cam_command(config_path: Path | None) -> str:
|
||||
@@ -31,50 +32,136 @@ def _default_cam_command(config_path: Path | None) -> str:
|
||||
return "python tools/cam/cam.py export"
|
||||
|
||||
|
||||
def _add_context_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--context",
|
||||
metavar="CODE",
|
||||
help="Single context code (e.g. w1-1, w1.git.001)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mask",
|
||||
metavar="PATTERN",
|
||||
help="Context code glob pattern (e.g. w1-*, w1.git.*)",
|
||||
)
|
||||
|
||||
|
||||
def cmd_paths(config_path: Path) -> int:
|
||||
config = load_config(config_path)
|
||||
print(f"workstation_id: {config.workstation_id}")
|
||||
print(f"workstation_index: {config.workstation_index}")
|
||||
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"registry_file: {config.export_dir / config.registry_file}")
|
||||
print(f"index_language: {config.index_language}")
|
||||
print(f"auto_code_format: {config.auto_code_format}")
|
||||
print(f"config_file: {config.source_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(config_path: Path) -> int:
|
||||
def cmd_export(
|
||||
config_path: Path,
|
||||
context: str | None,
|
||||
context_mask: str | None,
|
||||
) -> int:
|
||||
config = load_config(config_path)
|
||||
main_count, sub_count = export_transcripts(config, _default_cam_command(config_path))
|
||||
main_count, sub_count = export_transcripts(
|
||||
config,
|
||||
_default_cam_command(config_path),
|
||||
context=context,
|
||||
context_mask=context_mask,
|
||||
)
|
||||
label = ""
|
||||
if context or context_mask:
|
||||
label = f" (filter: {context or context_mask})"
|
||||
print(
|
||||
f"Exported {main_count} main sessions and {sub_count} subagents "
|
||||
f"to {config.export_dir}"
|
||||
f"to {config.export_dir}{label}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_import(config_path: Path, dry_run: bool, source: Path | None) -> int:
|
||||
def cmd_import(
|
||||
config_path: Path,
|
||||
dry_run: bool,
|
||||
source: Path | None,
|
||||
context: str | None,
|
||||
context_mask: str | None,
|
||||
) -> int:
|
||||
config = load_config(config_path)
|
||||
result = import_transcripts(config, dry_run=dry_run, source=source)
|
||||
result = import_transcripts(
|
||||
config,
|
||||
dry_run=dry_run,
|
||||
source=source,
|
||||
context=context,
|
||||
context_mask=context_mask,
|
||||
)
|
||||
mode = "Dry-run:" if dry_run else "Imported:"
|
||||
label = ""
|
||||
if context or context_mask:
|
||||
label = f" filter={context or context_mask}"
|
||||
print(
|
||||
f"{mode} {result.copied} file(s) to {config.transcripts_dir}; "
|
||||
f"skipped {result.skipped} existing"
|
||||
f"skipped {result.skipped} existing{label}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(config_path: Path) -> int:
|
||||
def cmd_list(
|
||||
config_path: Path,
|
||||
context_mask: str | None,
|
||||
) -> 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'}")
|
||||
rows = list_archive_contexts(config.export_dir, config, context_mask=context_mask)
|
||||
if not rows:
|
||||
print(f"No contexts in {config.export_dir}")
|
||||
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)}")
|
||||
print("CODE\tSESSION_ID\tTITLE\tRAW")
|
||||
for code, session_id, title, raw in rows:
|
||||
raw_flag = "yes" if raw else "missing"
|
||||
print(f"{code}\t{session_id}\t{title}\t{raw_flag}")
|
||||
print(f"Total: {len(rows)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_code_list(config_path: Path) -> int:
|
||||
config = load_config(config_path)
|
||||
registry = ContextRegistry(config.export_dir, config)
|
||||
registry.load()
|
||||
print("CODE\tSESSION_ID\tWS\tMANUAL\tMARKDOWN")
|
||||
for record in sorted(registry.contexts.values(), key=lambda r: r.code):
|
||||
manual = "yes" if record.manual_code else "no"
|
||||
print(
|
||||
f"{record.code}\t{record.session_id}\t{record.created_workstation}\t"
|
||||
f"{manual}\t{record.markdown_file}"
|
||||
)
|
||||
print(f"Total: {len(registry.contexts)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_code_set(config_path: Path, target: str, new_code: str) -> int:
|
||||
config = load_config(config_path)
|
||||
registry = ContextRegistry(config.export_dir, config)
|
||||
registry.load()
|
||||
record = registry.set_code(target, new_code)
|
||||
print(f"Set context code: {record.session_id} -> {record.code}")
|
||||
print(f"Markdown: {record.markdown_file}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_code_show(config_path: Path, code: str) -> int:
|
||||
config = load_config(config_path)
|
||||
registry = ContextRegistry(config.export_dir, config)
|
||||
registry.load()
|
||||
record = registry.get_by_code(code)
|
||||
if record is None:
|
||||
record = registry.get_by_session(code)
|
||||
if record is None:
|
||||
raise ValueError(f"Unknown context or session: {code}")
|
||||
for key, value in record.to_dict().items():
|
||||
print(f"{key}: {value}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -101,27 +188,42 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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")
|
||||
|
||||
export_parser = sub.add_parser("export", help="Export Cursor transcripts to project archive")
|
||||
_add_context_args(export_parser)
|
||||
export_parser.set_defaults(handler="export")
|
||||
|
||||
list_parser = sub.add_parser("list", help="List contexts in export archive")
|
||||
list_parser.add_argument("--mask", metavar="PATTERN", help="Context code glob pattern")
|
||||
list_parser.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.add_argument("--dry-run", action="store_true")
|
||||
import_parser.add_argument("--source", type=Path)
|
||||
_add_context_args(import_parser)
|
||||
import_parser.set_defaults(handler="import")
|
||||
|
||||
code_parser = sub.add_parser("code", help="Manage context codes")
|
||||
code_sub = code_parser.add_subparsers(dest="code_command", required=True)
|
||||
|
||||
code_sub.add_parser("list", help="List all context codes").set_defaults(
|
||||
code_handler="list"
|
||||
)
|
||||
|
||||
code_set = code_sub.add_parser("set", help="Assign or rename a context code")
|
||||
code_set.add_argument("target", help="Session UUID or existing context code")
|
||||
code_set.add_argument("new_code", help="New code (e.g. w1.git.001)")
|
||||
code_set.set_defaults(code_handler="set")
|
||||
|
||||
code_show = code_sub.add_parser("show", help="Show context record")
|
||||
code_show.add_argument("code", help="Context code or session UUID")
|
||||
code_show.set_defaults(code_handler="show")
|
||||
|
||||
code_parser.set_defaults(handler="code")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -139,11 +241,24 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.handler == "paths":
|
||||
return cmd_paths(config_path)
|
||||
if args.handler == "export":
|
||||
return cmd_export(config_path)
|
||||
return cmd_export(config_path, args.context, args.mask)
|
||||
if args.handler == "list":
|
||||
return cmd_list(config_path)
|
||||
return cmd_list(config_path, args.mask)
|
||||
if args.handler == "import":
|
||||
return cmd_import(config_path, args.dry_run, args.source)
|
||||
return cmd_import(
|
||||
config_path,
|
||||
args.dry_run,
|
||||
args.source,
|
||||
args.context,
|
||||
args.mask,
|
||||
)
|
||||
if args.handler == "code":
|
||||
if args.code_handler == "list":
|
||||
return cmd_code_list(config_path)
|
||||
if args.code_handler == "set":
|
||||
return cmd_code_set(config_path, args.target, args.new_code)
|
||||
if args.code_handler == "show":
|
||||
return cmd_code_show(config_path, args.code)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -15,6 +15,7 @@ from cam.paths import resolve_export_dir, resolve_transcripts_dir
|
||||
class CamConfig:
|
||||
workstation_id: str
|
||||
workstation_label: str
|
||||
workstation_index: str
|
||||
project_root: Path
|
||||
project_name: str
|
||||
transcripts_dir: Path
|
||||
@@ -22,6 +23,12 @@ class CamConfig:
|
||||
index_language: str
|
||||
overwrite_existing: bool
|
||||
source_path: Path
|
||||
registry_file: str
|
||||
prefix_workstation: bool
|
||||
auto_code_format: str
|
||||
subagent_auto_format: str
|
||||
slug_max_length: int
|
||||
cleanup_legacy_files: bool
|
||||
|
||||
@property
|
||||
def cursor_project_slug(self) -> str:
|
||||
@@ -53,9 +60,13 @@ def load_config(path: Path) -> CamConfig:
|
||||
cursor = raw.get("cursor") or {}
|
||||
export = raw.get("export") or {}
|
||||
import_cfg = raw.get("import") or {}
|
||||
context = raw.get("context") or {}
|
||||
registry = raw.get("registry") 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")
|
||||
if not isinstance(context, dict) or not isinstance(registry, dict):
|
||||
raise ValueError("Sections 'context' and 'registry' must be mappings when present")
|
||||
|
||||
project_root = Path(str(project.get("root", ""))).expanduser().resolve()
|
||||
if not project_root.is_dir():
|
||||
@@ -66,9 +77,14 @@ def load_config(path: Path) -> CamConfig:
|
||||
if index_language not in {"en", "ru"}:
|
||||
raise ValueError("export.index_language must be 'en' or 'ru'")
|
||||
|
||||
ws_index = str(workstation.get("index", "w1")).strip()
|
||||
if not ws_index:
|
||||
raise ValueError("workstation.index must be a non-empty code (e.g. w1, w2)")
|
||||
|
||||
return CamConfig(
|
||||
workstation_id=str(workstation.get("id", "unknown")),
|
||||
workstation_label=str(workstation.get("label", workstation.get("id", "unknown"))),
|
||||
workstation_index=ws_index,
|
||||
project_root=project_root,
|
||||
project_name=str(project.get("name", project_root.name)),
|
||||
transcripts_dir=resolve_transcripts_dir(
|
||||
@@ -80,6 +96,12 @@ def load_config(path: Path) -> CamConfig:
|
||||
index_language=index_language,
|
||||
overwrite_existing=bool(import_cfg.get("overwrite_existing", False)),
|
||||
source_path=path,
|
||||
registry_file=str(registry.get("file", "registry.yaml")),
|
||||
prefix_workstation=bool(context.get("prefix_workstation", True)),
|
||||
auto_code_format=str(context.get("auto_format", "{ws}-{seq}")),
|
||||
subagent_auto_format=str(context.get("subagent_format", "{parent}.{seq}")),
|
||||
slug_max_length=int(context.get("slug_max_length", 50)),
|
||||
cleanup_legacy_files=bool(context.get("cleanup_legacy_files", True)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+117
-36
@@ -10,54 +10,68 @@ from typing import Any
|
||||
|
||||
from cam.config import CamConfig
|
||||
from cam.markdown import records_to_markdown
|
||||
from cam.registry import ContextRegistry
|
||||
from cam.transcripts import SessionInfo, iter_transcript_files, parse_transcript
|
||||
|
||||
|
||||
def _write_index(
|
||||
sessions: list[SessionInfo],
|
||||
registry: ContextRegistry,
|
||||
output_root: Path,
|
||||
exported_at: str,
|
||||
transcripts_root: Path,
|
||||
language: str,
|
||||
cam_command: str,
|
||||
*,
|
||||
filter_label: str | None = None,
|
||||
message_counts: dict[str, int] | None = None,
|
||||
) -> 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)
|
||||
main_records = [r for r in registry.contexts.values() if not r.is_subagent]
|
||||
sub_records = [r for r in registry.contexts.values() if r.is_subagent]
|
||||
main_records.sort(key=lambda r: r.modified_at, reverse=True)
|
||||
|
||||
if language == "ru":
|
||||
title = "История сессий Cursor Agent"
|
||||
intro = f"Экспорт от **{exported_at}**. Источник: `{transcripts_root}`."
|
||||
if filter_label:
|
||||
intro += f" Фильтр: `{filter_label}`."
|
||||
structure = "Структура каталога:"
|
||||
raw_desc = "`raw/` — оригинальные `.jsonl` транскрипты"
|
||||
md_desc = "`markdown/` — читаемые версии диалогов"
|
||||
manifest_desc = "`manifest.json` — машиночитаемый реестр сессий"
|
||||
raw_desc = "`raw/` — оригинальные `.jsonl` (по session UUID)"
|
||||
md_desc = "`markdown/` — читаемые файлы `{код}--{тема}.md` для `@` в Cursor"
|
||||
registry_desc = "`registry.yaml` — коды контекстов (w1-1, w1.git.001, …)"
|
||||
manifest_desc = "`manifest.json` — машиночитаемый реестр"
|
||||
reexport = "Повторный экспорт:"
|
||||
sessions_heading = "Основные сессии"
|
||||
subagents_heading = "Subagents"
|
||||
parent_label = "Родитель"
|
||||
date_col = "Дата (UTC)"
|
||||
code_col = "Код"
|
||||
title_col = "Заголовок"
|
||||
ws_col = "WS"
|
||||
else:
|
||||
title = "Cursor Agent session history"
|
||||
intro = f"Exported at **{exported_at}**. Source: `{transcripts_root}`."
|
||||
if filter_label:
|
||||
intro += f" Filter: `{filter_label}`."
|
||||
structure = "Directory layout:"
|
||||
raw_desc = "`raw/` — original `.jsonl` transcripts"
|
||||
md_desc = "`markdown/` — readable conversation exports"
|
||||
manifest_desc = "`manifest.json` — machine-readable session registry"
|
||||
raw_desc = "`raw/` — original `.jsonl` (by session UUID)"
|
||||
md_desc = "`markdown/` — readable `{code}--{topic}.md` files for Cursor `@`"
|
||||
registry_desc = "`registry.yaml` — context codes (w1-1, w1.git.001, …)"
|
||||
manifest_desc = "`manifest.json` — machine-readable registry"
|
||||
reexport = "Re-export:"
|
||||
sessions_heading = "Main sessions"
|
||||
subagents_heading = "Subagents"
|
||||
parent_label = "Parent"
|
||||
date_col = "Date (UTC)"
|
||||
code_col = "Code"
|
||||
title_col = "Title"
|
||||
ws_col = "WS"
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
f"title: {title}",
|
||||
f"exported_at: {exported_at}",
|
||||
f"sessions_total: {len(main_sessions)}",
|
||||
f"subagents_total: {len(subagents)}",
|
||||
f"contexts_total: {len(main_records)}",
|
||||
f"subagents_total: {len(sub_records)}",
|
||||
"---",
|
||||
"",
|
||||
f"# {title}",
|
||||
@@ -68,6 +82,7 @@ def _write_index(
|
||||
"",
|
||||
f"- {raw_desc}",
|
||||
f"- {md_desc}",
|
||||
f"- {registry_desc}",
|
||||
f"- {manifest_desc}",
|
||||
"",
|
||||
reexport,
|
||||
@@ -78,77 +93,118 @@ def _write_index(
|
||||
"",
|
||||
f"## {sessions_heading}",
|
||||
"",
|
||||
f"| {date_col} | {title_col} | ID | Msg | Subagents |",
|
||||
"|---|---|---|---:|---:|",
|
||||
f"| {date_col} | {code_col} | {title_col} | {ws_col} | Msg | Sub |",
|
||||
"|---|---|---|---|---:|---:|",
|
||||
]
|
||||
|
||||
subagents_by_parent: dict[str, list[SessionInfo]] = {}
|
||||
for sub in subagents:
|
||||
key = sub.parent_id or "unknown"
|
||||
subagents_by_parent.setdefault(key, []).append(sub)
|
||||
subs_by_parent_code: dict[str, list] = {}
|
||||
for sub in sub_records:
|
||||
key = sub.parent_code or sub.parent_session_id or "unknown"
|
||||
subs_by_parent_code.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"
|
||||
for record in main_records:
|
||||
sub_count = len(subs_by_parent_code.get(record.code, []))
|
||||
rel_md = record.markdown_file
|
||||
lines.append(
|
||||
f"| {session.modified_at} | [{session.title}]({rel_md}) | `{session.session_id}` | "
|
||||
f"{session.message_count} | {sub_count} |"
|
||||
f"| {record.modified_at} | [`{record.code}`]({rel_md}) | "
|
||||
f"{record.title} | `{record.created_workstation}` | "
|
||||
f"{(message_counts or {}).get(record.session_id, 0)} | {sub_count} |"
|
||||
)
|
||||
|
||||
lines.extend(["", f"## {subagents_heading}", ""])
|
||||
for parent_id, subs in sorted(subagents_by_parent.items()):
|
||||
lines.append(f"### {parent_label} `{parent_id}`")
|
||||
for parent_code, subs in sorted(subs_by_parent_code.items()):
|
||||
lines.append(f"### {parent_label} `{parent_code}`")
|
||||
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)"
|
||||
f"- [`{sub.code}`]({sub.markdown_file}) — {sub.title} "
|
||||
f"({(message_counts or {}).get(sub.session_id, 0)} 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]:
|
||||
def export_transcripts(
|
||||
config: CamConfig,
|
||||
cam_command: str,
|
||||
*,
|
||||
context: str | None = None,
|
||||
context_mask: str | None = None,
|
||||
) -> 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}")
|
||||
|
||||
registry = ContextRegistry(output_root, config)
|
||||
registry.load()
|
||||
|
||||
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)
|
||||
|
||||
allowed_session_ids: set[str] | None = None
|
||||
if context or context_mask:
|
||||
allowed_session_ids = registry.session_ids_matching(
|
||||
pattern=context_mask, explicit=context
|
||||
)
|
||||
if not allowed_session_ids:
|
||||
raise ValueError(f"No contexts match filter: {context or context_mask}")
|
||||
|
||||
sessions: list[SessionInfo] = []
|
||||
manifest: list[dict[str, Any]] = []
|
||||
message_counts: dict[str, int] = {}
|
||||
|
||||
all_parsed: list[tuple[Path, list, SessionInfo]] = []
|
||||
for path in iter_transcript_files(transcripts_root):
|
||||
records, info = parse_transcript(path)
|
||||
sessions.append(info)
|
||||
all_parsed.append((path, records, info))
|
||||
all_parsed.sort(key=lambda item: (item[2].is_subagent, item[2].session_id))
|
||||
|
||||
for path, records, info in all_parsed:
|
||||
if allowed_session_ids is not None and info.session_id not in allowed_session_ids:
|
||||
if info.parent_id not in allowed_session_ids:
|
||||
continue
|
||||
|
||||
record = registry.ensure_context(info)
|
||||
registry.update_markdown_path(record, 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"
|
||||
md_dest = output_root / record.markdown_file
|
||||
|
||||
raw_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
md_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),
|
||||
records_to_markdown(
|
||||
info,
|
||||
records,
|
||||
config.index_language,
|
||||
context_code=record.code,
|
||||
created_workstation=record.created_workstation,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
record.raw_file = str(raw_dest.relative_to(output_root))
|
||||
record.modified_at = info.modified_at
|
||||
sessions.append(info)
|
||||
message_counts[info.session_id] = info.message_count
|
||||
|
||||
manifest.append(
|
||||
{
|
||||
"context_code": record.code,
|
||||
"session_id": info.session_id,
|
||||
"parent_id": info.parent_id,
|
||||
"parent_code": record.parent_code,
|
||||
"is_subagent": info.is_subagent,
|
||||
"title": info.title,
|
||||
"first_user_query": info.first_user_query,
|
||||
@@ -158,21 +214,44 @@ def export_transcripts(config: CamConfig, cam_command: str) -> tuple[int, int]:
|
||||
"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)),
|
||||
"raw_export": record.raw_file,
|
||||
"markdown_export": record.markdown_file,
|
||||
"created_workstation": record.created_workstation,
|
||||
"workstation_id": config.workstation_id,
|
||||
"manual_code": record.manual_code,
|
||||
}
|
||||
)
|
||||
|
||||
registry.save()
|
||||
if config.cleanup_legacy_files:
|
||||
registry.cleanup_legacy_markdown()
|
||||
|
||||
manifest.sort(key=lambda item: item["modified_at"], reverse=True)
|
||||
filter_label = context or context_mask
|
||||
|
||||
manifest_path = output_root / "manifest.json"
|
||||
if manifest_path.is_file():
|
||||
try:
|
||||
prev = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for item in prev.get("sessions", []):
|
||||
sid = item.get("session_id")
|
||||
if sid and sid not in message_counts:
|
||||
message_counts[sid] = int(item.get("message_count", 0))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
for item in manifest:
|
||||
message_counts[item["session_id"]] = item["message_count"]
|
||||
|
||||
(output_root / "manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"exported_at": exported_at,
|
||||
"transcripts_source": str(transcripts_root),
|
||||
"workstation_id": config.workstation_id,
|
||||
"workstation_index": config.workstation_index,
|
||||
"workstation_label": config.workstation_label,
|
||||
"project_root": str(config.project_root),
|
||||
"filter": filter_label,
|
||||
"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,
|
||||
@@ -183,12 +262,14 @@ def export_transcripts(config: CamConfig, cam_command: str) -> tuple[int, int]:
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_index(
|
||||
sessions,
|
||||
registry,
|
||||
output_root,
|
||||
exported_at,
|
||||
transcripts_root,
|
||||
config.index_language,
|
||||
cam_command,
|
||||
filter_label=filter_label,
|
||||
message_counts=message_counts,
|
||||
)
|
||||
|
||||
main_count = len([s for s in sessions if not s.is_subagent])
|
||||
|
||||
+48
-37
@@ -7,7 +7,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cam.config import CamConfig
|
||||
from cam.paths import cursor_transcript_path, raw_archive_path
|
||||
from cam.paths import cursor_transcript_path
|
||||
from cam.registry import ContextRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -26,6 +27,8 @@ def import_transcripts(
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
source: Path | None = None,
|
||||
context: str | None = None,
|
||||
context_mask: str | None = None,
|
||||
) -> ImportResult:
|
||||
archive_root = (source or config.export_dir).resolve()
|
||||
raw_root = archive_root / "raw"
|
||||
@@ -34,22 +37,32 @@ def import_transcripts(
|
||||
if not raw_root.is_dir():
|
||||
raise FileNotFoundError(f"Archive raw/ folder not found: {raw_root}")
|
||||
|
||||
registry = ContextRegistry(archive_root, config)
|
||||
registry.load()
|
||||
|
||||
allowed = registry.sessions_matching(pattern=context_mask, explicit=context)
|
||||
if context or context_mask:
|
||||
if not allowed:
|
||||
raise ValueError(f"No contexts match filter: {context or context_mask}")
|
||||
allowed_ids = {r.session_id for r in allowed}
|
||||
else:
|
||||
allowed_ids = {r.session_id for r in registry.contexts.values()}
|
||||
|
||||
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:
|
||||
for record in registry.contexts.values():
|
||||
if record.session_id not in allowed_ids:
|
||||
continue
|
||||
raw_path = archive_root / record.raw_file
|
||||
if not raw_path.is_file():
|
||||
continue
|
||||
|
||||
dest = cursor_transcript_path(transcripts_root, session_id, parent_id)
|
||||
result.paths.append(str(dest))
|
||||
dest = cursor_transcript_path(
|
||||
transcripts_root,
|
||||
record.session_id,
|
||||
record.parent_session_id,
|
||||
)
|
||||
result.paths.append(f"{record.code} -> {dest}")
|
||||
|
||||
if dest.exists() and not config.overwrite_existing:
|
||||
result.skipped += 1
|
||||
@@ -60,32 +73,30 @@ def import_transcripts(
|
||||
continue
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dest)
|
||||
shutil.copy2(raw_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
|
||||
def list_archive_contexts(
|
||||
archive_root: Path,
|
||||
config: CamConfig,
|
||||
*,
|
||||
context_mask: str | None = None,
|
||||
) -> list[tuple[str, str, str, Path | None]]:
|
||||
registry = ContextRegistry(archive_root, config)
|
||||
registry.load()
|
||||
rows: list[tuple[str, str, str, Path | None]] = []
|
||||
for record in registry.sessions_matching(pattern=context_mask):
|
||||
raw_path = archive_root / record.raw_file
|
||||
rows.append(
|
||||
(
|
||||
record.code,
|
||||
record.session_id,
|
||||
record.title,
|
||||
raw_path if raw_path.is_file() else None,
|
||||
)
|
||||
)
|
||||
rows.sort(key=lambda r: r[0])
|
||||
return rows
|
||||
|
||||
+22
-1
@@ -27,10 +27,20 @@ def block_to_markdown(block: dict[str, Any], language: str) -> str:
|
||||
return f"_{block_type or 'unknown'} block_\n"
|
||||
|
||||
|
||||
def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], language: str) -> str:
|
||||
def records_to_markdown(
|
||||
info: SessionInfo,
|
||||
records: list[dict[str, Any]],
|
||||
language: str,
|
||||
*,
|
||||
context_code: str | None = None,
|
||||
created_workstation: str | None = None,
|
||||
) -> str:
|
||||
if language == "ru":
|
||||
session_label = "Subagent" if info.is_subagent else "Сессия"
|
||||
parent_label = "Родительская сессия"
|
||||
parent_code_label = "Код родителя"
|
||||
code_label = "Код контекста"
|
||||
ws_label = "Создано на рабочем месте"
|
||||
updated_label = "Обновлено"
|
||||
messages_label = "Сообщений"
|
||||
tools_heading = "Использованные инструменты"
|
||||
@@ -39,6 +49,9 @@ def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], langua
|
||||
else:
|
||||
session_label = "Subagent" if info.is_subagent else "Session"
|
||||
parent_label = "Parent session"
|
||||
parent_code_label = "Parent code"
|
||||
code_label = "Context code"
|
||||
ws_label = "Created on workstation"
|
||||
updated_label = "Updated"
|
||||
messages_label = "Messages"
|
||||
tools_heading = "Tools used"
|
||||
@@ -50,6 +63,10 @@ def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], langua
|
||||
f"session_id: {info.session_id}",
|
||||
f"type: {'subagent' if info.is_subagent else 'main'}",
|
||||
]
|
||||
if context_code:
|
||||
lines.append(f"context_code: {context_code}")
|
||||
if created_workstation:
|
||||
lines.append(f"created_workstation: {created_workstation}")
|
||||
if info.parent_id:
|
||||
lines.append(f"parent_id: {info.parent_id}")
|
||||
lines.extend(
|
||||
@@ -63,6 +80,10 @@ def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], langua
|
||||
f"- **ID:** `{info.session_id}`",
|
||||
]
|
||||
)
|
||||
if context_code:
|
||||
lines.append(f"- **{code_label}:** `{context_code}`")
|
||||
if created_workstation:
|
||||
lines.append(f"- **{ws_label}:** `{created_workstation}`")
|
||||
if info.parent_id:
|
||||
lines.append(f"- **{parent_label}:** `{info.parent_id}`")
|
||||
lines.extend(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Context codes and readable archive file names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
CODE_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
||||
UUID_PATTERN = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
FORBIDDEN_FILENAME = re.compile(r'[\\/:*?"<>|]')
|
||||
|
||||
|
||||
def validate_context_code(code: str) -> str:
|
||||
code = code.strip()
|
||||
if not code or not CODE_PATTERN.match(code):
|
||||
raise ValueError(
|
||||
f"Invalid context code '{code}'. "
|
||||
"Use letters, digits, dots, dashes (e.g. w1-1, w1.git.001, sync-crm3)."
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
def slugify_title(title: str, max_len: int = 50) -> str:
|
||||
text = " ".join(title.split())
|
||||
if not text:
|
||||
return "untitled"
|
||||
normalized = unicodedata.normalize("NFKD", text)
|
||||
cleaned = FORBIDDEN_FILENAME.sub("-", normalized)
|
||||
cleaned = re.sub(r"\s+", "-", cleaned.strip())
|
||||
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
|
||||
if not cleaned:
|
||||
cleaned = "untitled"
|
||||
if len(cleaned) > max_len:
|
||||
cleaned = cleaned[: max_len - 1].rstrip("-")
|
||||
return cleaned
|
||||
|
||||
|
||||
def markdown_filename(code: str, title: str, *, slug_max: int = 50) -> str:
|
||||
slug = slugify_title(title, slug_max)
|
||||
return f"{code}--{slug}.md"
|
||||
|
||||
|
||||
def matches_context_pattern(code: str, pattern: str | None) -> bool:
|
||||
if not pattern:
|
||||
return True
|
||||
import fnmatch
|
||||
|
||||
return fnmatch.fnmatchcase(code, pattern)
|
||||
|
||||
|
||||
def filter_context_codes(codes: list[str], pattern: str | None, explicit: str | None) -> list[str]:
|
||||
if explicit:
|
||||
explicit = validate_context_code(explicit)
|
||||
if pattern and not matches_context_pattern(explicit, pattern):
|
||||
return []
|
||||
return [explicit] if explicit in codes else []
|
||||
if pattern:
|
||||
return [c for c in codes if matches_context_pattern(c, pattern)]
|
||||
return list(codes)
|
||||
|
||||
|
||||
def is_uuid_name(name: str) -> bool:
|
||||
return bool(UUID_PATTERN.match(name))
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
"""Context registry — stable codes and readable file names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from cam.config import CamConfig
|
||||
from cam.naming import (
|
||||
filter_context_codes,
|
||||
is_uuid_name,
|
||||
markdown_filename,
|
||||
matches_context_pattern,
|
||||
validate_context_code,
|
||||
)
|
||||
from cam.transcripts import SessionInfo
|
||||
|
||||
REGISTRY_VERSION = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextRecord:
|
||||
code: str
|
||||
session_id: str
|
||||
is_subagent: bool = False
|
||||
parent_session_id: str | None = None
|
||||
parent_code: str | None = None
|
||||
created_workstation: str = ""
|
||||
title: str = ""
|
||||
markdown_file: str = ""
|
||||
raw_file: str = ""
|
||||
manual_code: bool = False
|
||||
modified_at: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"session_id": self.session_id,
|
||||
"is_subagent": self.is_subagent,
|
||||
"parent_session_id": self.parent_session_id,
|
||||
"parent_code": self.parent_code,
|
||||
"created_workstation": self.created_workstation,
|
||||
"title": self.title,
|
||||
"markdown_file": self.markdown_file,
|
||||
"raw_file": self.raw_file,
|
||||
"manual_code": self.manual_code,
|
||||
"modified_at": self.modified_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> ContextRecord:
|
||||
return cls(
|
||||
code=str(data["code"]),
|
||||
session_id=str(data["session_id"]),
|
||||
is_subagent=bool(data.get("is_subagent", False)),
|
||||
parent_session_id=data.get("parent_session_id"),
|
||||
parent_code=data.get("parent_code"),
|
||||
created_workstation=str(data.get("created_workstation", "")),
|
||||
title=str(data.get("title", "")),
|
||||
markdown_file=str(data.get("markdown_file", "")),
|
||||
raw_file=str(data.get("raw_file", "")),
|
||||
manual_code=bool(data.get("manual_code", False)),
|
||||
modified_at=str(data.get("modified_at", "")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkstationRecord:
|
||||
index: str
|
||||
id: str
|
||||
label: str
|
||||
next_seq: int = 1
|
||||
next_subagent_seq: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"index": self.index,
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"next_seq": self.next_seq,
|
||||
"next_subagent_seq": dict(self.next_subagent_seq),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> WorkstationRecord:
|
||||
return cls(
|
||||
index=str(data["index"]),
|
||||
id=str(data.get("id", "")),
|
||||
label=str(data.get("label", "")),
|
||||
next_seq=int(data.get("next_seq", 1)),
|
||||
next_subagent_seq={
|
||||
str(k): int(v) for k, v in (data.get("next_subagent_seq") or {}).items()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ContextRegistry:
|
||||
def __init__(self, export_root: Path, config: CamConfig) -> None:
|
||||
self.export_root = export_root
|
||||
self.config = config
|
||||
self.path = export_root / config.registry_file
|
||||
self.workstations: dict[str, WorkstationRecord] = {}
|
||||
self.contexts: dict[str, ContextRecord] = {}
|
||||
self._by_session: dict[str, ContextRecord] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
if self.path.is_file():
|
||||
with self.path.open(encoding="utf-8") as fh:
|
||||
raw = yaml.safe_load(fh) or {}
|
||||
self._load_raw(raw)
|
||||
return
|
||||
if self._migrate_from_manifest():
|
||||
return
|
||||
self._ensure_current_workstation()
|
||||
|
||||
def _load_raw(self, raw: dict[str, Any]) -> None:
|
||||
self.workstations = {
|
||||
k: WorkstationRecord.from_dict(v)
|
||||
for k, v in (raw.get("workstations") or {}).items()
|
||||
}
|
||||
self.contexts = {
|
||||
k: ContextRecord.from_dict(v) for k, v in (raw.get("contexts") or {}).items()
|
||||
}
|
||||
self._rebuild_session_index()
|
||||
self._ensure_current_workstation()
|
||||
|
||||
def _rebuild_session_index(self) -> None:
|
||||
self._by_session = {r.session_id: r for r in self.contexts.values()}
|
||||
|
||||
def _ensure_current_workstation(self) -> None:
|
||||
ws_index = self.config.workstation_index
|
||||
if ws_index not in self.workstations:
|
||||
self.workstations[ws_index] = WorkstationRecord(
|
||||
index=ws_index,
|
||||
id=self.config.workstation_id,
|
||||
label=self.config.workstation_label,
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"version": REGISTRY_VERSION,
|
||||
"workstations": {k: v.to_dict() for k, v in sorted(self.workstations.items())},
|
||||
"contexts": {k: v.to_dict() for k, v in sorted(self.contexts.items())},
|
||||
}
|
||||
with self.path.open("w", encoding="utf-8") as fh:
|
||||
yaml.safe_dump(payload, fh, allow_unicode=True, sort_keys=False)
|
||||
|
||||
def _migrate_from_manifest(self) -> bool:
|
||||
manifest_path = self.export_root / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return False
|
||||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
sessions = data.get("sessions") or []
|
||||
if not sessions:
|
||||
return False
|
||||
|
||||
self._ensure_current_workstation()
|
||||
ws_index = self.config.workstation_index
|
||||
ordered = sorted(sessions, key=lambda s: (bool(s.get("is_subagent")), str(s.get("session_id"))))
|
||||
|
||||
for item in ordered:
|
||||
session_id = str(item["session_id"])
|
||||
if session_id in self._by_session:
|
||||
continue
|
||||
is_subagent = bool(item.get("is_subagent"))
|
||||
parent_id = item.get("parent_id")
|
||||
title = str(item.get("title") or session_id)
|
||||
parent_code = None
|
||||
if parent_id and parent_id in self._by_session:
|
||||
parent_code = self._by_session[parent_id].code
|
||||
code = self._assign_auto_code(is_subagent, parent_code)
|
||||
record = self._make_record(
|
||||
code=code,
|
||||
info=SessionInfo(
|
||||
session_id=session_id,
|
||||
is_subagent=is_subagent,
|
||||
parent_id=parent_id,
|
||||
source_path=Path(item.get("source_path", session_id)),
|
||||
title=title,
|
||||
modified_at=str(item.get("modified_at", "")),
|
||||
),
|
||||
created_workstation=ws_index,
|
||||
)
|
||||
self._register(record)
|
||||
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def get_by_session(self, session_id: str) -> ContextRecord | None:
|
||||
return self._by_session.get(session_id)
|
||||
|
||||
def get_by_code(self, code: str) -> ContextRecord | None:
|
||||
return self.contexts.get(validate_context_code(code))
|
||||
|
||||
def _assign_auto_code(self, is_subagent: bool, parent_code: str | None) -> str:
|
||||
if is_subagent:
|
||||
if not parent_code:
|
||||
raise ValueError("Subagent requires parent context code")
|
||||
ws = self.workstations.get(self.config.workstation_index)
|
||||
if ws is None:
|
||||
self._ensure_current_workstation()
|
||||
ws = self.workstations[self.config.workstation_index]
|
||||
seq = ws.next_subagent_seq.get(parent_code, 1)
|
||||
ws.next_subagent_seq[parent_code] = seq + 1
|
||||
fmt = self.config.subagent_auto_format
|
||||
return validate_context_code(
|
||||
fmt.replace("{parent}", parent_code).replace("{seq}", str(seq))
|
||||
)
|
||||
|
||||
ws_index = self.config.workstation_index
|
||||
ws = self.workstations[ws_index]
|
||||
seq = ws.next_seq
|
||||
ws.next_seq += 1
|
||||
fmt = self.config.auto_code_format
|
||||
code = fmt.replace("{ws}", ws_index).replace("{seq}", str(seq))
|
||||
if not self.config.prefix_workstation:
|
||||
code = code.replace(f"{ws_index}-", "").replace(f"{ws_index}.", "")
|
||||
return validate_context_code(code)
|
||||
|
||||
def ensure_context(self, info: SessionInfo) -> ContextRecord:
|
||||
existing = self.get_by_session(info.session_id)
|
||||
if existing:
|
||||
existing.title = info.title or existing.title
|
||||
existing.modified_at = info.modified_at
|
||||
return existing
|
||||
|
||||
parent_code = None
|
||||
if info.parent_id:
|
||||
parent = self.get_by_session(info.parent_id)
|
||||
if parent:
|
||||
parent_code = parent.code
|
||||
|
||||
code = self._assign_auto_code(info.is_subagent, parent_code)
|
||||
record = self._make_record(
|
||||
code=code,
|
||||
info=info,
|
||||
created_workstation=self.config.workstation_index,
|
||||
parent_code=parent_code,
|
||||
)
|
||||
self._register(record)
|
||||
return record
|
||||
|
||||
def _make_record(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
info: SessionInfo,
|
||||
created_workstation: str,
|
||||
parent_code: str | None = None,
|
||||
) -> ContextRecord:
|
||||
md_name = markdown_filename(code, info.title, slug_max=self.config.slug_max_length)
|
||||
if info.is_subagent:
|
||||
md_rel = f"markdown/subagents/{md_name}"
|
||||
raw_rel = (
|
||||
f"raw/{info.parent_id}/subagents/{info.session_id}.jsonl"
|
||||
if info.parent_id
|
||||
else f"raw/subagents/{info.session_id}.jsonl"
|
||||
)
|
||||
else:
|
||||
md_rel = f"markdown/{md_name}"
|
||||
raw_rel = f"raw/{info.session_id}.jsonl"
|
||||
|
||||
return ContextRecord(
|
||||
code=code,
|
||||
session_id=info.session_id,
|
||||
is_subagent=info.is_subagent,
|
||||
parent_session_id=info.parent_id,
|
||||
parent_code=parent_code,
|
||||
created_workstation=created_workstation,
|
||||
title=info.title,
|
||||
markdown_file=md_rel,
|
||||
raw_file=raw_rel,
|
||||
modified_at=info.modified_at,
|
||||
)
|
||||
|
||||
def _register(self, record: ContextRecord) -> None:
|
||||
if record.code in self.contexts and self.contexts[record.code].session_id != record.session_id:
|
||||
raise ValueError(f"Context code already used: {record.code}")
|
||||
self.contexts[record.code] = record
|
||||
self._by_session[record.session_id] = record
|
||||
|
||||
def update_markdown_path(self, record: ContextRecord, info: SessionInfo) -> None:
|
||||
"""Keep stable markdown path; refresh slug segment if title changed and not manual."""
|
||||
export_root = self.export_root
|
||||
old_path = export_root / record.markdown_file
|
||||
new_name = markdown_filename(record.code, info.title, slug_max=self.config.slug_max_length)
|
||||
if info.is_subagent:
|
||||
new_rel = f"markdown/subagents/{new_name}"
|
||||
else:
|
||||
new_rel = f"markdown/{new_name}"
|
||||
|
||||
if old_path.exists() and old_path != export_root / new_rel:
|
||||
if record.markdown_file != new_rel:
|
||||
(export_root / new_rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
old_path.rename(export_root / new_rel)
|
||||
record.markdown_file = new_rel
|
||||
record.title = info.title
|
||||
|
||||
def set_code(self, session_or_code: str, new_code: str) -> ContextRecord:
|
||||
new_code = validate_context_code(new_code)
|
||||
if new_code in self.contexts:
|
||||
raise ValueError(f"Context code already exists: {new_code}")
|
||||
|
||||
record = self.get_by_session(session_or_code)
|
||||
if record is None:
|
||||
record = self.get_by_code(session_or_code)
|
||||
if record is None:
|
||||
raise ValueError(f"Unknown session or context code: {session_or_code}")
|
||||
|
||||
old_code = record.code
|
||||
old_md = self.export_root / record.markdown_file
|
||||
record.code = new_code
|
||||
record.manual_code = True
|
||||
new_name = markdown_filename(new_code, record.title, slug_max=self.config.slug_max_length)
|
||||
if record.is_subagent:
|
||||
record.markdown_file = f"markdown/subagents/{new_name}"
|
||||
else:
|
||||
record.markdown_file = f"markdown/{new_name}"
|
||||
new_md = self.export_root / record.markdown_file
|
||||
del self.contexts[old_code]
|
||||
self.contexts[new_code] = record
|
||||
self._by_session[record.session_id] = record
|
||||
if old_md.exists() and old_md != new_md:
|
||||
new_md.parent.mkdir(parents=True, exist_ok=True)
|
||||
old_md.rename(new_md)
|
||||
self.save()
|
||||
return record
|
||||
|
||||
def codes_matching(
|
||||
self, *, pattern: str | None = None, explicit: str | None = None
|
||||
) -> list[str]:
|
||||
all_codes = list(self.contexts.keys())
|
||||
return filter_context_codes(all_codes, pattern, explicit)
|
||||
|
||||
def sessions_matching(
|
||||
self, *, pattern: str | None = None, explicit: str | None = None
|
||||
) -> list[ContextRecord]:
|
||||
codes = self.codes_matching(pattern=pattern, explicit=explicit)
|
||||
return [self.contexts[c] for c in codes]
|
||||
|
||||
def session_ids_matching(
|
||||
self, *, pattern: str | None = None, explicit: str | None = None
|
||||
) -> set[str]:
|
||||
return {r.session_id for r in self.sessions_matching(pattern=pattern, explicit=explicit)}
|
||||
|
||||
def cleanup_legacy_markdown(self) -> list[str]:
|
||||
removed: list[str] = []
|
||||
for folder in (self.export_root / "markdown", self.export_root / "markdown" / "subagents"):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
for path in folder.glob("*.md"):
|
||||
if is_uuid_name(path.stem):
|
||||
path.unlink()
|
||||
removed.append(str(path.relative_to(self.export_root)))
|
||||
return removed
|
||||
@@ -1,31 +1,40 @@
|
||||
# CAM configuration example
|
||||
# Version: 0.0.1
|
||||
# Version: 0.1.0
|
||||
#
|
||||
# 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"
|
||||
# Workstation index for context codes (w1-1, w1.git.001)
|
||||
index: "w1"
|
||||
|
||||
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"
|
||||
# transcripts_dir: "~/.cursor/projects/<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
|
||||
|
||||
context:
|
||||
# Include workstation index in auto codes: w1-1 (true) vs 1 (false)
|
||||
prefix_workstation: true
|
||||
# Auto code for new main sessions: {ws}=workstation.index, {seq}=counter
|
||||
auto_format: "{ws}-{seq}"
|
||||
# Auto code for subagents: {parent}=parent code, {seq}=counter
|
||||
subagent_format: "{parent}.{seq}"
|
||||
slug_max_length: 50
|
||||
# Remove legacy UUID-named markdown files on export
|
||||
cleanup_legacy_files: true
|
||||
|
||||
registry:
|
||||
file: "registry.yaml"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
# macOS workstation example for CAM
|
||||
# Copy: cp config/workstations/macos.example.yml config/workstations/local.yml
|
||||
# macOS workstation example
|
||||
# cp config/workstations/macos.example.yml config/workstations/local.yml
|
||||
|
||||
workstation:
|
||||
id: "macos-dev"
|
||||
label: "macOS development machine"
|
||||
index: "w1"
|
||||
|
||||
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
|
||||
|
||||
context:
|
||||
prefix_workstation: true
|
||||
auto_format: "{ws}-{seq}"
|
||||
subagent_format: "{parent}.{seq}"
|
||||
|
||||
registry:
|
||||
file: "registry.yaml"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 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).
|
||||
# CRM3-26 — macOS workstation nt-041
|
||||
# cp config/workstations/nt-041.example.yml config/workstations/local.yml
|
||||
|
||||
workstation:
|
||||
id: "nt-041"
|
||||
label: "nt-041 macOS"
|
||||
index: "w1"
|
||||
|
||||
project:
|
||||
root: "/Users/you/projects/crm3-26"
|
||||
@@ -16,3 +16,13 @@ export:
|
||||
|
||||
import:
|
||||
overwrite_existing: false
|
||||
|
||||
context:
|
||||
prefix_workstation: true
|
||||
auto_format: "{ws}-{seq}"
|
||||
subagent_format: "{parent}.{seq}"
|
||||
slug_max_length: 50
|
||||
cleanup_legacy_files: true
|
||||
|
||||
registry:
|
||||
file: "registry.yaml"
|
||||
|
||||
Reference in New Issue
Block a user