@aiyiran/myclaw 1.1.168 → 1.1.170

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.
@@ -327,22 +327,32 @@
327
327
 
328
328
  function buildTestDonePrompt() {
329
329
  var rec = window.myclawLastDebugRecord;
330
- var lines = [
331
- "我刚才已经完成了测试。",
332
- "请你检查我的测试数据 debug/debug-record.html,重点查看里面的 JS 日志。",
333
- ];
334
-
335
- if (rec && rec.debugRecordPath) {
336
- lines.push("如果你能读取本地路径,也可以查看:" + rec.debugRecordPath);
330
+ var lines = ["我刚才已经完成了测试。"];
331
+
332
+ // 调试数据落在 debug-data.js(window.__DEBUG_DATA__ = {...}),AI 直接读这个
333
+ // 数据文件即可,不要读同目录的 debug-record.html(那只是给人看的渲染壳)。
334
+ var dataFile = null;
335
+ if (rec) {
336
+ if (rec.debugRecordPath) {
337
+ dataFile = rec.debugRecordPath.replace(/debug-record\.html$/, "debug-data.js");
338
+ } else if (rec.workspace) {
339
+ dataFile = rec.workspace + "/debug/debug-data.js";
340
+ }
341
+ }
342
+ if (dataFile) {
343
+ lines.push("请你读取调试数据文件:" + dataFile);
344
+ } else {
345
+ lines.push("请你读取本次测试的调试数据文件 debug/debug-data.js。");
337
346
  }
347
+ lines.push("它是一行 `window.__DEBUG_DATA__ = {...}` 的赋值,里面的 events 数组就是完整运行现场;不要去读同目录的 debug-record.html。");
338
348
  if (rec && rec.entryFile) {
339
349
  lines.push("我刚才测试的文件是:" + rec.entryFile);
340
350
  }
341
351
 
342
352
  lines.push("");
343
353
  lines.push("请帮我判断:我们之前的问题是否已经修复。");
344
- lines.push("如果已经修复,请简单说明你从日志里看到了什么证据。");
345
- lines.push("如果还没有修复,请指出日志里最关键的问题,并建议下一步最小修改。");
354
+ lines.push("如果已经修复,请简单说明你从 events 里看到了什么证据。");
355
+ lines.push("如果还没有修复,请指出 events 里最关键的问题,并建议下一步最小修改。");
346
356
 
347
357
  return lines.join("\n");
348
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.1.168",
3
+ "version": "1.1.170",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -29,6 +29,11 @@
29
29
  "name": "chat-history-extractor",
30
30
  "strategy": "on",
31
31
  "description": "从 OpenClaw session URL 提取并渲染聊天记录"
32
+ },
33
+ {
34
+ "name": "yiran-skill-session-rollback",
35
+ "strategy": "on",
36
+ "description": "把会话还原到上一版 transcript(从 *.jsonl.reset 归档回滚)"
32
37
  }
33
38
  ],
34
39
  "_doc_agents": "Step 3: 将 agent-list/ 下的智能体分发到 ~/.openclaw/ 并注册到 openclaw.json",
@@ -27,38 +27,39 @@ python3 scripts/extract_chat.py '["url1","url2","url3"]' <output-dir>
27
27
 
28
28
  ## 输出文件
29
29
 
30
+ **只产出两个文件**,不再为每个会话单独落 js(所有会话的完整数据都汇入 `index.js`):
31
+
30
32
  | 文件 | 说明 |
31
33
  |---|---|
32
- | `01-<session-name>.js` | 1 个会话的数据 |
33
- | `02-<session-name>.js` | 2 个会话的数据 |
34
- | `...` | |
35
- | `index.js` | 所有会话的索引列表(仅批量模式生成) |
36
- | `chat_history.js` | 向后兼容,指向第一个会话 |
37
-
38
- 每个 JS 文件结构:
39
-
40
- ```javascript
41
- const chatData = {
42
- "session": "session-name",
43
- "session_id": "agent:xxx:yyy",
44
- "total_pairs": N,
45
- "initiator": "...",
46
- "conversations": [
47
- { "user": "...", "user_time": "...", "ai": "...", "ai_time": "..." },
48
- ...
49
- ]
50
- };
51
- ```
34
+ | `index.js` | 唯一数据产物:`chatIndex`(会话元数据列表)+ `chatAll`(每个会话的完整对话) |
35
+ | `index.html` | 查看器,复制自 `assets/chat-history-template.html`,只加载 `index.js` |
52
36
 
53
37
  `index.js` 结构:
54
38
 
55
39
  ```javascript
40
+ // chatIndex:用于会话切换下拉框的元数据
56
41
  const chatIndex = [
57
- { "index": 1, "session": "...", "js_file": "01-xxx.js", "total_pairs": 10 },
58
- { "index": 2, "session": "...", "js_file": "02-yyy.js", "total_pairs": 8 },
42
+ { "session_id": "agent:xxx:main", "workspace_name": "xxx", "session_name": "main",
43
+ "total_pairs": 10, "first_time": "...", "last_time": "...", "work_url": "" },
44
+ ...
59
45
  ];
46
+
47
+ // chatAll:按 session_id 索引的完整会话数据(HTML 渲染的真正数据源)
48
+ const chatAll = {
49
+ "agent:xxx:main": {
50
+ "workspace_name": "xxx", "session_name": "main", "session_id": "agent:xxx:main",
51
+ "total_pairs": 10, "first_time": "...", "last_time": "...", "work_url": "",
52
+ "conversations": [
53
+ { "user": "...", "user_time": "...", "ai": "...", "ai_time": "..." },
54
+ ...
55
+ ]
56
+ },
57
+ ...
58
+ };
60
59
  ```
61
60
 
