dsh-deepmoyu 2.7.1 → 2.8.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.
Files changed (59) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +43 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/README.md +10 -3
  5. package/SECURITY.md +4 -0
  6. package/client.js +509 -296
  7. package/docs/ACTIVITY_TUNING.md +50 -0
  8. package/docs/CONTINUOUS_BROWSING.md +35 -0
  9. package/docs/FUN_FEATURE_REFERENCES.md +128 -0
  10. package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
  11. package/docs/NATURAL_AUTONOMY.md +91 -0
  12. package/docs/PRIVACY.md +7 -0
  13. package/docs/RELEASE_2_8_0.md +27 -0
  14. package/docs/RELEASE_AUDIT.md +16 -2
  15. package/docs/RELEASING.md +15 -0
  16. package/docs/TEST_MATRIX.md +9 -0
  17. package/docs/WORKFLOW_REFERENCES.md +57 -0
  18. package/docs/tool-evidence.json +6 -1
  19. package/index.js +941 -268
  20. package/lib/asr.py +22 -0
  21. package/lib/background-tools.mjs +1 -1
  22. package/lib/browse-preferences.mjs +154 -0
  23. package/lib/browse-progress.mjs +165 -0
  24. package/lib/comment.mjs +29 -46
  25. package/lib/config-policy.mjs +2 -1
  26. package/lib/curiosity.mjs +153 -0
  27. package/lib/favops.py +38 -6
  28. package/lib/learn-review.mjs +13 -3
  29. package/lib/learn-store.mjs +7 -0
  30. package/lib/learn.mjs +234 -69
  31. package/lib/login.mjs +20 -3
  32. package/lib/mcp.mjs +5 -2
  33. package/lib/memory-center.mjs +21 -19
  34. package/lib/mirror.mjs +15 -1
  35. package/lib/mode.mjs +23 -9
  36. package/lib/model-relay-adapter.mjs +1 -1
  37. package/lib/model-relay.mjs +21 -4
  38. package/lib/pace.mjs +15 -7
  39. package/lib/persona.mjs +41 -30
  40. package/lib/pet-session.mjs +9 -3
  41. package/lib/product-status.mjs +38 -2
  42. package/lib/prompts.mjs +38 -37
  43. package/lib/pv-session.mjs +16 -1
  44. package/lib/pv.mjs +18 -3
  45. package/lib/pv.py +168 -30
  46. package/lib/pv_cover.py +11 -1
  47. package/lib/pv_editorial.py +28 -4
  48. package/lib/pv_plan.py +20 -2
  49. package/lib/pv_verify.py +14 -0
  50. package/lib/session-continuation.mjs +47 -0
  51. package/lib/state.mjs +6 -2
  52. package/lib/sticker-automation.mjs +16 -2
  53. package/lib/sticker.mjs +4 -1
  54. package/lib/uvfetch.mjs +38 -12
  55. package/package.json +1 -1
  56. package/presets/deepmoyu.patch.yml +8 -10
  57. package/prompts/duty.md +30 -38
  58. package/vendor/bilibili-mcp/mcp_server.py +204 -47
  59. package/vendor/bilibili-mcp/runtime_guard.py +164 -31
@@ -1,7 +1,8 @@
1
1
  """Durable, cross-process deduplication for both Bilibili MCP entry paths.
2
2
 
3
- Only state.json and seen.md are read for migration. No credential or message body
4
- is read into this database. Send reservations never expire automatically: after a
3
+ Only state.json and seen.md are read for migration. Public subtitle snapshots are
4
+ cached separately from permanent history. No credentials or private messages are
5
+ read into this database. Send reservations never expire automatically: after a
5
6
  crash or an ambiguous network outcome, retrying could publish a second message.
6
7
  """
7
8
  from __future__ import annotations
@@ -22,14 +23,16 @@ from pathlib import Path
22
23
  BV_RE = re.compile(r"^BV[0-9A-Za-z]{10}$")
23
24
  READ_LEASE_SECONDS = 600
24
25
  READ_TIMEOUT_SECONDS = 180
26
+ SUBTITLE_CACHE_MAX = 256
27
+ SUBTITLE_CACHE_BYTES = 2_000_000
25
28
 
26
29
  # Same limits as lib/pace.mjs; SQLite is the common floor for GUI and native MCP.
