@cnife/pi-miscs 0.1.2 → 0.2.1

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.1",
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,427 @@
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 argparse
29
+ import json
30
+ import os
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+
37
+ # ── Tokenizer selection ──────────────────────────────────────
38
+ _TOKENIZER_NAME = "deepseek" # default, may be overridden by --tokenizer
39
+ _ENCODER = None
40
+
41
+ def _load_tokenizer(name: str):
42
+ """Load the specified tokenizer. Called once at startup."""
43
+ global _ENCODER, _TOKENIZER_NAME
44
+ _TOKENIZER_NAME = name
45
+ if name == "deepseek":
46
+ from deepseek_tokenizer import ds_token
47
+ _ENCODER = ds_token
48
+ elif name == "cl100k_base":
49
+ from tiktoken import get_encoding
50
+ _ENCODER = get_encoding("cl100k_base")
51
+ elif name == "o200k_base":
52
+ from tiktoken import get_encoding
53
+ _ENCODER = get_encoding("o200k_base")
54
+ else:
55
+ raise ValueError(f"Unknown tokenizer: {name}")
56
+
57
+ def t(s: str) -> int:
58
+ """Encode and return token count."""
59
+ if _ENCODER is None:
60
+ _load_tokenizer("deepseek")
61
+ return len(_ENCODER.encode(s))
62
+
63
+ def detect_tokenizer_from_model(model_name: str) -> str:
64
+ """Heuristic: detect which tokenizer is appropriate for a model."""
65
+ model_lower = model_name.lower()
66
+ if any(k in model_lower for k in ("deepseek", "mimo")):
67
+ return "deepseek"
68
+ elif any(k in model_lower for k in ("gpt-4", "gpt-3.5", "cl100k")):
69
+ return "cl100k_base"
70
+ return "deepseek" # fallback
71
+
72
+
73
+ DEBUG_DIR = Path("/tmp/pi-token-measure") # may be overridden by --dir
74
+ SOCKET_DIR = Path("/tmp/claude-tmux-sockets")
75
+ SOCKET = SOCKET_DIR / "claude.sock"
76
+ SESSION = "pi-measure-tokens"
77
+ TARGET = f"{SESSION}:1.1"
78
+
79
+ # ── CLI arg parsing ──────────────────────────────────────────
80
+
81
+ def parse_args():
82
+ parser = argparse.ArgumentParser(
83
+ description=__doc__,
84
+ formatter_class=argparse.RawDescriptionHelpFormatter,
85
+ )
86
+ parser.add_argument(
87
+ "--analyze-only", action="store_true", help="只分析已有 payload"
88
+ )
89
+ parser.add_argument(
90
+ "--tokenizer",
91
+ default="deepseek",
92
+ help="指定分词器 (deepseek/cl100k_base/o200k_base/auto)",
93
+ )
94
+ parser.add_argument(
95
+ "--dir", default=None, help="从指定目录分析 payload"
96
+ )
97
+ return vars(parser.parse_args())
98
+
99
+
100
+ # ── Phase 1: Capture ──────────────────────────────────────────
101
+
102
+ def clean():
103
+ if DEBUG_DIR.exists():
104
+ shutil.rmtree(DEBUG_DIR)
105
+ DEBUG_DIR.mkdir(parents=True)
106
+ SOCKET_DIR.mkdir(parents=True, exist_ok=True)
107
+
108
+ def start_pi():
109
+ subprocess.run(["tmux", "-S", str(SOCKET), "kill-session", "-t", SESSION], capture_output=True)
110
+ time.sleep(0.3)
111
+ subprocess.run(["tmux", "-S", str(SOCKET), "new", "-d", "-s", SESSION], capture_output=True)
112
+ time.sleep(0.3)
113
+ subprocess.run(["tmux", "-S", str(SOCKET), "send-keys", "-t", TARGET, "--",
114
+ f"export PI_DEBUG_REQUEST_BODY={DEBUG_DIR} && pi"], capture_output=True)
115
+ print(f" pi started, debug at {DEBUG_DIR}")
116
+
117
+ def send(text: str):
118
+ subprocess.run(["tmux", "-S", str(SOCKET), "send-keys", "-t", TARGET, "--", text], capture_output=True)
119
+
120
+ def capture(lines: int = 50) -> str:
121
+ r = subprocess.run(["tmux", "-S", str(SOCKET), "capture-pane", "-p", "-J", "-t", TARGET,
122
+ "-S", f"-{lines}"], capture_output=True, text=True)
123
+ return r.stdout
124
+
125
+ def wait_output(pattern: str, timeout: int = 60) -> bool:
126
+ import re
127
+ deadline = time.time() + timeout
128
+ while time.time() < deadline:
129
+ if re.search(pattern, capture(100), re.IGNORECASE):
130
+ return True
131
+ time.sleep(1)
132
+ return False
133
+
134
+
135
+ # ── Phase 2: Analyze ──────────────────────────────────────────
136
+
137
+ def analyze() -> dict:
138
+ files = sorted(DEBUG_DIR.glob("*.json"))
139
+ if not files:
140
+ return {"from_payload": False}
141
+
142
+ print(f"\n Found {len(files)} payload files")
143
+
144
+ try:
145
+ with open(files[0]) as f:
146
+ base = json.load(f)
147
+ except (json.JSONDecodeError, OSError) as e:
148
+ print(f" ⚠ Failed to load payload: {e}")
149
+ return {"from_payload": False}
150
+
151
+ sys_msg = base["messages"][0]["content"] if base.get("messages") else ""
152
+ tools_list = base.get("tools", [])
153
+ model_name = base.get("model", "")
154
+
155
+ # Tool definitions from payload
156
+ ask_tool = todo_tool = None
157
+ for te in tools_list:
158
+ fn = te.get("function", te)
159
+ n = fn.get("name", "")
160
+ if n == "ask_user_question":
161
+ ask_tool = json.dumps(te, ensure_ascii=False)
162
+ elif n == "todo":
163
+ todo_tool = json.dumps(te, ensure_ascii=False)
164
+
165
+ ask_args = ask_result = ""
166
+ todo_calls = []
167
+ for fp in files:
168
+ try:
169
+ with open(fp) as f:
170
+ data = json.load(f)
171
+ except (json.JSONDecodeError, OSError) as e:
172
+ print(f" ⚠ Skipping corrupted payload {fp.name}: {e}")
173
+ continue
174
+ for msg in data.get("messages", []):
175
+ for tc in msg.get("tool_calls", []):
176
+ fn = tc.get("function", {})
177
+ n = fn.get("name", "")
178
+ if n == "ask_user_question" and not ask_args:
179
+ ask_args = fn.get("arguments", "")
180
+ elif n == "todo" and fn.get("arguments", "") not in todo_calls:
181
+ todo_calls.append(fn.get("arguments", ""))
182
+ if msg["role"] == "tool":
183
+ ct = msg.get("content", "")
184
+ if "User has answered your questions" in ct:
185
+ ask_result = ct
186
+
187
+ ask_g = todo_g = ""
188
+ if "Use ask_user_question whenever" in sys_msg:
189
+ s = sys_msg.find("Use ask_user_question whenever")
190
+ e = sys_msg.find("\n- Use `todo`")
191
+ if e > s: ask_g = sys_msg[s:e].strip()
192
+ if "Use `todo` for complex" in sys_msg:
193
+ s = sys_msg.find("Use `todo` for complex")
194
+ e = sys_msg.find("\n- Be concise in your")
195
+ if e > s: todo_g = sys_msg[s:e].strip()
196
+
197
+ return {
198
+ "ask_tool": ask_tool, "todo_tool": todo_tool,
199
+ "ask_args": ask_args, "ask_result": ask_result,
200
+ "ask_guide": ask_g, "todo_guide": todo_g,
201
+ "todo_calls": todo_calls,
202
+ "model_name": model_name,
203
+ "from_payload": True,
204
+ }
205
+
206
+
207
+ def compute(data: dict) -> dict:
208
+ # ── All-tools static from payload ──
209
+ all_tools = []
210
+ sys_tok = 0
211
+ if data["from_payload"]:
212
+ files = sorted(DEBUG_DIR.glob("*.json"))
213
+ try:
214
+ with open(files[0]) as f:
215
+ base = json.load(f)
216
+ except (json.JSONDecodeError, OSError) as e:
217
+ print(f" ⚠ Failed to reload payload: {e}")
218
+ base = {}
219
+ sys_content = base["messages"][0]["content"]
220
+ sys_tok = t(sys_content)
221
+ for te in base.get("tools", []):
222
+ fn = te.get("function", te)
223
+ full_tok = t(json.dumps(te, ensure_ascii=False))
224
+ desc_tok = t(fn.get("description", ""))
225
+ params_tok = t(json.dumps(fn.get("parameters", {}), ensure_ascii=False))
226
+ all_tools.append({"name": fn.get("name", "?"), "full": full_tok,
227
+ "desc": desc_tok, "params": params_tok})
228
+ all_tools.sort(key=lambda x: x["full"], reverse=True)
229
+
230
+ total_all_tools = sum(x["full"] for x in all_tools)
231
+
232
+ if data["from_payload"] and data.get("ask_tool") and data.get("todo_tool"):
233
+ ask_tok = t(data["ask_tool"])
234
+ todo_tok = t(data["todo_tool"])
235
+ else:
236
+ ask_tok = 910
237
+ todo_tok = 505
238
+
239
+ ask_g_tok = t(data["ask_guide"]) if data.get("ask_guide") else 0
240
+ todo_g_tok = t(data["todo_guide"]) if data.get("todo_guide") else 0
241
+
242
+ ask_ol = t("ask_user_question: Ask the user up to 4 structured questions (2-4 options each) when requirements are ambiguous")
243
+ todo_ol = t("todo: Manage a task list to track multi-step progress")
244
+
245
+ aa_tok = t(data["ask_args"]) if data.get("ask_args") else 0
246
+ ar_tok = t(data["ask_result"]) if data.get("ask_result") else 0
247
+ todo_toks = [t(c) for c in data.get("todo_calls", [])]
248
+
249
+ static_rpiv = ask_tok + todo_tok + ask_g_tok + todo_g_tok + ask_ol + todo_ol
250
+
251
+ return {
252
+ "tokenizer": _TOKENIZER_NAME,
253
+ "all_tools": all_tools,
254
+ "total_all_tools": total_all_tools,
255
+ "sys_tok": sys_tok,
256
+ "vocab": getattr(_ENCODER, 'vocab_size', None) or getattr(_ENCODER, 'max_token_value', None) or 100000,
257
+ "ask_tok": ask_tok,
258
+ "todo_tok": todo_tok,
259
+ "ask_g_tok": ask_g_tok,
260
+ "todo_g_tok": todo_g_tok,
261
+ "ask_ol": ask_ol,
262
+ "todo_ol": todo_ol,
263
+ "ask_args_tok": aa_tok,
264
+ "ask_result_tok": ar_tok,
265
+ "todo_toks": todo_toks,
266
+ "static_rpiv": static_rpiv,
267
+ "payload_count": len(list(DEBUG_DIR.glob("*.json"))) if data["from_payload"] else 0,
268
+ }
269
+
270
+
271
+ def report(r: dict):
272
+ avg_c = sum(r["todo_toks"]) // len(r["todo_toks"]) if r["todo_toks"] else 0
273
+ ask_static = r["ask_tok"] + r["ask_g_tok"] + r["ask_ol"]
274
+ todo_static = r["todo_tok"] + r["todo_g_tok"] + r["todo_ol"]
275
+ ask_dyn = r["ask_args_tok"] + r["ask_result_tok"]
276
+
277
+ print()
278
+ print("=" * 62)
279
+ tk_name = r["tokenizer"]
280
+ tk_vocab = f", vocab={r['vocab']}" if r['vocab'] else ""
281
+ print(f" Token Overhead Report ({tk_name}{tk_vocab})")
282
+ src = f"{r['payload_count']} payloads" if r["payload_count"] else "fallback strings"
283
+ print(f" Source: {src}")
284
+ print("=" * 62)
285
+
286
+ # ── All tools ranking ──
287
+ if r["all_tools"]:
288
+ print()
289
+ print(" ┌─ All Tools Static Overhead (tools[] JSON) ────────────┐")
290
+ print(f" │ {'Tool':<28s} {'Full':>6s} {'Desc':>6s} {'Params':>6s} │")
291
+ print(f" │ {'─'*28} {'─'*6} {'─'*6} {'─'*6} │")
292
+ for ts in r["all_tools"]:
293
+ tag = " ← rpiv" if ts["name"] in ("ask_user_question", "todo") else ""
294
+ print(f" │ {ts['name']:<28s} {ts['full']:>6d} {ts['desc']:>6d} {ts['params']:>6d}{tag}")
295
+ print(f" │ {'─'*28} {'─'*6} {'─'*6} {'─'*6} │")
296
+ print(f" │ {'Total':<28s} {r['total_all_tools']:>6d} │")
297
+ print(f" └──────────────────────────────────────────────────────┘")
298
+ if r["sys_tok"]:
299
+ non_tool = r["sys_tok"] - r["total_all_tools"]
300
+ print(f" System prompt: {r['sys_tok']:>6d} tokens total")
301
+ print(f" ├─ tools[] definitions: {r['total_all_tools']:>6d} ({r['total_all_tools']/r['sys_tok']*100:.1f}%)")
302
+ print(f" │ └─ rpiv tools: {r['ask_tok']+r['todo_tok']:>6d} ({(r['ask_tok']+r['todo_tok'])/r['sys_tok']*100:.1f}%)")
303
+ print(f" └─ other (instructions, {non_tool:>6d} ({non_tool/r['sys_tok']*100:.1f}%)")
304
+ print(f" skills, context, etc.)")
305
+
306
+ print()
307
+ print(" ┌─ rpiv Tools Focus ────────────────────────────────────┐")
308
+ print(f" │ ask_user_question tool JSON: {r['ask_tok']:>5d} tokens │")
309
+ print(f" │ todo tool JSON: {r['todo_tok']:>5d} tokens │")
310
+ print(f" │ ask guidelines (4 rules): {r['ask_g_tok']:>5d} tokens │")
311
+ print(f" │ todo guidelines (7 rules): {r['todo_g_tok']:>5d} tokens │")
312
+ print(f" │ one-liner refs (ask+todo): {r['ask_ol']+r['todo_ol']:>5d} tokens │")
313
+ print(f" ├──────────────────────────────────────────────────────┤")
314
+ print(f" │ Subtotal (rpiv static): {r['static_rpiv']:>5d} tokens │")
315
+ print(f" └──────────────────────────────────────────────────────┘")
316
+ print()
317
+ print(" ┌─ Dynamic Overhead ────────────────────────────────────┐")
318
+ print(f" │ ask_user_question (4 questions, ~3 options each): │")
319
+ print(f" │ call arguments: {r['ask_args_tok']:>5d} tokens │")
320
+ print(f" │ tool result: {r['ask_result_tok']:>5d} tokens │")
321
+ print(f" │ subtotal: ~{ask_dyn:>5d} tokens │")
322
+ print(f" ├──────────────────────────────────────────────────────┤")
323
+ print(f" │ todo: {len(r['todo_toks'])} creates │")
324
+ for i, tok in enumerate(r["todo_toks"]):
325
+ print(f" │ #{i+1}: {tok:>5d} tokens │")
326
+ print(f" │ avg: ~{avg_c:>5d} tokens/call │")
327
+ print(f" └──────────────────────────────────────────────────────┘")
328
+ print()
329
+ print(" ┌─ Decision Guide (per round) ─────────────────────────┐")
330
+ ROUNDING_BUFFER = 100 # unmeasured tool call overhead buffer
331
+ both = ask_static + ask_dyn + todo_static + avg_c * 3 + ROUNDING_BUFFER
332
+ print(f" │ ask_user_question only: {ask_static+ask_dyn:>5d} tokens/round │")
333
+ print(f" │ todo only (3 ops): {todo_static+avg_c*3+100:>5d} tokens/round │")
334
+ print(f" │ both tools used: {both:>5d} tokens/round │")
335
+ print(f" │ rpiv static only: {r['static_rpiv']:>5d} tokens/round │")
336
+ print(f" │ ALL tools static (13 tools): {r['total_all_tools']:>5d} tokens/round │")
337
+ print(f" ├──────────────────────────────────────────────────────┤")
338
+ print(f" │ 128K context: ~{128000//both:>3d} rounds (both tools) │")
339
+ print(f" └──────────────────────────────────────────────────────┘")
340
+
341
+
342
+ # ── CLI ────────────────────────────────────────────────────────
343
+
344
+ def main():
345
+ args = parse_args()
346
+
347
+ # Apply --dir override
348
+ global DEBUG_DIR
349
+ if args["dir"]:
350
+ DEBUG_DIR = Path(args["dir"])
351
+
352
+ # Initialize tokenizer
353
+ if args["tokenizer"] == "auto":
354
+ if not args["analyze_only"]:
355
+ print(" ⚠ --tokenizer auto only applies in --analyze-only mode; using deepseek for capture")
356
+ # Defer to after payload analysis for model detection
357
+ else:
358
+ _load_tokenizer(args["tokenizer"])
359
+
360
+ if args["analyze_only"]:
361
+ print(f"Tokenizer: {args['tokenizer']}")
362
+ data = analyze()
363
+ if args["tokenizer"] == "auto" and data.get("model_name"):
364
+ detected = detect_tokenizer_from_model(data["model_name"])
365
+ print(f" Auto-detected from model '{data['model_name']}': {detected}")
366
+ _load_tokenizer(detected)
367
+ elif args["tokenizer"] == "auto":
368
+ _load_tokenizer("deepseek")
369
+ r = compute(data)
370
+ report(r)
371
+ return
372
+
373
+ print("Phase 1/3: Capturing data")
374
+ clean()
375
+ start_pi()
376
+ if not wait_output(r"╰─", timeout=45):
377
+ print(" ⚠ pi may not have started fully")
378
+ # Continue anyway — best-effort capture
379
+ time.sleep(3)
380
+
381
+ send("帮我设计一个笔记方案。每天写技术笔记,需要快速搜索、标签、导出PDF。纠结用 Notion 还是本地文件。先问清楚需求再给建议。")
382
+ if wait_output(r"ask_user_question", timeout=45):
383
+ print(" ✓ ask_user_question triggered, answering...")
384
+ time.sleep(2)
385
+ # Answer questions dynamically — send Enter for each question
386
+ import re
387
+ for q in range(4):
388
+ pane_before = capture(50)
389
+ send("Enter")
390
+ time.sleep(1.5)
391
+ pane_after = capture(50)
392
+ if q < 3:
393
+ send("Tab")
394
+ time.sleep(0.5)
395
+ # If pane content didn't change, assume no more questions
396
+ if pane_before == pane_after:
397
+ break
398
+ send("Enter")
399
+ time.sleep(4)
400
+ print(" ✓ questionnaire answered")
401
+ else:
402
+ print(" ⚠ ask_user_question not triggered")
403
+
404
+ if wait_output(r"需要我帮你|推荐|方案", timeout=60):
405
+ print(" Response received, triggering todo...")
406
+ time.sleep(2)
407
+ send("做一个研究项目:对比分析 Docker、Podman、containerd。分三个阶段:收集资料、对比分析、写总结报告。请用 todos 管理任务进度。")
408
+ if wait_output(r"todo|Todos|Created #", timeout=45):
409
+ print(" ✓ todo triggered")
410
+ else:
411
+ print(" ⚠ todo not triggered")
412
+
413
+ time.sleep(3)
414
+ send("C-d")
415
+ time.sleep(2)
416
+ subprocess.run(["tmux", "-S", str(SOCKET), "kill-session", "-t", SESSION], capture_output=True)
417
+ print(" Data capture complete.\n")
418
+
419
+ print("Phase 2/3: Analyzing")
420
+ data = analyze()
421
+ print("\nPhase 3/3: Report")
422
+ r = compute(data)
423
+ report(r)
424
+
425
+
426
+ if __name__ == "__main__":
427
+ main()