61
+ > 单会话和批量模式产物一致,区别只是 `chatIndex` / `chatAll` 里的会话数量。
62
+
62
63
  ## 工作流
63
64
 
64
65
  ### Step 1: 解析输入
@@ -85,14 +86,14 @@ URL 示例:`https://claw1.kekouen.cn/chat?session=agent%3Ac108-v1-1811%3Amain`
85
86
  3. 多条 AI 回复合并为一条
86
87
  4. 跳过 `toolResult` 事件和空文本消息
87
88
 
88
- ### Step 4: 生成 JS 文件
89
+ ### Step 4: 生成 index.js
89
90
 
90
- 批量模式下,每个会话生成独立的 `NN-<name>.js`,并额外生成 `index.js` 索引。
91
+ 所有会话的数据在内存中汇总,一次性写入唯一的 `index.js`(`chatIndex` + `chatAll`),不为单个会话单独落文件。
91
92
 
92
93
  ### Step 5: 渲染
93
94
 
94
- 将模板 `assets/chat-history-template.html` 复制到输出目录。
95
- 模板会加载 `chat_history.js`(单会话)或可通过 `index.js` 切换(多会话)。
95
+ 将模板 `assets/chat-history-template.html` 复制到输出目录为 `index.html`。
96
+ 模板只加载 `index.js`,通过 `chatIndex` 渲染会话切换下拉框、从 `chatAll` 取完整对话。
96
97
 
97
98
  模板展示三种时间指标:
98
99
  - **课程流逝** — 距首条消息的时间
@@ -116,8 +117,8 @@ python3 scripts/extract_chat.py "https://claw1.kekouen.cn/chat?session=agent%3Ac
116
117
  ```
117
118
 
118
119
  输出:
119
- - `01-main.js`
120
- - `chat_history.js`(= 01-main.js 的副本)
120
+ - `index.js`(含 1 个会话)
121
+ - `index.html`
121
122
 
122
123
  ### 批量处理
123
124
 
@@ -131,9 +132,5 @@ python3 scripts/extract_chat.py '[
131
132
  ```
132
133
 
133
134
  输出:
134
- - `01-main.js`
135
- - `02-main.js`
136
- - `03-每天一个小习惯·宠物养成_1813.js`
137
- - `04-我的金币任务板_1810.js`
138
- - `index.js`
139
- - `chat_history.js`(= 01-main.js 的副本)
135
+ - `index.js`(含 4 个会话,全部数据在 `chatAll` 里)
136
+ - `index.html`
@@ -23,10 +23,11 @@ Usage:
23
23
  python3 extract_chat.py --scan [--html]
24
24
 
25
25
  输出(默认到当前 workspace /root/.openclaw/workspace-teacher):
26
- - 每个会话生成独立的 <agentId>_<sessionName>_<date>_<time>.js
27
- - 生成 index.js,包含所有会话的元信息列表(含 work_url 字段)
28
- - 生成 chat_history.js(向后兼容,指向第一个会话)
29
- - --html 时额外生成 summary.html(含可点击链接)
26
+ - index.js:唯一数据产物,含 chatIndex(会话元信息列表,含 work_url 字段)
27
+ + chatAll(每个会话的完整对话,按 session_id 索引)
28
+ - index.html:查看器,复制自 assets/chat-history-template.html,只加载 index.js
29
+ - 不再为每个会话单独落 js 文件
30
+ - --scan 模式额外生成 作品扫描_<时间>.html(含可点击链接)
30
31
 
31
32
  Host 检测:
32
33
  - 自动从 /root/.openclaw/myclaw/server/config.json 读取
@@ -34,7 +35,7 @@ Host 检测:
34
35
 
35
36
  work_url 字段:
36
37
  - 默认为空字符串