27
30
  PACE = {
28
- "comment": {"gap": 120, "jitter": 60, "hour": 8},
29
- "reply": {"gap": 60, "jitter": 30, "hour": 15},
30
- "opusReply": {"gap": 20, "jitter": 10, "hour": 30},
31
- "dm": {"gap": 30, "jitter": 15, "hour": 20},
32
- "moment": {"gap": 600, "jitter": 300, "hour": 1},
31
+ "comment": {"gap": 120, "jitter": 60, "hour": 16},
32
+ "reply": {"gap": 60, "jitter": 30, "hour": 30},
33
+ "opusReply": {"gap": 20, "jitter": 10, "hour": 40},
34
+ "dm": {"gap": 30, "jitter": 15, "hour": 30},
35
+ "moment": {"gap": 600, "jitter": 300, "hour": 2},
33
36
  }
34
37
  BURST_WINDOW, BURST_MAX, BURST_EXTRA = 600, 10, 180
35
38
  PACE_KEEP = 7200
@@ -45,6 +48,10 @@ def _finite(value, default=0):
45
48
  return float(default)
46
49
 
47
50
 
51
+ def _pace_token(value):
52
+ return value.lower() if isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{32}", value) else ""
53
+
54
+
48
55
  def pace_kind(key):
49
56
  prefix = str(key).split(":", 1)[0]
