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>
This commit is contained in:
+22
-22
@@ -2,33 +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
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `USAGE.md` — step-by-step workflow (setup, export, import, daily sync between workstations)
|
|
||||||
|
|
||||||
## [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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -3,179 +3,179 @@
|
|||||||
Step-by-step order for moving Cursor agent context between workstations.
|
Step-by-step order for moving Cursor agent context between workstations.
|
||||||
|
|
||||||
**Repository:** <https://git.p7net.ru/tools/cam.git>
|
**Repository:** <https://git.p7net.ru/tools/cam.git>
|
||||||
**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)
|
## One-time setup (each workstation)
|
||||||
|
|
||||||
### 1. Clone CAM into the project
|
### 1. Clone CAM
|
||||||
|
|
||||||
From the **consumer project root** (e.g. `crm3-migration`):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.p7net.ru/tools/cam.git tools/cam
|
git clone https://git.p7net.ru/tools/cam.git tools/cam
|
||||||
cd tools/cam
|
pip install -r tools/cam/requirements.txt
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Create a local workstation config
|
### 2. Workstation config
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config/workstations/macos.example.yml config/workstations/local.yml
|
cp tools/cam/config/workstations/macos.example.yml tools/cam/config/workstations/local.yml
|
||||||
# Windows: cp config/workstations/windows.example.yml config/workstations/local.yml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `config/workstations/local.yml`:
|
Required fields:
|
||||||
|
|
||||||
| Key | Set to |
|
```yaml
|
||||||
|-----|--------|
|
workstation:
|
||||||
| `workstation.id` | Short ID for this PC (e.g. `nt-041`, `office-pc`) |
|
id: "nt-041"
|
||||||
| `project.root` | **Absolute** path to the project opened in Cursor |
|
index: "w1" # w1, w2, … — prefix for auto codes
|
||||||
| `export.output_dir` | Usually `docs/cursor_agents` |
|
|
||||||
| `export.index_language` | `en` or `ru` |
|
|
||||||
|
|
||||||
`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
|
```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)
|
## Before leaving a workstation
|
||||||
|
|
||||||
Run from the **project root**:
|
|
||||||
|
|
||||||
```bash
|
```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
|
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:
|
Commit project archive:
|
||||||
|
|
||||||
- `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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add docs/cursor_agents/
|
git add docs/cursor_agents/
|
||||||
git commit -m "Export Cursor agent history from <workstation-id>"
|
git commit -m "Export Cursor agent contexts (w1)"
|
||||||
git push
|
git push
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## On a new workstation (restore context)
|
## On a new workstation
|
||||||
|
|
||||||
### 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:
|
|
||||||
|
|
||||||
```bash
|
```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 --dry-run
|
||||||
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml import
|
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.
|
Close Cursor before import. For new agent work, prefer `@markdown/w1-11--….md`.
|
||||||
|
|
||||||
### 4. Continue work with the agent (recommended)
|
|
||||||
|
|
||||||
In a new Cursor Agent chat:
|
|
||||||
|
|
||||||
```
|
|
||||||
@docs/cursor_agents/INDEX.md
|
|
||||||
Continue work on <topic>. Context was exported from workstation <id>.
|
|
||||||
```
|
|
||||||
|
|
||||||
This works reliably across machines; UI import is best-effort.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Daily cycle (two-way sync)
|
## Manual context codes
|
||||||
|
|
||||||
```
|
```bash
|
||||||
Workstation A Workstation B
|
# list all codes
|
||||||
───────────── ─────────────
|
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml code list
|
||||||
export git pull
|
|
||||||
git commit + push ──────► import (optional)
|
# rename w1-11 → thematic code
|
||||||
work…
|
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml code set w1-11 w1.git.zakaz
|
||||||
git pull ◄────── export
|
|
||||||
import (optional) git commit + push
|
# show details
|
||||||
work…
|
python tools/cam/cam.py -c tools/cam/config/workstations/local.yml code show w1.git.zakaz
|
||||||
export …
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Step | Command | Direction |
|
Rules for manual codes: letters, digits, `.`, `-`, `_` (e.g. `w1.git.001`, `sync-crm3`).
|
||||||
|------|---------|-----------|
|
|
||||||
| **export** | `python tools/cam/cam.py -c …/local.yml export` | Cursor local → project archive |
|
To drop workstation prefix in **new** auto codes only:
|
||||||
| **import** | `python tools/cam/cam.py -c …/local.yml import` | project archive → Cursor local |
|
|
||||||
|
```yaml
|
||||||
|
context:
|
||||||
|
prefix_workstation: false
|
||||||
|
auto_format: "{seq}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing codes in `registry.yaml` are not renamed automatically.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Command reference
|
## 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
|
Flags: `--context CODE`, `--mask PATTERN` (glob: `w1-*`, `*.git.*`).
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
```
|
||||||
|
docs/cursor_agents/
|
||||||
---
|
├── registry.yaml # codes ↔ session UUID ↔ file paths
|
||||||
|
├── INDEX.md
|
||||||
## Troubleshooting
|
├── manifest.json
|
||||||
|
├── raw/ # JSONL by session UUID (for Cursor import)
|
||||||
| Problem | Check |
|
└── markdown/
|
||||||
|---------|--------|
|
├── w1-1--topic.md # @-friendly names
|
||||||
| `Transcripts folder not found` | Open project in Cursor once; run `paths`; fix `project.root` |
|
└── subagents/
|
||||||
| Wrong slug / empty transcripts | `project.root` must match the path Cursor uses exactly |
|
└── w1-1.1--topic.md
|
||||||
| 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Do not commit workstation configs with secrets to `cam.git`.
|
Do not commit workstation `local.yml` with secrets to public repos.
|
||||||
- Agent transcripts may contain business data — treat `docs/cursor_agents/` like project documentation in your private migration repo.
|
Agent transcripts may contain business data — keep `docs/cursor_agents/` in your private project repo.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
"""CAM (Cursor Agents Manager) version."""
|
"""CAM (Cursor Agents Manager) version."""
|
||||||
|
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.1.0"
|
||||||
__status__ = "in development"
|
__status__ = "in development"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+103
-36
@@ -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,30 @@ 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
|
||||||
(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 +248,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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Context codes and readable archive file names."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
CODE_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
||||||
|
UUID_PATTERN = re.compile(
|
||||||
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
FORBIDDEN_FILENAME = re.compile(r'[\\/:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def validate_context_code(code: str) -> str:
|
||||||
|
code = code.strip()
|
||||||
|
if not code or not CODE_PATTERN.match(code):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid context code '{code}'. "
|
||||||
|
"Use letters, digits, dots, dashes (e.g. w1-1, w1.git.001, sync-crm3)."
|
||||||
|
)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def slugify_title(title: str, max_len: int = 50) -> str:
|
||||||
|
text = " ".join(title.split())
|
||||||
|
if not text:
|
||||||
|
return "untitled"
|
||||||
|
normalized = unicodedata.normalize("NFKD", text)
|
||||||
|
cleaned = FORBIDDEN_FILENAME.sub("-", normalized)
|
||||||
|
cleaned = re.sub(r"\s+", "-", cleaned.strip())
|
||||||
|
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
|
||||||
|
if not cleaned:
|
||||||
|
cleaned = "untitled"
|
||||||
|
if len(cleaned) > max_len:
|
||||||
|
cleaned = cleaned[: max_len - 1].rstrip("-")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_filename(code: str, title: str, *, slug_max: int = 50) -> str:
|
||||||
|
slug = slugify_title(title, slug_max)
|
||||||
|
return f"{code}--{slug}.md"
|
||||||
|
|
||||||
|
|
||||||
|
def matches_context_pattern(code: str, pattern: str | None) -> bool:
|
||||||
|
if not pattern:
|
||||||
|
return True
|
||||||
|
import fnmatch
|
||||||
|
|
||||||
|
return fnmatch.fnmatchcase(code, pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_context_codes(codes: list[str], pattern: str | None, explicit: str | None) -> list[str]:
|
||||||
|
if explicit:
|
||||||
|
explicit = validate_context_code(explicit)
|
||||||
|
if pattern and not matches_context_pattern(explicit, pattern):
|
||||||
|
return []
|
||||||
|
return [explicit] if explicit in codes else []
|
||||||
|
if pattern:
|
||||||
|
return [c for c in codes if matches_context_pattern(c, pattern)]
|
||||||
|
return list(codes)
|
||||||
|
|
||||||
|
|
||||||
|
def is_uuid_name(name: str) -> bool:
|
||||||
|
return bool(UUID_PATTERN.match(name))
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
"""Context registry — stable codes and readable file names."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from cam.config import CamConfig
|
||||||
|
from cam.naming import (
|
||||||
|
filter_context_codes,
|
||||||
|
is_uuid_name,
|
||||||
|
markdown_filename,
|
||||||
|
matches_context_pattern,
|
||||||
|
validate_context_code,
|
||||||
|
)
|
||||||
|
from cam.transcripts import SessionInfo
|
||||||
|
|
||||||
|
REGISTRY_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContextRecord:
|
||||||
|
code: str
|
||||||
|
session_id: str
|
||||||
|
is_subagent: bool = False
|
||||||
|
parent_session_id: str | None = None
|
||||||
|
parent_code: str | None = None
|
||||||
|
created_workstation: str = ""
|
||||||
|
title: str = ""
|
||||||
|
markdown_file: str = ""
|
||||||
|
raw_file: str = ""
|
||||||
|
manual_code: bool = False
|
||||||
|
modified_at: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": self.code,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"is_subagent": self.is_subagent,
|
||||||
|
"parent_session_id": self.parent_session_id,
|
||||||
|
"parent_code": self.parent_code,
|
||||||
|
"created_workstation": self.created_workstation,
|
||||||
|
"title": self.title,
|
||||||
|
"markdown_file": self.markdown_file,
|
||||||
|
"raw_file": self.raw_file,
|
||||||
|
"manual_code": self.manual_code,
|
||||||
|
"modified_at": self.modified_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> ContextRecord:
|
||||||
|
return cls(
|
||||||
|
code=str(data["code"]),
|
||||||
|
session_id=str(data["session_id"]),
|
||||||
|
is_subagent=bool(data.get("is_subagent", False)),
|
||||||
|
parent_session_id=data.get("parent_session_id"),
|
||||||
|
parent_code=data.get("parent_code"),
|
||||||
|
created_workstation=str(data.get("created_workstation", "")),
|
||||||
|
title=str(data.get("title", "")),
|
||||||
|
markdown_file=str(data.get("markdown_file", "")),
|
||||||
|
raw_file=str(data.get("raw_file", "")),
|
||||||
|
manual_code=bool(data.get("manual_code", False)),
|
||||||
|
modified_at=str(data.get("modified_at", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkstationRecord:
|
||||||
|
index: str
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
next_seq: int = 1
|
||||||
|
next_subagent_seq: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"index": self.index,
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"next_seq": self.next_seq,
|
||||||
|
"next_subagent_seq": dict(self.next_subagent_seq),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> WorkstationRecord:
|
||||||
|
return cls(
|
||||||
|
index=str(data["index"]),
|
||||||
|
id=str(data.get("id", "")),
|
||||||
|
label=str(data.get("label", "")),
|
||||||
|
next_seq=int(data.get("next_seq", 1)),
|
||||||
|
next_subagent_seq={
|
||||||
|
str(k): int(v) for k, v in (data.get("next_subagent_seq") or {}).items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextRegistry:
|
||||||
|
def __init__(self, export_root: Path, config: CamConfig) -> None:
|
||||||
|
self.export_root = export_root
|
||||||
|
self.config = config
|
||||||
|
self.path = export_root / config.registry_file
|
||||||
|
self.workstations: dict[str, WorkstationRecord] = {}
|
||||||
|
self.contexts: dict[str, ContextRecord] = {}
|
||||||
|
self._by_session: dict[str, ContextRecord] = {}
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
if self.path.is_file():
|
||||||
|
with self.path.open(encoding="utf-8") as fh:
|
||||||
|
raw = yaml.safe_load(fh) or {}
|
||||||
|
self._load_raw(raw)
|
||||||
|
return
|
||||||
|
if self._migrate_from_manifest():
|
||||||
|
return
|
||||||
|
self._ensure_current_workstation()
|
||||||
|
|
||||||
|
def _load_raw(self, raw: dict[str, Any]) -> None:
|
||||||
|
self.workstations = {
|
||||||
|
k: WorkstationRecord.from_dict(v)
|
||||||
|
for k, v in (raw.get("workstations") or {}).items()
|
||||||
|
}
|
||||||
|
self.contexts = {
|
||||||
|
k: ContextRecord.from_dict(v) for k, v in (raw.get("contexts") or {}).items()
|
||||||
|
}
|
||||||
|
self._rebuild_session_index()
|
||||||
|
self._ensure_current_workstation()
|
||||||
|
|
||||||
|
def _rebuild_session_index(self) -> None:
|
||||||
|
self._by_session = {r.session_id: r for r in self.contexts.values()}
|
||||||
|
|
||||||
|
def _ensure_current_workstation(self) -> None:
|
||||||
|
ws_index = self.config.workstation_index
|
||||||
|
if ws_index not in self.workstations:
|
||||||
|
self.workstations[ws_index] = WorkstationRecord(
|
||||||
|
index=ws_index,
|
||||||
|
id=self.config.workstation_id,
|
||||||
|
label=self.config.workstation_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {
|
||||||
|
"version": REGISTRY_VERSION,
|
||||||
|
"workstations": {k: v.to_dict() for k, v in sorted(self.workstations.items())},
|
||||||
|
"contexts": {k: v.to_dict() for k, v in sorted(self.contexts.items())},
|
||||||
|
}
|
||||||
|
with self.path.open("w", encoding="utf-8") as fh:
|
||||||
|
yaml.safe_dump(payload, fh, allow_unicode=True, sort_keys=False)
|
||||||
|
|
||||||
|
def _migrate_from_manifest(self) -> bool:
|
||||||
|
manifest_path = self.export_root / "manifest.json"
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
return False
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
sessions = data.get("sessions") or []
|
||||||
|
if not sessions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._ensure_current_workstation()
|
||||||
|
ws_index = self.config.workstation_index
|
||||||
|
ordered = sorted(sessions, key=lambda s: (bool(s.get("is_subagent")), str(s.get("session_id"))))
|
||||||
|
|
||||||
|
for item in ordered:
|
||||||
|
session_id = str(item["session_id"])
|
||||||
|
if session_id in self._by_session:
|
||||||
|
continue
|
||||||
|
is_subagent = bool(item.get("is_subagent"))
|
||||||
|
parent_id = item.get("parent_id")
|
||||||
|
title = str(item.get("title") or session_id)
|
||||||
|
parent_code = None
|
||||||
|
if parent_id and parent_id in self._by_session:
|
||||||
|
parent_code = self._by_session[parent_id].code
|
||||||
|
code = self._assign_auto_code(is_subagent, parent_code)
|
||||||
|
record = self._make_record(
|
||||||
|
code=code,
|
||||||
|
info=SessionInfo(
|
||||||
|
session_id=session_id,
|
||||||
|
is_subagent=is_subagent,
|
||||||
|
parent_id=parent_id,
|
||||||
|
source_path=Path(item.get("source_path", session_id)),
|
||||||
|
title=title,
|
||||||
|
modified_at=str(item.get("modified_at", "")),
|
||||||
|
),
|
||||||
|
created_workstation=ws_index,
|
||||||
|
)
|
||||||
|
self._register(record)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_by_session(self, session_id: str) -> ContextRecord | None:
|
||||||
|
return self._by_session.get(session_id)
|
||||||
|
|
||||||
|
def get_by_code(self, code: str) -> ContextRecord | None:
|
||||||
|
return self.contexts.get(validate_context_code(code))
|
||||||
|
|
||||||
|
def _assign_auto_code(self, is_subagent: bool, parent_code: str | None) -> str:
|
||||||
|
if is_subagent:
|
||||||
|
if not parent_code:
|
||||||
|
raise ValueError("Subagent requires parent context code")
|
||||||
|
ws = self.workstations.get(self.config.workstation_index)
|
||||||
|
if ws is None:
|
||||||
|
self._ensure_current_workstation()
|
||||||
|
ws = self.workstations[self.config.workstation_index]
|
||||||
|
seq = ws.next_subagent_seq.get(parent_code, 1)
|
||||||
|
ws.next_subagent_seq[parent_code] = seq + 1
|
||||||
|
fmt = self.config.subagent_auto_format
|
||||||
|
return validate_context_code(
|
||||||
|
fmt.replace("{parent}", parent_code).replace("{seq}", str(seq))
|
||||||
|
)
|
||||||
|
|
||||||
|
ws_index = self.config.workstation_index
|
||||||
|
ws = self.workstations[ws_index]
|
||||||
|
seq = ws.next_seq
|
||||||
|
ws.next_seq += 1
|
||||||
|
fmt = self.config.auto_code_format
|
||||||
|
code = fmt.replace("{ws}", ws_index).replace("{seq}", str(seq))
|
||||||
|
if not self.config.prefix_workstation:
|
||||||
|
code = code.replace(f"{ws_index}-", "").replace(f"{ws_index}.", "")
|
||||||
|
return validate_context_code(code)
|
||||||
|
|
||||||
|
def ensure_context(self, info: SessionInfo) -> ContextRecord:
|
||||||
|
existing = self.get_by_session(info.session_id)
|
||||||
|
if existing:
|
||||||
|
existing.title = info.title or existing.title
|
||||||
|
existing.modified_at = info.modified_at
|
||||||
|
return existing
|
||||||
|
|
||||||
|
parent_code = None
|
||||||
|
if info.parent_id:
|
||||||
|
parent = self.get_by_session(info.parent_id)
|
||||||
|
if parent:
|
||||||
|
parent_code = parent.code
|
||||||
|
|
||||||
|
code = self._assign_auto_code(info.is_subagent, parent_code)
|
||||||
|
record = self._make_record(
|
||||||
|
code=code,
|
||||||
|
info=info,
|
||||||
|
created_workstation=self.config.workstation_index,
|
||||||
|
parent_code=parent_code,
|
||||||
|
)
|
||||||
|
self._register(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _make_record(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
info: SessionInfo,
|
||||||
|
created_workstation: str,
|
||||||
|
parent_code: str | None = None,
|
||||||
|
) -> ContextRecord:
|
||||||
|
md_name = markdown_filename(code, info.title, slug_max=self.config.slug_max_length)
|
||||||
|
if info.is_subagent:
|
||||||
|
md_rel = f"markdown/subagents/{md_name}"
|
||||||
|
raw_rel = (
|
||||||
|
f"raw/{info.parent_id}/subagents/{info.session_id}.jsonl"
|
||||||
|
if info.parent_id
|
||||||
|
else f"raw/subagents/{info.session_id}.jsonl"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
md_rel = f"markdown/{md_name}"
|
||||||
|
raw_rel = f"raw/{info.session_id}.jsonl"
|
||||||
|
|
||||||
|
return ContextRecord(
|
||||||
|
code=code,
|
||||||
|
session_id=info.session_id,
|
||||||
|
is_subagent=info.is_subagent,
|
||||||
|
parent_session_id=info.parent_id,
|
||||||
|
parent_code=parent_code,
|
||||||
|
created_workstation=created_workstation,
|
||||||
|
title=info.title,
|
||||||
|
markdown_file=md_rel,
|
||||||
|
raw_file=raw_rel,
|
||||||
|
modified_at=info.modified_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register(self, record: ContextRecord) -> None:
|
||||||
|
if record.code in self.contexts and self.contexts[record.code].session_id != record.session_id:
|
||||||
|
raise ValueError(f"Context code already used: {record.code}")
|
||||||
|
self.contexts[record.code] = record
|
||||||
|
self._by_session[record.session_id] = record
|
||||||
|
|
||||||
|
def update_markdown_path(self, record: ContextRecord, info: SessionInfo) -> None:
|
||||||
|
"""Keep stable markdown path; refresh slug segment if title changed and not manual."""
|
||||||
|
export_root = self.export_root
|
||||||
|
old_path = export_root / record.markdown_file
|
||||||
|
new_name = markdown_filename(record.code, info.title, slug_max=self.config.slug_max_length)
|
||||||
|
if info.is_subagent:
|
||||||
|
new_rel = f"markdown/subagents/{new_name}"
|
||||||
|
else:
|
||||||
|
new_rel = f"markdown/{new_name}"
|
||||||
|
|
||||||
|
if old_path.exists() and old_path != export_root / new_rel:
|
||||||
|
if record.markdown_file != new_rel:
|
||||||
|
(export_root / new_rel).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
old_path.rename(export_root / new_rel)
|
||||||
|
record.markdown_file = new_rel
|
||||||
|
record.title = info.title
|
||||||
|
|
||||||
|
def set_code(self, session_or_code: str, new_code: str) -> ContextRecord:
|
||||||
|
new_code = validate_context_code(new_code)
|
||||||
|
if new_code in self.contexts:
|
||||||
|
raise ValueError(f"Context code already exists: {new_code}")
|
||||||
|
|
||||||
|
record = self.get_by_session(session_or_code)
|
||||||
|
if record is None:
|
||||||
|
record = self.get_by_code(session_or_code)
|
||||||
|
if record is None:
|
||||||
|
raise ValueError(f"Unknown session or context code: {session_or_code}")
|
||||||
|
|
||||||
|
old_code = record.code
|
||||||
|
old_md = self.export_root / record.markdown_file
|
||||||
|
record.code = new_code
|
||||||
|
record.manual_code = True
|
||||||
|
new_name = markdown_filename(new_code, record.title, slug_max=self.config.slug_max_length)
|
||||||
|
if record.is_subagent:
|
||||||
|
record.markdown_file = f"markdown/subagents/{new_name}"
|
||||||
|
else:
|
||||||
|
record.markdown_file = f"markdown/{new_name}"
|
||||||
|
new_md = self.export_root / record.markdown_file
|
||||||
|
del self.contexts[old_code]
|
||||||
|
self.contexts[new_code] = record
|
||||||
|
self._by_session[record.session_id] = record
|
||||||
|
if old_md.exists() and old_md != new_md:
|
||||||
|
new_md.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
old_md.rename(new_md)
|
||||||
|
self.save()
|
||||||
|
return record
|
||||||
|
|
||||||
|
def codes_matching(
|
||||||
|
self, *, pattern: str | None = None, explicit: str | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
all_codes = list(self.contexts.keys())
|
||||||
|
return filter_context_codes(all_codes, pattern, explicit)
|
||||||
|
|
||||||
|
def sessions_matching(
|
||||||
|
self, *, pattern: str | None = None, explicit: str | None = None
|
||||||
|
) -> list[ContextRecord]:
|
||||||
|
codes = self.codes_matching(pattern=pattern, explicit=explicit)
|
||||||
|
return [self.contexts[c] for c in codes]
|
||||||
|
|
||||||
|
def session_ids_matching(
|
||||||
|
self, *, pattern: str | None = None, explicit: str | None = None
|
||||||
|
) -> set[str]:
|
||||||
|
return {r.session_id for r in self.sessions_matching(pattern=pattern, explicit=explicit)}
|
||||||
|
|
||||||
|
def cleanup_legacy_markdown(self) -> list[str]:
|
||||||
|
removed: list[str] = []
|
||||||
|
for folder in (self.export_root / "markdown", self.export_root / "markdown" / "subagents"):
|
||||||
|
if not folder.is_dir():
|
||||||
|
continue
|
||||||
|
for path in folder.glob("*.md"):
|
||||||
|
if is_uuid_name(path.stem):
|
||||||
|
path.unlink()
|
||||||
|
removed.append(str(path.relative_to(self.export_root)))
|
||||||
|
return removed
|
||||||
@@ -1,31 +1,40 @@
|
|||||||
# CAM configuration example
|
# 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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user