37
- - AI 可后续手动编辑 JS 文件填入学生作品链接
38
+ - AI 可后续编辑 index.js 中对应会话的 work_url 填入学生作品链接
38
39
  """
39
40
 
40
41
  import json
@@ -166,7 +167,8 @@ def count_conversations(jsonl_path):
166
167
  messages.append({
167
168
  'role': role,
168
169
  'text': text.strip(),
169
- 'has_text': bool(text.strip())
170
+ 'has_text': bool(text.strip()),
171
+ 'timestamp': timestamp
170
172
  })
171
173
 
172
174
  # Count conversation pairs (user messages with at least one AI reply)
@@ -183,9 +185,13 @@ def count_conversations(jsonl_path):
183
185
  ai_messages.append(messages[j])
184
186
  j += 1
185
187
  ai_text = '\n\n'.join([m['text'] for m in ai_messages if m['has_text']])
188
+ user_time = msg.get('timestamp', '')
189
+ ai_time = ai_messages[0].get('timestamp', '') if ai_messages else ''
186
190
  conversations.append({
187
191
  'user': user_text,
188
192
  'ai': ai_text,
193
+ 'user_time': user_time,
194
+ 'ai_time': ai_time,
189
195
  })
190
196
  i += 1
191
197
  else:
@@ -210,17 +216,26 @@ def build_filename(session_key):
210
216
  parts = session_key.split(':')
211
217
  agent_id = parts[1] if len(parts) > 1 else 'unknown'
212
218
  session_name = parts[2] if len(parts) > 2 else 'main'
219
+ # Use last part (UUID or 'main') as unique suffix to avoid collisions
220
+ unique_id = parts[-1] if len(parts) > 3 else session_name
213
221
  agent_safe = sanitize_filename(agent_id)
214
222
  name_safe = sanitize_filename(session_name)
215
- return f"{agent_safe}_{name_safe}.js"
223
+ unique_safe = sanitize_filename(unique_id)
224
+ if unique_safe == name_safe:
225
+ return f"{agent_safe}_{name_safe}.js"
226
+ return f"{agent_safe}_{name_safe}_{unique_safe}.js"
216
227
 
217
228
 
218
- def generate_js(conversations, session_key, first_ts, last_ts, output_path):
229
+ def build_chat_data(conversations, session_key, first_ts, last_ts):
230
+ """构建单个会话的完整数据对象(仅内存,不写盘)。
231
+
232
+ 数据最终全部汇入 index.js 的 chatAll,不再为每个会话单独落 js 文件。
233
+ """
219
234
  parts = session_key.split(':')
220
235
  workspace_name = parts[1] if len(parts) > 1 else 'unknown'
221
236
  session_name = parts[2] if len(parts) > 2 else 'main'
222
237
 
223
- output_data = {
238
+ return {
224
239
  'workspace_name': workspace_name,
225
240
  'session_name': session_name,
226
241
  'session_id': session_key,
@@ -230,15 +245,6 @@ def generate_js(conversations, session_key, first_ts, last_ts, output_path):
230
245
  'work_url': '',
231
246
  'conversations': conversations
232
247
  }
233
- js_content = f'''// Chat History - {session_name}
234
- // Session Key: {session_key}
235
- // Generated by chat-history-extractor skill
236
-
237
- const chatData = {json.dumps(output_data, ensure_ascii=False, indent=2)};
238
- '''
239
- with open(output_path, 'w', encoding='utf-8') as f:
240
- f.write(js_content)
241
- return len(conversations), session_name
242
248
 
243
249
 
244
250
  def session_key_to_url(session_key, host=None):
@@ -387,57 +393,43 @@ def process_one(url_or_key, output_dir, index):
387
393
  conversations, first_ts, last_ts = parse_jsonl_to_conversations(jsonl_path)
388
394
  print(f"[{index}] {len(conversations)} pairs")
389
395
 
390
- js_filename = build_filename(session_key)
391
- js_path = os.path.join(output_dir, js_filename)
392
- count, _ = generate_js(conversations, session_key, first_ts, last_ts, js_path)
393
- print(f"[{index}] Generated: {js_filename}")
396
+ data = build_chat_data(conversations, session_key, first_ts, last_ts)
394
397
 
395
398
  return {
396
399
  'index': index,
397
400
  'session_key': session_key,
398
401
  'session_name': session_name,
399
402
  'agent_id': agent_id,
400
- 'js_file': js_filename,
401
- 'total_pairs': count,
402
- 'first_time': parse_time(first_ts) if first_ts else '',
403
- 'last_time': parse_time(last_ts) if last_ts else '',
403
+ 'total_pairs': data['total_pairs'],
404
+ 'first_time': data['first_time'],
405
+ 'last_time': data['last_time'],
404
406
  'work_url': '',
407
+ 'data': data, # 完整会话数据,汇入 index.js 的 chatAll
405
408
  }
406
409
 
407
410
 
408
- def generate_index_js(output_dir):
409
- """Scan output_dir for all .js files, merge into index.js with full data."""
411
+ def generate_index_js(results, output_dir):
412
+ """直接用内存中的 results 拼出唯一产物 index.js(chatIndex + chatAll)。
413
+
414
+ 不再为每个会话单独落 js 文件——每个会话的完整对话都进 chatAll,
415
+ HTML 只加载 index.js 即可渲染全部会话。
416
+ """
410
417
  all_entries = []
411
418
  all_data = {}
412
419
 
413
- for fname in sorted(os.listdir(output_dir)):
414
- if not fname.endswith('.js') or fname in ('index.js', 'chat_history.js'):
415
- continue
416
- fpath = os.path.join(output_dir, fname)
417
- mtime = int(os.path.getmtime(fpath))
418
- try:
419
- with open(fpath, 'r', encoding='utf-8') as f:
420
- fcontent = f.read()
421
- m = re.search(r'const chatData = (\{.*\});?\s*$', fcontent, re.DOTALL)
422
- if not m:
423
- continue
424
- data = json.loads(m.group(1))
425
- sid = data.get('session_id', fname.replace('.js', ''))
426
- all_data[sid] = data
427
- all_entries.append({
428
- 'workspace_name': data.get('workspace_name', ''),
429
- 'session_name': data.get('session_name', ''),
430
- 'session_id': sid,
431
- 'js_file': fname,
432
- 'total_pairs': data.get('total_pairs', 0),
433
- 'first_time': data.get('first_time', ''),
434
- 'last_time': data.get('last_time', ''),
435
- 'work_url': data.get('work_url', ''),
436
- 'mtime': mtime,
437
- })
438
- except Exception as ex:
439
- print(' [WARN] Skip %s: %s' % (fname, ex))
440
- continue
420
+ for r in results:
421
+ data = r['data']
422
+ sid = data['session_id']
423
+ all_data[sid] = data
424
+ all_entries.append({
425
+ 'workspace_name': data.get('workspace_name', ''),
426
+ 'session_name': data.get('session_name', ''),
427
+ 'session_id': sid,
428
+ 'total_pairs': data.get('total_pairs', 0),
429
+ 'first_time': data.get('first_time', ''),
430
+ 'last_time': data.get('last_time', ''),
431
+ 'work_url': data.get('work_url', ''),
432
+ })
441
433
 
442
434
  now_ts = int(time.time())
443
435
  idx = '// Chat History Index - %d session(s)\n' % len(all_entries)
@@ -546,7 +538,7 @@ def main():
546
538
 
547
539
  os.makedirs(output_dir, exist_ok=True)
548
540
 
549
- # Clean old JS + index.html before fresh extraction
541
+ # 清理旧产物(含历史遗留的单会话 js)后再重新生成
550
542
  for old_f in os.listdir(output_dir):
551
543
  if old_f.endswith('.js') or old_f == 'index.html':
552
544
  try:
@@ -564,8 +556,8 @@ def main():
564
556
  print("\nERROR: No sessions processed successfully.")
565
557
  sys.exit(1)
566
558
 
567
- # Always regenerate index.js by scanning directory
568
- generate_index_js(output_dir)
559
+ # 唯一数据产物:index.js(chatIndex + chatAll)
560
+ generate_index_js(results, output_dir)
569
561
 
570
562
  # Copy chat-history-template.html as index.html
571
563
  import shutil
@@ -0,0 +1,120 @@
1
+ ---
2
+ name: yiran-skill-session-rollback
3
+ description: Restore an OpenClaw conversation/session to its previous transcript version by locating the right session key, current sessionId, and archived `*.jsonl.reset.<timestamp>` transcript, then repointing the key back to the chosen prior version. Use when the user asks to recover a chat, roll back a conversation, restore the previous version of a session, or revert a session after reset. Trigger even if the user provides only a session id fragment, a TUI id, an agent name, or conversation text and you need to search for the matching session first.
4
+ ---
5
+
6
+ Restore the target conversation to an earlier transcript version.
7
+
8
+ Read `references/usage-and-design.md` when you need the full design rationale, safety model, or the division of labor between skill, script, and agent judgment.
9
+
10
+ ## Previous version definition
11
+
12
+ **Previous version / 上一版** means:
13
+
14
+ > For a given session key, the most recent valid transcript version that directly preceded the current live version.
15
+
16
+ Use these rules:
17
+
18
+ 1. Treat the `sessionId` currently referenced by `sessions.json` as the **current version**.
19
+ 2. Look for the most recent prior transcript that can be tied to the same session key with high confidence.
20
+ 3. If there is exactly one clear prior candidate, that candidate is the **previous version**.
21
+ 4. If multiple reasonable prior candidates exist, treat "previous version" as **ambiguous** and ask the user before switching.
22
+
23
+ Important clarifications:
24
+
25
+ - "Previous version" is **not** the oldest version.
26
+ - "Previous version" is **not** just the newest `*.jsonl.reset.*` file anywhere in the agent directory.
27
+ - "Previous version" means the **direct predecessor** of the current live version for that specific session key.
28
+ - In simple one-main-session agents, a single reset archive is often sufficient to define the previous version.
29
+ - In complex agents with multiple historical branches, never guess.
30
+
31
+ ## Workflow
32
+
33
+ 1. Identify the target session.
34
+ - Accept any of these as clues:
35
+ - full session key, like `agent:daidai:main`
36
+ - agent name, like `daidai`
37
+ - TUI id, like `tui-...`
38
+ - partial session id
39
+ - distinctive text from the conversation
40
+ - Prefer the bundled discovery script first:
41
+ - `scripts/find_session_candidates.py <query> [--agent <agentId>] [--limit N]`
42
+ - Search `~/.openclaw/agents/*/sessions/sessions.json` and the sibling transcript files until one session is clearly identified.
43
+ - If multiple candidates remain, stop and ask the user to disambiguate.
44
+ - Treat short aliases like `ming` as likely human shorthand for the agent (`mingzi`) if the match is unique; otherwise ask.
45
+
46
+ 2. Inspect the session store before editing.
47
+ - Read the target agent's `sessions.json`.
48
+ - Record:
49
+ - current `sessionId`
50
+ - current `sessionFile`
51
+ - matching reset archives in the same `sessions/` directory
52
+ - Prefer the **most recent previous version** unless the user explicitly asks for an older one.
53
+
54
+ 3. Verify that the rollback target is real.
55
+ - Prefer a reset archive that can be tied to the target session key by explicit text evidence inside the transcript.
56
+ - If explicit evidence is unavailable, use stronger heuristics only when the mapping is unambiguous.
57
+ - A single reset archive in that agent's session directory is usually acceptable as the "previous version" candidate for a simple `agent:<id>:main` test restore.
58
+ - If the mapping is ambiguous, do not guess.
59
+
60
+ 4. Restore both index and transcript file.
61
+ - Prefer the bundled script for the final switch step:
62
+ - `scripts/rollback_session.py --agent <agentId> --key <sessionKey> --target-session-id <oldSessionId> [--archive <path>]`
63
+ - The script updates `sessions.json`, restores `<sessionId>.jsonl` from an archive when needed, and leaves safety backups behind.
64
+ - Keep archive files in place unless the user explicitly asks to delete them.
65
+
66
+ 5. Prevent immediate re-breakage.
67
+ - If the environment still uses aggressive reset rules and the user is likely to test immediately, warn that the next message may mint a fresh session again.
68
+ - If the user asked to make rollback stick, suggest changing `session.reset` to a longer `idle` policy before testing.
69
+
70
+ 6. Reload runtime state when needed.
71
+ - Restart the gateway if the session store is cached in memory or if the user asks for a full refresh.
72
+
73
+ 7. Report exactly what changed.
74
+ - Include:
75
+ - resolved session key
76
+ - old sessionId
77
+ - restored sessionId
78
+ - whether transcript file had to be recreated from `.reset`
79
+ - where the previous live version was backed up
80
+ - whether a gateway restart was performed
81
+
82
+ ## Safety rules
83
+
84
+ - Back up `sessions.json` before editing it.
85
+ - Preserve the current live transcript before switching.
86
+ - The bundled script copies the current `.jsonl` to a timestamped `.manual-switch-backup.*` file so a mistaken switch can be undone without loss.
87
+ - Do not batch-edit unrelated sessions unless the user explicitly asked for that.
88
+ - Do not claim success based only on `sessions.json`; confirm the target `.jsonl` file exists.
89
+ - Do not guess across multiple ambiguous candidates.
90
+
91
+ ## Fast search hints
92
+
93
+ Use these patterns when searching:
94
+
95
+ - key search:
96
+ - `agent:<agentId>:main`
97
+ - `agent:<agentId>:tui-...`
98
+ - transcript archives:
99
+ - `*.jsonl.reset.*`
100
+ - clue text:
101
+ - grep distinctive conversation text across `~/.openclaw/agents/*/sessions/*`
102
+
103
+ ## Typical recovery logic
104
+
105
+ 1. Find session key.
106
+ 2. Find current `sessionId` in `sessions.json`.
107
+ 3. Find the previous archived transcript for that same session.
108
+ 4. Run `scripts/rollback_session.py` to:
109
+ - back up `sessions.json`
110
+ - back up the current live transcript
111
+ - restore the target transcript file if needed
112
+ - rewrite `sessions.json` to point back to the target session
113
+ 5. Restart gateway if required.
114
+
115
+ ## When to stop and ask
116
+
117
+ Stop and ask if:
118
+ - more than one session matches the user's clue
119
+ - the reset archive cannot be tied to the target session confidently
120
+ - the user asks for "the earliest version" and there are multiple historical candidates with no clear chain
@@ -0,0 +1,291 @@
1
+ # Session Rollback Skill - Usage and Design
2
+
3
+ 这份说明面向两类读者:
4
+
5
+ - **人类维护者**:想知道这个 skill 到底怎么工作、边界在哪里
6
+ - **AI/agent**:想知道什么时候该用 skill,什么时候该停下来问用户
7
+
8
+ ---
9
+
10
+ ## 1. 能力边界
11
+
12
+ 这个 skill 解决的是:
13
+
14
+ > 把某个 OpenClaw 会话 key 重新指回某个更早的 transcript 版本。
15
+
16
+ 它不是“修复所有历史问题”的万能工具,而是一个**安全切换器**。
17
+
18
+ 它做的事情包括:
19
+
20
+ 1. 定位目标会话
21
+ 2. 找到要恢复的旧 transcript 版本
22
+ 3. 保留当前 live 版本备份
23
+ 4. 把 session key 改指到旧版本
24
+ 5. 必要时恢复 `<sessionId>.jsonl`
25
+ 6. 必要时提醒重启 gateway
26
+
27
+ ---
28
+
29
+ ## 2. 实际工作分层
30
+
31
+ 这套能力分三层:
32
+
33
+ ### A. Skill(流程层)
34
+ `SKILL.md` 负责:
35
+
36
+ - 定义什么时候触发这个能力
37
+ - 规定恢复流程
38
+ - 规定什么时候可以自动做
39
+ - 规定什么时候必须停下来问用户
40
+
41
+ ### B. Script(执行层)
42
+ `scripts/rollback_session.py` 负责:
43
+
44
+ - 备份 `sessions.json`
45
+ - 备份当前 live transcript
46
+ - 恢复目标 transcript 文件
47
+ - 改写 `sessions.json`
48
+
49
+ ### C. Agent 判断(决策层)
50
+ 模型负责:
51
+
52
+ - 从用户线索里定位目标 session
53
+ - 判断“上一版”是不是唯一明确
54
+ - 判断是否有歧义
55
+ - 决定是否需要重启 gateway
56
+
57
+ 一句话:
58
+
59
+ > **skill 定流程,脚本做切换,agent 负责判断。**
60
+
61
+ ---
62
+
63
+ ## 3. 为什么不能只靠脚本
64
+
65
+ 脚本适合做“已知目标”的安全切换。
66
+
67
+ 但用户经常不会直接给:
68
+
69
+ - 完整 session key
70
+ - 完整旧 session id
71
+ - 完整 archive 路径
72
+
73
+ 用户更可能只给:
74
+
75
+ - agent 名字
76
+ - 一段对话文本
77
+ - 一个 TUI id
78
+ - “切回上一版”
79
+
80
+ 这些都需要 agent 先做定位和判断。
81
+
82
+ 所以:
83
+
84
+ - **纯脚本不够用**
85
+ - **纯 prompt 也不稳定**
86
+ - 最合理的是:**skill + script + agent 判断**
87
+
88
+ ---
89
+
90
+ ## 4. 为什么切换时必须保留当前版本
91
+
92
+ 恢复动作本身也可能出错,比如:
93
+
94
+ - 目标选错了
95
+ - “上一版”理解错了
96
+ - 用户其实想回到更早版本
97
+ - 同一个 agent 下有多个历史候选
98
+
99
+ 所以切换时必须默认保留当前 live 版本。
100
+
101
+ 当前脚本已经这样做:
102
+
103
+ - 备份 `sessions.json`
104
+ - 备份当前 `.jsonl`
105
+
106
+ 这样即使切错,也能无损切回来。
107
+
108
+ 这是这个 skill 的核心安全原则之一。
109
+
110
+ ---
111
+
112
+ ## 5. “上一版” 的正式定义
113
+
114
+ **上一版 / previous version** 指的是:
115
+
116
+ > 对某个 session key 来说,当前 live 版本之前最近一次有效绑定的 transcript 版本。
117
+
118
+ 判断规则:
119
+
120
+ 1. 先把 `sessions.json` 当前指向的 `sessionId` 视为**当前版**。
121
+ 2. 再寻找与该 session key 高置信关联的最近前身 transcript。
122
+ 3. 如果只有一个明确候选,它就是**上一版**。
123
+ 4. 如果存在多个合理候选,则“上一版”视为**有歧义**,必须先向用户确认。
124
+
125
+ 特别说明:
126
+
127
+ - 上一版**不是**最老版本
128
+ - 上一版**不是**目录里最新的任意 `*.jsonl.reset.*`
129
+ - 上一版是**当前版本的直接前身**
130
+ - 对简单单主会话 agent,唯一 reset 档案通常足以定义上一版
131
+ - 对复杂多分支 agent,不能靠猜
132
+
133
+ ## 6. 什么情况下可以自动切
134
+
135
+ 可以自动切的典型情况:
136
+
137
+ ### 情况 A:用户给了明确目标
138
+ 例如:
139
+
140
+ - `session key = agent:main:main`
141
+ - `session id = 50b65539-...`
142
+
143
+ 这种情况可以直接执行。
144
+
145
+ ### 情况 B:只有一个明确的上一版候选
146
+ 例如:
147
+
148
+ - 某个 agent 的 `main` 当前只有一份 `*.jsonl.reset.*`
149
+ - 没有多份歧义历史
150
+
151
+ 这种情况下,“切到上一版”可以直接做。
152
+
153
+ ---
154
+
155
+ ## 6. 什么情况下必须停下来问
156
+
157
+ 必须停下来问的典型情况:
158
+
159
+ ### 情况 A:存在多个历史候选
160
+ 例如:
161
+
162
+ - `agent:main:main` 这种会有多份 reset 档案
163
+ - “上一版”可能有多种理解
164
+
165
+ ### 情况 B:找不到能明确绑定到这个 key 的 archive
166
+ 例如:
167
+
168
+ - reset 文件存在
169
+ - 但无法判断它是否真属于当前目标 session
170
+
171
+ ### 情况 C:用户给的线索太模糊
172
+ 例如:
173
+
174
+ - 只说“帮我恢复 main”
175
+ - 但有多个 main-like 对话
176
+
177
+ 这时不要猜。
178
+
179
+ ---
180
+
181
+ ## 7. 当前脚本的职责
182
+
183
+ `scripts/rollback_session.py` 当前已经支持:
184
+
185
+ - 指定 agent
186
+ - 指定 session key
187
+ - 指定目标 session id
188
+ - 可选指定 archive 路径
189
+ - 自动备份当前 live transcript
190
+ - 自动备份 `sessions.json`
191
+ - 自动恢复目标 `.jsonl`
192
+
193
+ 它当前**不负责**:
194
+
195
+ - 从模糊文本自动搜索候选 session
196
+ - 自动决定哪个才是“上一版”
197
+ - 自动批量处理多个歧义 session
198
+
199
+ 这些仍然属于 agent 决策层。
200
+
201
+ ---
202
+
203
+ ## 8. 推荐使用方式
204
+
205
+ ### 场景 1:明确恢复到指定版本
206
+ 直接调用脚本:
207
+
208
+ ```bash
209
+ python3 skills/yiran-skill-session-rollback/scripts/rollback_session.py \
210
+ --agent main \
211
+ --key agent:main:main \
212
+ --target-session-id 50b65539-0224-4f97-ae29-10e3fda589fc \
213
+ --archive /Users/.../50b65539-0224-4f97-ae29-10e3fda589fc.jsonl.reset....
214
+ ```
215
+
216
+ ### 场景 2:恢复到“上一版”
217
+ 先由 agent:
218
+
219
+ 1. 定位目标 session
220
+ 2. 判断唯一上一版候选
221
+ 3. 再调用脚本
222
+
223
+ ---
224
+
225
+ ## 9. 当前成熟度
226
+
227
+ 目前这个 skill 的成熟度可以定义为:
228
+
229
+ > **半自动、可安全执行的 session rollback 工具**
230
+
231
+ 这意味着:
232
+
233
+ - 单次恢复已经比较稳
234
+ - 有回滚保护
235
+ - 适合人工确认后执行
236
+ - 还不适合无监督批量自动恢复复杂多分支历史
237
+
238
+ ---
239
+
240
+ ## 10. 当前脚本清单
241
+
242
+ ### `scripts/find_session_candidates.py`
243
+ 作用:
244
+ - 输入 agent 名 / 文本 / TUI id / session id 片段
245
+ - 输出候选 session 列表
246
+ - 只做查找,不做修改
247
+
248
+ 示例:
249
+
250
+ ```bash
251
+ python3 skills/yiran-skill-session-rollback/scripts/find_session_candidates.py ming --limit 5
252
+ python3 skills/yiran-skill-session-rollback/scripts/find_session_candidates.py tui-65d670f7 --limit 5
253
+ python3 skills/yiran-skill-session-rollback/scripts/find_session_candidates.py "假设现场出现 5 种常见对话" --agent sun --limit 5
254
+ ```
255
+
256
+ 注意:
257
+ - 它会给出排序和命中原因
258
+ - 如果有多个接近候选,仍然应该先问用户,不要直接切
259
+
260
+ ### `scripts/rollback_session.py`
261
+ 作用:
262
+ - 在目标已明确时,安全切换到旧版本
263
+ - 自动保留当前 live 版本备份
264
+
265
+ ---
266
+
267
+ ## 11. 后续可扩展方向
268
+
269
+ 如果以后还要增强,可以优先做:
270
+
271
+ 1. `find_previous_version.py`
272
+ - 自动给出“上一版”的候选与置信度
273
+
274
+ 2. 批量模式
275
+ - 只处理“唯一明确上一版”的 session
276
+ - 歧义项自动跳过并报告
277
+
278
+ 3. 更强的内容匹配
279
+ - 减少因为 transcript 文本偶然命中带来的噪音候选
280
+
281
+ ---
282
+
283
+ ## 11. 一句话总结
284
+
285
+ 这个 skill 的正确心智模型不是:
286
+
287
+ > “一个神奇脚本,自动把一切恢复好”
288
+
289
+ 而是:
290
+
291
+ > **一个安全恢复工作流:先判断,再切换,并且默认保留当前版本作为回滚点。**
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Find likely OpenClaw session candidates from vague user clues.
3
+
4
+ This script is the discovery companion to `rollback_session.py`.
5
+ It does not modify anything.
6
+
7
+ Use it when the user provides incomplete input such as:
8
+ - an agent name or shorthand
9
+ - a TUI id fragment
10
+ - a partial session id
11
+ - a distinctive piece of conversation text
12
+ - a partial session key
13
+
14
+ The script searches `~/.openclaw/agents/*/sessions/` and returns ranked candidates.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Dict, List, Tuple
21
+
22
+
23
+ def load_json(path: Path):
24
+ with path.open('r', encoding='utf-8') as f:
25
+ return json.load(f)
26
+
27
+
28
+ def safe_read_prefix(path: Path, max_chars: int = 12000) -> str:
29
+ try:
30
+ with path.open('r', encoding='utf-8', errors='ignore') as f:
31
+ return f.read(max_chars)
32
+ except Exception:
33
+ return ''
34
+
35
+
36
+ def score_candidate(query: str, agent_id: str, key: str, item: dict, transcript_prefix: str) -> Tuple[int, List[str]]:
37
+ q = query.strip().lower()
38
+ score = 0
39
+ reasons: List[str] = []
40
+
41
+ hay_agent = agent_id.lower()
42
+ hay_key = key.lower()
43
+ hay_sid = str(item.get('sessionId', '')).lower()
44
+ hay_file = str(item.get('sessionFile', '')).lower()
45
+ hay_text = transcript_prefix.lower()
46
+
47
+ if q == hay_agent or q == agent_id:
48
+ score += 100
49
+ reasons.append('exact agent match')
50
+ elif q in hay_agent:
51
+ score += 40
52
+ reasons.append('agent substring match')
53
+
54
+ if q == hay_key:
55
+ score += 120
56
+ reasons.append('exact session key match')
57
+ elif q in hay_key:
58
+ score += 60
59
+ reasons.append('session key substring match')
60
+
61
+ if q and q in hay_sid:
62
+ score += 70
63
+ reasons.append('session id substring match')
64
+
65
+ if q and q in hay_file:
66
+ score += 20
67
+ reasons.append('session file path match')
68
+
69
+ if q and q in hay_text:
70
+ score += 50
71
+ reasons.append('transcript text match')
72
+
73
+ # helpful shorthand heuristics
74
+ if q == 'ming' and agent_id == 'mingzi':
75
+ score += 30
76
+ reasons.append('known shorthand: ming -> mingzi')
77
+
78
+ return score, reasons
79
+
80
+
81
+ def main():
82
+ p = argparse.ArgumentParser(description='Find likely OpenClaw session candidates from vague clues.')
83
+ p.add_argument('query', help='Agent name, session key fragment, session id fragment, tui id, or distinctive text')
84
+ p.add_argument('--limit', type=int, default=10, help='Maximum candidates to print')
85
+ p.add_argument('--agent', help='Restrict search to one agent id')
86
+ p.add_argument('--include-transcript-prefix', action='store_true', help='Include a short transcript preview in output')
87
+ args = p.parse_args()
88
+
89
+ root = Path.home() / '.openclaw' / 'agents'
90
+ if not root.exists():
91
+ raise SystemExit('Agents directory not found')
92
+
93
+ candidates = []
94
+ for agent_dir in sorted(root.iterdir()):
95
+ if not agent_dir.is_dir():
96
+ continue
97
+ agent_id = agent_dir.name
98
+ if args.agent and agent_id != args.agent:
99
+ continue
100
+ sessions_dir = agent_dir / 'sessions'
101
+ sessions_json = sessions_dir / 'sessions.json'
102
+ if not sessions_json.exists():
103
+ continue
104
+ try:
105
+ data = load_json(sessions_json)
106
+ except Exception:
107
+ continue
108
+ for key, item in data.items():
109
+ if not isinstance(item, dict):
110
+ continue
111
+ session_file = Path(item.get('sessionFile', '')) if item.get('sessionFile') else None
112
+ prefix = safe_read_prefix(session_file) if session_file and session_file.exists() else ''
113
+ score, reasons = score_candidate(args.query, agent_id, key, item, prefix)
114
+ if score <= 0:
115
+ continue
116
+ candidates.append({
117
+ 'score': score,
118
+ 'reasons': reasons,
119
+ 'agent': agent_id,
120
+ 'key': key,
121
+ 'sessionId': item.get('sessionId'),
122
+ 'sessionFile': item.get('sessionFile'),
123
+ 'updatedAt': item.get('updatedAt'),
124
+ 'hasSessionFile': bool(session_file and session_file.exists()),
125
+ 'transcriptPreview': prefix[:300] if args.include_transcript_prefix and prefix else None,
126
+ })
127
+
128
+ candidates.sort(key=lambda x: (-x['score'], str(x.get('updatedAt') or 0), x['key']))
129
+ result = {
130
+ 'query': args.query,
131
+ 'count': len(candidates),
132
+ 'candidates': candidates[: args.limit],
133
+ 'note': 'Use the top candidate only when the match is unambiguous. If several close candidates remain, ask the user to confirm.'
134
+ }
135
+ print(json.dumps(result, ensure_ascii=False, indent=2))
136
+
137
+
138
+ if __name__ == '__main__':
139
+ main()
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """Safely repoint an OpenClaw session key to an earlier transcript version.
3
+
4
+ Design goals:
5
+ - Make the final switch deterministic once a target sessionId is known.
6
+ - Preserve the current live version before switching so mistakes are reversible.
7
+ - Restore the target plain `.jsonl` file from an archive when needed.
8
+
9
+ What this script does not do:
10
+ - It does not discover the correct target from vague user language.
11
+ - It does not decide which historical candidate is "the right one" when multiple
12
+ archives are plausible.
13
+
14
+ Expected operating model:
15
+ - An agent or human identifies the target session key and target session id first.
16
+ - This script performs the safe on-disk switch.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import shutil
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Optional, Tuple
26
+
27
+
28
+ def now_tag() -> str:
29
+ return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
30
+
31
+
32
+ def fail(msg: str, code: int = 1):
33
+ print(msg, file=sys.stderr)
34
+ raise SystemExit(code)
35
+
36
+
37
+ def load_json(path: Path):
38
+ with path.open('r', encoding='utf-8') as f:
39
+ return json.load(f)
40
+
41
+
42
+ def save_json(path: Path, data):
43
+ with path.open('w', encoding='utf-8') as f:
44
+ json.dump(data, f, ensure_ascii=False, indent=2)
45
+ f.write('\n')
46
+
47
+
48
+ def backup_file(path: Path, suffix: str) -> Path:
49
+ """Create a timestamped backup next to the original file.
50
+
51
+ This is the core safety feature that makes mistaken switches reversible.
52
+ """
53
+ tag = now_tag()
54
+ backup = path.with_name(f"{path.name}.{suffix}.{tag}")
55
+ shutil.copy2(path, backup)
56
+ return backup
57
+
58
+
59
+ def ensure_target_plain_file(sessions_dir: Path, target_session_id: str, archive_path: Optional[Path]) -> Tuple[Path, Optional[Path]]:
60
+ """Ensure the target `<sessionId>.jsonl` exists.
61
+
62
+ OpenClaw often leaves historical transcripts as `*.jsonl.reset.<timestamp>`.
63
+ A rollback is not complete until the plain `.jsonl` file exists again.
64
+ """
65
+ plain = sessions_dir / f'{target_session_id}.jsonl'
66
+ restored_from = None
67
+ if plain.exists():
68
+ return plain, restored_from
69
+
70
+ if archive_path and archive_path.exists():
71
+ shutil.copy2(archive_path, plain)
72
+ restored_from = archive_path
73
+ return plain, restored_from
74
+
75
+ # Fallback: auto-find matching archive for target sid
76
+ matches = sorted(sessions_dir.glob(f'{target_session_id}.jsonl.reset.*'))
77
+ if len(matches) == 1:
78
+ shutil.copy2(matches[0], plain)
79
+ restored_from = matches[0]
80
+ return plain, restored_from
81
+ if len(matches) > 1:
82
+ fail(f'Ambiguous target archive for {target_session_id}; pass --archive explicitly.')
83
+
84
+ fail(f'Target transcript missing: neither {plain.name} nor a usable archive was found.')
85
+
86
+
87
+ def main():
88
+ p = argparse.ArgumentParser(description='Repoint an OpenClaw session key to a previous transcript version safely.')
89
+ p.add_argument('--agent', required=True, help='Agent id, e.g. daidai')
90
+ p.add_argument('--key', required=True, help='Full session key, e.g. agent:daidai:main')
91
+ p.add_argument('--target-session-id', required=True, help='Session id to restore to')
92
+ p.add_argument('--archive', help='Optional explicit archive path to restore from')
93
+ p.add_argument('--allow-missing-current-file', action='store_true', help='Do not fail if current sessionFile is missing')
94
+ args = p.parse_args()
95
+
96
+ sessions_dir = Path.home() / '.openclaw' / 'agents' / args.agent / 'sessions'
97
+ sessions_json = sessions_dir / 'sessions.json'
98
+ if not sessions_json.exists():
99
+ fail(f'sessions.json not found: {sessions_json}')
100
+
101
+ data = load_json(sessions_json)
102
+ if args.key not in data or not isinstance(data[args.key], dict):
103
+ fail(f'Session key not found: {args.key}')
104
+
105
+ item = data[args.key]
106
+ current_session_id = item.get('sessionId')
107
+ current_session_file = Path(item.get('sessionFile', '')) if item.get('sessionFile') else None
108
+ if not current_session_id:
109
+ fail(f'Current sessionId missing for {args.key}')
110
+ if not current_session_file:
111
+ fail(f'Current sessionFile missing for {args.key}')
112
+ if not current_session_file.exists() and not args.allow_missing_current_file:
113
+ fail(f'Current sessionFile does not exist: {current_session_file}')
114
+
115
+ archive_path = Path(args.archive).expanduser() if args.archive else None
116
+ if archive_path and not archive_path.exists():
117
+ fail(f'Archive path does not exist: {archive_path}')
118
+
119
+ # Safety backups: always preserve the current state before switching.
120
+ sessions_json_backup = backup_file(sessions_json, 'bak.manual-switch')
121
+ current_file_backup = None
122
+ if current_session_file.exists():
123
+ current_file_backup = backup_file(current_session_file, 'manual-switch-backup')
124
+
125
+ target_plain, restored_from = ensure_target_plain_file(sessions_dir, args.target_session_id, archive_path)
126
+
127
+ item['sessionId'] = args.target_session_id
128
+ item['sessionFile'] = str(target_plain)
129
+ item['updatedAt'] = int(target_plain.stat().st_mtime * 1000)
130
+ save_json(sessions_json, data)
131
+
132
+ result = {
133
+ 'ok': True,
134
+ 'agent': args.agent,
135
+ 'key': args.key,
136
+ 'fromSessionId': current_session_id,
137
+ 'fromSessionFile': str(current_session_file),
138
+ 'fromSessionFileBackup': str(current_file_backup) if current_file_backup else None,
139
+ 'sessionsJsonBackup': str(sessions_json_backup),
140
+ 'toSessionId': args.target_session_id,
141
+ 'toSessionFile': str(target_plain),
142
+ 'restoredTargetFromArchive': str(restored_from) if restored_from else None,
143
+ 'note': 'Gateway restart may still be needed if runtime has cached session state.'
144
+ }
145
+ print(json.dumps(result, ensure_ascii=False, indent=2))
146
+
147
+
148
+ if __name__ == '__main__':
149
+ main()