50
57
  return {"nu": "comment", "video": "reply", "opus": "opusReply",
@@ -122,8 +129,12 @@ def stringify_ids(value):
122
129
  result[key] = str(item) # Python SDK integers are exact, unlike JSON numbers in JS.
123
130
  elif isinstance(item, str) and re.fullmatch(r"[0-9]+", item.strip()):
124
131
  result[key] = item.strip().lstrip("0") or "0"
132
+ elif isinstance(item, str):
133
+ # P3-44: 非数值字符串原样保留({"id":"hot-word-12"} 不能被静默清成 "",
134
+ # 模型会把「有数据」误读成「没有」);空串在这里自然仍是空串(缺失)。
135
+ result[key] = item
125
136
  else:
126
- result[key] = "" # unavailable/malformed is never guessed as another ID
137
+ result[key] = "" # 空值/非字符串仍按「缺失」处理,绝不猜成另一个 ID
127
138
  else:
128
139
  result[key] = stringify_ids(item)
129
140
  return result
@@ -234,14 +245,23 @@ class RuntimeGuard:
234
245
  conn.execute("CREATE INDEX IF NOT EXISTS guard_pace_kind_at ON guard_pace_events(kind,at)")
235
246
  conn.execute("CREATE TABLE IF NOT EXISTS guard_pace_state ("
236
247
  "name TEXT PRIMARY KEY, value TEXT NOT NULL)")
248
+ conn.execute("CREATE TABLE IF NOT EXISTS subtitle_cache ("
249
+ "bvid TEXT PRIMARY KEY, fetched REAL NOT NULL, payload TEXT NOT NULL)")
250
+ conn.execute("CREATE TABLE IF NOT EXISTS subtitle_reads ("
251
+ "bvid TEXT PRIMARY KEY, token TEXT NOT NULL, expires REAL NOT NULL)")
237
252
  finally:
238
253
  conn.close()
239
254
 
240
255
  def _connect(self):
241
- conn = sqlite3.connect(str(self.db_path), timeout=15, isolation_level=None)
242
- conn.row_factory = sqlite3.Row
243
- conn.execute("PRAGMA busy_timeout=15000")
244
- conn.execute("PRAGMA synchronous=FULL")
256
+ try:
257
+ conn = sqlite3.connect(str(self.db_path), timeout=15, isolation_level=None)
258
+ conn.row_factory = sqlite3.Row
259
+ conn.execute("PRAGMA busy_timeout=15000")
260
+ conn.execute("PRAGMA synchronous=FULL")
261
+ except sqlite3.OperationalError as exc:
262
+ # E2: node/python 临时目录解析不一致时这里报「unable to open database file」
263
+ # 且不带路径,排障只能盲猜 —— 报错必须带上 db_path 本身。
264
+ raise RuntimeError(f"runtime guard 打不开账本库 {self.db_path}: {exc}") from exc
245
265
  return conn
246
266
 
247
267
  @contextmanager
@@ -296,17 +316,19 @@ class RuntimeGuard:
296
316
  if isinstance(row, dict):
297
317
  at = _finite(row.get("at"))
298
318
  nxt = _finite(row.get("next"))
299
- stored.append((kind, at, nxt if nxt > 0 else None))
319
+ stored.append((kind, at, nxt if nxt > 0 else None, row.get("token") if "token" in row else None))
300
320
  else:
301
- stored.append((kind, _finite(row), None))
321
+ stored.append((kind, _finite(row), None, None))
302
322
 
303
- def recorded_next(kind, at):
304
- for s_kind, s_at, s_next in stored:
305
- if s_kind == kind and s_next and abs(s_at - at) <= 2000:
323
+ def recorded_next(kind, at, token):
324
+ for s_kind, s_at, s_next, s_token in stored:
325
+ same_event = (_pace_token(token) == _pace_token(s_token) and bool(_pace_token(token))
326
+ if token is not None else s_token is None and abs(s_at - at) <= 2000)
327
+ if s_kind == kind and s_next and same_event:
306
328
  return s_next
307
329
  return None
308
330
 
309
- def event(kind, stamp, next_stamp=None, pinned=False):
331
+ def event(kind, stamp, next_stamp=None, pinned=False, token=None):
310
332
  at = _finite(stamp) / 1000
311
333
  if kind not in PACE or at <= 0:
312
334
  return
@@ -314,12 +336,24 @@ class RuntimeGuard:
314
336
  free = max(at, free)
315
337
  if at < now - PACE_KEEP and free <= now:
316
338
  return
317
- # GUI records success just after Python does. Reconcile that one event,
318
- # not a second send; distinct pre-upgrade seed records remain distinct.
319
- native = conn.execute("SELECT event_key,next FROM guard_pace_events "
320
- "WHERE kind=? AND status='sent' AND token<>'' AND ABS(at-?)<=2 "
321
- "ORDER BY ABS(at-?) LIMIT 1", (kind, at, at)).fetchone()
339
+ # The host may finish its state lock seconds after Python committed. A receipt
340
+ # token identifies that exact send without consuming another hourly/burst slot.
341
+ # Only old records with no token use the legacy two-second migration heuristic.
342
+ valid_token = _pace_token(token)
343
+ if valid_token:
344
+ native = conn.execute("SELECT event_key,next FROM guard_pace_events "
345
+ "WHERE kind=? AND status='sent' AND token=? LIMIT 1",
346
+ (kind, valid_token)).fetchone()
347
+ elif token is None:
348
+ native = conn.execute("SELECT event_key,next FROM guard_pace_events "
349
+ "WHERE kind=? AND status='sent' AND token<>'' AND ABS(at-?)<=2 "
350
+ "ORDER BY ABS(at-?) LIMIT 1", (kind, at, at)).fetchone()
351
+ else:
352
+ native = None
322
353
  if native:
354
+ if valid_token:
355
+ conn.execute("DELETE FROM guard_pace_events WHERE kind=? AND status='seed' AND token=?",
356
+ (kind, valid_token))
323
357
  # The host already pinned this interval (one random draw, shown in the GUI).
324
358
  # Python's own independent draw must not silently lengthen it — otherwise the
325
359
  # UI says "40s left" while the native guard still waits 90s.
@@ -330,19 +364,20 @@ class RuntimeGuard:
330
364
  conn.execute("UPDATE guard_pace_events SET next=MAX(next,?) WHERE event_key=?",
331
365
  (free, native["event_key"]))
332
366
  return
333
- key = f"seed:{kind}:{int(round(at * 1000))}"
367
+ key = f"seed:{kind}:token:{valid_token}" if valid_token else f"seed:{kind}:{int(round(at * 1000))}"
334
368
  conn.execute("INSERT INTO guard_pace_events(event_key,kind,at,next,status,token) "
335
- "VALUES (?,?,?,?,'seed','') ON CONFLICT(event_key) DO UPDATE SET next=MAX(next,excluded.next)",
336
- (key, kind, at, free))
369
+ "VALUES (?,?,?,?,'seed',?) ON CONFLICT(event_key) DO UPDATE SET next=MAX(next,excluded.next)",
370
+ (key, kind, at, free, valid_token))
337
371
 
338
372
  recent = raw.get("recent")
339
373
  for row in (recent[-200:] if isinstance(recent, list) else []):
340
374
  if isinstance(row, dict):
341
375
  kind, at = str(row.get("k") or ""), _finite(row.get("t"))
342
- nxt = recorded_next(kind, at)
343
- event(kind, at, nxt, pinned=nxt is not None)
344
- for kind, at, nxt in stored:
345
- event(kind, at, nxt, pinned=nxt is not None)
376
+ token = row.get("token") if "token" in row else None
377
+ nxt = recorded_next(kind, at, token)
378
+ event(kind, at, nxt, pinned=nxt is not None, token=token)
379
+ for kind, at, nxt, token in stored:
380
+ event(kind, at, nxt, pinned=nxt is not None, token=token)
346
381
  book = self._pace_state(conn)
