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.
- package/AGENTS.md +9 -0
- package/CHANGELOG.md +43 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +10 -3
- package/SECURITY.md +4 -0
- package/client.js +509 -296
- package/docs/ACTIVITY_TUNING.md +50 -0
- package/docs/CONTINUOUS_BROWSING.md +35 -0
- package/docs/FUN_FEATURE_REFERENCES.md +128 -0
- package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
- package/docs/NATURAL_AUTONOMY.md +91 -0
- package/docs/PRIVACY.md +7 -0
- package/docs/RELEASE_2_8_0.md +27 -0
- package/docs/RELEASE_AUDIT.md +16 -2
- package/docs/RELEASING.md +15 -0
- package/docs/TEST_MATRIX.md +9 -0
- package/docs/WORKFLOW_REFERENCES.md +57 -0
- package/docs/tool-evidence.json +6 -1
- package/index.js +941 -268
- package/lib/asr.py +22 -0
- package/lib/background-tools.mjs +1 -1
- package/lib/browse-preferences.mjs +154 -0
- package/lib/browse-progress.mjs +165 -0
- package/lib/comment.mjs +29 -46
- package/lib/config-policy.mjs +2 -1
- package/lib/curiosity.mjs +153 -0
- package/lib/favops.py +38 -6
- package/lib/learn-review.mjs +13 -3
- package/lib/learn-store.mjs +7 -0
- package/lib/learn.mjs +234 -69
- package/lib/login.mjs +20 -3
- package/lib/mcp.mjs +5 -2
- package/lib/memory-center.mjs +21 -19
- package/lib/mirror.mjs +15 -1
- package/lib/mode.mjs +23 -9
- package/lib/model-relay-adapter.mjs +1 -1
- package/lib/model-relay.mjs +21 -4
- package/lib/pace.mjs +15 -7
- package/lib/persona.mjs +41 -30
- package/lib/pet-session.mjs +9 -3
- package/lib/product-status.mjs +38 -2
- package/lib/prompts.mjs +38 -37
- package/lib/pv-session.mjs +16 -1
- package/lib/pv.mjs +18 -3
- package/lib/pv.py +168 -30
- package/lib/pv_cover.py +11 -1
- package/lib/pv_editorial.py +28 -4
- package/lib/pv_plan.py +20 -2
- package/lib/pv_verify.py +14 -0
- package/lib/session-continuation.mjs +47 -0
- package/lib/state.mjs +6 -2
- package/lib/sticker-automation.mjs +16 -2
- package/lib/sticker.mjs +4 -1
- package/lib/uvfetch.mjs +38 -12
- package/package.json +1 -1
- package/presets/deepmoyu.patch.yml +8 -10
- package/prompts/duty.md +30 -38
- package/vendor/bilibili-mcp/mcp_server.py +204 -47
- 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.
|
|
4
|
-
|
|
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":
|
|
29
|
-
"reply": {"gap": 60, "jitter": 30, "hour":
|
|
30
|
-
"opusReply": {"gap": 20, "jitter": 10, "hour":
|
|
31
|
-
"dm": {"gap": 30, "jitter": 15, "hour":
|
|
32
|
-
"moment": {"gap": 600, "jitter": 300, "hour":
|
|
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] = "" #
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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'
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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:
|