@holdyourvoice/hyv 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/ai-eliminator-rules.md +130 -0
- package/assets/ai-eliminator-skill.md +63 -0
- package/assets/chatgpt-instructions 2.txt +8 -0
- package/assets/chatgpt-instructions 3.txt +8 -0
- package/assets/chatgpt-instructions.txt +8 -0
- package/assets/claude-code-skill 2.md +24 -0
- package/assets/claude-code-skill.md +24 -0
- package/assets/cursor-rules 2.md +12 -0
- package/assets/cursor-rules 3.md +12 -0
- package/assets/cursor-rules.md +12 -0
- package/assets/economic-drift-voice.md +42 -0
- package/assets/hold-your-voice-skill.md +174 -0
- package/assets/voice-matcher-skill.md +57 -0
- package/assets/voice-profile-schema.json +28 -0
- package/dist/index.js +6484 -315
- package/package.json +8 -8
- package/scripts/hold_voice.py +2013 -0
- package/scripts/hold_voice_sync.py +194 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#!/usr/bin/env python3
|
|
3
|
+
"""Push voice profile, meta, and voice.md to Cloudflare R2 for backup.
|
|
4
|
+
|
|
5
|
+
Cost design: R2 has no egress fees. This script uses S3-compatible PUT only
|
|
6
|
+
(no Workers, no compute). Typical payload: 5-20KB. At $0.015/GB stored with
|
|
7
|
+
1 sync/day, annual cost is essentially zero.
|
|
8
|
+
|
|
9
|
+
Requires: pip install boto3
|
|
10
|
+
|
|
11
|
+
Env vars (set once):
|
|
12
|
+
HYV_R2_ACCESS_KEY_ID
|
|
13
|
+
HYV_R2_SECRET_ACCESS_KEY
|
|
14
|
+
HYV_R2_ENDPOINT (e.g. https://<account>.r2.cloudflarestorage.com)
|
|
15
|
+
HYV_R2_BUCKET (e.g. hyv-voice-profiles)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now_iso() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).isoformat()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sync_profile(
|
|
35
|
+
profile_path: Path,
|
|
36
|
+
meta_path: Path | None = None,
|
|
37
|
+
voice_md_path: Path | None = None,
|
|
38
|
+
dry_run: bool = False,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""push files to R2. returns summary dict."""
|
|
41
|
+
try:
|
|
42
|
+
import boto3
|
|
43
|
+
except ImportError:
|
|
44
|
+
return {"synced": False, "error": "boto3 not installed. pip install boto3"}
|
|
45
|
+
|
|
46
|
+
required = ["HYV_R2_ACCESS_KEY_ID", "HYV_R2_SECRET_ACCESS_KEY", "HYV_R2_ENDPOINT", "HYV_R2_BUCKET"]
|
|
47
|
+
missing = [v for v in required if not os.environ.get(v)]
|
|
48
|
+
if missing:
|
|
49
|
+
return {"synced": False, "error": f"missing env vars: {', '.join(missing)}"}
|
|
50
|
+
|
|
51
|
+
if not profile_path.exists():
|
|
52
|
+
return {"synced": False, "error": f"profile not found: {profile_path}"}
|
|
53
|
+
|
|
54
|
+
s3 = boto3.client(
|
|
55
|
+
"s3",
|
|
56
|
+
endpoint_url=os.environ["HYV_R2_ENDPOINT"],
|
|
57
|
+
aws_access_key_id=os.environ["HYV_R2_ACCESS_KEY_ID"],
|
|
58
|
+
aws_secret_access_key=os.environ["HYV_R2_SECRET_ACCESS_KEY"],
|
|
59
|
+
region_name="auto",
|
|
60
|
+
)
|
|
61
|
+
bucket = os.environ["HYV_R2_BUCKET"]
|
|
62
|
+
profile_name = profile_path.stem # e.g. "voice-profile"
|
|
63
|
+
|
|
64
|
+
files_to_sync: list[tuple[Path, str]] = [
|
|
65
|
+
(profile_path, f"{profile_name}/{profile_path.name}"),
|
|
66
|
+
]
|
|
67
|
+
if meta_path and meta_path.exists():
|
|
68
|
+
files_to_sync.append((meta_path, f"{profile_name}/{meta_path.name}"))
|
|
69
|
+
if voice_md_path and voice_md_path.exists():
|
|
70
|
+
files_to_sync.append((voice_md_path, f"{profile_name}/{voice_md_path.name}"))
|
|
71
|
+
|
|
72
|
+
total_size = sum(f.stat().st_size for f, _ in files_to_sync if f.exists())
|
|
73
|
+
if total_size > 1_000_000: # 1MB safety cap
|
|
74
|
+
return {"synced": False, "error": f"payload too large: {total_size} bytes"}
|
|
75
|
+
|
|
76
|
+
if dry_run:
|
|
77
|
+
return {"synced": False, "dry_run": True, "would_sync": [k for _, k in files_to_sync], "size": total_size}
|
|
78
|
+
|
|
79
|
+
sync_time = _now_iso()
|
|
80
|
+
uploaded = []
|
|
81
|
+
for local_path, remote_key in files_to_sync:
|
|
82
|
+
if not local_path.exists():
|
|
83
|
+
continue
|
|
84
|
+
s3.upload_file(
|
|
85
|
+
str(local_path),
|
|
86
|
+
bucket,
|
|
87
|
+
remote_key,
|
|
88
|
+
ExtraArgs={"ContentType": "application/json" if local_path.suffix == ".json" else "text/markdown"},
|
|
89
|
+
)
|
|
90
|
+
uploaded.append(remote_key)
|
|
91
|
+
|
|
92
|
+
return {"synced": True, "uploaded": uploaded, "size": total_size, "time": sync_time}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def was_synced_recently(meta: dict[str, Any], max_hours: int = 24) -> bool:
|
|
96
|
+
"""check if a sync happened within max_hours."""
|
|
97
|
+
last = meta.get("last_sync")
|
|
98
|
+
if not last:
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
last_dt = datetime.fromisoformat(last)
|
|
102
|
+
hours_since = (datetime.now(timezone.utc) - last_dt.replace(tzinfo=timezone.utc)).total_seconds() / 3600
|
|
103
|
+
return hours_since < max_hours
|
|
104
|
+
except (ValueError, TypeError):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def try_auto_sync(
|
|
109
|
+
profile_path: str,
|
|
110
|
+
meta_path: str | None = None,
|
|
111
|
+
voice_md_path: str | None = None,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""called by profile-evolve after saving. syncs if env is configured and
|
|
114
|
+
last sync was > 24h ago. returns True if sync happened."""
|
|
115
|
+
p = Path(profile_path).expanduser()
|
|
116
|
+
if not p.exists():
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
m = Path(meta_path).expanduser() if meta_path else p.with_suffix(".meta.json")
|
|
120
|
+
meta: dict[str, Any] = {}
|
|
121
|
+
if m.exists():
|
|
122
|
+
try:
|
|
123
|
+
meta = json.loads(m.read_text(encoding="utf-8", errors="ignore"))
|
|
124
|
+
except (json.JSONDecodeError, OSError):
|
|
125
|
+
meta = {}
|
|
126
|
+
|
|
127
|
+
if was_synced_recently(meta, max_hours=23):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
vm = Path(voice_md_path).expanduser() if voice_md_path else p.with_suffix(".voice.md")
|
|
131
|
+
result = sync_profile(p, meta_path=m, voice_md_path=vm)
|
|
132
|
+
|
|
133
|
+
if result.get("synced"):
|
|
134
|
+
meta["last_sync"] = result.get("time", _now_iso())
|
|
135
|
+
m.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
m.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> int:
|
|
142
|
+
import argparse
|
|
143
|
+
|
|
144
|
+
parser = argparse.ArgumentParser(description="Sync voice profile to Cloudflare R2")
|
|
145
|
+
parser.add_argument("--profile", required=True, help="voice profile JSON file")
|
|
146
|
+
parser.add_argument("--meta", help="meta JSON file (default: profile path with .meta.json)")
|
|
147
|
+
parser.add_argument("--voice-md", help="voice.md file (default: profile path with .voice.md)")
|
|
148
|
+
parser.add_argument("--dry-run", action="store_true", help="print what would be synced without uploading")
|
|
149
|
+
parser.add_argument("--force", action="store_true", help="sync even if recently synced")
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
|
|
152
|
+
profile_path = Path(args.profile).expanduser()
|
|
153
|
+
meta_path = Path(args.meta).expanduser() if args.meta else profile_path.with_suffix(".meta.json")
|
|
154
|
+
voice_md_path = Path(args.voice_md).expanduser() if args.voice_md else profile_path.with_suffix(".voice.md")
|
|
155
|
+
|
|
156
|
+
if not args.force:
|
|
157
|
+
meta: dict[str, Any] = {}
|
|
158
|
+
if meta_path.exists():
|
|
159
|
+
try:
|
|
160
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8", errors="ignore"))
|
|
161
|
+
except (json.JSONDecodeError, OSError):
|
|
162
|
+
pass
|
|
163
|
+
if was_synced_recently(meta):
|
|
164
|
+
print("sync skipped — already synced within 24h (use --force to override)")
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
result = sync_profile(profile_path, meta_path=meta_path, voice_md_path=voice_md_path, dry_run=args.dry_run)
|
|
168
|
+
|
|
169
|
+
if args.dry_run:
|
|
170
|
+
print(json.dumps(result, indent=2))
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if result.get("synced"):
|
|
174
|
+
meta: dict[str, Any] = {}
|
|
175
|
+
if meta_path.exists():
|
|
176
|
+
try:
|
|
177
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8", errors="ignore"))
|
|
178
|
+
except (json.JSONDecodeError, OSError):
|
|
179
|
+
meta = {}
|
|
180
|
+
meta["last_sync"] = result.get("time", _now_iso())
|
|
181
|
+
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
183
|
+
|
|
184
|
+
print(f"synced {len(result.get('uploaded', []))} files ({result.get('size', 0)} bytes)")
|
|
185
|
+
for f in result.get("uploaded", []):
|
|
186
|
+
print(f" {f}")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
print(f"sync failed: {result.get('error', 'unknown')}", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
raise SystemExit(main())
|