347
382
  cool = _finite(raw.get("cooldownUntil")) / 1000
348
383
  if cool > book["cooldown_until"]:
@@ -585,6 +620,81 @@ class RuntimeGuard:
585
620
  raise ValueError("invalid bvid")
586
621
  return self._reserve("seen:" + bvid, "read")
587
622
 
623
+ def cached_subtitle(self, bvid):
624
+ """Read a bounded public transcript; history alone is never transcript evidence."""
625
+ bvid = str(bvid).strip()
626
+ if not BV_RE.fullmatch(bvid):
627
+ raise ValueError("invalid bvid")
628
+ conn = self._connect()
629
+ try:
630
+ row = conn.execute("SELECT payload FROM subtitle_cache WHERE bvid=?", (bvid,)).fetchone()
631
+ if row is None or len(row["payload"].encode("utf-8")) > SUBTITLE_CACHE_BYTES:
632
+ return None
633
+ try:
634
+ payload = json.loads(row["payload"])
635
+ except (ValueError, TypeError):
636
+ return None
637
+ if (not isinstance(payload, dict) or payload.get("bvid") != bvid
638
+ or payload.get("error") or payload.get("message")
639
+ or not str(payload.get("text") or "").strip()):
640
+ return None
641
+ return payload
642
+ finally:
643
+ conn.close()
644
+
645
+ def reserve_subtitle(self, bvid):
646
+ """Single-flight fetch lease independent of permanent seen/send reservations."""
647
+ bvid = str(bvid).strip()
648
+ if not BV_RE.fullmatch(bvid):
649
+ raise ValueError("invalid bvid")
650
+ now = self._clock()
651
+ with self._transaction() as conn:
652
+ self._import_legacy(conn)
653
+ legacy = conn.execute("SELECT * FROM guard_entries WHERE key=? AND kind='read'",
654
+ ("seen:" + bvid,)).fetchone()
655
+ if legacy is not None and legacy["status"] == "pending" and (legacy["expires"] or 0) > now:
656
+ return {"allowed": False, "status": "pending", "kind": "read",
657
+ "retryAt": _iso(legacy["expires"]), "at": _iso(now)}
658
+ row = conn.execute("SELECT * FROM subtitle_reads WHERE bvid=?", (bvid,)).fetchone()
659
+ if row is not None and row["expires"] > now:
660
+ return {"allowed": False, "status": "pending", "kind": "read",
661
+ "retryAt": _iso(row["expires"]), "at": _iso(now)}
662
+ token = uuid.uuid4().hex
663
+ conn.execute("INSERT INTO subtitle_reads(bvid,token,expires) VALUES (?,?,?) "
664
+ "ON CONFLICT(bvid) DO UPDATE SET token=excluded.token,expires=excluded.expires",
665
+ (bvid, token, now + self.read_lease_seconds))
666
+ return {"allowed": True, "kind": "read", "subtitle": True, "bvid": bvid, "token": token}
667
+
668
+ def commit_subtitle(self, reservation, payload):
669
+ """Publish cache + first-seen record atomically; eviction never removes history."""
670
+ bvid = str(reservation.get("bvid") or "")
671
+ if not reservation.get("subtitle") or not BV_RE.fullmatch(bvid):
672
+ raise ValueError("invalid subtitle reservation")
673
+ if (not isinstance(payload, dict) or payload.get("bvid") != bvid
674
+ or payload.get("error") or payload.get("message")
675
+ or not str(payload.get("text") or "").strip()):
676
+ raise ValueError("subtitle content required")
677
+ packed = json.dumps(payload, ensure_ascii=False)
678
+ now = self._clock()
679
+ with self._transaction() as conn:
680
+ changed = conn.execute("DELETE FROM subtitle_reads WHERE bvid=? AND token=?",
681
+ (bvid, reservation["token"])).rowcount
682
+ if changed != 1:
683
+ raise RuntimeError("subtitle fetch lease was lost")
684
+ if len(packed.encode("utf-8")) <= SUBTITLE_CACHE_BYTES:
685
+ conn.execute("INSERT INTO subtitle_cache(bvid,fetched,payload) VALUES (?,?,?) "
686
+ "ON CONFLICT(bvid) DO UPDATE SET fetched=excluded.fetched,payload=excluded.payload",
687
+ (bvid, now, packed))
688
+ conn.execute("DELETE FROM subtitle_cache WHERE bvid NOT IN "
689
+ "(SELECT bvid FROM subtitle_cache ORDER BY fetched DESC,bvid LIMIT ?)",
690
+ (SUBTITLE_CACHE_MAX,))
691
+ # INSERT OR IGNORE keeps the original first-seen timestamp and its provenance.
692
+ conn.execute("DELETE FROM guard_entries WHERE key=? AND kind='read' AND status='pending' AND expires<=?",
693
+ ("seen:" + bvid, now))
694
+ self._import_one(conn, "seen:" + bvid, "read",
695
+ {"bvid": bvid, "title": payload.get("title", ""), "at": _iso(now)})
696
+ return len(packed.encode("utf-8")) <= SUBTITLE_CACHE_BYTES
697
+
588
698
  def reserve_send(self, key, legacy_keys=()):
