dots-agent 0.1.0__py3-none-any.whl

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.
dot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dot —— 常驻主动式 agent。"""
2
+
3
+ __version__ = "0.1.0"
dot/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from dot.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
dot/approvals.py ADDED
@@ -0,0 +1,173 @@
1
+ """审批:发邮件 + 解析回复。
2
+
3
+ 邮箱同时是事件源和审批通道,靠主题前缀区分。
4
+
5
+ 自激循环警告:dot 自己发出去的审批邮件会被 imap_poll 再收一次。
6
+ 必须靠主题前缀识别并拦下,否则后果是 dot 给自己回信回一晚上。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import secrets
13
+ import smtplib
14
+ import sys
15
+ from email.message import EmailMessage
16
+
17
+ from dot import db
18
+
19
+ APPROVE_WORDS = {"y", "yes", "ok", "okay", "approve", "approved", "同意", "批准", "是", "可以", "准"}
20
+ REJECT_WORDS = {"n", "no", "reject", "rejected", "deny", "denied", "拒绝", "否", "不要", "不行", "驳回"}
21
+
22
+ APPROVED = "approved"
23
+ REJECTED = "rejected"
24
+
25
+
26
+ def make_token() -> str:
27
+ """4 个十六进制字符。自用够,猜测窗口也只有 65536。"""
28
+ return secrets.token_hex(2)
29
+
30
+
31
+ def token_pattern(prefix: str) -> re.Pattern:
32
+ return re.compile(re.escape(prefix) + r"([0-9a-f]{2,8})\]")
33
+
34
+
35
+ def is_approval_subject(prefix: str, subject: str) -> bool:
36
+ return bool(subject) and token_pattern(prefix).search(subject) is not None
37
+
38
+
39
+ def subject_for(prefix: str, token: str, summary: str) -> str:
40
+ return f"{prefix}{token}] {summary}"
41
+
42
+
43
+ def parse_decision(body: str) -> str | None:
44
+ """只认第一个有意义的词。看不懂的一律返回 None,不猜。"""
45
+ for raw in (body or "").strip().splitlines():
46
+ word = raw.strip().strip("。.!!").lower()
47
+ if not word:
48
+ continue
49
+ if word in APPROVE_WORDS:
50
+ return APPROVED
51
+ if word in REJECT_WORDS:
52
+ return REJECTED
53
+ # 回信里常带引用,只在第一行找;
54
+ # 第一行看不懂就再给正文一次机会见下面的 fallback
55
+ break
56
+ tokens = set(re.findall(r"[a-zA-Z一-鿿]+", (body or "").lower()))
57
+ hits_a = tokens & APPROVE_WORDS
58
+ hits_r = tokens & REJECT_WORDS
59
+ if hits_a and not hits_r:
60
+ return APPROVED
61
+ if hits_r and not hits_a:
62
+ return REJECTED
63
+ return None
64
+
65
+
66
+ def send(cfg, conn, goal_id: str, payload: dict, *, dry_run: bool = False) -> str:
67
+ """记一条 approvals,然后发邮件。发失败也保留 token —— 你还能用 CLI 批。"""
68
+ token = make_token()
69
+ while db.get_approval(conn, token) is not None:
70
+ token = make_token()
71
+
72
+ tool = payload.get("action", "?")
73
+ args = payload.get("args", {})
74
+ db.create_approval(conn, token, goal_id, tool, args)
75
+
76
+ summary = _summarize(tool, args)
77
+ subject = subject_for(cfg.approval_subject_prefix, token, summary)
78
+ body = _body_text(token, tool, args)
79
+
80
+ db.audit(conn, goal_id, "approval_requested", f"{token} {summary}")
81
+
82
+ if dry_run or not cfg.smtp.usable:
83
+ reason = "dry-run" if dry_run else "没配 [smtp]"
84
+ print(f"\n[dot] 需要你批准({reason},已记入待批队列)")
85
+ print(f" 主题: {subject}")
86
+ print(f" 替代做法: python -m dot approve {token}\n", file=sys.stderr)
87
+ return token
88
+
89
+ try:
90
+ send_mail(cfg, cfg.smtp.to, subject, body)
91
+ db.audit(conn, goal_id, "approval_sent", f"{token} -> {cfg.smtp.to}")
92
+ except Exception as e:
93
+ db.audit(conn, goal_id, "approval_send_failed", f"{token}: {type(e).__name__}: {e}")
94
+ print(
95
+ f"[dot] 审批邮件发不出去({type(e).__name__}: {e})。\n"
96
+ f" 用 CLI 顶一下: python -m dot approve {token}",
97
+ file=sys.stderr,
98
+ )
99
+ return token
100
+
101
+
102
+ def send_mail(cfg, to: str, subject: str, body: str) -> None:
103
+ """全项目唯一的发信出口。审批和 agent 的动作共用它。失败就抛。"""
104
+ if not cfg.smtp.usable:
105
+ raise RuntimeError("没配 [smtp],发不了邮件")
106
+ msg = EmailMessage()
107
+ msg["Subject"] = subject
108
+ msg["From"] = cfg.smtp.sender
109
+ msg["To"] = to
110
+ msg.set_content(body)
111
+ with smtplib.SMTP(cfg.smtp.host, cfg.smtp.port, timeout=30) as s:
112
+ if cfg.smtp.use_tls:
113
+ s.starttls()
114
+ if cfg.smtp.user:
115
+ s.login(cfg.smtp.user, cfg.smtp.password)
116
+ s.send_message(msg)
117
+
118
+
119
+ def parse_reply(cfg, subject: str, body: str) -> tuple[str, str] | None:
120
+ """从一封回信里取出 (token, 决定)。不是审批回信就返回 None。"""
121
+ m = token_pattern(cfg.approval_subject_prefix).search(subject or "")
122
+ if not m:
123
+ return None
124
+ decision = parse_decision(body)
125
+ if decision is None:
126
+ return None
127
+ return m.group(1), decision
128
+
129
+
130
+ def _summarize(tool: str, args: dict) -> str:
131
+ if tool == "send_email":
132
+ return f"发邮件给 {args.get('to', '?')}: {str(args.get('subject', ''))[:40]}"
133
+ if tool == "run_shell":
134
+ return f"执行命令: {str(args.get('cmd', ''))[:60]}"
135
+ return f"{tool} {str(args)[:60]}"
136
+
137
+
138
+ def _body_text(token: str, tool: str, args: dict) -> str:
139
+ lines = [
140
+ f"dot 想执行一个动作,需要你批准。",
141
+ "",
142
+ f" 编号 {token}",
143
+ f" 动作 {tool}",
144
+ ]
145
+ for k, v in args.items():
146
+ lines.append(f" {k:<6} {str(v)[:500]}")
147
+ lines += [
148
+ "",
149
+ "直接回复这封邮件:",
150
+ " 回 y 或 同意 → 执行",
151
+ " 回 n 或 拒绝 → 不执行",
152
+ "",
153
+ "只认第一行。其他回复会被忽略(不会产生新目标)。",
154
+ "不确定它想干什么,就先 python -m dot log 看一眼。",
155
+ ]
156
+ return "\n".join(lines)
157
+
158
+
159
+ def demo() -> None:
160
+ prefix = "[dot#"
161
+ assert is_approval_subject(prefix, "[dot#a7f3] 发邮件给 x")
162
+ assert not is_approval_subject(prefix, "你好")
163
+ assert parse_decision("y") == APPROVED
164
+ assert parse_decision("同意\n\n引用原文...") == APPROVED
165
+ assert parse_decision("拒绝") == REJECTED
166
+ assert parse_decision("随便说点什么") is None
167
+ # 引用里同时出现两边时不给结论,别猜
168
+ assert parse_decision("yes or no?") in (APPROVED, REJECTED, None)
169
+ print("approvals: ok")
170
+
171
+
172
+ if __name__ == "__main__":
173
+ demo()
dot/cli.py ADDED
@@ -0,0 +1,505 @@
1
+ """命令行。
2
+
3
+ CLI 只往 SQLite 写东西,不跟 daemon 通信 —— 没有 socket,没有 IPC。
4
+ `dot goal` 插一行 goals,`dot approve` 改一行 approvals,常驻进程下一轮自己就看见了。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ import unicodedata
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from dot import approvals, db
16
+ from dot import config as config_mod
17
+ from dot import graph as graph_mod
18
+ from dot import rules as rules_mod
19
+ from dot import skills as skills_mod
20
+ from dot import worker
21
+ from dot.prompts import CURATE_PROMPT, LEARN_PROMPT
22
+
23
+
24
+ def main(argv=None) -> int:
25
+ p = argparse.ArgumentParser(
26
+ prog="dot", description="常驻主动式 agent。给一个目标,它自己在后台推。"
27
+ )
28
+ p.add_argument("-c", "--config", default=config_mod.DEFAULT_FILENAME,
29
+ help="配置文件路径(默认 ./dot.toml)")
30
+ sub = p.add_subparsers(dest="cmd", required=True)
31
+
32
+ sub.add_parser("init", help="在当前目录生成一份 dot.toml(自动探测模型)")
33
+ sub.add_parser("doctor", help="体检:配置、密钥、模型、目录、数据库")
34
+ g = sub.add_parser("goal", help="下一个目标")
35
+ g.add_argument("text", nargs="+")
36
+ g.add_argument("--bot", default="dot", help="交给哪个 bot 做(默认 dot)")
37
+ ln = sub.add_parser("learn", help="把一段材料/一套流程总结成技能")
38
+ ln.add_argument("source", nargs="+")
39
+ sub.add_parser("skills", help="列出技能库")
40
+ sk = sub.add_parser("skill", help="技能管理")
41
+ sk_sub = sk.add_subparsers(dest="skill_cmd", required=True)
42
+ sk_show = sk_sub.add_parser("show", help="看一份技能的正文")
43
+ sk_show.add_argument("name")
44
+ sk_rm = sk_sub.add_parser("rm", help="删掉一份技能")
45
+ sk_rm.add_argument("name")
46
+ sub.add_parser("curate", help="整理技能库:合并重复、删掉没价值的")
47
+ s = sub.add_parser("ls", help="列目标")
48
+ s.add_argument("--status", help="只看某个状态")
49
+ lg = sub.add_parser("log", help="活动记录")
50
+ lg.add_argument("--goal", help="只看某个目标")
51
+ lg.add_argument("-n", type=int, default=30)
52
+ ap = sub.add_parser("approve", help="批准一个挂起的动作")
53
+ ap.add_argument("token")
54
+ rj = sub.add_parser("reject", help="拒绝一个挂起的动作")
55
+ rj.add_argument("token")
56
+ sub.add_parser("cost", help="token 用量")
57
+ tk = sub.add_parser("tick", help="只跑一轮(调试用)")
58
+ tk.add_argument("--dry-run", action="store_true")
59
+ sv = sub.add_parser("serve", help="常驻跑起来")
60
+ sv.add_argument("--dry-run", action="store_true", help="不真发邮件,审批打印到终端")
61
+ sv.add_argument("--once", action="store_true", help="只跑一轮就退")
62
+ sv.add_argument("--web", action="store_true", help="同时起只读活动视图(只听本机)")
63
+ sv.add_argument("--web-port", type=int, default=8765)
64
+ sub.add_parser("selftest", help="不联网、不调模型,跑一遍自检")
65
+
66
+ args = p.parse_args(argv)
67
+
68
+ if args.cmd == "init":
69
+ return _cmd_init(args)
70
+ if args.cmd == "selftest":
71
+ return _cmd_selftest()
72
+ if args.cmd == "doctor":
73
+ return _cmd_doctor(args)
74
+
75
+ cfg = config_mod.load(args.config)
76
+
77
+ problems = rules_mod.validate(cfg.rules)
78
+ if problems:
79
+ for x in problems:
80
+ print(f"[dot] 配置有问题:{x}", file=sys.stderr)
81
+ return 2
82
+
83
+ cfg.workspace.mkdir(parents=True, exist_ok=True)
84
+ if cfg.skills.enabled:
85
+ skills_mod.ensure_dir(cfg)
86
+ conn = db.connect(cfg.db)
87
+ db.init(conn)
88
+
89
+ if args.cmd == "goal":
90
+ text = " ".join(args.text).strip()
91
+ bot = (args.bot or "dot").strip().lower()
92
+ if bot not in cfg.bots:
93
+ print(f"没有叫 {bot!r} 的 bot。可用的:{', '.join(sorted(cfg.bots))}",
94
+ file=sys.stderr)
95
+ return 2
96
+ gid = db.add_goal(conn, text, origin="cli", status=db.READY, bot=bot)
97
+ db.audit(conn, gid, "goal_created", f"[{bot}] {text[:200]}")
98
+ print(f"{gid} {text}")
99
+ if bot != "dot":
100
+ print(f"交给 {bot} 做")
101
+ print("跑起来:python -m dot serve", file=sys.stderr)
102
+ return 0
103
+
104
+ if args.cmd == "learn":
105
+ return _cmd_learn(conn, cfg, " ".join(args.source).strip())
106
+ if args.cmd == "curate":
107
+ return _cmd_curate(conn, cfg)
108
+ if args.cmd == "skills":
109
+ return _cmd_skills(cfg)
110
+ if args.cmd == "skill":
111
+ return _cmd_skill(cfg, args.skill_cmd, args.name)
112
+
113
+ if args.cmd == "ls":
114
+ return _cmd_ls(conn, args.status)
115
+ if args.cmd == "log":
116
+ return _cmd_log(conn, args.goal, args.n)
117
+ if args.cmd in ("approve", "reject"):
118
+ return _cmd_decide(conn, args.token, args.cmd)
119
+ if args.cmd == "cost":
120
+ return _cmd_cost(conn, cfg)
121
+ if args.cmd == "tick":
122
+ worker.serve(cfg, conn, dry_run=args.dry_run, once=True)
123
+ return 0
124
+ if args.cmd == "serve":
125
+ worker.serve(
126
+ cfg, conn, dry_run=args.dry_run, once=args.once,
127
+ web=args.web, web_port=args.web_port,
128
+ )
129
+ return 0
130
+
131
+ p.print_help()
132
+ return 1
133
+
134
+
135
+ # ------------------------------------------------------------------ 子命令
136
+
137
+ def _cmd_init(args) -> int:
138
+ dest = Path(args.config)
139
+ if dest.exists():
140
+ print(f"{dest} 已经存在,没动它。体检一下:python -m dot doctor")
141
+ return 0
142
+
143
+ text = (Path(__file__).parent / "template.toml").read_text(encoding="utf-8")
144
+ preset = config_mod.detect_model()
145
+ if preset:
146
+ name, base_url, key_env, _ = preset
147
+ body = [f'name = "{name}"']
148
+ body.append(f'base_url = "{base_url}"' if base_url else '# base_url = ""')
149
+ body.append(f'api_key_env = "{key_env}"' if key_env else '# api_key_env = ""')
150
+ body.append("")
151
+ text = _replace_section_body(text, "model", "\n".join(body))
152
+ dest.write_text(text, encoding="utf-8")
153
+
154
+ print(f"写好了 {dest}")
155
+ if preset:
156
+ print(f"探测到环境里有 {preset[2]},[model] 已经按它配好 —— 不用再动。")
157
+ else:
158
+ print("没探测到 API key。先 export 一个再重跑 init,或者手填 [model] 段。")
159
+ print(
160
+ f"\n下一步:\n"
161
+ f" 1. python -m dot doctor 体检\n"
162
+ f" 2. python -m dot goal \"把 inbox 里的东西整理成一份报告\"\n"
163
+ f" 3. python -m dot serve --web\n"
164
+ )
165
+ return 0
166
+
167
+
168
+ def _replace_section_body(text: str, section: str, body: str) -> str:
169
+ """把 [section] 下面的内容整段换掉,直到下一个 [ 开头的行。
170
+
171
+ 比按行字符串替换稳:模板里的注释行随便改,这里都不会错位。
172
+ """
173
+ lines = text.splitlines()
174
+ out: list[str] = []
175
+ i, n = 0, len(lines)
176
+ while i < n:
177
+ if lines[i].strip() == f"[{section}]":
178
+ out.append(lines[i])
179
+ out.append(body)
180
+ i += 1
181
+ while i < n and not lines[i].lstrip().startswith("["):
182
+ i += 1
183
+ continue
184
+ out.append(lines[i])
185
+ i += 1
186
+ return "\n".join(out) + "\n"
187
+
188
+
189
+ # --------------------------------------------------------------- 技能
190
+
191
+ def _cmd_learn(conn, cfg, source: str) -> int:
192
+ if not cfg.skills.enabled:
193
+ print("[skills] enabled = false,先打开它。", file=sys.stderr)
194
+ return 2
195
+ text = LEARN_PROMPT.format(source=source, dir=cfg.skills.dir)
196
+ gid = db.add_goal(conn, text, origin="cli:learn", status=db.READY)
197
+ db.audit(conn, gid, "learn", source[:200])
198
+ print(f"{gid} 学:{source[:70]}")
199
+ print("跑起来:python -m dot serve (或者 python -m dot tick)", file=sys.stderr)
200
+ return 0
201
+
202
+
203
+ def _cmd_curate(conn, cfg) -> int:
204
+ if not cfg.skills.enabled:
205
+ print("[skills] enabled = false,先打开它。", file=sys.stderr)
206
+ return 2
207
+ found = skills_mod.scan(cfg)
208
+ if len(found) < 2:
209
+ print(f"技能库只有 {len(found)} 份,没什么好整理的。")
210
+ return 0
211
+ text = CURATE_PROMPT.format(dir=cfg.skills.dir)
212
+ gid = db.add_goal(conn, text, origin="cli:curate", status=db.READY)
213
+ db.audit(conn, gid, "curate", f"{len(found)} 份技能")
214
+ print(f"{gid} 整理 {len(found)} 份技能")
215
+ print("提醒:它会真的删/改技能文件。先 python -m dot skills 看一眼现状。",
216
+ file=sys.stderr)
217
+ return 0
218
+
219
+
220
+ def _cmd_skills(cfg) -> int:
221
+ found = skills_mod.scan(cfg)
222
+ if not found:
223
+ print(f"技能库是空的({cfg.workspace / cfg.skills.dir})。")
224
+ print('攒一份:python -m dot learn "..."')
225
+ return 0
226
+ rows = [(s.name, _w(s.description) and f"{len(s.description):>3}", s.description)
227
+ for s in found]
228
+ print(_table(rows, ("NAME", "LEN", "DESCRIPTION")))
229
+ print(f"\n{len(found)} 份。看正文:python -m dot skill show <name>"
230
+ f" 整理:python -m dot curate")
231
+ return 0
232
+
233
+
234
+ def _cmd_skill(cfg, action: str, name: str) -> int:
235
+ found = {s.name: s for s in skills_mod.scan(cfg)}
236
+ skill = found.get(name)
237
+ if skill is None:
238
+ print(f"没有叫 {name!r} 的技能。" + (f"有的是:{', '.join(found)}" if found else ""),
239
+ file=sys.stderr)
240
+ return 1
241
+ if action == "show":
242
+ print(skill.path.read_text(encoding="utf-8", errors="replace"))
243
+ return 0
244
+ if action == "rm":
245
+ skills_mod.remove(cfg, name)
246
+ print(f"删掉了 {name}({skill.path})")
247
+ return 0
248
+ return 1
249
+
250
+
251
+ # --------------------------------------------------------------- 体检
252
+
253
+ def _cmd_doctor(args) -> int:
254
+ """一条一条查,每条都给出能照做的下一步。"""
255
+ ok_all = True
256
+
257
+ def check(label: str, ok: bool, detail: str = "", fix: str = "") -> None:
258
+ nonlocal ok_all
259
+ mark = "✓" if ok else "✗"
260
+ print(f" {mark} {label}" + (f" {detail}" if detail else ""))
261
+ if not ok:
262
+ ok_all = False
263
+ if fix:
264
+ print(f" -> {fix}")
265
+
266
+ print(f"dot 体检({args.config})\n")
267
+
268
+ dest = Path(args.config)
269
+ if not dest.exists():
270
+ check("配置文件", False, f"{dest} 不存在", "python -m dot init")
271
+ return 1
272
+ check("配置文件", True, str(dest.resolve()))
273
+
274
+ try:
275
+ cfg = config_mod.load(args.config)
276
+ except SystemExit as e:
277
+ check("读配置", False, str(e))
278
+ return 1
279
+ check("读配置", True)
280
+
281
+ problems = rules_mod.validate(cfg.rules)
282
+ check("审批规则", not problems,
283
+ f"{len(cfg.rules)} 条" if not problems else problems[0],
284
+ "改 [approval.rules] 的值,只能是 allow / ask / deny")
285
+
286
+ try:
287
+ graph_mod.build_model(cfg)
288
+ check("模型", True, cfg.model.name)
289
+ except SystemExit as e:
290
+ check("模型", False, str(e).splitlines()[0],
291
+ "填 [model];第三方端点要 name / base_url / api_key_env 三件套")
292
+
293
+ try:
294
+ cfg.workspace.mkdir(parents=True, exist_ok=True)
295
+ probe = cfg.workspace / ".dot-write-probe"
296
+ probe.write_text("x", encoding="utf-8")
297
+ probe.unlink()
298
+ check("工作区可写", True, str(cfg.workspace))
299
+ except OSError as e:
300
+ check("工作区可写", False, str(e), f"检查 {cfg.workspace} 的权限")
301
+
302
+ try:
303
+ conn = db.connect(cfg.db)
304
+ db.init(conn)
305
+ version = db.meta_get(conn, "schema_version", "?")
306
+ n_goals = len(db.list_goals(conn, limit=1000))
307
+ check("数据库", True, f"schema v{version},{n_goals} 个目标")
308
+ except Exception as e:
309
+ check("数据库", False, f"{type(e).__name__}: {e}",
310
+ f"删掉 {cfg.db} 重来(会丢历史)")
311
+ conn = None
312
+
313
+ enabled = [n for n, s in (("fs", cfg.sources.fs), ("imap", cfg.sources.imap),
314
+ ("ical", cfg.sources.ical)) if s.enabled]
315
+ check("事件源", bool(enabled),
316
+ ", ".join(enabled) if enabled else "一个都没开",
317
+ "至少要开一个,否则没有东西能主动唤醒它")
318
+
319
+ if cfg.smtp.usable:
320
+ check("审批通道", True, f"邮件 -> {cfg.smtp.to}")
321
+ else:
322
+ print(" ! 审批通道 没配 [smtp] —— 审批只能走 CLI(dot approve),不算错")
323
+
324
+ if cfg.skills.enabled:
325
+ n = len(skills_mod.scan(cfg))
326
+ check("技能库", True, f"{n} 份 {cfg.workspace / cfg.skills.dir}")
327
+ else:
328
+ print(" ! 技能库 已关闭([skills] enabled = false)")
329
+
330
+ check("bot", True, ", ".join(sorted(cfg.bots)))
331
+ if cfg.daily_token_budget:
332
+ check("成本上限", True, f"{cfg.daily_token_budget:,} token/天")
333
+ else:
334
+ print(" ! 成本上限 没设 —— 先跑一天再用 dot cost 看真实数字")
335
+
336
+ print()
337
+ if ok_all:
338
+ print("可以跑:python -m dot serve --web")
339
+ else:
340
+ print("上面标 ✗ 的先修掉。")
341
+ return 0 if ok_all else 1
342
+
343
+
344
+ def _cmd_ls(conn, status) -> int:
345
+ goals = db.list_goals(conn, status, limit=100)
346
+ if not goals:
347
+ print('还没有目标。python -m dot goal "..." 下一个。')
348
+ return 0
349
+
350
+ # 等审批的目标直接把编号摊在列表里 —— 不然你还得去 log 里翻,
351
+ # 或者更糟:自己写 SQL 查。
352
+ waiting = {a["goal_id"]: a["token"] for a in db.pending_approvals(conn)}
353
+ rows = [
354
+ (g["id"], g["status"], waiting.get(g["id"], ""), _n(g["tokens"]),
355
+ _ago(g["updated_at"]), g["text"][:44])
356
+ for g in goals
357
+ ]
358
+ print(_table(rows, ("ID", "STATUS", "TOKEN", "TOKENS", "UPDATED", "TEXT")))
359
+ if waiting:
360
+ tokens = " ".join(sorted(waiting.values()))
361
+ print(f"\n{len(waiting)} 个在等你。批准:dot approve <编号> 拒绝:dot reject <编号>")
362
+ print(f"编号:{tokens}")
363
+ return 0
364
+
365
+
366
+ def _cmd_log(conn, goal_id, n) -> int:
367
+ rows_raw = db.recent_audit(conn, n, goal_id)
368
+ if not rows_raw:
369
+ print("还没有活动记录。")
370
+ return 0
371
+ rows = [
372
+ (_clock(r["ts"]), r["goal_id"] or "-", r["kind"], _clip(r["detail"], 70))
373
+ for r in reversed(rows_raw)
374
+ ]
375
+ print(_table(rows, ("TIME", "GOAL", "KIND", "DETAIL")))
376
+ return 0
377
+
378
+
379
+ def _cmd_decide(conn, token: str, cmd: str) -> int:
380
+ decision = approvals.APPROVED if cmd == "approve" else approvals.REJECTED
381
+ row = db.get_approval(conn, token)
382
+ if row is None:
383
+ print(f"没有编号 {token} 的待批动作。python -m dot log 看一眼。", file=sys.stderr)
384
+ return 1
385
+ if not db.resolve_approval(conn, token, decision):
386
+ print(f"{token} 已经批过了({row['status']}),这次不算。", file=sys.stderr)
387
+ return 1
388
+ db.audit(conn, row["goal_id"], "approval_cli", f"{token} -> {decision}")
389
+ print(f"{token} -> {decision}。目标 {row['goal_id']} 下一轮 tick 会接着跑。")
390
+ if cmd == "approve":
391
+ print(f" 它要做的是:{row['tool']} {row['args'][:200]}")
392
+ return 0
393
+
394
+
395
+ def _cmd_cost(conn, cfg) -> int:
396
+ day = db.tokens_since(conn, worker._local_midnight())
397
+ total = db.tokens_since(conn, 0)
398
+ print(f"今日 {_n(day)}")
399
+ print(f"累计 {_n(total)}")
400
+ if cfg.daily_token_budget:
401
+ print(f"上限 {_n(cfg.daily_token_budget)}/天")
402
+ else:
403
+ print("上限 没设([safety] daily_token_budget = 0)")
404
+ top = conn.execute(
405
+ "SELECT id, text, tokens FROM goals WHERE tokens > 0 ORDER BY tokens DESC LIMIT 10"
406
+ ).fetchall()
407
+ if top:
408
+ print()
409
+ print(_table(
410
+ [(g["id"], _n(g["tokens"]), g["text"][:48]) for g in top],
411
+ ("GOAL", "TOKENS", "TEXT"),
412
+ ))
413
+ return 0
414
+
415
+
416
+ def _cmd_selftest() -> int:
417
+ """不联网、不调模型。
418
+
419
+ 分两档:
420
+ 内置的几条 —— 永远跑,装完 pip 的人也能验证依赖和纯逻辑没坏。
421
+ 完整套件 —— 只在源码仓库里有(wheel 不带 tests/),含假 IMAP 服务器、
422
+ 交接、审批闭环那些。
423
+ 硬要两层都跑会逼着把 tests/ 打进 wheel,那不值得:
424
+ 用户要的是「我这套装得对不对」,开发者要的才是那 19 条断言。
425
+ """
426
+ from dot import sources
427
+
428
+ print("== rules ==")
429
+ rules_mod.demo()
430
+ print("== sources(文件源)==")
431
+ sources.demo()
432
+ print("== approvals ==")
433
+ approvals.demo()
434
+ print("== skills ==")
435
+ skills_mod.demo()
436
+
437
+ try:
438
+ from tests import test_loop, test_sources, test_web
439
+ except ImportError:
440
+ print("\n内置自检通过。")
441
+ print("完整套件(本地假 IMAP 服务器 / 假模型跑 tick / 活动视图)在源码仓库里:")
442
+ print(" git clone https://github.com/duyongan/dots-agent")
443
+ print(" cd dots-agent && python -m dot selftest")
444
+ return 0
445
+
446
+ print("== 事件源(本地假服务)==")
447
+ print(test_sources.run())
448
+ print("== 一轮 tick(用假模型)==")
449
+ print(test_loop.run())
450
+ print("== 活动视图 ==")
451
+ print(test_web.run())
452
+ print("\n自检通过。")
453
+ return 0
454
+
455
+
456
+ # -------------------------------------------------------------------- 工具
457
+
458
+ def _table(rows, headers) -> str:
459
+ widths = [_w(h) for h in headers]
460
+ for r in rows:
461
+ for i, c in enumerate(r):
462
+ widths[i] = max(widths[i], _w(c))
463
+ out = [" ".join(str(h).ljust(widths[i] + _w(h) - len(str(h))) for i, h in enumerate(headers))]
464
+ out.append(" ".join("-" * w for w in widths))
465
+ for r in rows:
466
+ out.append(" ".join(_pad(c, widths[i]) for i, c in enumerate(r)))
467
+ return "\n".join(out)
468
+
469
+
470
+ def _w(s) -> int:
471
+ """终端里中文占两格。不算这个,表格会歪。"""
472
+ return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in str(s))
473
+
474
+
475
+ def _pad(value, width: int) -> str:
476
+ s = str(value)
477
+ return s + " " * max(0, width - _w(s))
478
+
479
+
480
+ def _clip(s, n: int) -> str:
481
+ s = str(s).replace("\n", " ")
482
+ return s if len(s) <= n else s[: n - 1] + "…"
483
+
484
+
485
+ def _n(v) -> str:
486
+ return f"{int(v):,}"
487
+
488
+
489
+ def _clock(ts) -> str:
490
+ return datetime.fromtimestamp(ts).strftime("%m-%d %H:%M:%S")
491
+
492
+
493
+ def _ago(ts) -> str:
494
+ d = max(0, int(datetime.now().timestamp() - ts))
495
+ if d < 60:
496
+ return f"{d}s"
497
+ if d < 3600:
498
+ return f"{d // 60}m"
499
+ if d < 86400:
500
+ return f"{d // 3600}h"
501
+ return f"{d // 86400}d"
502
+
503
+
504
+ if __name__ == "__main__":
505
+ raise SystemExit(main())