@aiyiran/myclaw 1.1.124 → 1.1.128

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/index.js CHANGED
@@ -107,7 +107,7 @@ function runInstall() {
107
107
  // 重装命令 (mci)
108
108
  // ============================================================================
109
109
 
110
- const OPENCLAW_VERSION = '2026.4.5';
110
+ const OPENCLAW_VERSION = '2026.5.7';
111
111
 
112
112
  function runReinstall() {
113
113
  const bar = '────────────────────────────────────────';
@@ -212,7 +212,7 @@ function runReinstall() {
212
212
 
213
213
  function runLock42() {
214
214
  const bar = '────────────────────────────────────────';
215
- const TARGET = '2026.4.2';
215
+ const TARGET = OPENCLAW_VERSION;
216
216
 
217
217
  console.log('');
218
218
  console.log(bar);
@@ -1383,8 +1383,8 @@ function runUpdate() {
1383
1383
 
1384
1384
  if (detectPlatform() === 'wsl' || detectPlatform() === 'linux' || detectPlatform() === 'mac') {
1385
1385
  console.log('');
1386
- console.log('如果需要同时升级 OpenClaw 核心,请运行:');
1387
- console.log(' ' + colors.yellow + 'myclaw install' + colors.nc);
1386
+ console.log('如需重装 OpenClaw 核心(锁定版本 ' + OPENCLAW_VERSION + '),请运行:');
1387
+ console.log(' ' + colors.yellow + 'mc longxia' + colors.nc);
1388
1388
  }
1389
1389
  break;
1390
1390
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.1.124",
3
+ "version": "1.1.128",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -13,8 +13,6 @@ import threading
13
13
  import socketserver
14
14
  from http.server import HTTPServer, BaseHTTPRequestHandler
15
15
  from urllib.parse import urlparse, parse_qs, unquote
16
- from watchdog.observers import Observer
17
- from watchdog.events import FileSystemEventHandler
18
16
  from qiniu import Auth, put_file_v2, CdnManager, BucketManager
19
17
 
20
18
  # 确保同目录的共享模块可导入
@@ -63,7 +61,9 @@ HISTORY_FILENAME = "history.json"
63
61
  # 回滚压制表:{ abs_path: 写入时间戳 }
64
62
  # 回滚时往 workspace 写文件会触发 on_modified,用此表跳过快照,避免产生多余版本
65
63
  _rollback_suppressed: dict = {}
66
- _ROLLBACK_SUPPRESS_TTL = 3.0 #
64
+ # 轮询模式下,回滚写盘后要等到下一轮扫描才会被发现,故 TTL 必须 > 轮询间隔,
65
+ # 否则压制提前过期会导致回滚被误判为"修改"而多生成一个历史快照。
66
+ _ROLLBACK_SUPPRESS_TTL = 30.0 # 秒
67
67
 
68
68
  # 时间窗口合并:同一文件在此时间窗口内的多次变更只保留最新快照(覆盖,不新增版本)
69
69
  _HISTORY_MERGE_WINDOW = 20.0 # 秒
@@ -226,53 +226,8 @@ def _path_has_ignored_dir(filepath):
226
226
  return any(part in IGNORE_DIR_PATTERNS for part in parts)
227
227
 
228
228
 
229
- class MyHandler(FileSystemEventHandler):
230
-
231
- def _is_file_event(self, event):
232
- return not getattr(event, "is_directory", False)
233
-
234
- def _should_ignore(self, filepath):
235
- basename = os.path.basename(filepath)
236
- if basename in IGNORE_FILE_PATTERNS:
237
- return True
238
- if _path_has_ignored_dir(filepath):
239
- return True
240
- # 忽略 .openclaw 根目录下的隐藏文件(非 workspace 目录内的)
241
- if basename.startswith('.') and 'workspace' not in filepath:
242
- return True
243
- return False
244
-
245
- # def on_created(self, event):
246
- # if not self._is_file_event(event):
247
- # return
248
- # print(f"🟢 新建: {event.src_path}")
249
- # file_gen(event.src_path, "add")
250
-
251
- def on_modified(self, event):
252
- if not self._is_file_event(event):
253
- return
254
- if self._should_ignore(event.src_path):
255
- return
256
- print(f"🟡 修改: {event.src_path}")
257
- file_gen(event.src_path, "add")
258
-
259
- def on_deleted(self, event):
260
- if not self._is_file_event(event):
261
- return
262
- if self._should_ignore(event.src_path):
263
- return
264
- print(f"🔴 删除: {event.src_path}")
265
- file_gen(event.src_path, "delete")
266
-
267
- def on_moved(self, event):
268
- if not self._is_file_event(event):
269
- return
270
- if self._should_ignore(event.src_path):
271
- return
272
- print(f"🔵 移动: {event.src_path} -> {getattr(event, 'dest_path', '')}")
273
- file_gen(event.src_path, "delete")
274
- if getattr(event, 'dest_path', ''):
275
- file_gen(event.dest_path, "add")
229
+ # 注:原 watchdog 事件监听器 MyHandler 已移除,
230
+ # 改为轮询对账引擎(见下方「轮询对账同步引擎」段落 / reconcile_loop)。
276
231
 
277
232
 
278
233
  def now_iso():
@@ -347,65 +302,201 @@ def init_config(workspace_id, file_path, method="add"):
347
302
  json.dump(data, f, ensure_ascii=False, indent=2)
348
303
 
349
304
 
350
- def file_gen(path, method):
351
- # 规范化路径,兼容 Windows 和 *nix
352
- norm_path = os.path.normpath(path)
353
- parts = norm_path.split(os.sep)
305
+ # ═══════════════════════════════════════════════════════════════
306
+ # 轮询对账同步引擎(替代 watchdog 事件监听)
307
+ #
308
+ # 设计原则(AK47:少零件、自愈、可排查):
309
+ # · 磁盘是唯一真相,七牛是镜像,manifest.json 只是加速缓存
310
+ # · 每轮:扫描 → 对比 size+mtime → 变化的顺序上传(同 key 覆盖,天然去重)
311
+ # · 服务中断不丢:重启后扫描发现「磁盘有 / manifest 无」自动补传
312
+ # · manifest 损坏/丢失 → 当空清单 → 自动全量重传(覆盖无副作用)
313
+ # · 单文件出错只跳过 + 退避重试,循环永不退出
314
+ # · 本地删除:七牛永久保留,仅从 manifest 和 artifacts.json 移除本地记录
315
+ # ═══════════════════════════════════════════════════════════════
354
316
 
355
- # 查找包含 workspace 标识的分段(例如 workspace 或 workspace-xxx
356
- space_idx = -1
357
- for i, p in enumerate(parts):
358
- if "workspace" in p:
359
- space_idx = i
360
- break
317
+ # 扫描间隔(秒),可用环境变量覆盖;扫描本身极快(约 1.8 万文件 < 0.3s
318
+ SYNC_INTERVAL = float(os.environ.get("MYCLAW_SYNC_INTERVAL", "5"))
319
+
320
+ # manifest:单一状态文件,记录已上传文件的 [size, mtime]
321
+ MANIFEST_PATH = os.path.join(get_openclaw_path(), ".myclaw-sync-manifest.json")
322
+
323
+ # 每上传 N 个文件 checkpoint 一次 manifest(抗崩溃,首次全量时尤其重要)
324
+ _MANIFEST_CHECKPOINT_EVERY = 50
325
+
326
+ # 失败退避表(内存,进程重启即清空 = 重启等于自然重试一遍)
327
+ # { rel: {"retries": n, "next_try": ts} }
328
+ _upload_failures = {}
329
+ _RETRY_BACKOFF = [10, 30, 60, 120, 300] # 秒,指数退避封顶 5 分钟
330
+
331
+ # 连续无变化时,每隔多少轮打一次心跳(既不刷屏,又能用时间戳证明进程活着)
332
+ _HEARTBEAT_EVERY = 12
333
+
334
+
335
+ def _eng_log(tag, msg):
336
+ """结构化单行日志,便于 AI 排查(grep BOOT / SCAN / PUT / HEAL / ERR)"""
337
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {tag:<5} {msg}", flush=True)
338
+
339
+
340
+ def load_manifest():
341
+ """读取 manifest;任何异常都当空清单 → 触发全量重传(自愈,覆盖无副作用)"""
342
+ try:
343
+ with open(MANIFEST_PATH, "r", encoding="utf-8") as f:
344
+ data = json.load(f)
345
+ if isinstance(data, dict):
346
+ return data
347
+ _eng_log("HEAL", "manifest 格式异常 → 当空清单,本轮全量重传")
348
+ except FileNotFoundError:
349
+ pass
350
+ except Exception as e:
351
+ _eng_log("HEAL", f"manifest 损坏({e})→ 当空清单,本轮全量重传")
352
+ return {}
361
353
 
362
- if space_idx == -1:
363
- # 路径中不包含 workspace,不处理
364
- return
365
354
 
366
- space_id = parts[space_idx]
367
- # 相对路径:workspace 后面的所有段,使用 '/' 作为存储格式(保证跨平台一致)
368
- relative_parts = parts[space_idx + 1:]
369
- if not relative_parts:
370
- # 没有文件名,可能是目录事件,忽略
355
+ def save_manifest(manifest):
356
+ """原子写:临时文件 + os.replace,断电也不会写出半个文件"""
357
+ tmp = MANIFEST_PATH + ".tmp"
358
+ try:
359
+ with open(tmp, "w", encoding="utf-8") as f:
360
+ json.dump(manifest, f, ensure_ascii=False)
361
+ os.replace(tmp, MANIFEST_PATH)
362
+ except Exception as e:
363
+ _eng_log("ERR", f"manifest 写入失败: {e}")
364
+
365
+
366
+ def scan_workspace_files():
367
+ """扫描所有 workspace* 目录,返回 { rel_path: [size, mtime] }。
368
+ rel_path 相对 .openclaw 根、含 workspace 段,例如 workspace/学生作品报表/x.html"""
369
+ base_path = get_openclaw_path()
370
+ result = {}
371
+ for entry in os.scandir(base_path):
372
+ if not (entry.is_dir() and "workspace" in entry.name):
373
+ continue
374
+ for root, dirs, files in os.walk(entry.path):
375
+ # 剪枝:跳过忽略目录(.git / node_modules / __history__ 等),不递归进入
376
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIR_PATTERNS]
377
+ for fn in files:
378
+ if fn == ARTIFACTS_FILENAME or fn in IGNORE_FILE_PATTERNS:
379
+ continue
380
+ abs_path = os.path.join(root, fn)
381
+ try:
382
+ st = os.stat(abs_path)
383
+ except OSError:
384
+ continue
385
+ rel = os.path.relpath(abs_path, base_path).replace(os.sep, "/")
386
+ result[rel] = [st.st_size, int(st.st_mtime)]
387
+ return result
388
+
389
+
390
+ def upload_one(rel_path, save_history):
391
+ """上传单个文件到七牛 + 写入 artifacts.json 记录。
392
+ rel_path 相对 .openclaw 根(含 workspace 段)。失败时抛异常由调用方处理。"""
393
+ base_path = get_openclaw_path()
394
+ abs_path = os.path.join(base_path, rel_path)
395
+ parts = rel_path.split("/")
396
+ space_id = parts[0]
397
+ relative_path = "/".join(parts[1:])
398
+ if not relative_path:
371
399
  return
372
- relative_path = "/".join(relative_parts)
400
+ key = f"{claw}/{rel_path}"
401
+
402
+ # 历史快照:仅对「已存在又变化」的文件存档(首次全量不存,避免快照爆炸);
403
+ # 回滚写盘触发的变更通过压制表跳过,避免多生成版本。
404
+ is_artifacts = os.path.basename(abs_path) == ARTIFACTS_FILENAME
405
+ if save_history and not is_artifacts and not _is_rollback_suppressed(abs_path):
406
+ try:
407
+ save_history_version(space_id, relative_path, abs_path)
408
+ except Exception as e:
409
+ _eng_log("ERR", f"history 快照失败 {relative_path}: {e}")
373
410
 
374
- # 上传 key 保留 workspace 段(用于 CDN 存储),但写入 JSON 时只保存相对路径
375
- path_url = f"{space_id}/{relative_path}"
376
- key = f"{claw}/{path_url}"
411
+ # 上传到七牛(同 key 覆盖 = 天然去重,不会产生多份)
412
+ token = q.upload_token(bucket_name, key, 3600)
413
+ ret, info = put_file_v2(token, key, abs_path)
414
+ if not (info and getattr(info, "status_code", None) == 200):
415
+ raise RuntimeError(f"七牛上传失败 status={getattr(info, 'status_code', '?')}")
416
+ cdn_manager.refresh_urls([f"https://cdn.yiranlaoshi.com/{key}"])
417
+
418
+ # __MY_ARTIFACTS__.json 本身不写入记录(防循环);普通文件写入 artifacts 列表
419
+ if not is_artifacts:
420
+ init_config(space_id, relative_path, method="add")
377
421
 
378
- if method == "delete":
379
- # 删除时不需要上传,只清理配置文件记录,传入的 file_path 不包含 space_id(relative_path)
380
- init_config(space_id, relative_path, method="delete")
381
- print(f"🗑️ 已删除配置记录: {relative_path}")
382
- else:
383
- # ── 历史版本:把当前内容作为快照存档(仅覆盖行为,非配置文件)──
384
- # 回滚写盘触发的 on_modified 跳过,避免产生多余版本
385
- if os.path.basename(path) != ARTIFACTS_FILENAME and not _is_rollback_suppressed(path):
386
- save_history_version(space_id, relative_path, path)
387
422
 
388
- # ── 上传到 OSS ───────────────────────────────────────────
423
+ def reconcile_loop():
424
+ """主循环:每 SYNC_INTERVAL 秒对账一次。整个循环包 try,任何异常都不退出。"""
425
+ base_path = get_openclaw_path()
426
+ manifest = load_manifest()
427
+ _eng_log("BOOT",
428
+ f"claw={claw} interval={SYNC_INTERVAL}s 监听={base_path}/workspace* "
429
+ f"忽略={','.join(sorted(IGNORE_DIR_PATTERNS))} manifest={len(manifest)}条")
430
+
431
+ idle_rounds = 0
432
+ while True:
433
+ t0 = time.time()
389
434
  try:
390
- token = q.upload_token(bucket_name, key, 3600)
391
- # 直接使用 put_file_v2 上传文件路径,避免读取到空内容时导致 SDK 报错缺少 data 参数
392
- ret, info = put_file_v2(token, key, path)
393
-
394
- # 刷新 CDN
395
- cdn_url = f"https://cdn.yiranlaoshi.com/{key}"
396
- cdn_ret, cdn_info = cdn_manager.refresh_urls([cdn_url])
397
-
398
- # 如果是 __MY_ARTIFACTS__.json 配置文件,只上传不写入(防止循环触发)
399
- if os.path.basename(path) == ARTIFACTS_FILENAME:
400
- print(f"🚀 已上传配置文件: {relative_path}")
401
- print(f"风 CDN刷新: {cdn_url} - {cdn_info.status_code if info else 'unknown'}")
435
+ current = scan_workspace_files()
436
+ now = time.time()
437
+
438
+ # 1) 找出需要上传的:新文件 or size/mtime 变化
439
+ changed = [rel for rel, sm in current.items() if manifest.get(rel) != sm]
440
+
441
+ # 2) 顺序上传(单线程,日志线性可读,无锁无竞态)
442
+ uploaded = failed = skipped = 0
443
+ since_ckpt = 0
444
+ for rel in changed:
445
+ fb = _upload_failures.get(rel)
446
+ if fb and now < fb["next_try"]:
447
+ skipped += 1 # 退避中,本轮先跳过
448
+ continue
449
+ is_update = rel in manifest # 已在清单 = 修改,触发 history 快照
450
+ try:
451
+ upload_one(rel, save_history=is_update)
452
+ manifest[rel] = current[rel]
453
+ _upload_failures.pop(rel, None)
454
+ uploaded += 1
455
+ since_ckpt += 1
456
+ _eng_log("PUT", f"ok {rel} {current[rel][0]}B")
457
+ if since_ckpt >= _MANIFEST_CHECKPOINT_EVERY:
458
+ save_manifest(manifest)
459
+ since_ckpt = 0
460
+ except Exception as e:
461
+ failed += 1
462
+ n = _upload_failures.get(rel, {}).get("retries", 0) + 1
463
+ backoff = _RETRY_BACKOFF[min(n - 1, len(_RETRY_BACKOFF) - 1)]
464
+ _upload_failures[rel] = {"retries": n, "next_try": now + backoff}
465
+ _eng_log("PUT", f"FAIL {rel} 原因={e} 重试={n} 下次+{backoff}s")
466
+
467
+ # 3) 清理 manifest 中磁盘已删的条目:七牛永久保留,仅同步本地视图
468
+ # (从 manifest 移除 + 从 artifacts.json 剔除该记录,但不删七牛)
469
+ stale = [k for k in manifest if k not in current]
470
+ for k in stale:
471
+ manifest.pop(k, None)
472
+ _upload_failures.pop(k, None)
473
+ p = k.split("/")
474
+ if len(p) >= 2:
475
+ try:
476
+ init_config(p[0], "/".join(p[1:]), method="delete")
477
+ except Exception:
478
+ pass
479
+
480
+ if uploaded or stale:
481
+ save_manifest(manifest)
482
+
483
+ dt = (time.time() - t0) * 1000
484
+ if changed or stale:
485
+ idle_rounds = 0
486
+ _eng_log("SCAN",
487
+ f"文件={len(current)} 变化={len(changed)} 上传={uploaded} "
488
+ f"失败={failed} 跳过={skipped} 删除={len(stale)} 耗时={dt:.0f}ms")
402
489
  else:
403
- # 记录时使用不包含 workspace 的相对路径
404
- init_config(space_id, relative_path, method="add")
405
- print(f"🚀 已上传并记录: {relative_path}")
406
- print(f"风 CDN刷新: {cdn_url} - {cdn_info.status_code if info else 'unknown'}")
490
+ idle_rounds += 1
491
+ if idle_rounds % _HEARTBEAT_EVERY == 0:
492
+ _eng_log("SCAN", f"文件={len(current)} 变化=0 (心跳) 耗时={dt:.0f}ms")
493
+
407
494
  except Exception as e:
408
- print(f"❌ 上传/读取文件失败: {e}")
495
+ import traceback
496
+ _eng_log("ERR", f"对账循环异常(已忽略,循环继续): {e}")
497
+ _eng_log("ERR", traceback.format_exc().replace("\n", " | "))
498
+
499
+ time.sleep(SYNC_INTERVAL)
409
500
 
410
501
 
411
502
  def sync_all(workspace_id):
@@ -1491,33 +1582,23 @@ if __name__ == "__main__":
1491
1582
  API_PORT = args.port
1492
1583
 
1493
1584
  base_path = get_openclaw_path()
1494
- path = base_path
1495
1585
 
1496
1586
  # 启动时清理所有 workspace 中的忽略文件记录
1497
1587
  for entry in os.scandir(base_path):
1498
1588
  if entry.is_dir() and "workspace" in entry.name:
1499
1589
  cleanup_ignored_assets(entry.name)
1500
1590
 
1501
- # 如果指定了 --agent,先全量同步
1591
+ # 如果指定了 --agent,先全量同步(向后兼容旧调用方式)
1502
1592
  if args.agent:
1503
1593
  if not sync_all(args.agent):
1504
1594
  sys.exit(1)
1505
1595
  print("")
1506
1596
 
1507
- # 启动 HTTP API 服务
1597
+ # 启动 HTTP API 服务(前端业务接口,原样保留)
1508
1598
  start_api_server()
1509
1599
 
1510
- event_handler = MyHandler()
1511
- observer = Observer()
1512
- observer.schedule(event_handler, path, recursive=True)
1513
-
1514
- observer.start()
1515
- print(f"开始监听目录: {path}")
1516
-
1600
+ # 启动轮询对账引擎(前台主循环,替代 watchdog 事件监听)
1517
1601
  try:
1518
- while True:
1519
- time.sleep(1)
1602
+ reconcile_loop()
1520
1603
  except KeyboardInterrupt:
1521
- observer.stop()
1522
-
1523
- observer.join()
1604
+ print("\n[exit] 收到中断,退出")