589
699
  if not key:
590
700
  raise ValueError("empty send key")
@@ -621,6 +731,9 @@ class RuntimeGuard:
621
731
  def release(self, reservation):
622
732
  """Only use for failed reads or a send proven not to have started."""
623
733
  with self._transaction() as conn:
734
+ if reservation.get("subtitle"):
735
+ return conn.execute("DELETE FROM subtitle_reads WHERE bvid=? AND token=?",
736
+ (reservation["bvid"], reservation["token"])).rowcount == 1
624
737
  changed = conn.execute("DELETE FROM guard_entries WHERE key=? AND token=? AND status='pending'",
625
738
  (reservation["key"], reservation["token"])).rowcount == 1
626
739
  if changed:
@@ -636,10 +749,27 @@ class RuntimeGuard:
636
749
  (reservation["key"], reservation["token"])).rowcount
637
750
  if changed != 1:
638
751
  raise RuntimeError("guard reservation was lost; refusing an unsafe retry")
639
- conn.execute("DELETE FROM guard_pace_events WHERE event_key=?", ("send:" + reservation["token"],))
752
+ # P2-16: 节奏事件不再删,改标 rejected 并保留 at/next —— burst/hour/gap
753
+ # (_pace_check 按 at/next 统计、不看 status)要把「确定没发出去」的尝试也计入,
754
+ # 否则坏凭据下模型可以对 B站 无限速连打(探针实测 12 连发全放行,
755
+ # 而 -352 的最短触发路径恰恰就是高频尝试)。
756
+ conn.execute("UPDATE guard_pace_events SET status='rejected' WHERE event_key=?",
757
+ ("send:" + reservation["token"],))
640
758
  self._note_rejection(conn, code, now)
641
759
  return self._pace_check(conn, pace_kind(reservation["key"]), now)
642
760
 
761
+ def release_unknown(self, key):
762
+ """P1-2: unknown 槽位的**人工**解除入口。
763
+
764
+ 只删 status='unknown' 的行(pending/sent 绝不可删:前者正被某个进程占着,
765
+ 后者是发出去的铁账);返回是否真的解除了一行。
766
+ 调用前提是人工核实「那次结果不明的发送确实没发出去」—— 这不是自动重试的后门。
767
+ """
768
+ with self._transaction() as conn:
769
+ changed = conn.execute("DELETE FROM guard_entries WHERE key=? AND status='unknown'",
770
+ (str(key),)).rowcount
771
+ return changed == 1
772
+
643
773
  def pace_status(self, action):
644
774
  """Read the current common floor without reserving or consuming a send slot."""
645
775
  with self._transaction() as conn:
@@ -684,6 +814,9 @@ def guard_snapshot(home=None, bvids=None, limit=200):
684
814
  result["recentSends"] = [_public(row) for row in conn.execute(
685
815
  "SELECT * FROM guard_entries WHERE kind='send' ORDER BY updated DESC LIMIT ?", (limit,))]
686
816
  tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
817
+ if "subtitle_reads" in tables:
818
+ leases = conn.execute("SELECT bvid FROM subtitle_reads WHERE expires>? LIMIT ?", (now, limit)).fetchall()
819
+ result["activeReads"] = list(dict.fromkeys(result["activeReads"] + [row["bvid"] for row in leases]))[:limit]
687
820
  if {"guard_pace_events", "guard_pace_state"} <= tables:
688
821
  result["pace"] = {action: RuntimeGuard._pace_check(conn, action, now) for action in PACE}
689
822
  finally: