oh-pi 0.1.75 → 0.1.77

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/README.fr.md CHANGED
@@ -22,20 +22,28 @@ npx oh-pi
22
22
 
23
23
  ---
24
24
 
25
- ## Pourquoi
26
-
27
- pi-coding-agent est puissant dès l'installation. Mais configurer manuellement les fournisseurs, thèmes, extensions, compétences et modèles de prompts est fastidieux. oh-pi offre une TUI moderne qui fait tout en moins d'une minute — et embarque un **essaim de fourmis** qui transforme pi en système multi-agents.
28
-
29
- ## Démarrage rapide
25
+ ## Démarrage en 30 secondes
30
26
 
31
27
  ```bash
32
28
  npx oh-pi # tout configurer
33
29
  pi # commencer à coder
34
30
  ```
35
31
 
36
- C'est tout. oh-pi détecte votre environnement, vous guide dans la configuration et écrit `~/.pi/agent/` pour vous.
32
+ oh-pi détecte automatiquement votre environnement, vous guide dans une TUI moderne, puis écrit `~/.pi/agent/`.
33
+
34
+ Déjà configuré ? oh-pi détecte les fichiers existants et propose une **sauvegarde avant écrasement**.
35
+
36
+ ## Valeur en 2 minutes
37
+
38
+ pi-coding-agent est puissant par défaut, mais la configuration manuelle (fournisseurs, thèmes, extensions, skills, prompts) prend du temps. oh-pi compresse cette phase en moins d'une minute — puis ajoute la colonie pour les tâches complexes.
39
+
40
+ - [`docs/DEMO-SCRIPT.md`](./docs/DEMO-SCRIPT.md) — démo rapide en 2 minutes
41
+ - [`ROADMAP.md`](./ROADMAP.md) — positionnement, jalons, métriques
42
+ - [`DECISIONS.md`](./DECISIONS.md) — décisions produit et compromis techniques
43
+
44
+ ## Quand ne pas utiliser la colonie
37
45
 
38
- Vous avez déjà une config ? oh-pi la détecte et propose une **sauvegarde avant écrasement**.
46
+ Utilisez le flux pi classique (sans colonie) si la tâche est petite, très exploratoire, ou nécessite un pilotage humain continu.
39
47
 
40
48
  ## Ce que vous obtenez
41
49
 
@@ -139,14 +147,19 @@ Utilisez `/colony-stop` pour arrêter une colonie en cours.
139
147
 
140
148
  ### Protocole de signaux
141
149
 
142
- La colonie communique avec la conversation principale via des signaux structurés, empêchant le LLM de vérifier ou deviner l'état :
150
+ La colonie communique via des signaux structurés, pour éviter toute supposition côté modèle :
143
151
 
144
152
  | Signal | Signification |
145
153
  |--------|---------------|
146
- | `COLONY_SIGNAL:LAUNCHED` | Colonie démarrée ne pas vérifier |
147
- | `COLONY_SIGNAL:RUNNING` | Colonie active injecté à chaque tour |
148
- | `COLONY_SIGNAL:COMPLETE` | Colonie terminée consulter le rapport |
149
- | `COLONY_SIGNAL:FAILED` | Colonie crashée signaler l'erreur |
154
+ | `COLONY_SIGNAL:LAUNCHED` | Colonie démarrée en arrière-plan |
155
+ | `COLONY_SIGNAL:SCOUTING` | Vague d'éclaireuses en exploration/planification |
156
+ | `COLONY_SIGNAL:PLANNING_RECOVERY` | Boucle de récupération du plan en cours |
157
+ | `COLONY_SIGNAL:WORKING` | Exécution des tâches par les ouvrières |
158
+ | `COLONY_SIGNAL:REVIEWING` | Revue qualité par les soldats |
159
+ | `COLONY_SIGNAL:TASK_DONE` | Tâche terminée (jalon de progression) |
160
+ | `COLONY_SIGNAL:COMPLETE` | Mission terminée, rapport injecté |
161
+ | `COLONY_SIGNAL:FAILED` | Mission échouée avec diagnostic |
162
+ | `COLONY_SIGNAL:BUDGET_EXCEEDED` | Budget maximal atteint |
150
163
 
151
164
  ### Contrôle des tours
152
165
 
package/README.md CHANGED
@@ -22,20 +22,28 @@ npx oh-pi
22
22
 
23
23
  ---
24
24
 
25
- ## Why
26
-
27
- pi-coding-agent is powerful out of the box. But configuring providers, themes, extensions, skills, and prompts by hand is tedious. oh-pi gives you a modern TUI that does it all in under a minute — and ships an **ant colony swarm** that turns pi into a multi-agent system.
28
-
29
- ## Quick Start
25
+ ## 30-Second Start
30
26
 
31
27
  ```bash
32
28
  npx oh-pi # configure everything
33
29
  pi # start coding
34
30
  ```
35
31
 
36
- That's it. oh-pi detects your environment, walks you through setup, and writes `~/.pi/agent/` for you.
32
+ oh-pi auto-detects your environment, guides setup in a modern TUI, and writes `~/.pi/agent/` for you.
33
+
34
+ Already configured? It detects existing files and offers **backup before overwriting**.
35
+
36
+ ## 2-Minute Value
37
+
38
+ pi-coding-agent is powerful by default, but manual setup across providers, themes, extensions, skills, and prompts is slow. oh-pi compresses that setup into under a minute — then adds an **ant colony swarm** for multi-agent execution.
39
+
40
+ Want the fast walkthrough? See [`docs/DEMO-SCRIPT.md`](./docs/DEMO-SCRIPT.md).
37
41
 
38
- Already have a config? oh-pi detects it and offers **backup before overwriting**.
42
+ ## When Not to Use Ant Colony
43
+
44
+ Use plain pi workflows (without colony) when your task is tiny, highly exploratory, or needs constant human steering.
45
+
46
+ For positioning, scope, and milestones, see [`ROADMAP.md`](./ROADMAP.md). For rationale behind key trade-offs, see [`DECISIONS.md`](./DECISIONS.md).
39
47
 
40
48
  ## What You Get
41
49
 
@@ -96,6 +104,16 @@ oh-pi:
96
104
  ✅ Done — report auto-injected into conversation
