@researai/deepscientist 1.5.11 → 1.5.12

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 (102) hide show
  1. package/README.md +8 -8
  2. package/bin/ds.js +358 -61
  3. package/docs/en/00_QUICK_START.md +35 -3
  4. package/docs/en/01_SETTINGS_REFERENCE.md +11 -0
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +68 -4
  6. package/docs/en/09_DOCTOR.md +28 -3
  7. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +21 -2
  8. package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
  9. package/docs/en/README.md +4 -0
  10. package/docs/zh/00_QUICK_START.md +34 -2
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +11 -0
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +69 -3
  13. package/docs/zh/09_DOCTOR.md +28 -1
  14. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +21 -2
  15. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +285 -0
  16. package/docs/zh/README.md +4 -1
  17. package/package.json +1 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +1 -1
  20. package/src/deepscientist/bash_exec/monitor.py +7 -5
  21. package/src/deepscientist/bash_exec/service.py +84 -21
  22. package/src/deepscientist/channels/local.py +3 -3
  23. package/src/deepscientist/channels/qq.py +7 -7
  24. package/src/deepscientist/channels/relay.py +7 -7
  25. package/src/deepscientist/channels/weixin_ilink.py +90 -19
  26. package/src/deepscientist/config/models.py +1 -0
  27. package/src/deepscientist/config/service.py +121 -20
  28. package/src/deepscientist/daemon/app.py +314 -6
  29. package/src/deepscientist/doctor.py +1 -5
  30. package/src/deepscientist/mcp/server.py +124 -3
  31. package/src/deepscientist/prompts/builder.py +113 -11
  32. package/src/deepscientist/quest/service.py +247 -31
  33. package/src/deepscientist/runners/codex.py +121 -22
  34. package/src/deepscientist/runners/runtime_overrides.py +6 -0
  35. package/src/deepscientist/shared.py +33 -14
  36. package/src/prompts/connectors/qq.md +2 -1
  37. package/src/prompts/connectors/weixin.md +2 -1
  38. package/src/prompts/contracts/shared_interaction.md +4 -1
  39. package/src/prompts/system.md +59 -9
  40. package/src/skills/analysis-campaign/SKILL.md +46 -6
  41. package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
  42. package/src/skills/baseline/SKILL.md +1 -1
  43. package/src/skills/decision/SKILL.md +1 -1
  44. package/src/skills/experiment/SKILL.md +1 -1
  45. package/src/skills/finalize/SKILL.md +1 -1
  46. package/src/skills/idea/SKILL.md +1 -1
  47. package/src/skills/intake-audit/SKILL.md +1 -1
  48. package/src/skills/rebuttal/SKILL.md +74 -1
  49. package/src/skills/rebuttal/references/response-letter-template.md +55 -11
  50. package/src/skills/review/SKILL.md +118 -1
  51. package/src/skills/review/references/experiment-todo-template.md +23 -0
  52. package/src/skills/review/references/review-report-template.md +16 -0
  53. package/src/skills/review/references/revision-log-template.md +4 -0
  54. package/src/skills/scout/SKILL.md +1 -1
  55. package/src/skills/write/SKILL.md +168 -7
  56. package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
  57. package/src/tui/package.json +1 -1
  58. package/src/ui/dist/assets/{AiManusChatView-D0mTXG4-.js → AiManusChatView-CnJcXynW.js} +12 -12
  59. package/src/ui/dist/assets/{AnalysisPlugin-Db0cTXxm.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
  60. package/src/ui/dist/assets/{CliPlugin-DrV8je02.js → CliPlugin-CB1YODQn.js} +9 -9
  61. package/src/ui/dist/assets/{CodeEditorPlugin-QXMSCH71.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
  62. package/src/ui/dist/assets/{CodeViewerPlugin-7hhtWj_E.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
  63. package/src/ui/dist/assets/{DocViewerPlugin-BWMSnRJe.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
  64. package/src/ui/dist/assets/{GitDiffViewerPlugin-7J9h9Vy_.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -20
  65. package/src/ui/dist/assets/{ImageViewerPlugin-CHJl_0lr.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
  66. package/src/ui/dist/assets/{LabCopilotPanel-1qSow1es.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
  67. package/src/ui/dist/assets/{LabPlugin-eQpPPCEp.js → LabPlugin-Ciz1gDaX.js} +2 -2
  68. package/src/ui/dist/assets/{LatexPlugin-BwRfi89Z.js → LatexPlugin-BhmjNQRC.js} +37 -11
  69. package/src/ui/dist/assets/{MarkdownViewerPlugin-836PVQWV.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
  70. package/src/ui/dist/assets/{MarketplacePlugin-C2y_556i.js → MarketplacePlugin-DmyHspXt.js} +3 -3
  71. package/src/ui/dist/assets/{NotebookEditor-DIX7Mlzu.js → NotebookEditor-BMXKrDRk.js} +1 -1
  72. package/src/ui/dist/assets/{NotebookEditor-BRzJbGsn.js → NotebookEditor-BTVYRGkm.js} +11 -11
  73. package/src/ui/dist/assets/{PdfLoader-DzRaTAlq.js → PdfLoader-CvcjJHXv.js} +1 -1
  74. package/src/ui/dist/assets/{PdfMarkdownPlugin-DZUfIUnp.js → PdfMarkdownPlugin-DW2ej8Vk.js} +2 -2
  75. package/src/ui/dist/assets/{PdfViewerPlugin-BwtICzue.js → PdfViewerPlugin-CmlDxbhU.js} +10 -10
  76. package/src/ui/dist/assets/{SearchPlugin-DHeIAMsx.js → SearchPlugin-DAjQZPSv.js} +1 -1
  77. package/src/ui/dist/assets/{TextViewerPlugin-C3tCmFox.js → TextViewerPlugin-C-nVAZb_.js} +5 -5
  78. package/src/ui/dist/assets/{VNCViewer-CQsKVm3t.js → VNCViewer-D7-dIYon.js} +10 -10
  79. package/src/ui/dist/assets/{bot-BEA2vWuK.js → bot-C_G4WtNI.js} +1 -1
  80. package/src/ui/dist/assets/{code-XfbSR8K2.js → code-Cd7WfiWq.js} +1 -1
  81. package/src/ui/dist/assets/{file-content-BjxNaIfy.js → file-content-B57zsL9y.js} +1 -1
  82. package/src/ui/dist/assets/{file-diff-panel-D_lLVQk0.js → file-diff-panel-DVoheLFq.js} +1 -1
  83. package/src/ui/dist/assets/{file-socket-D9x_5vlY.js → file-socket-B5kXFxZP.js} +1 -1
  84. package/src/ui/dist/assets/{image-BhWT33W1.js → image-LLOjkMHF.js} +1 -1
  85. package/src/ui/dist/assets/{index-Dqj-Mjb4.css → index-BQG-1s2o.css} +40 -2
  86. package/src/ui/dist/assets/{index--c4iXtuy.js → index-C3r2iGrp.js} +12 -12
  87. package/src/ui/dist/assets/{index-DZTZ8mWP.js → index-CLQauncb.js} +911 -120
  88. package/src/ui/dist/assets/{index-PJbSbPTy.js → index-Dxa2eYMY.js} +1 -1
  89. package/src/ui/dist/assets/{index-BDxipwrC.js → index-hOUOWbW2.js} +2 -2
  90. package/src/ui/dist/assets/{monaco-K8izTGgo.js → monaco-BGGAEii3.js} +1 -1
  91. package/src/ui/dist/assets/{pdf-effect-queue-DfBors6y.js → pdf-effect-queue-DlEr1_y5.js} +1 -1
  92. package/src/ui/dist/assets/{popover-yFK1J4fL.js → popover-CWJbJuYY.js} +1 -1
  93. package/src/ui/dist/assets/{project-sync-PENr2zcz.js → project-sync-CRJiucYO.js} +18 -4
  94. package/src/ui/dist/assets/{select-CAbJDfYv.js → select-CoHB7pvH.js} +2 -2
  95. package/src/ui/dist/assets/{sigma-DEuYJqTl.js → sigma-D5aJWR8J.js} +1 -1
  96. package/src/ui/dist/assets/{square-check-big-omoSUmcd.js → square-check-big-DUK_mnkS.js} +1 -1
  97. package/src/ui/dist/assets/{trash--F119N47.js → trash-ChU3SEE3.js} +1 -1
  98. package/src/ui/dist/assets/{useCliAccess-D31UR23I.js → useCliAccess-BrJBV3tY.js} +1 -1
  99. package/src/ui/dist/assets/{useFileDiffOverlay-BH6KcMzq.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
  100. package/src/ui/dist/assets/{wrap-text-CZ613PM5.js → wrap-text-C7Qqh-om.js} +1 -1
  101. package/src/ui/dist/assets/{zoom-out-BgDLAv3z.js → zoom-out-rtX0FKya.js} +1 -1
  102. package/src/ui/dist/index.html +2 -2
@@ -11,12 +11,13 @@ import sys
11
11
  import tempfile
12
12
  import threading
13
13
  import time
14
+ from collections import deque
14
15
  from datetime import UTC, datetime
15
16
  from pathlib import Path
16
17
  from typing import Any
17
18
 
18
19
  from ..mcp.context import McpContext
19
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now
20
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, utc_now
20
21
  from .runtime import TerminalRuntimeManager
21
22
 
22
23
  BASH_STATUS_MARKER_PREFIX = "__DS_BASH_STATUS__"
@@ -24,6 +25,9 @@ BASH_CARRIAGE_RETURN_PREFIX = "__DS_BASH_CR__"
24
25
  BASH_PROGRESS_PREFIX = "__DS_PROGRESS__"
25
26
  BASH_TERMINAL_PROMPT_PREFIX = "__DS_TERMINAL_PROMPT__"
26
27
  DEFAULT_LOG_TAIL_LIMIT = 200
28
+ DEFAULT_INLINE_BASH_LOG_LINE_LIMIT = 2000
29
+ DEFAULT_INLINE_BASH_LOG_HEAD_LINES = 500
30
+ DEFAULT_INLINE_BASH_LOG_TAIL_LINES = 1500
27
31
  DEFAULT_POLL_INTERVAL_SECONDS = 0.35
28
32
  TERMINAL_STATUSES = {"completed", "failed", "terminated"}
29
33
  DEFAULT_TERMINAL_SESSION_ID = "terminal-main"
@@ -46,6 +50,52 @@ def _atomic_write_json(path: Path, payload: Any) -> None:
46
50
  temp_path.replace(path)
47
51
 
48
52
 
53
+ def _count_jsonl_records(path: Path) -> int:
54
+ return sum(1 for _ in iter_jsonl(path))
55
+
56
+
57
+ def _build_terminal_log_preview_payload(path: Path) -> dict[str, Any]:
58
+ if not path.exists():
59
+ return {
60
+ "log": "",
61
+ "log_line_count": 0,
62
+ "log_truncated": False,
63
+ }
64
+
65
+ head_lines: list[str] = []
66
+ tail_lines: deque[str] = deque(maxlen=DEFAULT_INLINE_BASH_LOG_TAIL_LINES)
67
+ total = 0
68
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
69
+ for raw_line in handle:
70
+ line = raw_line.rstrip("\n")
71
+ total += 1
72
+ if total <= DEFAULT_INLINE_BASH_LOG_HEAD_LINES:
73
+ head_lines.append(line)
74
+ tail_lines.append(line)
75
+
76
+ if total <= DEFAULT_INLINE_BASH_LOG_LINE_LIMIT:
77
+ return {
78
+ "log": "\n".join(list(tail_lines)),
79
+ "log_line_count": total,
80
+ "log_truncated": False,
81
+ }
82
+
83
+ omitted = max(0, total - DEFAULT_INLINE_BASH_LOG_HEAD_LINES - DEFAULT_INLINE_BASH_LOG_TAIL_LINES)
84
+ marker = (
85
+ "[... omitted "
86
+ f"{omitted} lines from the middle of this log. "
87
+ "Use bash_exec(mode='read', id=..., start=..., tail=...) for a specific window.]"
88
+ )
89
+ return {
90
+ "log": "\n".join(head_lines + [marker] + list(tail_lines)),
91
+ "log_line_count": total,
92
+ "log_truncated": True,
93
+ "log_preview_head_lines": DEFAULT_INLINE_BASH_LOG_HEAD_LINES,
94
+ "log_preview_tail_lines": DEFAULT_INLINE_BASH_LOG_TAIL_LINES,
95
+ "log_preview_omitted_lines": omitted,
96
+ }
97
+
98
+
49
99
  def _normalize_string(value: object) -> str:
50
100
  return str(value or "").strip()
51
101
 
@@ -568,7 +618,8 @@ class BashExecService:
568
618
  if not self.meta_path(quest_root, bash_id).exists():
569
619
  raise FileNotFoundError(f"Unknown bash session `{bash_id}`.")
570
620
  deadline = time.monotonic() + 0.6
571
- entries = read_jsonl(self.log_path(quest_root, bash_id))
621
+ path = self.log_path(quest_root, bash_id)
622
+ entries = read_jsonl_tail(path, max(1, limit))
572
623
  while time.monotonic() < deadline:
573
624
  if any(str(entry.get("stream") or "") not in {"system", "prompt"} for entry in entries):
574
625
  break
@@ -580,24 +631,33 @@ class BashExecService:
580
631
  time.sleep(0.05)
581
632
  else:
582
633
  time.sleep(0.03)
583
- entries = read_jsonl(self.log_path(quest_root, bash_id))
634
+ entries = read_jsonl_tail(path, max(1, limit))
584
635
  latest_seq = int(entries[-1].get("seq") or 0) if entries else 0
585
636
  normalized_before = before_seq if isinstance(before_seq, int) and before_seq > 0 else None
586
637
  normalized_after = after_seq if isinstance(after_seq, int) and after_seq >= 0 else None
587
- if normalized_after is not None:
588
- entries = [entry for entry in entries if int(entry.get("seq") or 0) > normalized_after]
589
- if normalized_before is not None:
590
- entries = [entry for entry in entries if int(entry.get("seq") or 0) < normalized_before]
591
- selection_pool = entries
592
- if prefer_visible:
593
- visible_entries = [
594
- entry for entry in entries if str(entry.get("stream") or "") not in {"system", "prompt"}
595
- ]
596
- if visible_entries:
597
- selection_pool = visible_entries
598
638
  normalized_limit = max(1, limit)
599
- truncated = len(selection_pool) > normalized_limit
600
- selected = selection_pool[-normalized_limit:]
639
+ selection_pool: deque[dict[str, Any]] = deque(maxlen=normalized_limit)
640
+ visible_pool: deque[dict[str, Any]] = deque(maxlen=normalized_limit)
641
+ total_filtered = 0
642
+ for entry in iter_jsonl(path):
643
+ seq = int(entry.get("seq") or 0)
644
+ latest_seq = max(latest_seq, seq)
645
+ if normalized_after is not None and seq <= normalized_after:
646
+ continue
647
+ if normalized_before is not None and seq >= normalized_before:
648
+ continue
649
+ total_filtered += 1
650
+ selection_pool.append(entry)
651
+ if str(entry.get("stream") or "") not in {"system", "prompt"}:
652
+ visible_pool.append(entry)
653
+ selected_source: list[dict[str, Any]]
654
+ if prefer_visible and visible_pool:
655
+ selected_source = list(visible_pool)
656
+ truncated = total_filtered > len(visible_pool)
657
+ else:
658
+ selected_source = list(selection_pool)
659
+ truncated = total_filtered > len(selection_pool)
660
+ selected = selected_source[-normalized_limit:]
601
661
  if order == "desc":
602
662
  selected = list(reversed(selected))
603
663
  tail_start_seq = int(selected[0].get("seq") or 0) if selected else None
@@ -868,7 +928,7 @@ class BashExecService:
868
928
  "last_input_at": None,
869
929
  "last_prompt_at": None,
870
930
  "last_command": None,
871
- "history_count": len(read_jsonl(self.history_path(quest_root, bash_id))),
931
+ "history_count": _count_jsonl_records(self.history_path(quest_root, bash_id)),
872
932
  }
873
933
 
874
934
  def ensure_terminal_session(
@@ -918,7 +978,7 @@ class BashExecService:
918
978
  self.prompt_events_path(resolved_quest_root, bash_id).touch()
919
979
  _atomic_write_json(
920
980
  self.input_cursor_path(resolved_quest_root, bash_id),
921
- {"offset": len(read_jsonl(self.input_path(resolved_quest_root, bash_id))), "updated_at": utc_now()},
981
+ {"offset": _count_jsonl_records(self.input_path(resolved_quest_root, bash_id)), "updated_at": utc_now()},
922
982
  )
923
983
  _atomic_write_json(
924
984
  self.line_buffer_path(resolved_quest_root, bash_id),
@@ -1072,7 +1132,7 @@ class BashExecService:
1072
1132
  append_jsonl(self.history_path(quest_root, bash_id), item)
1073
1133
  meta = read_json(self.meta_path(quest_root, bash_id), {})
1074
1134
  meta["last_command"] = completed[-1]["command"]
1075
- meta["history_count"] = len(read_jsonl(self.history_path(quest_root, bash_id)))
1135
+ meta["history_count"] = _count_jsonl_records(self.history_path(quest_root, bash_id))
1076
1136
  meta["updated_at"] = utc_now()
1077
1137
  meta["last_input_at"] = utc_now()
1078
1138
  self._write_meta(quest_root, bash_id, meta)
@@ -1138,7 +1198,7 @@ class BashExecService:
1138
1198
  before_seq=None,
1139
1199
  order="asc",
1140
1200
  )
1141
- history = read_jsonl(self.history_path(quest_root, bash_id))
1201
+ history = read_jsonl_tail(self.history_path(quest_root, bash_id), max(1, command_limit))
1142
1202
  latest_commands = [
1143
1203
  {
1144
1204
  "command_id": item.get("command_id"),
@@ -1208,7 +1268,7 @@ class BashExecService:
1208
1268
  "watchdog_overdue": session.get("watchdog_overdue"),
1209
1269
  }
1210
1270
  if include_log:
1211
- result["log"] = self.read_terminal_log(quest_root, str(session["bash_id"]))
1271
+ result.update(self._log_preview_payload(quest_root, str(session["bash_id"])))
1212
1272
  if export_log or _normalize_string(export_log_to):
1213
1273
  cwd, _ = self.resolve_workdir(context, str(session.get("workdir") or ""))
1214
1274
  result.update(
@@ -1221,3 +1281,6 @@ class BashExecService:
1221
1281
  )
1222
1282
  )
1223
1283
  return result
1284
+
1285
+ def _log_preview_payload(self, quest_root: Path, bash_id: str) -> dict[str, Any]:
1286
+ return _build_terminal_log_preview_payload(self.terminal_log_path(quest_root, bash_id))
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
- from ..shared import append_jsonl, ensure_dir, read_jsonl, utc_now
6
+ from ..shared import append_jsonl, count_jsonl, ensure_dir, read_jsonl, utc_now
7
7
  from .base import BaseChannel
8
8
 
9
9
 
@@ -27,6 +27,6 @@ class LocalChannel(BaseChannel):
27
27
  return {
28
28
  "name": self.name,
29
29
  "display_mode": self.display_mode,
30
- "inbox_count": len(read_jsonl(self.root / "inbox.jsonl")),
31
- "outbox_count": len(read_jsonl(self.root / "outbox.jsonl")),
30
+ "inbox_count": count_jsonl(self.root / "inbox.jsonl"),
31
+ "outbox_count": count_jsonl(self.root / "outbox.jsonl"),
32
32
  }
@@ -6,7 +6,7 @@ from typing import Any
6
6
  from ..connector_runtime import build_discovered_target, conversation_identity_key, format_conversation_id, merge_discovered_targets, parse_conversation_id
7
7
  from ..bridges import get_connector_bridge
8
8
  from ..connector.qq_profiles import find_qq_profile, list_qq_profiles, merge_qq_profile_config, qq_profile_label
9
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
9
+ from ..shared import append_jsonl, count_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_jsonl_tail, utc_now, write_json
10
10
  from .base import BaseChannel
11
11
 
12
12
 
@@ -387,9 +387,9 @@ class QQRelayChannel(BaseChannel):
387
387
  "main_chat_id": main_chat_id,
388
388
  "last_conversation_id": last_conversation_id,
389
389
  "last_error": last_error,
390
- "inbox_count": len(read_jsonl(self.inbox_path)),
391
- "outbox_count": len(read_jsonl(self.outbox_path)),
392
- "ignored_count": len(read_jsonl(self.ignored_path)),
390
+ "inbox_count": count_jsonl(self.inbox_path),
391
+ "outbox_count": count_jsonl(self.outbox_path),
392
+ "ignored_count": count_jsonl(self.ignored_path),
393
393
  "binding_count": len(bindings),
394
394
  "bindings": bindings,
395
395
  "known_targets": known_targets,
@@ -947,15 +947,15 @@ class QQRelayChannel(BaseChannel):
947
947
 
948
948
  def _recent_events(self) -> list[dict[str, Any]]:
949
949
  events: list[dict[str, Any]] = []
950
- for record in read_jsonl(self.inbox_path)[-self.recent_event_limit :]:
950
+ for record in read_jsonl_tail(self.inbox_path, self.recent_event_limit):
951
951
  event = self._build_recent_event("inbound", record)
952
952
  if event is not None:
953
953
  events.append(event)
954
- for record in read_jsonl(self.outbox_path)[-self.recent_event_limit :]:
954
+ for record in read_jsonl_tail(self.outbox_path, self.recent_event_limit):
955
955
  event = self._build_recent_event("outbound", record)
956
956
  if event is not None:
957
957
  events.append(event)
958
- for record in read_jsonl(self.ignored_path)[-self.recent_event_limit :]:
958
+ for record in read_jsonl_tail(self.ignored_path, self.recent_event_limit):
959
959
  event = self._build_recent_event("ignored", record)
960
960
  if event is not None:
961
961
  events.append(event)
@@ -21,7 +21,7 @@ from ..connector.connector_profiles import (
21
21
  merge_connector_profile_config,
22
22
  )
23
23
  from ..bridges import get_connector_bridge
24
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
24
+ from ..shared import append_jsonl, count_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_jsonl_tail, utc_now, write_json
25
25
  from .base import BaseChannel
26
26
 
27
27
 
@@ -412,9 +412,9 @@ class GenericRelayChannel(BaseChannel):
412
412
  ),
413
413
  str(runtime_state.get("last_error") or "").strip() or None if isinstance(runtime_state, dict) else None,
414
414
  ),
415
- "inbox_count": len(read_jsonl(self.inbox_path)),
416
- "outbox_count": len(read_jsonl(self.outbox_path)),
417
- "ignored_count": len(read_jsonl(self.ignored_path)),
415
+ "inbox_count": count_jsonl(self.inbox_path),
416
+ "outbox_count": count_jsonl(self.outbox_path),
417
+ "ignored_count": count_jsonl(self.ignored_path),
418
418
  "binding_count": len(bindings),
419
419
  "bindings": bindings,
420
420
  "known_targets": known_targets,
@@ -894,15 +894,15 @@ class GenericRelayChannel(BaseChannel):
894
894
 
895
895
  def _recent_events(self) -> list[dict[str, Any]]:
896
896
  events: list[dict[str, Any]] = []
897
- for record in read_jsonl(self.inbox_path)[-self.recent_event_limit :]:
897
+ for record in read_jsonl_tail(self.inbox_path, self.recent_event_limit):
898
898
  event = self._build_recent_event("inbound", record)
899
899
  if event is not None:
900
900
  events.append(event)
901
- for record in read_jsonl(self.outbox_path)[-self.recent_event_limit :]:
901
+ for record in read_jsonl_tail(self.outbox_path, self.recent_event_limit):
902
902
  event = self._build_recent_event("outbound", record)
903
903
  if event is not None:
904
904
  events.append(event)
905
- for record in read_jsonl(self.ignored_path)[-self.recent_event_limit :]:
905
+ for record in read_jsonl_tail(self.ignored_path, self.recent_event_limit):
906
906
  event = self._build_recent_event("ignored", record)
907
907
  if event is not None:
908
908
  events.append(event)
@@ -19,7 +19,10 @@ from ..connector.weixin_support import (
19
19
  save_weixin_get_updates_buf,
20
20
  )
21
21
 
22
- _SESSION_PAUSE_SECONDS = 60 * 60
22
+ _SESSION_RETRY_INITIAL_SECONDS = 5.0
23
+ _SESSION_RETRY_MAX_SECONDS = 60.0
24
+ _POLL_RETRY_INITIAL_SECONDS = 2.0
25
+ _POLL_RETRY_MAX_SECONDS = 30.0
23
26
 
24
27
 
25
28
  class WeixinIlinkService:
@@ -89,7 +92,8 @@ class WeixinIlinkService:
89
92
 
90
93
  def _run(self) -> None:
91
94
  timeout_ms = DEFAULT_WEIXIN_LONG_POLL_TIMEOUT_MS
92
- pause_until = 0.0
95
+ retry_until = 0.0
96
+ retry_reason: str | None = None
93
97
  sync_buf = load_weixin_get_updates_buf(self._root)
94
98
  base_url = normalize_weixin_base_url(self.config.get("base_url"))
95
99
  cdn_base_url = normalize_weixin_cdn_base_url(self.config.get("cdn_base_url"))
@@ -97,6 +101,10 @@ class WeixinIlinkService:
97
101
  login_user_id = str(self.config.get("login_user_id") or "").strip() or None
98
102
  token = self._secret("bot_token", "bot_token_env")
99
103
  route_tag = str(self.config.get("route_tag") or "").strip() or None
104
+ session_retry_seconds = _SESSION_RETRY_INITIAL_SECONDS
105
+ poll_retry_seconds = _POLL_RETRY_INITIAL_SECONDS
106
+ session_expired_count = 0
107
+ session_expired_since: str | None = None
100
108
 
101
109
  self._write_state(
102
110
  enabled=True,
@@ -108,20 +116,31 @@ class WeixinIlinkService:
108
116
  login_user_id=login_user_id,
109
117
  base_url=base_url,
110
118
  cdn_base_url=cdn_base_url,
119
+ retry_reason=None,
120
+ retry_after_seconds=None,
121
+ pause_until=None,
111
122
  updated_at=utc_now(),
112
123
  )
113
124
 
114
125
  while not self._stop_event.is_set():
115
126
  now = time.time()
116
- if pause_until > now:
117
- self._write_state(
118
- connected=False,
119
- connection_state="paused",
120
- auth_state="error",
121
- pause_until=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(pause_until)),
122
- updated_at=utc_now(),
123
- )
124
- self._stop_event.wait(min(max(pause_until - now, 1.0), 5.0))
127
+ if retry_until > now:
128
+ retry_after_seconds = max(int(retry_until - now + 0.999), 1)
129
+ state_patch: dict[str, Any] = {
130
+ "connected": False,
131
+ "connection_state": "connecting" if retry_reason == "session_expired" else "error",
132
+ "auth_state": "ready" if token and account_id else "missing_credentials",
133
+ "retry_reason": retry_reason,
134
+ "retry_after_seconds": retry_after_seconds,
135
+ "session_expired_count": session_expired_count or None,
136
+ "session_expired_since": session_expired_since,
137
+ "pause_until": None,
138
+ "updated_at": utc_now(),
139
+ }
140
+ if retry_reason == "session_expired":
141
+ state_patch["last_error"] = f"session expired ({SESSION_EXPIRED_ERRCODE}); retrying automatically"
142
+ self._write_state(**state_patch)
143
+ self._stop_event.wait(min(max(retry_until - now, 0.5), 5.0))
125
144
  continue
126
145
  try:
127
146
  response = get_weixin_updates(
@@ -137,14 +156,35 @@ class WeixinIlinkService:
137
156
  errcode = int(response.get("errcode") or 0)
138
157
  retcode = int(response.get("ret") or 0)
139
158
  if errcode == SESSION_EXPIRED_ERRCODE or retcode == SESSION_EXPIRED_ERRCODE:
140
- pause_until = time.time() + _SESSION_PAUSE_SECONDS
141
- self.log("warning", "weixin.ilink: session expired; pausing for one hour")
159
+ session_expired_count += 1
160
+ if session_expired_since is None:
161
+ session_expired_since = utc_now()
162
+ if sync_buf:
163
+ sync_buf = ""
164
+ save_weixin_get_updates_buf(self._root, "")
165
+ retry_delay_seconds = session_retry_seconds
166
+ retry_after_seconds = max(int(retry_delay_seconds + 0.999), 1)
167
+ session_retry_seconds = min(session_retry_seconds * 2.0, _SESSION_RETRY_MAX_SECONDS)
168
+ retry_reason = "session_expired"
169
+ retry_until = time.time() + retry_delay_seconds
170
+ timeout_ms = DEFAULT_WEIXIN_LONG_POLL_TIMEOUT_MS
171
+ self.log(
172
+ "warning",
173
+ (
174
+ "weixin.ilink: session expired; cleared sync state and "
175
+ f"retrying in {retry_after_seconds}s"
176
+ ),
177
+ )
142
178
  self._write_state(
143
179
  connected=False,
144
- connection_state="paused",
145
- auth_state="error",
146
- last_error=f"session expired ({SESSION_EXPIRED_ERRCODE})",
147
- pause_until=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(pause_until)),
180
+ connection_state="connecting",
181
+ auth_state="ready" if token and account_id else "missing_credentials",
182
+ last_error=f"session expired ({SESSION_EXPIRED_ERRCODE}); retrying automatically",
183
+ retry_reason=retry_reason,
184
+ retry_after_seconds=retry_after_seconds,
185
+ session_expired_count=session_expired_count,
186
+ session_expired_since=session_expired_since,
187
+ pause_until=None,
148
188
  updated_at=utc_now(),
149
189
  )
150
190
  continue
@@ -156,11 +196,26 @@ class WeixinIlinkService:
156
196
  if next_sync_buf:
157
197
  sync_buf = next_sync_buf
158
198
  save_weixin_get_updates_buf(self._root, sync_buf)
199
+ if session_expired_count > 0:
200
+ self.log(
201
+ "info",
202
+ f"weixin.ilink: session recovered after {session_expired_count} retry attempt(s)",
203
+ )
204
+ retry_reason = None
205
+ retry_until = 0.0
206
+ session_retry_seconds = _SESSION_RETRY_INITIAL_SECONDS
207
+ poll_retry_seconds = _POLL_RETRY_INITIAL_SECONDS
208
+ session_expired_count = 0
209
+ session_expired_since = None
159
210
  self._write_state(
160
211
  connected=True,
161
212
  connection_state="connected",
162
213
  auth_state="ready",
163
214
  last_error=None,
215
+ retry_reason=None,
216
+ retry_after_seconds=None,
217
+ session_expired_count=None,
218
+ session_expired_since=None,
164
219
  pause_until=None,
165
220
  updated_at=utc_now(),
166
221
  )
@@ -182,18 +237,34 @@ class WeixinIlinkService:
182
237
  except Exception as exc:
183
238
  if self._stop_event.is_set():
184
239
  break
185
- self.log("warning", f"weixin.ilink: polling failed: {exc}")
240
+ retry_reason = "poll_error"
241
+ retry_delay_seconds = poll_retry_seconds
242
+ retry_after_seconds = max(int(retry_delay_seconds + 0.999), 1)
243
+ retry_until = time.time() + retry_delay_seconds
244
+ poll_retry_seconds = min(poll_retry_seconds * 2.0, _POLL_RETRY_MAX_SECONDS)
245
+ self.log(
246
+ "warning",
247
+ f"weixin.ilink: polling failed: {exc}; retrying in {retry_after_seconds}s",
248
+ )
186
249
  self._write_state(
187
250
  connected=False,
188
251
  connection_state="error",
189
252
  auth_state="ready" if token and account_id else "missing_credentials",
190
253
  last_error=str(exc),
254
+ retry_reason=retry_reason,
255
+ retry_after_seconds=retry_after_seconds,
256
+ session_expired_count=session_expired_count or None,
257
+ session_expired_since=session_expired_since,
258
+ pause_until=None,
191
259
  updated_at=utc_now(),
192
260
  )
193
- self._stop_event.wait(2.0)
261
+ self._stop_event.wait(retry_delay_seconds)
194
262
  self._write_state(
195
263
  connected=False,
196
264
  connection_state="stopped",
265
+ retry_reason=None,
266
+ retry_after_seconds=None,
267
+ pause_until=None,
197
268
  updated_at=utc_now(),
198
269
  )
199
270
 
@@ -95,6 +95,7 @@ def default_runners() -> dict:
95
95
  "enabled": True,
96
96
  "binary": "codex",
97
97
  "config_dir": "~/.codex",
98
+ "profile": "",
98
99
  "model": "gpt-5.4",
99
100
  "model_reasoning_effort": "xhigh",
100
101
  "approval_policy": "on-request",