diff --git a/CHANGELOG.md b/CHANGELOG.md index 4468ab9..f685b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,33 +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/). - -## [Unreleased] - -### Added - -- `USAGE.md` — step-by-step workflow (setup, export, import, daily sync between workstations) - -## [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 diff --git a/README.md b/README.md index 78f885b..264e1d0 100644 --- a/README.md +++ b/README.md @@ -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//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 diff --git a/USAGE.md b/USAGE.md index b11f6f6..a0cbb16 100644 --- a/USAGE.md +++ b/USAGE.md @@ -3,179 +3,179 @@ Step-by-step order for moving Cursor agent context between workstations. **Repository:** -**Version:** 0.0.1 (in development) +**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 into the project - -From the **consumer project root** (e.g. `crm3-migration`): +### 1. Clone CAM ```bash git clone https://git.p7net.ru/tools/cam.git tools/cam -cd tools/cam -pip install -r requirements.txt +pip install -r tools/cam/requirements.txt ``` -### 2. Create a local workstation config +### 2. Workstation config ```bash -cp config/workstations/macos.example.yml config/workstations/local.yml -# Windows: cp config/workstations/windows.example.yml config/workstations/local.yml +cp tools/cam/config/workstations/macos.example.yml tools/cam/config/workstations/local.yml ``` -Edit `config/workstations/local.yml`: +Required fields: -| Key | Set to | -|-----|--------| -| `workstation.id` | Short ID for this PC (e.g. `nt-041`, `office-pc`) | -| `project.root` | **Absolute** path to the project opened in Cursor | -| `export.output_dir` | Usually `docs/cursor_agents` | -| `export.index_language` | `en` or `ru` | +```yaml +workstation: + id: "nt-041" + index: "w1" # w1, w2, … — prefix for auto codes -`local.yml` is **gitignored** — never commit real paths to the public CAM repo. +context: + prefix_workstation: true # w1-1 (true) or 1 (false) + auto_format: "{ws}-{seq}" + subagent_format: "{parent}.{seq}" -### 3. Verify paths +registry: + file: "registry.yaml" +``` + +### 3. Verify ```bash -python cam.py -c config/workstations/local.yml paths +python tools/cam/cam.py -c tools/cam/config/workstations/local.yml paths ``` -Check `transcripts_dir` points to your Cursor `agent-transcripts` folder. - --- -## Before leaving a workstation (save context) - -Run from the **project root**: +## Before leaving a workstation ```bash -source .venv-nt-041/bin/activate # or your project venv +# 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-*' ``` -This updates in the project: - -- `docs/cursor_agents/raw/` — JSONL transcripts -- `docs/cursor_agents/markdown/` — readable exports -- `docs/cursor_agents/manifest.json`, `INDEX.md` - -Commit and push the **consumer project** repository: +Commit project archive: ```bash git add docs/cursor_agents/ -git commit -m "Export Cursor agent history from " +git commit -m "Export Cursor agent contexts (w1)" git push ``` --- -## On a new workstation (restore context) - -### 1. Clone the consumer project - -```bash -git clone https://git.p7net.ru/rhana/crm3-migration.git crm3-26 -cd crm3-26 -``` - -See the project’s `docs/meta/REPOSITORIES.md` for other required clones (configs, `tools/run_bsl`, etc.). - -### 2. Set up CAM (one-time on this PC) - -```bash -git clone https://git.p7net.ru/tools/cam.git tools/cam -cp tools/cam/config/workstations/macos.example.yml tools/cam/config/workstations/local.yml -# edit project.root to this machine’s absolute path -python tools/cam/cam.py -c tools/cam/config/workstations/local.yml paths -``` - -### 3. Import into Cursor (optional — chat sidebar) - -**Close Cursor completely**, then: +## 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-*' ``` -Re-open Cursor. Existing local files are **not** overwritten unless `import.overwrite_existing: true` in config. - -### 4. Continue work with the agent (recommended) - -In a new Cursor Agent chat: - -``` -@docs/cursor_agents/INDEX.md -Continue work on . Context was exported from workstation . -``` - -This works reliably across machines; UI import is best-effort. +Close Cursor before import. For new agent work, prefer `@markdown/w1-11--….md`. --- -## Daily cycle (two-way sync) +## Manual context codes -``` -Workstation A Workstation B -───────────── ───────────── - export git pull - git commit + push ──────► import (optional) - work… - git pull ◄────── export - import (optional) git commit + push - work… - export … +```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 ``` -| Step | Command | Direction | -|------|---------|-----------| -| **export** | `python tools/cam/cam.py -c …/local.yml export` | Cursor local → project archive | -| **import** | `python tools/cam/cam.py -c …/local.yml import` | project archive → Cursor local | +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 -All commands use `-c` **before** the subcommand: +| 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 | -```bash -python tools/cam/cam.py -c tools/cam/config/workstations/local.yml paths -python tools/cam/cam.py -c tools/cam/config/workstations/local.yml export -python tools/cam/cam.py -c tools/cam/config/workstations/local.yml list -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 -``` +Flags: `--context CODE`, `--mask PATTERN` (glob: `w1-*`, `*.git.*`). --- -## CRM3-26 / crm3-migration example +## Archive layout (0.1.0) -```bash -# nt-041 (macOS) — template -cp tools/cam/config/workstations/nt-041.example.yml tools/cam/config/workstations/local.yml -# set project.root: /Users/you/projects/crm3-26 - -python tools/cam/cam.py -c tools/cam/config/workstations/local.yml export -git add docs/cursor_agents/ && git commit -m "Export Cursor agent history" && git push ``` - ---- - -## Troubleshooting - -| Problem | Check | -|---------|--------| -| `Transcripts folder not found` | Open project in Cursor once; run `paths`; fix `project.root` | -| Wrong slug / empty transcripts | `project.root` must match the path Cursor uses exactly | -| Import skips files | Set `import.overwrite_existing: true` or delete old local transcripts | -| Chats not in sidebar after import | Use `@docs/cursor_agents/INDEX.md` — UI restore is not guaranteed | +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 configs with secrets to `cam.git`. -- Agent transcripts may contain business data — treat `docs/cursor_agents/` like project documentation in your private migration repo. +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. diff --git a/__version__.py b/__version__.py index 0866eba..e17892a 100644 --- a/__version__.py +++ b/__version__.py @@ -1,4 +1,4 @@ """CAM (Cursor Agents Manager) version.""" -__version__ = "0.0.1" +__version__ = "0.1.0" __status__ = "in development" diff --git a/cam.py b/cam.py index 1dcc648..e537218 100644 --- a/cam.py +++ b/cam.py @@ -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_label: {config.workstation_label}") - print(f"project_root: {config.project_root}") - print(f"cursor_project_slug:{config.cursor_project_slug}") - print(f"transcripts_dir: {config.transcripts_dir}") - print(f"export_dir: {config.export_dir}") - print(f"index_language: {config.index_language}") - print(f"config_file: {config.source_path}") + 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 diff --git a/cam/config.py b/cam/config.py index 4573f18..029e3ee 100644 --- a/cam/config.py +++ b/cam/config.py @@ -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)), ) diff --git a/cam/export.py b/cam/export.py index 1a04b16..eb7b2ec 100644 --- a/cam/export.py +++ b/cam/export.py @@ -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,30 @@ 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 (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 +248,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]) diff --git a/cam/import_transcripts.py b/cam/import_transcripts.py index eefe9a3..07ac0cf 100644 --- a/cam/import_transcripts.py +++ b/cam/import_transcripts.py @@ -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 diff --git a/cam/markdown.py b/cam/markdown.py index 6765428..ee838a7 100644 --- a/cam/markdown.py +++ b/cam/markdown.py @@ -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( diff --git a/cam/naming.py b/cam/naming.py new file mode 100644 index 0000000..8799cb8 --- /dev/null +++ b/cam/naming.py @@ -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)) diff --git a/cam/registry.py b/cam/registry.py new file mode 100644 index 0000000..0f7654c --- /dev/null +++ b/cam/registry.py @@ -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 diff --git a/config/config.example.yml b/config/config.example.yml index 0310537..771611f 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,31 +1,40 @@ # CAM configuration example -# Version: 0.0.1 +# Version: 0.1.0 # # Copy to config/workstations/.yml or config/workstations/local.yml # Do not commit workstation-specific files with real paths to the public CAM repo. workstation: - # Short identifier for manifest metadata (no secrets) id: "example" label: "Example workstation" + # 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//agent-transcripts" + # transcripts_dir: "~/.cursor/projects//agent-transcripts" export: - # Relative to project.root unless absolute output_dir: "docs/cursor_agents" - # INDEX.md language: en | ru index_language: "en" import: - # When false, existing Cursor transcript files are not overwritten overwrite_existing: false + +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" diff --git a/config/workstations/macos.example.yml b/config/workstations/macos.example.yml index 0963cfb..13ea512 100644 --- a/config/workstations/macos.example.yml +++ b/config/workstations/macos.example.yml @@ -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" diff --git a/config/workstations/nt-041.example.yml b/config/workstations/nt-041.example.yml index c4161e3..d225eef 100644 --- a/config/workstations/nt-041.example.yml +++ b/config/workstations/nt-041.example.yml @@ -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"