97
105
  ```
98
106
 
107
+ ### What's new in v0.1.75
108
+
109
+ - **Planning Recovery Loop**: if scouts return unstructured intel, colony enters `planning_recovery` instead of failing immediately.
110
+ - **Plan Validation Gate**: before workers start, tasks are validated (title/description/caste/priority).
111
+ - **Scout Quorum for complex goals**: multi-step goals default to at least 2 scouts for better planning reliability.
112
+
113
+ ### Colony lifecycle (simple)
114
+
115
+ `SCOUTING → (if needed) PLANNING_RECOVERY → WORKING → REVIEWING → DONE`
116
+
99
117
  ### Architecture
100
118
 
101
119
  Each ant is an in-process `AgentSession` (pi SDK), not a child process:
@@ -139,14 +157,19 @@ Use `/colony-stop` to abort a running colony.
139
157
 
140
158
  ### Signal Protocol
141
159
 
142
- The colony communicates with the main conversation via structured signals, preventing the LLM from polling or guessing colony status:
160
+ The colony communicates with the main conversation via structured signals, so the model never has to guess background state. Updates are passively pushed (non-blocking), so polling is optional:
143
161
 
144
162
  | Signal | Meaning |
145
163
  |--------|---------|
146
- | `COLONY_SIGNAL:LAUNCHED` | Colony started don't poll |
147
- | `COLONY_SIGNAL:RUNNING` | Colony active injected each turn |
148
- | `COLONY_SIGNAL:COMPLETE` | Colony finished review report |
149
- | `COLONY_SIGNAL:FAILED` | Colony crashed report error |
164
+ | `COLONY_SIGNAL:LAUNCHED` | Colony started in background |
165
+ | `COLONY_SIGNAL:SCOUTING` | Scout wave is exploring / planning |
166
+ | `COLONY_SIGNAL:PLANNING_RECOVERY` | Plan recovery loop is restructuring tasks |
167
+ | `COLONY_SIGNAL:WORKING` | Worker phase is executing tasks |
168
+ | `COLONY_SIGNAL:REVIEWING` | Soldier review phase is active |
169
+ | `COLONY_SIGNAL:TASK_DONE` | A task finished (progress checkpoint) |
170
+ | `COLONY_SIGNAL:COMPLETE` | Colony finished and report injected |
171
+ | `COLONY_SIGNAL:FAILED` | Colony failed with diagnostics |
172
+ | `COLONY_SIGNAL:BUDGET_EXCEEDED` | Budget limit reached |
150
173
 
151
174
  ### Turn Control
152
175
 
package/README.zh.md CHANGED
@@ -22,20 +22,34 @@ npx oh-pi
22
22
 
23
23
  ---
24
24
 
25
- ## 为什么
26
-
27
- pi-coding-agent 开箱即用很强大,但手动配置提供商、主题、扩展、技能和提示词模板很繁琐。oh-pi 提供现代化 TUI,一分钟内搞定一切 —— 还附带一个**蚁群系统**,把 pi 变成多智能体平台。
28
-
29
- ## 快速开始
25
+ ## 30 秒上手
30
26
 
31
27
  ```bash
32
28
  npx oh-pi # 配置一切
33
29
  pi # 开始编码
34
30
  ```
35
31
 
36
- 就这样。oh-pi 检测你的环境,引导你完成设置,自动写入 `~/.pi/agent/`。
32
+ 就这样。oh-pi 会自动检测环境、引导配置,并写入 `~/.pi/agent/`。
33
+
34
+ 已有配置?会先备份,再覆盖。
35
+
36
+ ## 2 分钟看懂价值
37
+
38
+ oh-pi 把原本分散且手工的配置流程(提供商、主题、扩展、技能、提示词模板)整合成一次引导,通常 1 分钟内完成。
39
+
40
+ 当任务涉及多文件或可并行流程时,可启用蚁群系统,把 pi 升级为可协作的多智能体执行流。
41
+
42
+ - [`docs/DEMO-SCRIPT.zh.md`](./docs/DEMO-SCRIPT.zh.md) — 2 分钟演示脚本(价值与节奏)
43
+ - [`ROADMAP.md`](./ROADMAP.md) — 定位、里程碑与衡量指标
44
+ - [`DECISIONS.md`](./DECISIONS.md) — 阶段性关键决策与取舍依据
37
45
 
38
- 已有配置?oh-pi 会检测到并提供**覆盖前备份**。
46
+ ## 何时不该用蚁群
47
+
48
+ 以下场景建议**不要**启用蚁群,直接单代理更快:
49
+
50
+ - 只改 1 个文件、改动范围明确
51
+ - 快速问答、解释代码、一次性小修复
52
+ - 你需要严格串行控制每一步修改
39
53
 
40
54
  ## 你会得到
41
55
 
@@ -96,6 +110,16 @@ oh-pi:
96
110
  ✅ 完成 — 报告自动注入对话
97
111
  ```
98
112
 
113
+ ### v0.1.75 更新亮点
114
+
115
+ - **Planning Recovery 回路**:当 scout 产出非结构化情报时,进入 `planning_recovery`,而不是直接失败。
116
+ - **执行前计划校验**:worker 启动前会校验任务字段完整性(title/description/caste/priority)。
117
+ - **复杂目标 Scout Quorum**:多步骤目标默认至少 2 个 scout,提升规划可靠性。
118
+
119
+ ### 生命周期(简化)
120
+
121
+ `SCOUTING →(必要时)PLANNING_RECOVERY → WORKING → REVIEWING → DONE`
122
+
99
123
  ### 架构
100
124
 
101
125
  每只蚂蚁是进程内的 `AgentSession`(pi SDK),而非子进程:
@@ -139,14 +163,19 @@ pi(主进程)
139
163
 
140
164
  ### 信号协议
141
165
 
142
- 蚁群通过结构化信号与主对话通信,防止 LLM 轮询或猜测蚁群状态:
166
+ 蚁群通过结构化信号与主对话通信,让模型无需猜测后台状态。更新采用被动推送(非阻塞),轮询仅在手动排障时需要:
143
167
 
144
168
  | 信号 | 含义 |
145
169
  |------|------|
146
- | `COLONY_SIGNAL:LAUNCHED` | 蚁群已启动 — 不要轮询 |
147
- | `COLONY_SIGNAL:RUNNING` | 蚁群运行中 — 每轮注入 |
148
- | `COLONY_SIGNAL:COMPLETE` | 蚁群完成 — 查看报告 |
149
- | `COLONY_SIGNAL:FAILED` | 蚁群崩溃 — 报告错误 |
170
+ | `COLONY_SIGNAL:LAUNCHED` | 蚁群已在后台启动 |
171
+ | `COLONY_SIGNAL:SCOUTING` | 侦察波次正在探索/规划 |
172
+ | `COLONY_SIGNAL:PLANNING_RECOVERY` | 计划恢复回路正在重组任务 |
173
+ | `COLONY_SIGNAL:WORKING` | 工蚁执行阶段进行中 |
174
+ | `COLONY_SIGNAL:REVIEWING` | 兵蚁审查阶段进行中 |
175
+ | `COLONY_SIGNAL:TASK_DONE` | 单个任务完成(进度检查点) |
176
+ | `COLONY_SIGNAL:COMPLETE` | 蚁群完成并注入报告 |
177
+ | `COLONY_SIGNAL:FAILED` | 蚁群失败并附带诊断信息 |
178
+ | `COLONY_SIGNAL:BUDGET_EXCEEDED` | 达到预算上限 |
150
179
 
