@cnife/pi-miscs 0.1.2 → 0.2.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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cnife/pi-miscs",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "pi-package"
7
7
  ],
8
- "description": "Miscellaneous small pi extensions (debug, exit)",
8
+ "description": "Miscellaneous small pi extensions and skills (debug, exit, measure-tokens)",
9
9
  "homepage": "https://github.com/CNife/pi-extensions#readme",
10
10
  "bugs": {
11
11
  "url": "https://github.com/CNife/pi-extensions/issues"
@@ -23,6 +23,9 @@
23
23
  "pi": {
24
24
  "extensions": [
25
25
  "./extensions"
26
+ ],
27
+ "skills": [
28
+ "./skills"
26
29
  ]
27
30
  },
28
31
  "peerDependencies": {
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: measure-tokens
3
+ description: 测量 pi 工具和插件的 LLM 上下文占用(token 数)
4
+ argument-hint: "[--analyze-only | --tokenizer <type> | --dir <path> | --help]"
5
+ ---
6
+
7
+ # measure-tokens — 测量 pi 工具的 token 开销
8
+
9
+ 测量 pi 工具和插件在 LLM 上下文中的 token 占用情况,帮助优化提示词和工具定义。
10
+
11
+ ## 背景
12
+
13
+ pi 工具和插件会向 LLM 发送工具定义、系统提示等文本,这些都会占用上下文窗口。了解 token 占用情况有助于:
14
+
15
+ - 识别过大的工具定义
16
+ - 优化提示词长度
17
+ - 避免上下文溢出
18
+ - 比较不同工具的 token 效率
19
+
20
+ ## 触发方式
21
+
22
+ ```bash
23
+ # 分析现有 payload 文件
24
+ pi run skill:measure-tokens --analyze-only
25
+
26
+ # 指定 tokenizer 类型
27
+ pi run skill:measure-tokens --tokenizer auto
28
+
29
+ # 捕获新的 payload(需要设置环境变量)
30
+ PI_DEBUG_REQUEST_BODY=1 pi [原始命令]
31
+ ```
32
+
33
+ ## 使用步骤
34
+
35
+ ### 1. 捕获 payload
36
+
37
+ 设置环境变量 `PI_DEBUG_REQUEST_BODY=1` 运行任何 pi 命令,会将完整请求 payload 保存到临时文件:
38
+
39
+ ```bash
40
+ PI_DEBUG_REQUEST_BODY=1 pi list
41
+ PI_DEBUG_REQUEST_BODY=1 pi run skill:some-skill
42
+ ```
43
+
44
+ 每个请求会生成一个 JSON 文件,包含发送给 LLM 的完整内容。
45
+
46
+ ### 2. 分析 token 占用
47
+
48
+ 运行分析脚本:
49
+
50
+ ```bash
51
+ # 分析当前目录下的所有 payload 文件
52
+ uv run packages/miscs/skills/measure-tokens/measure-tokens.py --analyze-only
53
+
54
+ # 指定特定目录
55
+ uv run packages/miscs/skills/measure-tokens/measure-tokens.py --analyze-only --dir /path/to/payloads
56
+
57
+ # 使用特定 tokenizer(deepseek, cl100k_base, o200k_base, auto)
58
+ uv run packages/miscs/skills/measure-tokens/measure-tokens.py --tokenizer o200k_base
59
+
60
+ # 查看帮助
61
+ uv run packages/miscs/skills/measure-tokens/measure-tokens.py --help
62
+ ```
63
+
64
+ ### 3. 解读输出
65
+
66
+ 脚本会输出:
67
+
68
+ - 每个 payload 文件的 token 统计
69
+ - 按工具/系统提示分类的 token 占用
70
+ - 总 token 数和占比
71
+ - 识别最大的 token 消耗者
72
+
73
+ ### 4. 优化建议
74
+
75
+ 根据分析结果:
76
+
77
+ - **工具定义过大**:考虑拆分工具或简化描述
78
+ - **系统提示过长**:精简或分段加载
79
+ - **重复内容**:合并相似工具或使用模板
80
+ - **上下文溢出**:减少同时注册的工具数量
81
+
82
+ ## 输出格式
83
+
84
+ ```text
85
+ === Token Analysis Report ===
86
+ Payload: /tmp/pi-request-20260602-100234.json
87
+ Total tokens: 15,432
88
+
89
+ Breakdown:
90
+ System prompt: 2,456 tokens (15.9%)
91
+ Tool definitions: 8,976 tokens (58.2%)
92
+ - tool1: 1,234 tokens
93
+ - tool2: 2,345 tokens
94
+ - ...
95
+ Messages: 4,000 tokens (25.9%)
96
+
97
+ Largest consumers:
98
+ 1. tool3 (3,456 tokens)
99
+ 2. tool1 (1,234 tokens)
100
+ 3. system (2,456 tokens)
101
+ ```
102
+
103
+ ## 停止条件
104
+
105
+ - 分析完成并输出报告 → 停止
106
+ - 无 payload 文件且 `--analyze-only` → 报告无文件并停止
107
+ - 用户中断 → 停止
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.12"
4
+ # dependencies = [
5
+ # "deepseek-tokenizer",
6
+ # "tiktoken",
7
+ # ]
8
+ # ///
9
+
10
+ """
11
+ 测量 pi 工具的 token 开销。
12
+
13
+ 用法:
14
+ uv run measure-tokens.py # 完整流程: 捕获 → 分析 → 报告
15
+ uv run measure-tokens.py --analyze-only # 只分析已有 payload
16
+ uv run measure-tokens.py --analyze-only --dir <path> # 从指定目录分析 payload
17
+ uv run measure-tokens.py --tokenizer cl100k_base # 指定分词器
18
+ uv run measure-tokens.py --tokenizer auto # 从 payload model 字段自动识别
19
+ uv run measure-tokens.py --help # 显示此帮助
20
+
21
+ 支持的分词器:
22
+ deepseek DeepSeek 自研分词器 (vocab=128818), 对中文高效
23
+ cl100k_base OpenAI GPT-4 分词器 (vocab=100k)
24
+ o200k_base OpenAI o1 / GPT-4o 分词器 (vocab=200k)
25
+ auto 从 payload model 字段自动识别, 默认 deepseek
26
+ """
27
+
28
+ import json
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ # ── Tokenizer selection ──────────────────────────────────────
37
+ _TOKENIZER_NAME = "deepseek" # default, may be overridden by --tokenizer
38
+ _ENCODER = None
39
+
40
+ def _load_tokenizer(name: str):
41
+ """Load the specified tokenizer. Called once at startup."""
42
+ global _ENCODER, _TOKENIZER_NAME
43
+ _TOKENIZER_NAME = name
44
+ if name == "deepseek":
45
+ from deepseek_tokenizer import ds_token
46
+ _ENCODER = ds_token
47
+ elif name == "cl100k_base":
48
+ from tiktoken import get_encoding
49
+ _ENCODER = get_encoding("cl100k_base")
50
+ elif name == "o200k_base":
51
+ from tiktoken import get_encoding
52
+ _ENCODER = get_encoding("o200k_base")
53
+ else:
54
+ raise ValueError(f"Unknown tokenizer: {name}")
55
+
56
+ def t(s: str) -> int:
57
+ """Encode and return token count."""
58
+ if _ENCODER is None:
59
+ _load_tokenizer("deepseek")
60
+ return len(_ENCODER.encode(s))
61
+
62
+ def detect_tokenizer_from_model(model_name: str) -> str:
63
+ """Heuristic: detect which tokenizer is appropriate for a model."""
64
+ model_lower = model_name.lower()
65
+ if any(k in model_lower for k in ("deepseek", "mimo")):
66
+ return "deepseek"
67
+ elif any(k in model_lower for k in ("gpt-4", "gpt-3.5", "cl100k")):
68
+ return "cl100k_base"
69
+ return "deepseek" # fallback
70
+
71
+
72
+ DEBUG_DIR = Path("/tmp/pi-token-measure") # may be overridden by --dir
73
+ SOCKET_DIR = Path("/tmp/claude-tmux-sockets")
74
+ SOCKET = SOCKET_DIR / "claude.sock"
75
+ SESSION = "pi-measure-tokens"
76
+ TARGET = f"{SESSION}:1.1"
77
+
78
+ # ── CLI arg parsing ──────────────────────────────────────────
79
+
80
+ def parse_args():
81
+ args = {"analyze_only": False, "tokenizer": "deepseek", "dir": None}
82
+ i = 1
83
+ while i < len(sys.argv):
84
+ arg = sys.argv[i]
85
+ if arg in ("--help", "-h"):
86
+ print(__doc__.strip())
87
+ sys.exit(0)
88
+ elif arg == "--analyze-only":
89
+ args["analyze_only"] = True
90
+ elif arg == "--tokenizer":
91
+ i += 1
92
+ if i < len(sys.argv):
93
+ args["tokenizer"] = sys.argv[i]
94
+ elif arg == "--dir":
95
+ i += 1
96
+ if i < len(sys.argv):
97
+ args["dir"] = sys.argv[i]
98
+ i += 1
99
+ return args
100
+
101
+
102
+ # ── Phase 1: Capture ──────────────────────────────────────────
103
+
104
+ def clean():
105
+ if DEBUG_DIR.exists():
106
+ shutil.rmtree(DEBUG_DIR)
107
+ DEBUG_DIR.mkdir(parents=True)
108
+ SOCKET_DIR.mkdir(parents=True, exist_ok=True)
109
+
110
+ def start_pi():
111
+ subprocess.run(["tmux", "-S", str(SOCKET), "kill-session", "-t", SESSION], capture_output=True)
112
+ time.sleep(0.3)
113
+ subprocess.run(["tmux", "-S", str(SOCKET), "new", "-d", "-s", SESSION], capture_output=True)
114
+ time.sleep(0.3)
115
+ subprocess.run(["tmux", "-S", str(SOCKET), "send-keys", "-t", TARGET, "--",
116
+ f"export PI_DEBUG_REQUEST_BODY={DEBUG_DIR} && pi"], capture_output=True)
117
+ print(f" pi started, debug at {DEBUG_DIR}")
118
+
119
+ def send(text: str):
120
+ subprocess.run(["tmux", "-S", str(SOCKET), "send-keys", "-t", TARGET, "--", text], capture_output=True)
121
+
122
+ def capture(lines: int = 50) -> str:
123
+ r = subprocess.run(["tmux", "-S", str(SOCKET), "capture-pane", "-p", "-J", "-t", TARGET,
124
+ "-S", f"-{lines}"], capture_output=True, text=True)
125
+ return r.stdout
126
+
127
+ def wait_output(pattern: str, timeout: int = 60) -> bool:
128
+ import re
129
+ deadline = time.time() + timeout
130
+ while time.time() < deadline:
131
+ if re.search(pattern, capture(100), re.IGNORECASE):
132
+ return True
133
+ time.sleep(1)
134
+ return False
135
+
136
+
137
+ # ── Phase 2: Analyze ──────────────────────────────────────────
138
+
139
+ def analyze() -> dict:
140
+ files = sorted(DEBUG_DIR.glob("*.json"))
141
+ if not files:
142
+ return {"from_payload": False}
143
+
144
+ print(f"\n Found {len(files)} payload files")
145
+
146
+ try:
147
+ with open(files[0]) as f:
148
+ base = json.load(f)
149
+ except (json.JSONDecodeError, OSError) as e:
150
+ print(f" ⚠ Failed to load payload: {e}")
151
+ return {"from_payload": False}
152
+
153
+ sys_msg = base["messages"][0]["content"] if base.get("messages") else ""
154
+ tools_list = base.get("tools", [])
155
+ model_name = base.get("model", "")
156
+
157
+ # Tool definitions from payload
158
+ ask_tool = todo_tool = None
159
+ for te in tools_list:
160
+ fn = te.get("function", te)
161
+ n = fn.get("name", "")
162
+ if n == "ask_user_question":
163
+ ask_tool = json.dumps(te, ensure_ascii=False)
164
+ elif n == "todo":
165
+ todo_tool = json.dumps(te, ensure_ascii=False)
166
+
167
+ ask_args = ask_result = ""
168
+ todo_calls = []
169
+ for fp in files:
170
+ try:
171
+ with open(fp) as f:
172
+ data = json.load(f)
173
+ except (json.JSONDecodeError, OSError) as e:
174
+ print(f" ⚠ Skipping corrupted payload {fp.name}: {e}")
175
+ continue
176
+ for msg in data.get("messages", []):
177
+ for tc in msg.get("tool_calls", []):
178
+ fn = tc.get("function", {})
179
+ n = fn.get("name", "")
180
+ if n == "ask_user_question" and not ask_args:
181
+ ask_args = fn.get("arguments", "")
182
+ elif n == "todo" and fn.get("arguments", "") not in todo_calls:
183
+ todo_calls.append(fn.get("arguments", ""))
184
+ if msg["role"] == "tool":
185
+ ct = msg.get("content", "")
186
+ if "User has answered your questions" in ct:
187
+ ask_result = ct
188
+
189
+ ask_g = todo_g = ""
190
+ if "Use ask_user_question whenever" in sys_msg:
191
+ s = sys_msg.find("Use ask_user_question whenever")
192
+ e = sys_msg.find("\n- Use `todo`")
193
+ if e > s: ask_g = sys_msg[s:e].strip()
194
+ if "Use `todo` for complex" in sys_msg:
195
+ s = sys_msg.find("Use `todo` for complex")
196
+ e = sys_msg.find("\n- Be concise in your")
197
+ if e > s: todo_g = sys_msg[s:e].strip()
198
+
199
+ return {
200
+ "ask_tool": ask_tool, "todo_tool": todo_tool,
201
+ "ask_args": ask_args, "ask_result": ask_result,
202
+ "ask_guide": ask_g, "todo_guide": todo_g,
203
+ "todo_calls": todo_calls,
204
+ "model_name": model_name,
205
+ "from_payload": True,
206
+ }
207
+
208
+
209
+ def compute(data: dict) -> dict:
210
+ # ── All-tools static from payload ──
211
+ all_tools = []
212
+ sys_tok = 0
213
+ if data["from_payload"]:
214
+ files = sorted(DEBUG_DIR.glob("*.json"))
215
+ try:
216
+ with open(files[0]) as f:
217
+ base = json.load(f)
218
+ except (json.JSONDecodeError, OSError) as e:
219
+ print(f" ⚠ Failed to reload payload: {e}")
220
+ base = {}
221
+ sys_content = base["messages"][0]["content"]
222
+ sys_tok = t(sys_content)
223
+ for te in base.get("tools", []):
224
+ fn = te.get("function", te)
225
+ full_tok = t(json.dumps(te, ensure_ascii=False))
226
+ desc_tok = t(fn.get("description", ""))
227
+ params_tok = t(json.dumps(fn.get("parameters", {}), ensure_ascii=False))
228
+ all_tools.append({"name": fn.get("name", "?"), "full": full_tok,
229
+ "desc": desc_tok, "params": params_tok})
230
+ all_tools.sort(key=lambda x: x["full"], reverse=True)
231
+
232
+ total_all_tools = sum(x["full"] for x in all_tools)
233
+
234
+ if data["from_payload"] and data.get("ask_tool") and data.get("todo_tool"):
235
+ ask_tok = t(data["ask_tool"])
236
+ todo_tok = t(data["todo_tool"])
237
+ else:
238
+ ask_tok = 910
239
+ todo_tok = 505
240
+
241
+ ask_g_tok = t(data["ask_guide"]) if data.get("ask_guide") else 0
242
+ todo_g_tok = t(data["todo_guide"]) if data.get("todo_guide") else 0
243
+
244
+ ask_ol = t("ask_user_question: Ask the user up to 4 structured questions (2-4 options each) when requirements are ambiguous")
245
+ todo_ol = t("todo: Manage a task list to track multi-step progress")
246
+
247
+ aa_tok = t(data["ask_args"]) if data.get("ask_args") else 0
248
+ ar_tok = t(data["ask_result"]) if data.get("ask_result") else 0
249
+ todo_toks = [t(c) for c in data.get("todo_calls", [])]
250
+
251
+ static_rpiv = ask_tok + todo_tok + ask_g_tok + todo_g_tok + ask_ol + todo_ol
252
+
253
+ return {
254
+ "tokenizer": _TOKENIZER_NAME,
255
+ "all_tools": all_tools,
256
+ "total_all_tools": total_all_tools,
257
+ "sys_tok": sys_tok,
258
+ "vocab": getattr(_ENCODER, 'vocab_size', None) or getattr(_ENCODER, 'max_token_value', None) or 100000,
259
+ "ask_tok": ask_tok,
260
+ "todo_tok": todo_tok,
261
+ "ask_g_tok": ask_g_tok,
262
+ "todo_g_tok": todo_g_tok,
263
+ "ask_ol": ask_ol,
264
+ "todo_ol": todo_ol,
265
+ "ask_args_tok": aa_tok,
266
+ "ask_result_tok": ar_tok,
267
+ "todo_toks": todo_toks,
268
+ "static_rpiv": static_rpiv,
269
+ "payload_count": len(list(DEBUG_DIR.glob("*.json"))) if data["from_payload"] else 0,
270
+ }
271
+
272
+
273
+ def report(r: dict):
274
+ avg_c = sum(r["todo_toks"]) // len(r["todo_toks"]) if r["todo_toks"] else 0
275
+ ask_static = r["ask_tok"] + r["ask_g_tok"] + r["ask_ol"]
276
+ todo_static = r["todo_tok"] + r["todo_g_tok"] + r["todo_ol"]
277
+ ask_dyn = r["ask_args_tok"] + r["ask_result_tok"]
278
+
279
+ print()
280
+ print("=" * 62)
281
+ tk_name = r["tokenizer"]
282
+ tk_vocab = f", vocab={r['vocab']}" if r['vocab'] else ""
283
+ print(f" Token Overhead Report ({tk_name}{tk_vocab})")
284
+ src = f"{r['payload_count']} payloads" if r["payload_count"] else "fallback strings"
285
+ print(f" Source: {src}")
286
+ print("=" * 62)
287
+
288
+ # ── All tools ranking ──
289
+ if r["all_tools"]:
290
+ print()
291
+ print(" ┌─ All Tools Static Overhead (tools[] JSON) ────────────┐")
292
+ print(f" │ {'Tool':<28s} {'Full':>6s} {'Desc':>6s} {'Params':>6s} │")
293
+ print(f" │ {'─'*28} {'─'*6} {'─'*6} {'─'*6} │")
294
+ for ts in r["all_tools"]:
295
+ tag = " ← rpiv" if ts["name"] in ("ask_user_question", "todo") else ""
296
+ print(f" │ {ts['name']:<28s} {ts['full']:>6d} {ts['desc']:>6d} {ts['params']:>6d}{tag}")
297
+ print(f" │ {'─'*28} {'─'*6} {'─'*6} {'─'*6} │")
298
+ print(f" │ {'Total':<28s} {r['total_all_tools']:>6d} │")
299
+ print(f" └──────────────────────────────────────────────────────┘")
300
+ if r["sys_tok"]:
301
+ non_tool = r["sys_tok"] - r["total_all_tools"]
302
+ print(f" System prompt: {r['sys_tok']:>6d} tokens total")
303
+ print(f" ├─ tools[] definitions: {r['total_all_tools']:>6d} ({r['total_all_tools']/r['sys_tok']*100:.1f}%)")
304
+ print(f" │ └─ rpiv tools: {r['ask_tok']+r['todo_tok']:>6d} ({(r['ask_tok']+r['todo_tok'])/r['sys_tok']*100:.1f}%)")
305
+ print(f" └─ other (instructions, {non_tool:>6d} ({non_tool/r['sys_tok']*100:.1f}%)")
306
+ print(f" skills, context, etc.)")
307
+
308
+ print()
309
+ print(" ┌─ rpiv Tools Focus ────────────────────────────────────┐")
310
+ print(f" │ ask_user_question tool JSON: {r['ask_tok']:>5d} tokens │")
311
+ print(f" │ todo tool JSON: {r['todo_tok']:>5d} tokens │")
312
+ print(f" │ ask guidelines (4 rules): {r['ask_g_tok']:>5d} tokens │")
313
+ print(f" │ todo guidelines (7 rules): {r['todo_g_tok']:>5d} tokens │")
314
+ print(f" │ one-liner refs (ask+todo): {r['ask_ol']+r['todo_ol']:>5d} tokens │")
315
+ print(f" ├──────────────────────────────────────────────────────┤")
316
+ print(f" │ Subtotal (rpiv static): {r['static_rpiv']:>5d} tokens │")
317
+ print(f" └──────────────────────────────────────────────────────┘")
318
+ print()
319
+ print(" ┌─ Dynamic Overhead ────────────────────────────────────┐")
320
+ print(f" │ ask_user_question (4 questions, ~3 options each): │")
321
+ print(f" │ call arguments: {r['ask_args_tok']:>5d} tokens │")
322
+ print(f" │ tool result: {r['ask_result_tok']:>5d} tokens │")
323
+ print(f" │ subtotal: ~{ask_dyn:>5d} tokens │")
324
+ print(f" ├──────────────────────────────────────────────────────┤")
325
+ print(f" │ todo: {len(r['todo_toks'])} creates │")
326
+ for i, tok in enumerate(r["todo_toks"]):
327
+ print(f" │ #{i+1}: {tok:>5d} tokens │")
328
+ print(f" │ avg: ~{avg_c:>5d} tokens/call │")
329
+ print(f" └──────────────────────────────────────────────────────┘")
330
+ print()
331
+ print(" ┌─ Decision Guide (per round) ─────────────────────────┐")
332
+ ROUNDING_BUFFER = 100 # unmeasured tool call overhead buffer
333
+ both = ask_static + ask_dyn + todo_static + avg_c * 3 + ROUNDING_BUFFER
334
+ print(f" │ ask_user_question only: {ask_static+ask_dyn:>5d} tokens/round │")
335
+ print(f" │ todo only (3 ops): {todo_static+avg_c*3+100:>5d} tokens/round │")
336
+ print(f" │ both tools used: {both:>5d} tokens/round │")
337
+ print(f" │ rpiv static only: {r['static_rpiv']:>5d} tokens/round │")
338
+ print(f" │ ALL tools static (13 tools): {r['total_all_tools']:>5d} tokens/round │")
339
+ print(f" ├──────────────────────────────────────────────────────┤")
340
+ print(f" │ 128K context: ~{128000//both:>3d} rounds (both tools) │")
341
+ print(f" └──────────────────────────────────────────────────────┘")
342
+
343
+
344
+ # ── CLI ────────────────────────────────────────────────────────
345
+
346
+ def main():
347
+ args = parse_args()
348
+
349
+ # Apply --dir override
350
+ global DEBUG_DIR
351
+ if args["dir"]:
352
+ DEBUG_DIR = Path(args["dir"])
353
+
354
+ # Initialize tokenizer
355
+ if args["tokenizer"] == "auto":
356
+ if not args["analyze_only"]:
357
+ print(" ⚠ --tokenizer auto only applies in --analyze-only mode; using deepseek for capture")
358
+ # Defer to after payload analysis for model detection
359
+ else:
360
+ _load_tokenizer(args["tokenizer"])
361
+
362
+ if args["analyze_only"]:
363
+ print(f"Tokenizer: {args['tokenizer']}")
364
+ data = analyze()
365
+ if args["tokenizer"] == "auto" and data.get("model_name"):
366
+ detected = detect_tokenizer_from_model(data["model_name"])
367
+ print(f" Auto-detected from model '{data['model_name']}': {detected}")
368
+ _load_tokenizer(detected)
369
+ elif args["tokenizer"] == "auto":
370
+ _load_tokenizer("deepseek")
371
+ r = compute(data)
372
+ report(r)
373
+ return
374
+
375
+ print("Phase 1/3: Capturing data")
376
+ clean()
377
+ start_pi()
378
+ if not wait_output(r"╰─", timeout=45):
379
+ print(" ⚠ pi may not have started fully")
380
+ # Continue anyway — best-effort capture
381
+ time.sleep(3)
382
+
383
+ send("帮我设计一个笔记方案。每天写技术笔记,需要快速搜索、标签、导出PDF。纠结用 Notion 还是本地文件。先问清楚需求再给建议。")
384
+ if wait_output(r"ask_user_question", timeout=45):
385
+ print(" ✓ ask_user_question triggered, answering...")
386
+ time.sleep(2)
387
+ # Answer questions dynamically — send Enter for each question
388
+ import re
389
+ for q in range(4):
390
+ pane_before = capture(50)
391
+ send("Enter")
392
+ time.sleep(1.5)
393
+ pane_after = capture(50)
394
+ if q < 3:
395
+ send("Tab")
396
+ time.sleep(0.5)
397
+ # If pane content didn't change, assume no more questions
398
+ if pane_before == pane_after:
399
+ break
400
+ send("Enter")
401
+ time.sleep(4)
402
+ print(" ✓ questionnaire answered")
403
+ else:
404
+ print(" ⚠ ask_user_question not triggered")
405
+
406
+ if wait_output(r"需要我帮你|推荐|方案", timeout=60):
407
+ print(" Response received, triggering todo...")
408
+ time.sleep(2)
409
+ send("做一个研究项目:对比分析 Docker、Podman、containerd。分三个阶段:收集资料、对比分析、写总结报告。请用 todos 管理任务进度。")
410
+ if wait_output(r"todo|Todos|Created #", timeout=45):
411
+ print(" ✓ todo triggered")
412
+ else:
413
+ print(" ⚠ todo not triggered")
414
+
415
+ time.sleep(3)
416
+ send("C-d")
417
+ time.sleep(2)
418
+ subprocess.run(["tmux", "-S", str(SOCKET), "kill-session", "-t", SESSION], capture_output=True)
419
+ print(" Data capture complete.\n")
420
+
421
+ print("Phase 2/3: Analyzing")
422
+ data = analyze()
423
+ print("\nPhase 3/3: Report")
424
+ r = compute(data)
425
+ report(r)
426
+
427
+
428
+ if __name__ == "__main__":
429
+ main()