3 Commits

Author SHA1 Message Date
mk da351ba770 Fix INDEX message counts after partial export.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 09:44:17 +03:00
mk 2cab3c2b02 Release v0.1.0: context codes and readable archive file names.
- registry.yaml maps codes (w1-1, w1.git.001) to sessions and stable paths
- Markdown files named {code}--{slug}.md for Cursor @ picker
- Selective export/import via --context and --mask
- cam code list|set|show for manual code assignment
- Re-export updates same file per context code

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 09:41:57 +03:00
mk ae61eb9c9d Add USAGE.md with step-by-step workflow for workstation handoff.
Link from README; document export/import cycle and troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:09:17 +03:00
14 changed files with 1054 additions and 150 deletions
+22 -16
View File
@@ -2,27 +2,33 @@
All notable changes to **CAM (Cursor Agents Manager)** are documented in this file. 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.1.0] - 2026-07-13
## [0.0.1] - 2026-07-10
### Status ### Status
Initial release. **In development.** **In development.** Context registry and readable file names.
### Added ### Added
- CLI `cam.py` with subcommands: `export`, `import`, `list`, `paths` - **Context codes** (`w1-1`, `w2-110`, manual `w1.git.001`, …) stored in `registry.yaml`
- Export Cursor `agent-transcripts` to a project archive: - **Readable markdown files** for Cursor `@` picker: `{code}--{topic-slug}.md`
- `raw/` — original `.jsonl` files - **Stable paths** — re-export updates the same file for a given context code
- `markdown/` — readable session exports - **Workstation index** (`workstation.index: w1`) in config; optional `prefix_workstation: false`
- `manifest.json` — machine-readable registry - **Selective export/import** via `--context CODE` and `--mask PATTERN` (e.g. `w1-*`, `w1.git.*`)
- `INDEX.md` — session index with links - **`cam code`** subcommands: `list`, `set`, `show` for manual code assignment
- Import archived `raw/` transcripts back into Cursor `agent-transcripts` folder - Auto-migration from legacy UUID-based `manifest.json` on first export
- YAML workstation configuration (`config/config.example.yml`, OS-specific examples) - Legacy UUID markdown cleanup (`context.cleanup_legacy_files`)
- Auto-detection of Cursor transcripts path from project root - Subagent codes: `{parent}.{seq}` (e.g. `w1-11.1`)
- `export.index_language`: `en` or `ru` for generated Markdown
- `import.overwrite_existing` safety flag (default: `false`)
- `--dry-run` for import
### 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 [0.0.1]: https://git.p7net.ru/tools/cam/-/tags/v0.0.1
+16 -2
View File
@@ -1,6 +1,6 @@
# CAM — Cursor Agents Manager # CAM — Cursor Agents Manager
**Version:** 0.0.1 **Version:** 0.1.0
**Status:** in development **Status:** in development
**License:** [MIT](LICENSE) **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 1. **Export** — copy transcripts into `docs/cursor_agents/` (or another folder) as JSONL + Markdown + index
2. **Commit** — push the archive with your project 2. **Commit** — push the archive with your project
3. **Import** (optional) — restore `.jsonl` files into Cursor on another machine 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 ## Requirements
@@ -28,6 +40,8 @@ pip install -r requirements.txt
## Quick start ## 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 ### 1. Clone CAM into your project
```bash ```bash
+181
View File
@@ -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
View File
@@ -1,4 +1,4 @@
"""CAM (Cursor Agents Manager) version.""" """CAM (Cursor Agents Manager) version."""
__version__ = "0.0.1" __version__ = "0.1.0"
__status__ = "in development" __status__ = "in development"
+147 -32
View File
@@ -14,7 +14,8 @@ if str(CAM_ROOT) not in sys.path:
from __version__ import __status__, __version__ # noqa: E402 from __version__ import __status__, __version__ # noqa: E402
from cam.config import load_config, resolve_config_path # noqa: E402 from cam.config import load_config, resolve_config_path # noqa: E402
from cam.export import export_transcripts # 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: 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" 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: def cmd_paths(config_path: Path) -> int:
config = load_config(config_path) config = load_config(config_path)
print(f"workstation_id: {config.workstation_id}") print(f"workstation_id: {config.workstation_id}")
print(f"workstation_index: {config.workstation_index}")
print(f"workstation_label: {config.workstation_label}") print(f"workstation_label: {config.workstation_label}")
print(f"project_root: {config.project_root}") print(f"project_root: {config.project_root}")
print(f"cursor_project_slug: {config.cursor_project_slug}") print(f"cursor_project_slug: {config.cursor_project_slug}")
print(f"transcripts_dir: {config.transcripts_dir}") print(f"transcripts_dir: {config.transcripts_dir}")
print(f"export_dir: {config.export_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"index_language: {config.index_language}")
print(f"auto_code_format: {config.auto_code_format}")
print(f"config_file: {config.source_path}") print(f"config_file: {config.source_path}")
return 0 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) 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( print(
f"Exported {main_count} main sessions and {sub_count} subagents " f"Exported {main_count} main sessions and {sub_count} subagents "
f"to {config.export_dir}" f"to {config.export_dir}{label}"
) )
return 0 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) 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:" mode = "Dry-run:" if dry_run else "Imported:"
label = ""
if context or context_mask:
label = f" filter={context or context_mask}"
print( print(
f"{mode} {result.copied} file(s) to {config.transcripts_dir}; " f"{mode} {result.copied} file(s) to {config.transcripts_dir}; "
f"skipped {result.skipped} existing" f"skipped {result.skipped} existing{label}"
) )
return 0 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) config = load_config(config_path)
sessions = list_archive_sessions(config.export_dir) rows = list_archive_contexts(config.export_dir, config, context_mask=context_mask)
if not sessions: if not rows:
print(f"No sessions in {config.export_dir / 'raw'}") print(f"No contexts in {config.export_dir}")
return 0 return 0
for session_id, parent_id, path in sessions: print("CODE\tSESSION_ID\tTITLE\tRAW")
parent = parent_id or "-" for code, session_id, title, raw in rows:
print(f"{session_id}\tparent={parent}\t{path}") raw_flag = "yes" if raw else "missing"
print(f"Total: {len(sessions)}") 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 return 0
@@ -101,27 +188,42 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("paths", help="Show resolved paths from config").set_defaults( sub.add_parser("paths", help="Show resolved paths from config").set_defaults(
handler="paths" handler="paths"
) )
sub.add_parser("export", help="Export Cursor transcripts to project archive").set_defaults(
handler="export" export_parser = sub.add_parser("export", help="Export Cursor transcripts to project archive")
) _add_context_args(export_parser)
sub.add_parser("list", help="List sessions in export archive").set_defaults(handler="list") 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_parser = sub.add_parser(
"import", "import",
help="Import archived transcripts into Cursor agent-transcripts folder", help="Import archived transcripts into Cursor agent-transcripts folder",
) )
import_parser.add_argument( import_parser.add_argument("--dry-run", action="store_true")
"--dry-run", import_parser.add_argument("--source", type=Path)
action="store_true", _add_context_args(import_parser)
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") 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 return parser
@@ -139,11 +241,24 @@ def main(argv: list[str] | None = None) -> int:
if args.handler == "paths": if args.handler == "paths":
return cmd_paths(config_path) return cmd_paths(config_path)
if args.handler == "export": if args.handler == "export":
return cmd_export(config_path) return cmd_export(config_path, args.context, args.mask)
if args.handler == "list": if args.handler == "list":
return cmd_list(config_path) return cmd_list(config_path, args.mask)
if args.handler == "import": 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: except (FileNotFoundError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr) print(f"Error: {exc}", file=sys.stderr)
return 1 return 1
+22
View File
@@ -15,6 +15,7 @@ from cam.paths import resolve_export_dir, resolve_transcripts_dir
class CamConfig: class CamConfig:
workstation_id: str workstation_id: str
workstation_label: str workstation_label: str
workstation_index: str
project_root: Path project_root: Path
project_name: str project_name: str
transcripts_dir: Path transcripts_dir: Path
@@ -22,6 +23,12 @@ class CamConfig:
index_language: str index_language: str
overwrite_existing: bool overwrite_existing: bool
source_path: Path 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 @property
def cursor_project_slug(self) -> str: def cursor_project_slug(self) -> str:
@@ -53,9 +60,13 @@ def load_config(path: Path) -> CamConfig:
cursor = raw.get("cursor") or {} cursor = raw.get("cursor") or {}
export = raw.get("export") or {} export = raw.get("export") or {}
import_cfg = raw.get("import") 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): 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") 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() project_root = Path(str(project.get("root", ""))).expanduser().resolve()
if not project_root.is_dir(): if not project_root.is_dir():
@@ -66,9 +77,14 @@ def load_config(path: Path) -> CamConfig:
if index_language not in {"en", "ru"}: if index_language not in {"en", "ru"}:
raise ValueError("export.index_language must be 'en' or '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( return CamConfig(
workstation_id=str(workstation.get("id", "unknown")), workstation_id=str(workstation.get("id", "unknown")),
workstation_label=str(workstation.get("label", workstation.get("id", "unknown"))), workstation_label=str(workstation.get("label", workstation.get("id", "unknown"))),
workstation_index=ws_index,
project_root=project_root, project_root=project_root,
project_name=str(project.get("name", project_root.name)), project_name=str(project.get("name", project_root.name)),
transcripts_dir=resolve_transcripts_dir( transcripts_dir=resolve_transcripts_dir(
@@ -80,6 +96,12 @@ def load_config(path: Path) -> CamConfig:
index_language=index_language, index_language=index_language,
overwrite_existing=bool(import_cfg.get("overwrite_existing", False)), overwrite_existing=bool(import_cfg.get("overwrite_existing", False)),
source_path=path, 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
View File
@@ -10,54 +10,68 @@ from typing import Any
from cam.config import CamConfig from cam.config import CamConfig
from cam.markdown import records_to_markdown from cam.markdown import records_to_markdown
from cam.registry import ContextRegistry
from cam.transcripts import SessionInfo, iter_transcript_files, parse_transcript from cam.transcripts import SessionInfo, iter_transcript_files, parse_transcript
def _write_index( def _write_index(
sessions: list[SessionInfo], registry: ContextRegistry,
output_root: Path, output_root: Path,
exported_at: str, exported_at: str,
transcripts_root: Path, transcripts_root: Path,
language: str, language: str,
cam_command: str, cam_command: str,
*,
filter_label: str | None = None,
message_counts: dict[str, int] | None = None,
) -> None: ) -> None:
main_sessions = [s for s in sessions if not s.is_subagent] main_records = [r for r in registry.contexts.values() if not r.is_subagent]
subagents = [s for s in sessions if s.is_subagent] sub_records = [r for r in registry.contexts.values() if r.is_subagent]
main_sessions.sort(key=lambda s: s.modified_at, reverse=True) main_records.sort(key=lambda r: r.modified_at, reverse=True)
if language == "ru": if language == "ru":
title = "История сессий Cursor Agent" title = "История сессий Cursor Agent"
intro = f"Экспорт от **{exported_at}**. Источник: `{transcripts_root}`." intro = f"Экспорт от **{exported_at}**. Источник: `{transcripts_root}`."
if filter_label:
intro += f" Фильтр: `{filter_label}`."
structure = "Структура каталога:" structure = "Структура каталога:"
raw_desc = "`raw/` — оригинальные `.jsonl` транскрипты" raw_desc = "`raw/` — оригинальные `.jsonl` (по session UUID)"
md_desc = "`markdown/` — читаемые версии диалогов" md_desc = "`markdown/` — читаемые файлы `{код}--{тема}.md` для `@` в Cursor"
manifest_desc = "`manifest.json` — машиночитаемый реестр сессий" registry_desc = "`registry.yaml` — коды контекстов (w1-1, w1.git.001, …)"
manifest_desc = "`manifest.json` — машиночитаемый реестр"
reexport = "Повторный экспорт:" reexport = "Повторный экспорт:"
sessions_heading = "Основные сессии" sessions_heading = "Основные сессии"
subagents_heading = "Subagents" subagents_heading = "Subagents"
parent_label = "Родитель" parent_label = "Родитель"
date_col = "Дата (UTC)" date_col = "Дата (UTC)"
code_col = "Код"
title_col = "Заголовок" title_col = "Заголовок"
ws_col = "WS"
else: else:
title = "Cursor Agent session history" title = "Cursor Agent session history"
intro = f"Exported at **{exported_at}**. Source: `{transcripts_root}`." intro = f"Exported at **{exported_at}**. Source: `{transcripts_root}`."
if filter_label:
intro += f" Filter: `{filter_label}`."
structure = "Directory layout:" structure = "Directory layout:"
raw_desc = "`raw/` — original `.jsonl` transcripts" raw_desc = "`raw/` — original `.jsonl` (by session UUID)"
md_desc = "`markdown/` — readable conversation exports" md_desc = "`markdown/` — readable `{code}--{topic}.md` files for Cursor `@`"
manifest_desc = "`manifest.json` — machine-readable session registry" registry_desc = "`registry.yaml` — context codes (w1-1, w1.git.001, …)"
manifest_desc = "`manifest.json` — machine-readable registry"
reexport = "Re-export:" reexport = "Re-export:"
sessions_heading = "Main sessions" sessions_heading = "Main sessions"
subagents_heading = "Subagents" subagents_heading = "Subagents"
parent_label = "Parent" parent_label = "Parent"
date_col = "Date (UTC)" date_col = "Date (UTC)"
code_col = "Code"
title_col = "Title" title_col = "Title"
ws_col = "WS"
lines = [ lines = [
"---", "---",
f"title: {title}", f"title: {title}",
f"exported_at: {exported_at}", f"exported_at: {exported_at}",
f"sessions_total: {len(main_sessions)}", f"contexts_total: {len(main_records)}",
f"subagents_total: {len(subagents)}", f"subagents_total: {len(sub_records)}",
"---", "---",
"", "",
f"# {title}", f"# {title}",
@@ -68,6 +82,7 @@ def _write_index(
"", "",
f"- {raw_desc}", f"- {raw_desc}",
f"- {md_desc}", f"- {md_desc}",
f"- {registry_desc}",
f"- {manifest_desc}", f"- {manifest_desc}",
"", "",
reexport, reexport,
@@ -78,77 +93,118 @@ def _write_index(
"", "",
f"## {sessions_heading}", 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]] = {} subs_by_parent_code: dict[str, list] = {}
for sub in subagents: for sub in sub_records:
key = sub.parent_id or "unknown" key = sub.parent_code or sub.parent_session_id or "unknown"
subagents_by_parent.setdefault(key, []).append(sub) subs_by_parent_code.setdefault(key, []).append(sub)
for session in main_sessions: for record in main_records:
sub_count = len(subagents_by_parent.get(session.session_id, [])) sub_count = len(subs_by_parent_code.get(record.code, []))
rel_md = f"markdown/{session.session_id}.md" rel_md = record.markdown_file
lines.append( lines.append(
f"| {session.modified_at} | [{session.title}]({rel_md}) | `{session.session_id}` | " f"| {record.modified_at} | [`{record.code}`]({rel_md}) | "
f"{session.message_count} | {sub_count} |" f"{record.title} | `{record.created_workstation}` | "
f"{(message_counts or {}).get(record.session_id, 0)} | {sub_count} |"
) )
lines.extend(["", f"## {subagents_heading}", ""]) lines.extend(["", f"## {subagents_heading}", ""])
for parent_id, subs in sorted(subagents_by_parent.items()): for parent_code, subs in sorted(subs_by_parent_code.items()):
lines.append(f"### {parent_label} `{parent_id}`") lines.append(f"### {parent_label} `{parent_code}`")
lines.append("") lines.append("")
for sub in sorted(subs, key=lambda s: s.modified_at, reverse=True): for sub in sorted(subs, key=lambda s: s.modified_at, reverse=True):
rel_md = f"markdown/subagents/{sub.session_id}.md"
lines.append( 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("") lines.append("")
(output_root / "INDEX.md").write_text("\n".join(lines), encoding="utf-8") (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 transcripts_root = config.transcripts_dir
output_root = config.export_dir output_root = config.export_dir
if not transcripts_root.exists(): if not transcripts_root.exists():
raise FileNotFoundError(f"Transcripts folder not found: {transcripts_root}") 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") exported_at = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
raw_root = output_root / "raw" raw_root = output_root / "raw"
md_root = output_root / "markdown" md_root = output_root / "markdown"
md_sub_root = md_root / "subagents" md_sub_root = md_root / "subagents"
for folder in (raw_root, md_root, md_sub_root): for folder in (raw_root, md_root, md_sub_root):
folder.mkdir(parents=True, exist_ok=True) 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] = [] sessions: list[SessionInfo] = []
manifest: list[dict[str, Any]] = [] 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): for path in iter_transcript_files(transcripts_root):
records, info = parse_transcript(path) 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: if info.is_subagent:
raw_dest = raw_root / info.parent_id / "subagents" / path.name raw_dest = raw_root / info.parent_id / "subagents" / path.name
md_dest = md_sub_root / f"{info.session_id}.md"
else: else:
raw_dest = raw_root / path.name 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) raw_dest.parent.mkdir(parents=True, exist_ok=True)
md_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, raw_dest) shutil.copy2(path, raw_dest)
md_dest.write_text( 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", 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( manifest.append(
{ {
"context_code": record.code,
"session_id": info.session_id, "session_id": info.session_id,
"parent_id": info.parent_id, "parent_id": info.parent_id,
"parent_code": record.parent_code,
"is_subagent": info.is_subagent, "is_subagent": info.is_subagent,
"title": info.title, "title": info.title,
"first_user_query": info.first_user_query, "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, "assistant_messages": info.assistant_messages,
"tool_uses": info.tool_uses, "tool_uses": info.tool_uses,
"source_path": str(path), "source_path": str(path),
"raw_export": str(raw_dest.relative_to(output_root)), "raw_export": record.raw_file,
"markdown_export": str(md_dest.relative_to(output_root)), "markdown_export": record.markdown_file,
"created_workstation": record.created_workstation,
"workstation_id": config.workstation_id, "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) 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( (output_root / "manifest.json").write_text(
json.dumps( json.dumps(
{ {
"exported_at": exported_at, "exported_at": exported_at,
"transcripts_source": str(transcripts_root), "transcripts_source": str(transcripts_root),
"workstation_id": config.workstation_id, "workstation_id": config.workstation_id,
"workstation_index": config.workstation_index,
"workstation_label": config.workstation_label, "workstation_label": config.workstation_label,
"project_root": str(config.project_root), "project_root": str(config.project_root),
"filter": filter_label,
"sessions_total": len([s for s in sessions if not s.is_subagent]), "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]), "subagents_total": len([s for s in sessions if s.is_subagent]),
"sessions": manifest, "sessions": manifest,
@@ -183,12 +262,14 @@ def export_transcripts(config: CamConfig, cam_command: str) -> tuple[int, int]:
encoding="utf-8", encoding="utf-8",
) )
_write_index( _write_index(
sessions, registry,
output_root, output_root,
exported_at, exported_at,
transcripts_root, transcripts_root,
config.index_language, config.index_language,
cam_command, cam_command,
filter_label=filter_label,
message_counts=message_counts,
) )
main_count = len([s for s in sessions if not s.is_subagent]) main_count = len([s for s in sessions if not s.is_subagent])
+48 -37
View File
@@ -7,7 +7,8 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from cam.config import CamConfig 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 @dataclass
@@ -26,6 +27,8 @@ def import_transcripts(
*, *,
dry_run: bool = False, dry_run: bool = False,
source: Path | None = None, source: Path | None = None,
context: str | None = None,
context_mask: str | None = None,
) -> ImportResult: ) -> ImportResult:
archive_root = (source or config.export_dir).resolve() archive_root = (source or config.export_dir).resolve()
raw_root = archive_root / "raw" raw_root = archive_root / "raw"
@@ -34,22 +37,32 @@ def import_transcripts(
if not raw_root.is_dir(): if not raw_root.is_dir():
raise FileNotFoundError(f"Archive raw/ folder not found: {raw_root}") 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() result = ImportResult()
for path in sorted(raw_root.rglob("*.jsonl")): for record in registry.contexts.values():
rel = path.relative_to(raw_root) if record.session_id not in allowed_ids:
parts = rel.parts continue
if len(parts) == 1: raw_path = archive_root / record.raw_file
session_id = path.stem if not raw_path.is_file():
parent_id = None
elif len(parts) == 3 and parts[1] == "subagents":
parent_id = parts[0]
session_id = path.stem
else:
continue continue
dest = cursor_transcript_path(transcripts_root, session_id, parent_id) dest = cursor_transcript_path(
result.paths.append(str(dest)) transcripts_root,
record.session_id,
record.parent_session_id,
)
result.paths.append(f"{record.code} -> {dest}")
if dest.exists() and not config.overwrite_existing: if dest.exists() and not config.overwrite_existing:
result.skipped += 1 result.skipped += 1
@@ -60,32 +73,30 @@ def import_transcripts(
continue continue
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest) shutil.copy2(raw_path, dest)
result.copied += 1 result.copied += 1
return result return result
def list_archive_sessions(archive_root: Path) -> list[tuple[str, str | None, Path]]: def list_archive_contexts(
raw_root = archive_root / "raw" archive_root: Path,
sessions: list[tuple[str, str | None, Path]] = [] config: CamConfig,
if not raw_root.is_dir(): *,
return sessions context_mask: str | None = None,
) -> list[tuple[str, str, str, Path | None]]:
for path in sorted(raw_root.rglob("*.jsonl")): registry = ContextRegistry(archive_root, config)
rel = path.relative_to(raw_root) registry.load()
parts = rel.parts rows: list[tuple[str, str, str, Path | None]] = []
if len(parts) == 1: for record in registry.sessions_matching(pattern=context_mask):
sessions.append((path.stem, None, path)) raw_path = archive_root / record.raw_file
elif len(parts) == 3 and parts[1] == "subagents": rows.append(
sessions.append((path.stem, parts[0], path)) (
return sessions record.code,
record.session_id,
record.title,
def validate_archive_layout(archive_root: Path) -> list[str]: raw_path if raw_path.is_file() else None,
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) rows.sort(key=lambda r: r[0])
if path != expected: return rows
issues.append(f"Unexpected layout for {path}")
return issues
+22 -1
View File
@@ -27,10 +27,20 @@ def block_to_markdown(block: dict[str, Any], language: str) -> str:
return f"_{block_type or 'unknown'} block_\n" 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": if language == "ru":
session_label = "Subagent" if info.is_subagent else "Сессия" session_label = "Subagent" if info.is_subagent else "Сессия"
parent_label = "Родительская сессия" parent_label = "Родительская сессия"
parent_code_label = "Код родителя"
code_label = "Код контекста"
ws_label = "Создано на рабочем месте"
updated_label = "Обновлено" updated_label = "Обновлено"
messages_label = "Сообщений" messages_label = "Сообщений"
tools_heading = "Использованные инструменты" tools_heading = "Использованные инструменты"
@@ -39,6 +49,9 @@ def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], langua
else: else:
session_label = "Subagent" if info.is_subagent else "Session" session_label = "Subagent" if info.is_subagent else "Session"
parent_label = "Parent session" parent_label = "Parent session"
parent_code_label = "Parent code"
code_label = "Context code"
ws_label = "Created on workstation"
updated_label = "Updated" updated_label = "Updated"
messages_label = "Messages" messages_label = "Messages"
tools_heading = "Tools used" 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"session_id: {info.session_id}",
f"type: {'subagent' if info.is_subagent else 'main'}", 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: if info.parent_id:
lines.append(f"parent_id: {info.parent_id}") lines.append(f"parent_id: {info.parent_id}")
lines.extend( lines.extend(
@@ -63,6 +80,10 @@ def records_to_markdown(info: SessionInfo, records: list[dict[str, Any]], langua
f"- **ID:** `{info.session_id}`", 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: if info.parent_id:
lines.append(f"- **{parent_label}:** `{info.parent_id}`") lines.append(f"- **{parent_label}:** `{info.parent_id}`")
lines.extend( lines.extend(
+67
View File
@@ -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
View File
@@ -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
+18 -9
View File
@@ -1,31 +1,40 @@
# CAM configuration example # CAM configuration example
# Version: 0.0.1 # Version: 0.1.0
# #
# Copy to config/workstations/<your-hostname>.yml or config/workstations/local.yml # 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. # Do not commit workstation-specific files with real paths to the public CAM repo.
workstation: workstation:
# Short identifier for manifest metadata (no secrets)
id: "example" id: "example"
label: "Example workstation" label: "Example workstation"
# Workstation index for context codes (w1-1, w1.git.001)
index: "w1"
project: project:
# Absolute path to the project root opened in Cursor
root: "/path/to/your/project" root: "/path/to/your/project"
name: "your-project" name: "your-project"
cursor: cursor:
# Optional override for ~/.cursor (default: user home)
# home: "~/.cursor" # home: "~/.cursor"
# Optional explicit transcripts path; if omitted, derived from project.root: # transcripts_dir: "~/.cursor/projects/<slug>/agent-transcripts"
# transcripts_dir: "~/.cursor/projects/<cursor-project-slug>/agent-transcripts"
export: export:
# Relative to project.root unless absolute
output_dir: "docs/cursor_agents" output_dir: "docs/cursor_agents"
# INDEX.md language: en | ru
index_language: "en" index_language: "en"
import: import:
# When false, existing Cursor transcript files are not overwritten
overwrite_existing: false 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"
+11 -5
View File
@@ -1,20 +1,26 @@
# macOS workstation example for CAM # macOS workstation example
# Copy: cp config/workstations/macos.example.yml config/workstations/local.yml # cp config/workstations/macos.example.yml config/workstations/local.yml
workstation: workstation:
id: "macos-dev" id: "macos-dev"
label: "macOS development machine" label: "macOS development machine"
index: "w1"
project: project:
root: "/Users/you/projects/your-project" root: "/Users/you/projects/your-project"
name: "your-project" name: "your-project"
cursor:
# home: "~/.cursor"
export: export:
output_dir: "docs/cursor_agents" output_dir: "docs/cursor_agents"
index_language: "en" index_language: "en"
import: import:
overwrite_existing: false overwrite_existing: false
context:
prefix_workstation: true
auto_format: "{ws}-{seq}"
subagent_format: "{parent}.{seq}"
registry:
file: "registry.yaml"
+13 -3
View File
@@ -1,10 +1,10 @@
# CRM3-26 project — macOS workstation nt-041 # CRM3-26 — macOS workstation nt-041
# Copy: cp config/workstations/nt-041.example.yml config/workstations/nt-041.yml # cp config/workstations/nt-041.example.yml config/workstations/local.yml
# File nt-041.yml is gitignored (local paths only).
workstation: workstation:
id: "nt-041" id: "nt-041"
label: "nt-041 macOS" label: "nt-041 macOS"
index: "w1"
project: project:
root: "/Users/you/projects/crm3-26" root: "/Users/you/projects/crm3-26"
@@ -16,3 +16,13 @@ export:
import: import:
overwrite_existing: false 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"