151
180
  ### 轮次控制
152
181
 
package/dist/index.js CHANGED
@@ -43,7 +43,7 @@ async function quickFlow(env) {
43
43
  providers,
44
44
  theme: "dark",
45
45
  keybindings: "default",
46
- extensions: ["safe-guard", "git-guard", "auto-session-name", "custom-footer", "compact-header", "auto-update", "smart-compact"],
46
+ extensions: ["safe-guard", "git-guard", "auto-session-name", "custom-footer", "compact-header", "auto-update"],
47
47
  prompts: ["review", "fix", "explain", "commit", "test"],
48
48
  agents: "general-developer",
49
49
  thinking: "medium",
package/dist/registry.js CHANGED
@@ -49,7 +49,6 @@ export const EXTENSIONS = [
49
49
  { name: "compact-header", label: "⚡ Compact Header — Dense startup info replacing verbose output", default: true },
50
50
  { name: "ant-colony", label: "🐜 Ant Colony — Autonomous multi-agent swarm with adaptive concurrency", default: false },
51
51
  { name: "auto-update", label: "🔄 Auto Update — Check for oh-pi updates on startup and notify", default: true },
52
- { name: "smart-compact", label: "🗜️ Smart Compact — Trim large tool outputs and old messages in-flight", default: true },
53
52
  { name: "bg-process", label: "⏳ Bg Process — Auto-background long-running commands (dev servers, etc.)", default: false },
54
53
  ];
55
54
  /** 快捷键绑定方案(default / vim / emacs) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-pi",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "One-click setup for pi-coding-agent. Like oh-my-zsh for pi.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,8 +17,9 @@ You command an autonomous ant colony. Complex tasks are delegated to the swarm,
17
17
  ## Workflow
18
18
  1. Assess task scope
19
19
  2. If colony-worthy → use `ant_colony` tool with clear goal
20
- 3. If simple do it directly
21
- 4. Review colony output, fix gaps manually if needed
20
+ 3. After launch, use passive mode: wait for `COLONY_SIGNAL:*` updates; do not poll `bg_colony_status` unless user explicitly asks
21
+ 4. If simple do it directly
22
+ 5. Review colony output, fix gaps manually if needed
22
23
 
23
24
  ## Code Standards
24
25
  - Follow existing conventions
@@ -18,7 +18,7 @@ import { runColony, resumeColony, type QueenCallbacks } from "./queen.js";
18
18
  import { Nest } from "./nest.js";
19
19
  import type { ColonyState, ColonyMetrics, AntStreamEvent } from "./types.js";
20
20
 
21
- import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
21
+ import { formatDuration, formatCost, formatTokens, statusIcon, statusLabel, progressBar, casteIcon, buildReport } from "./ui.js";
22
22
 
23
23
  // ═══ Background colony state ═══
24
24
 
@@ -38,12 +38,19 @@ interface AntStreamState {
38
38
  tokens: number;
39
39
  }
40
40
 
41
+ interface ColonyLogEntry {
42
+ timestamp: number;
43
+ level: "info" | "warning" | "error";
44
+ text: string;
45
+ }
46
+
41
47
  interface BackgroundColony {
42
48
  goal: string;
43
49
  abortController: AbortController;
44
50
  state: ColonyState | null;
45
51
  phase: string;
46
52
  antStreams: Map<string, AntStreamState>;
53
+ logs: ColonyLogEntry[];
47
54
  promise: Promise<ColonyState>;
48
55
  }
49
56
 
@@ -52,6 +59,56 @@ export default function antColonyExtension(pi: ExtensionAPI) {
52
59
  // 当前运行中的后台蚁群(同时只允许一个)
53
60
  let activeColony: BackgroundColony | null = null;
54
61
 
62
+ // 防止主进程主动轮询导致阻塞:仅允许显式请求的手动快照,并加冷却
63
+ let lastBgStatusSnapshotAt = 0;
64
+ const STATUS_SNAPSHOT_COOLDOWN_MS = 15_000;
65
+
66
+ const extractMessageText = (message: any): string => {
67
+ const c = message?.content;
68
+ if (typeof c === "string") return c;
69
+ if (Array.isArray(c)) {
70
+ return c.map((p: any) => {
71
+ if (typeof p === "string") return p;
72
+ if (typeof p?.text === "string") return p.text;
73
+ if (typeof p?.content === "string") return p.content;
74
+ return "";
75
+ }).join("\n");
76
+ }
77
+ return "";
78
+ };
79
+
80
+ const lastUserMessageText = (ctx: any): string => {
81
+ try {
82
+ const branch = ctx?.sessionManager?.getBranch?.() ?? [];
83
+ for (let i = branch.length - 1; i >= 0; i--) {
84
+ const e = branch[i];
85
+ if (e?.type === "message" && e.message?.role === "user") {
86
+ return extractMessageText(e.message).trim();
87
+ }
88
+ }
89
+ } catch {
90
+ // ignore
91
+ }
92
+ return "";
93
+ };
94
+
95
+ const isExplicitStatusRequest = (ctx: any): boolean => {
96
+ const text = lastUserMessageText(ctx);
97
+ return /(?:\/colony-status|bg_colony_status)|(?:(?:蚁群|colony).{0,20}(?:状态|进度|进展|汇报|快照|status|progress|snapshot|update|check))|(?:(?:状态|进度|进展|汇报|快照|status|progress|snapshot|update|check).{0,20}(?:蚁群|colony))/i.test(text);
98
+ };
99
+
100
+ const calcProgress = (m?: ColonyMetrics | null) => {
101
+ if (!m || m.tasksTotal <= 0) return 0;
102
+ return Math.max(0, Math.min(1, m.tasksDone / m.tasksTotal));
103
+ };
104
+
105
+ const trim = (text: string, max: number) => text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
106
+
107
+ const pushLog = (colony: BackgroundColony, entry: Omit<ColonyLogEntry, "timestamp">) => {
108
+ colony.logs.push({ timestamp: Date.now(), ...entry });
109
+ if (colony.logs.length > 40) colony.logs.splice(0, colony.logs.length - 40);
110
+ };
111
+
55
112
  // ─── Status 渲染 ───
56
113
 
57
114
  let lastRender = 0;
@@ -78,10 +135,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
78
135
  const { state } = activeColony;
79
136
  const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
80
137
  const m = state?.metrics;
81
- const colonyStatus = state?.status || "scouting";
82
-
83
- const parts = [`🐜 ${statusIcon(colonyStatus)}`];
84
- if (m) parts.push(`${m.tasksDone}/${m.tasksTotal}`);
138
+ const phase = state?.status || "scouting";
139
+ const progress = calcProgress(m);
140
+ const pct = `${Math.round(progress * 100)}%`;
141
+ const active = activeColony.antStreams.size;
142
+
143
+ const parts = [`🐜 ${statusIcon(phase)} ${statusLabel(phase)}`];
144
+ parts.push(m ? `${m.tasksDone}/${m.tasksTotal} (${pct})` : `0/0 (${pct})`);
145
+ parts.push(`⚡${active}`);
85
146
  if (m) parts.push(formatCost(m.totalCost));
86
147
  parts.push(elapsed);
87
148
 
@@ -162,9 +223,12 @@ export default function antColonyExtension(pi: ExtensionAPI) {
162
223
  state: null,
163
224
  phase: "initializing",
164
225
  antStreams: new Map(),
226
+ logs: [],
165
227
  promise: null as any, // set below
166
228
  };
167
229
 
230
+ pushLog(colony, { level: "info", text: "INITIALIZING · Colony launched in background" });
231
+
168
232
  let lastPhase = "";
169
233
 
170
234
  const callbacks: QueenCallbacks = {
@@ -174,6 +238,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
174
238
  if (signal.phase !== lastPhase) {
175
239
  lastPhase = signal.phase;
176
240
  const pct = Math.round(signal.progress * 100);
241
+ pushLog(colony, { level: "info", text: `${statusLabel(signal.phase)} ${pct}% · ${signal.message}` });
177
242
  pi.sendMessage({
178
243
  customType: "ant-colony-progress",
179
244
  content: `[COLONY_SIGNAL:${signal.phase.toUpperCase()}] 🐜 ${signal.message} (${pct}%, ${formatCost(signal.cost)})`,
@@ -184,6 +249,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
184
249
  },
185
250
  onPhase(phase, detail) {
186
251
  colony.phase = detail;
252
+ pushLog(colony, { level: "info", text: `${statusLabel(phase)} · ${detail}` });
187
253
  throttledRender();
188
254
  },
189
255
  onAntSpawn(ant, task) {
@@ -199,6 +265,10 @@ export default function antColonyExtension(pi: ExtensionAPI) {
199
265
  const icon = ant.status === "done" ? "✓" : "✗";
200
266
  const progress = m ? `${m.tasksDone}/${m.tasksTotal}` : "";
201
267
  const cost = m ? formatCost(m.totalCost) : "";
268
+ pushLog(colony, {
269
+ level: ant.status === "done" ? "info" : "warning",
270
+ text: `${icon} ${task.title.slice(0, 80)} (${progress}${cost ? `, ${cost}` : ""})`,
271
+ });
202
272
  pi.sendMessage({
203
273
  customType: "ant-colony-progress",
204
274
  content: `[COLONY_SIGNAL:TASK_DONE] 🐜 ${icon} ${task.title.slice(0, 60)} (${progress}, ${cost})`,
@@ -221,6 +291,10 @@ export default function antColonyExtension(pi: ExtensionAPI) {
221
291
  onComplete(state) {
222
292
  colony.state = state;
223
293
  colony.phase = state.status === "done" ? "Colony mission complete" : "Colony failed";
294
+ pushLog(colony, {
295
+ level: state.status === "done" ? "info" : "error",
296
+ text: `${statusLabel(state.status)} · ${state.metrics.tasksDone}/${state.metrics.tasksTotal} · ${formatCost(state.metrics.totalCost)}`,
297
+ });
224
298
  colony.antStreams.clear();
225
299
  throttledRender();
226
300
  },
@@ -244,6 +318,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
244
318
  colony.promise = resume ? resumeColony(colonyOpts) : runColony(colonyOpts);
245
319
 
246
320
  activeColony = colony;
321
+ lastBgStatusSnapshotAt = 0;
247
322
  throttledRender();
248
323
 
249
324
  // 后台等待完成,注入结果
@@ -251,6 +326,10 @@ export default function antColonyExtension(pi: ExtensionAPI) {
251
326
  const ok = state.status === "done";
252
327
  const report = buildReport(state);
253
328
  const m = state.metrics;
329
+ pushLog(colony, {
330
+ level: ok ? "info" : "error",
331
+ text: `${ok ? "COMPLETE" : "FAILED"} · ${m.tasksDone}/${m.tasksTotal} · ${formatCost(m.totalCost)}`,
332
+ });
254
333
 
255
334
  // 清理 UI
256
335
  pi.events.emit("ant-colony:clear-ui");
@@ -268,6 +347,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
268
347
  level: ok ? "success" : "error",
269
348
  });
270
349
  }).catch((e) => {
350
+ pushLog(colony, { level: "error", text: `CRASHED · ${String(e).slice(0, 120)}` });
271
351
  pi.events.emit("ant-colony:clear-ui");
272
352
  activeColony = null;
273
353
  pi.events.emit("ant-colony:notify", { msg: `🐜 Colony crashed: ${e}`, level: "error" });
@@ -283,6 +363,29 @@ export default function antColonyExtension(pi: ExtensionAPI) {
283
363
 
284
364
 
285
365
 
366
+ // ═══ Custom message renderer for colony progress signals ═══
367
+ pi.registerMessageRenderer("ant-colony-progress", (message, theme) => {
368
+ const content = typeof message.content === "string" ? message.content : "";
369
+ const line = content.split("\n")[0] || content;
370
+ const phaseMatch = line.match(/\[COLONY_SIGNAL:([A-Z_]+)\]/);
371
+ const text = line.replace(/\[COLONY_SIGNAL:[A-Z_]+\]\s*/, "").trim();
372
+
373
+ const phase = phaseMatch?.[1]?.toLowerCase() || "working";
374
+ const icon = statusIcon(phase);
375
+ const label = statusLabel(phase);
376
+
377
+ const body = trim(text, 120);
378
+ const coloredBody = phase === "failed"
379
+ ? theme.fg("error", body)
380
+ : phase === "budget_exceeded"
381
+ ? theme.fg("warning", body)
382
+ : phase === "done" || phase === "complete"
383
+ ? theme.fg("success", body)
384
+ : theme.fg("muted", body);
385
+
386
+ return new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))} ${coloredBody}`, 0, 0);
387
+ });
388
+
286
389
  // ═══ Custom message renderer for colony reports ═══
287
390
  pi.registerMessageRenderer("ant-colony-report", (message, theme) => {
288
391
  const content = typeof message.content === "string" ? message.content : "";
@@ -304,7 +407,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
304
407
  const taskLines = content.split("\n").filter(l => l.startsWith("- ✓") || l.startsWith("- ✗"));
305
408
  for (const l of taskLines.slice(0, 8)) {
306
409
  const icon = l.startsWith("- ✓") ? theme.fg("success", "✓") : theme.fg("error", "✗");
307
- container.addChild(new Text(` ${icon} ${theme.fg("dim", l.slice(4).trim().slice(0, 70))}`, 0, 0));
410
+ container.addChild(new Text(` ${icon} ${theme.fg("muted", l.slice(4).trim().slice(0, 70))}`, 0, 0));
308
411
  }
309
412
  if (taskLines.length > 8) {
310
413
  container.addChild(new Text(theme.fg("muted", ` ⋯ +${taskLines.length - 8} more`), 0, 0));
@@ -331,6 +434,8 @@ export default function antColonyExtension(pi: ExtensionAPI) {
331
434
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
332
435
  let cachedWidth: number | undefined;
333
436
  let cachedLines: string[] | undefined;
437
+ let currentTab: "tasks" | "streams" | "log" = "tasks";
438
+ let taskFilter: "all" | "active" | "done" | "failed" = "all";
334
439
 
335
440
  const buildLines = (width: number): string[] => {
336
441
  const c = activeColony;
@@ -341,36 +446,121 @@ export default function antColonyExtension(pi: ExtensionAPI) {
341
446
 
342
447
  // ── Header ──
343
448
  const elapsed = c.state ? formatDuration(Date.now() - c.state.createdAt) : "0s";
344
- const cost = c.state ? formatCost(c.state.metrics.totalCost) : "$0";
449
+ const m = c.state?.metrics;
450
+ const phase = c.state?.status || "scouting";
451
+ const progress = calcProgress(m);
452
+ const pct = Math.round(progress * 100);
453
+ const cost = m ? formatCost(m.totalCost) : "$0";
454
+ const activeAnts = c.antStreams.size;
455
+ const barWidth = Math.max(10, Math.min(24, w - 28));
456
+
345
457
  lines.push(theme.fg("accent", theme.bold(` 🐜 Colony Details`)) + theme.fg("muted", ` │ ${elapsed} │ ${cost}`));
346
- lines.push(theme.fg("dim", ` Goal: ${c.goal.slice(0, w - 8)}`));
458
+ lines.push(theme.fg("muted", ` Goal: ${trim(c.goal, w - 8)}`));
459
+ lines.push(` ${statusIcon(phase)} ${theme.bold(statusLabel(phase))} │ ${m ? `${m.tasksDone}/${m.tasksTotal}` : "0/0"} │ ${pct}% │ ⚡${activeAnts}`);
460
+ lines.push(theme.fg("muted", ` ${progressBar(progress, barWidth)} ${pct}%`));
461
+ if (c.phase && c.phase !== "initializing") {
462
+ lines.push(theme.fg("muted", ` Phase: ${trim(c.phase, w - 10)}`));
463
+ }
464
+ lines.push("");
465
+
466
+ // ── Tabs ──
467
+ const tabs: Array<{ key: "tasks" | "streams" | "log"; hotkey: string; label: string }> = [
468
+ { key: "tasks", hotkey: "1", label: "Tasks" },
469
+ { key: "streams", hotkey: "2", label: "Streams" },
470
+ { key: "log", hotkey: "3", label: "Log" },
471
+ ];
472
+ const tabLine = tabs.map((t) => {
473
+ const label = `[${t.hotkey}] ${t.label}`;
474
+ return currentTab === t.key ? theme.fg("accent", theme.bold(label)) : theme.fg("muted", label);
475
+ }).join(" ");
476
+ lines.push(` ${tabLine}`);
347
477
  lines.push("");
348
478
 
349
- // ── Tasks ──
350
479
  const tasks = c.state?.tasks || [];
351
- if (tasks.length > 0) {
480
+ const streams = Array.from(c.antStreams.values());
481
+
482
+ // ── Tab: Tasks ──
483
+ if (currentTab === "tasks") {
484
+ const counts = {
485
+ done: tasks.filter(t => t.status === "done").length,
486
+ active: tasks.filter(t => t.status === "active").length,
487
+ failed: tasks.filter(t => t.status === "failed").length,
488
+ pending: tasks.filter(t => t.status === "pending" || t.status === "claimed" || t.status === "blocked").length,
489
+ };
352
490
  lines.push(theme.fg("accent", " Tasks"));
353
- for (const t of tasks.slice(0, 15)) {
354
- const icon = t.status === "done" ? theme.fg("success", "")
355
- : t.status === "failed" ? theme.fg("error", "✗")
356
- : t.status === "active" ? theme.fg("warning", "●")
357
- : theme.fg("dim", "○");
358
- const dur = t.finishedAt && t.startedAt ? theme.fg("dim", ` ${formatDuration(t.finishedAt - t.startedAt)}`) : "";
359
- lines.push(` ${icon} ${casteIcon(t.caste)} ${theme.fg("text", t.title.slice(0, w - 12))}${dur}`);
491
+ lines.push(theme.fg("muted", ` done:${counts.done} active:${counts.active} │ pending:${counts.pending} │ failed:${counts.failed}`));
492
+ lines.push(theme.fg("muted", " Filter: [0] all [a] active [d] done [f] failed"));
493
+ lines.push(theme.fg("muted", ` Current filter: ${taskFilter.toUpperCase()}`));
494
+ lines.push("");
495
+
496
+ const filtered = tasks.filter(t =>
497
+ taskFilter === "all" ? true :
498
+ taskFilter === "active" ? t.status === "active" :
499
+ taskFilter === "done" ? t.status === "done" :
500
+ t.status === "failed"
501
+ );
502
+
503
+ if (filtered.length === 0) {
504
+ lines.push(theme.fg("muted", " (no tasks match current filter)"));
505
+ } else {
506
+ for (const t of filtered.slice(0, 16)) {
507
+ const icon = t.status === "done" ? theme.fg("success", "✓")
508
+ : t.status === "failed" ? theme.fg("error", "✗")
509
+ : t.status === "active" ? theme.fg("warning", "●")
510
+ : theme.fg("dim", "○");
511
+ const dur = t.finishedAt && t.startedAt ? theme.fg("dim", ` ${formatDuration(t.finishedAt - t.startedAt)}`) : "";
512
+ lines.push(` ${icon} ${casteIcon(t.caste)} ${theme.fg("text", trim(t.title, w - 12))}${dur}`);
513
+ }
514
+ if (filtered.length > 16) lines.push(theme.fg("muted", ` ⋯ +${filtered.length - 16} more`));
360
515
  }
361
- if (tasks.length > 15) lines.push(theme.fg("muted", ` ⋯ +${tasks.length - 15} more`));
362
516
  lines.push("");
363
517
  }
364
518
 
365
- // ── Active Ants ──
366
- const streams = Array.from(c.antStreams.values());
367
- if (streams.length > 0) {
368
- lines.push(theme.fg("accent", ` Active: ${streams.length} ants working`));
519
+ // ── Tab: Streams ──
520
+ if (currentTab === "streams") {
521
+ lines.push(theme.fg("accent", ` Active Ant Streams (${streams.length})`));
522
+ lines.push(theme.fg("muted", " Shows latest line + token count for active ants"));
523
+ lines.push("");
524
+ if (streams.length === 0) {
525
+ lines.push(theme.fg("muted", " (no active streams right now)"));
526
+ } else {
527
+ for (const s of streams.slice(0, 10)) {
528
+ const excerpt = trim((s.lastLine || "...").replace(/\s+/g, " "), Math.max(20, w - 24));
529
+ lines.push(` ${casteIcon(s.caste)} ${theme.fg("muted", s.antId.slice(0, 12))} ${theme.fg("muted", `${formatTokens(s.tokens)}t`)} ${theme.fg("text", excerpt)}`);
530
+ }
531
+ if (streams.length > 10) lines.push(theme.fg("muted", ` ⋯ +${streams.length - 10} more streams`));
532
+ }
369
533
  lines.push("");
370
534
  }
371
535
 
372
- lines.push("");
373
- lines.push(theme.fg("muted", " esc close"));
536
+ // ── Tab: Log ──
537
+ if (currentTab === "log") {
538
+ const failedTasks = tasks.filter(t => t.status === "failed");
539
+ if (failedTasks.length > 0) {
540
+ lines.push(theme.fg("warning", ` Warnings (${failedTasks.length})`));
541
+ for (const t of failedTasks.slice(0, 4)) {
542
+ lines.push(` ${theme.fg("error", "✗")} ${theme.fg("text", trim(t.title, w - 8))}`);
543
+ }
544
+ if (failedTasks.length > 4) lines.push(theme.fg("muted", ` ⋯ +${failedTasks.length - 4} more failed tasks`));
545
+ lines.push("");
546
+ }
547
+
548
+ const recentLogs = c.logs.slice(-12);
549
+ lines.push(theme.fg("accent", " Recent Signals"));
550
+ if (recentLogs.length === 0) {
551
+ lines.push(theme.fg("muted", " (no signal logs yet)"));
552
+ } else {
553
+ const now = Date.now();
554
+ for (const log of recentLogs) {
555
+ const age = formatDuration(Math.max(0, now - log.timestamp));
556
+ const levelIcon = log.level === "error" ? theme.fg("error", "✗") : log.level === "warning" ? theme.fg("warning", "!") : theme.fg("muted", "•");
557
+ lines.push(` ${levelIcon} ${theme.fg("muted", age)} ${theme.fg("text", trim(log.text, w - 12))}`);
558
+ }
559
+ }
560
+ lines.push("");
561
+ }
562
+
563
+ lines.push(theme.fg("muted", " [1/2/3] switch tabs │ [0/a/d/f] task filter │ esc close"));
374
564
  return lines;
375
565
  };
376
566
 
@@ -395,7 +585,21 @@ export default function antColonyExtension(pi: ExtensionAPI) {
395
585
  if (matchesKey(data, "escape")) {
396
586
  cleanup();
397
587
  done(undefined);
588
+ return;
398
589
  }
590
+
591
+ if (data === "1") currentTab = "tasks";
592
+ else if (data === "2") currentTab = "streams";
593
+ else if (data === "3") currentTab = "log";
594
+ else if (data === "0") taskFilter = "all";
595
+ else if (data.toLowerCase() === "a") taskFilter = "active";
596
+ else if (data.toLowerCase() === "d") taskFilter = "done";
597
+ else if (data.toLowerCase() === "f") taskFilter = "failed";
598
+ else return;
599
+
600
+ cachedWidth = undefined;
601
+ cachedLines = undefined;
602
+ tui.requestRender();
399
603
  },
400
604
  };
401
605
  }, { overlay: true, overlayOptions: { anchor: "center", width: "80%", maxHeight: "80%" } });
@@ -462,7 +666,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
462
666
  launchBackgroundColony(colonyParams);
463
667
 
464
668
  return {
465
- content: [{ type: "text", text: `[COLONY_SIGNAL:LAUNCHED]\n🐜 Colony launched in background.\nGoal: ${params.goal}\n\nThe colony is now running autonomously. Results will be injected when it finishes.` }],
669
+ content: [{ type: "text", text: `[COLONY_SIGNAL:LAUNCHED]\n🐜 Colony launched in background.\nGoal: ${params.goal}\n\nThe colony runs autonomously in passive mode. Progress is pushed via [COLONY_SIGNAL:*] follow-up messages. Do not poll bg_colony_status unless the user explicitly asks for a manual snapshot.` }],
466
670
  };
467
671
  },
468
672
 
@@ -471,7 +675,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
471
675
  let text = theme.fg("toolTitle", theme.bold("🐜 ant_colony"));
472
676
  if (args.maxAnts) text += theme.fg("muted", ` ×${args.maxAnts}`);
473
677
  if (args.maxCost) text += theme.fg("warning", ` $${args.maxCost}`);
474
- text += "\n" + theme.fg("dim", ` ${goal || "..."}`);
678
+ text += "\n" + theme.fg("muted", ` ${goal || "..."}`);
475
679
  return new Text(text, 0, 0);
476
680
  },
477
681
 
@@ -486,7 +690,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
486
690
  0, 0,
487
691
  ));
488
692
  if (activeColony) {
489
- container.addChild(new Text(theme.fg("dim", ` Goal: ${activeColony.goal.slice(0, 70)}`), 0, 0));
693
+ container.addChild(new Text(theme.fg("muted", ` Goal: ${activeColony.goal.slice(0, 70)}`), 0, 0));
490
694
  container.addChild(new Text(theme.fg("muted", ` Ctrl+Shift+A for details │ /colony-stop to cancel`), 0, 0));
491
695
  }
492
696
  return container;
@@ -502,12 +706,19 @@ export default function antColonyExtension(pi: ExtensionAPI) {
502
706
  const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
503
707
  const m = state?.metrics;
504
708
  const phase = state?.status || "scouting";
709
+ const progress = calcProgress(m);
710
+ const pct = Math.round(progress * 100);
711
+ const activeAnts = c.antStreams.size;
505
712
 
506
713
  const lines: string[] = [
507
- `🐜 ${statusIcon(phase)} ${c.goal.slice(0, 80)}`,
508
- `${phase} │ ${m ? `${m.tasksDone}/${m.tasksTotal} tasks` : "starting"} │ ${m ? formatCost(m.totalCost) : "$0"} │ ${elapsed}`,
714
+ `🐜 ${statusIcon(phase)} ${trim(c.goal, 80)}`,
715
+ `${statusLabel(phase)} │ ${m ? `${m.tasksDone}/${m.tasksTotal} tasks` : "starting"} │ ${pct}% │ ⚡${activeAnts} │ ${m ? formatCost(m.totalCost) : "$0"} │ ${elapsed}`,
716
+ `${progressBar(progress, 18)} ${pct}%`,
509
717
  ];
510
718
 
719
+ if (c.phase && c.phase !== "initializing") lines.push(`Phase: ${trim(c.phase, 100)}`);
720
+ const lastLog = c.logs[c.logs.length - 1];
721
+ if (lastLog) lines.push(`Last: ${trim(lastLog.text, 100)}`);
511
722
  if (m && m.tasksFailed > 0) lines.push(`⚠ ${m.tasksFailed} failed`);
512
723
 
513
724
  return lines.join("\n");
@@ -517,9 +728,40 @@ export default function antColonyExtension(pi: ExtensionAPI) {
517
728
  pi.registerTool({
518
729
  name: "bg_colony_status",
519
730
  label: "Colony Status",
520
- description: "Check the status of a running background ant colony. Use this instead of bg_status to monitor colony progress.",
731
+ description: "Optional manual snapshot for a running colony. Progress is pushed passively via COLONY_SIGNAL follow-up messages; call this only when the user explicitly asks.",
521
732
  parameters: Type.Object({}),
522
- async execute() {
733
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
734
+ if (!activeColony) {
735
+ return {
736
+ content: [{ type: "text" as const, text: "No colony is currently running." }],
737
+ };
738
+ }
739
+
740
+ const explicit = isExplicitStatusRequest(ctx);
741
+ if (!explicit) {
742
+ return {
743
+ content: [{
744
+ type: "text" as const,
745
+ text: "Passive mode is active. Colony progress is already pushed via [COLONY_SIGNAL:*] follow-up messages. Skipping bg_colony_status polling to avoid blocking the main process. Ask explicitly for a manual snapshot if needed.",
746
+ }],
747
+ isError: true,
748
+ };
749
+ }
750
+
751
+ const now = Date.now();
752
+ const delta = now - lastBgStatusSnapshotAt;
753
+ if (delta < STATUS_SNAPSHOT_COOLDOWN_MS) {
754
+ const waitSec = Math.ceil((STATUS_SNAPSHOT_COOLDOWN_MS - delta) / 1000);
755
+ return {
756
+ content: [{
757
+ type: "text" as const,
758
+ text: `Manual status snapshot is rate-limited. Please wait ${waitSec}s to avoid active polling loops.`,
759
+ }],
760
+ isError: true,
761
+ };
762
+ }
763
+
764
+ lastBgStatusSnapshotAt = now;
523
765
  return {
524
766
  content: [{ type: "text" as const, text: buildStatusText() }],
525
767
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
2
+ import { formatDuration, formatCost, formatTokens, statusIcon, statusLabel, progressBar, casteIcon, buildReport } from "./ui.js";
3
3
  import type { ColonyState } from "./types.js";
4
4
 
5
5
  describe("formatDuration", () => {
@@ -25,16 +25,33 @@ describe("formatTokens", () => {
25
25
  });
26
26
 
27
27
  describe("statusIcon", () => {
28
+ it("launched", () => expect(statusIcon("launched")).toBe("🚀"));
28
29
  it("scouting", () => expect(statusIcon("scouting")).toBe("🔍"));
29
30
  it("working", () => expect(statusIcon("working")).toBe("⚒️"));
30
31
  it("planning_recovery", () => expect(statusIcon("planning_recovery")).toBe("♻️"));
31
32
  it("reviewing", () => expect(statusIcon("reviewing")).toBe("🛡️"));
33
+ it("task_done", () => expect(statusIcon("task_done")).toBe("✅"));
32
34
  it("done", () => expect(statusIcon("done")).toBe("✅"));
33
35
  it("failed", () => expect(statusIcon("failed")).toBe("❌"));
34
36
  it("budget_exceeded", () => expect(statusIcon("budget_exceeded")).toBe("💰"));
35
37
  it("unknown", () => expect(statusIcon("xyz")).toBe("🐜"));
36
38
  });
37
39
 
40
+ describe("statusLabel", () => {
41
+ it("launched", () => expect(statusLabel("launched")).toBe("LAUNCHED"));
42
+ it("scouting", () => expect(statusLabel("scouting")).toBe("SCOUTING"));
43
+ it("planning_recovery", () => expect(statusLabel("planning_recovery")).toBe("PLANNING_RECOVERY"));
44
+ it("task_done", () => expect(statusLabel("task_done")).toBe("TASK_DONE"));
45
+ it("budget_exceeded", () => expect(statusLabel("budget_exceeded")).toBe("BUDGET_EXCEEDED"));
46
+ it("unknown", () => expect(statusLabel("custom")).toBe("CUSTOM"));
47
+ });
48
+
49
+ describe("progressBar", () => {
50
+ it("0%", () => expect(progressBar(0, 10)).toBe("[----------]"));
51
+ it("50%", () => expect(progressBar(0.5, 10)).toBe("[#####-----]"));
52
+ it("100%", () => expect(progressBar(1, 10)).toBe("[##########]"));
53
+ });
54
+
38
55
  describe("casteIcon", () => {
39
56
  it("scout", () => expect(casteIcon("scout")).toBe("🔍"));
40
57
  it("soldier", () => expect(casteIcon("soldier")).toBe("🛡️"));
@@ -17,12 +17,42 @@ export function formatTokens(n: number): string {
17
17
 
18
18
  export function statusIcon(status: string): string {
19
19
  const icons: Record<string, string> = {
20
- scouting: "🔍", planning_recovery: "♻️", working: "⚒️", reviewing: "🛡️",
21
- done: "", failed: "❌", budget_exceeded: "💰",
20
+ launched: "🚀",
21
+ scouting: "🔍",
22
+ planning_recovery: "♻️",
23
+ working: "⚒️",
24
+ reviewing: "🛡️",
25
+ task_done: "✅",
26
+ done: "✅",
27
+ complete: "✅",
28
+ failed: "❌",
29
+ budget_exceeded: "💰",
22
30
  };
23
31
  return icons[status] || "🐜";
24
32
  }
25
33
 
34
+ export function statusLabel(status: string): string {
35
+ const labels: Record<string, string> = {
36
+ launched: "LAUNCHED",
37
+ scouting: "SCOUTING",
38
+ planning_recovery: "PLANNING_RECOVERY",
39
+ working: "WORKING",
40
+ reviewing: "REVIEWING",
41
+ task_done: "TASK_DONE",
42
+ done: "DONE",
43
+ complete: "COMPLETE",
44
+ failed: "FAILED",
45
+ budget_exceeded: "BUDGET_EXCEEDED",
46
+ };
47
+ return labels[status] || status.toUpperCase();
48
+ }
49
+
50
+ export function progressBar(progress: number, width = 14): string {
51
+ const p = Math.max(0, Math.min(1, Number.isFinite(progress) ? progress : 0));
52
+ const filled = Math.round(width * p);
53
+ return `[${"#".repeat(filled)}${"-".repeat(Math.max(0, width - filled))}]`;
54
+ }
55
+
26
56
  export function casteIcon(caste: string): string {
27
57
  return caste === "scout" ? "🔍" : caste === "soldier" ? "🛡️" : caste === "drone" ? "⚙️" : "⚒️";
28
58
  }
@@ -1,64 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { truncateText, compactContent } from "./smart-compact";
3
-
4
- const longMultiline = (lines: number, lineLen = 100) =>
5
- Array.from({ length: lines }, (_, i) => "x".repeat(lineLen) + i).join("\n");
6
-
7
- describe("truncateText", () => {
8
- it("short text returns unchanged", () => {
9
- expect(truncateText("hello", 8000)).toBe("hello");
10
- });
11
-
12
- it("few lines but long chars returns unchanged", () => {
13
- const text = "x".repeat(9000) + "\n" + "y".repeat(9000);
14
- expect(truncateText(text, 8000)).toBe(text);
15
- });
16
-
17
- it("long text with many lines gets truncated", () => {
18
- const text = longMultiline(200);
19
- const result = truncateText(text, 8000);
20
- expect(result).toContain("[...truncated");
21
- });
22
-
23
- it("preserves head and tail", () => {
24
- const text = longMultiline(200);
25
- const result = truncateText(text, 8000);
26
- expect(result.startsWith(text.slice(0, 1500))).toBe(true);
27
- expect(result.endsWith(text.slice(-2500))).toBe(true);
28
- });
29
-
30
- it("custom head/tail params work", () => {
31
- const text = longMultiline(200);
32
- const result = truncateText(text, 100, 50, 50);
33
- expect(result).toContain("[...truncated");
34
- expect(result.startsWith(text.slice(0, 50))).toBe(true);
35
- expect(result.endsWith(text.slice(-50))).toBe(true);
36
- });
37
- });
38
-
39
- describe("compactContent", () => {
40
- it("short string returns unchanged", () => {
41
- expect(compactContent("short")).toBe("short");
42
- });
43
-
44
- it("long string gets truncated", () => {
45
- const text = longMultiline(200);
46
- expect(compactContent(text)).toContain("[...truncated");
47
- });
48
-
49
- it("array text block gets truncated", () => {
50
- const text = longMultiline(200);
51
- const result = compactContent([{ type: "text", text }]);
52
- expect(result[0].text).toContain("[...truncated");
53
- });
54
-
55
- it("array non-text block returned unchanged", () => {
56
- const block = { type: "image", url: "x" };
57
- expect(compactContent([block])).toEqual([block]);
58
- });
59
-
60
- it("non-array non-string returned as-is", () => {
61
- expect(compactContent(42)).toBe(42);
62
- expect(compactContent({ a: 1 })).toEqual({ a: 1 });
63
- });
64
- });
@@ -1,89 +0,0 @@
1
- /**
2
- * 智能压缩扩展 — 在发送给 LLM 前裁剪大块内容
3
- *
4
- * 策略:
5
- * 1. 工具输出超过阈值 → 保留首尾,中间替换为 "[...truncated N lines]"
6
- * 2. 用户粘贴的大块文本 → 同上
7
- * 3. 越旧的消息裁剪越激进
8
- */
9
-
10
- const MAX_TOOL_OUTPUT_CHARS = 8000;
11
- const MAX_USER_BLOCK_CHARS = 12000;
12
- const KEEP_HEAD = 1500;
13
- const KEEP_TAIL = 2500;
14
-
15
- export function truncateText(text: string, max: number, head = KEEP_HEAD, tail = KEEP_TAIL): string {
16
- if (text.length <= max) return text;
17
- const lines = text.split("\n");
18
- if (lines.length <= 10) return text; // 短文本不裁
19
- const headText = text.slice(0, head);
20
- const tailText = text.slice(-tail);
21
- const removedLines = text.slice(head, -tail).split("\n").length;
22
- return `${headText}\n\n[...truncated ${removedLines} lines...]\n\n${tailText}`;
23
- }
24
-
25
- export function compactContent(content: any): any {
26
- if (typeof content === "string") {
27
- return truncateText(content, MAX_TOOL_OUTPUT_CHARS);
28
- }
29
- if (!Array.isArray(content)) return content;
30
- return content.map((block: any) => {
31
- if (block.type === "text" && typeof block.text === "string") {
32
- return { ...block, text: truncateText(block.text, MAX_TOOL_OUTPUT_CHARS) };
33
- }
34
- return block;
35
- });
36
- }
37
-
38
- export default function smartCompact(pi: any) {
39
- pi.on("context", async (event: any) => {
40
- const messages = event.messages;
41
- if (!messages || messages.length < 4) return; // 太短不处理
42
-
43
- // 只处理非最近 3 条消息(保留最近上下文完整)
44
- const cutoff = messages.length - 3;
45
-
46
- for (let i = 0; i < cutoff; i++) {
47
- const msg = messages[i];
48
- if (!msg) continue;
49
-
50
- if (msg.role === "toolResult") {
51
- msg.content = compactContent(msg.content);
52
- } else if (msg.role === "user") {
53
- // 用户消息用更宽松的阈值
54
- if (typeof msg.content === "string" && msg.content.length > MAX_USER_BLOCK_CHARS) {
55
- msg.content = truncateText(msg.content, MAX_USER_BLOCK_CHARS);
56
- } else if (Array.isArray(msg.content)) {
57
- msg.content = msg.content.map((block: any) => {
58
- if (block.type === "text" && typeof block.text === "string" && block.text.length > MAX_USER_BLOCK_CHARS) {
59
- return { ...block, text: truncateText(block.text, MAX_USER_BLOCK_CHARS) };
60
- }
61
- return block;
62
- });
63
- }
64
- } else if (msg.role === "assistant" && Array.isArray(msg.content)) {
65
- // 裁剪 assistant 的大块工具调用参数
66
- msg.content = msg.content.map((block: any) => {
67
- if (block.type === "toolCall" && block.arguments) {
68
- const args = JSON.stringify(block.arguments);
69
- if (args.length > MAX_TOOL_OUTPUT_CHARS) {
70
- try {
71
- const parsed = typeof block.arguments === "string" ? JSON.parse(block.arguments) : block.arguments;
72
- // 裁剪大字符串参数
73
- for (const key of Object.keys(parsed)) {
74
- if (typeof parsed[key] === "string" && parsed[key].length > MAX_TOOL_OUTPUT_CHARS) {
75
- parsed[key] = truncateText(parsed[key], MAX_TOOL_OUTPUT_CHARS);
76
- }
77
- }
78
- return { ...block, arguments: parsed };
79
- } catch { return block; }
80
- }
81
- }
82
- return block;
83
- });
84
- }
85
- }
86
-
87
- return { messages };
88
- });
89
- }