@lincoln504/pi-research 0.1.13 → 1.0.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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Research Observer Implementation
3
+ *
4
+ * Implements the ResearchObserver interface to handle research lifecycle events:
5
+ * - Research planning
6
+ * - Searching and scraping
7
+ * - Researcher lifecycle
8
+ * - Evaluation
9
+ * - Completion and errors
10
+ */
11
+
12
+ import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
13
+
14
+ import { getUnitsPerResearcher, LEAD_EVAL_UNITS } from '../constants.ts';
15
+ import type { ResearchPanelState } from '../tui/research-panel.ts';
16
+ import { tuiPulse } from '../tui/pulse.ts';
17
+ import {
18
+ addSlice,
19
+ activateSlice,
20
+ completeSlice,
21
+ removeSlice,
22
+ flashSlice,
23
+ updateSliceTokens,
24
+ updateSliceStatus,
25
+ reactivateSlice,
26
+ clearCompletedResearchers,
27
+ } from '../tui/research-panel.ts';
28
+
29
+ export interface ObserverContext {
30
+ panelState: ResearchPanelState;
31
+ debouncedRefresh: () => void;
32
+ /**
33
+ * Immediate (non-debounced) render for animation frames. Optional so
34
+ * headless/test contexts can omit it; falls back to debouncedRefresh.
35
+ */
36
+ renderImmediate?: () => void;
37
+ researchComplexity: number;
38
+ }
39
+
40
+ export interface ObserverState {
41
+ progressCredits: Map<string, number>;
42
+ quickSliceLabel: string;
43
+ idToNumberMap: Map<string, string>;
44
+ unsubPulse: (() => void) | null;
45
+ /** Global pulse frame captured when the current search began, so each
46
+ * search's wave starts at the left edge (frame 0) regardless of the
47
+ * never-reset global pulse counter. */
48
+ waveBaseFrame: number;
49
+ }
50
+
51
+ /**
52
+ * Create a ResearchObserver implementation
53
+ */
54
+ export function createResearchObserver(
55
+ ctx: ObserverContext,
56
+ state: ObserverState
57
+ ): ResearchObserver {
58
+ const {
59
+ panelState,
60
+ debouncedRefresh,
61
+ } = ctx;
62
+
63
+ // Drive animation frames through the immediate render path so they are not
64
+ // swallowed by the state-refresh debounce. Falls back to debouncedRefresh
65
+ // when no immediate path is wired (headless/tests).
66
+ const renderFrame = ctx.renderImmediate ?? debouncedRefresh;
67
+
68
+ const {
69
+ progressCredits,
70
+ } = state;
71
+
72
+ return {
73
+ onStart: (query, complexity) => {
74
+ if (complexity === 0) {
75
+ // Truncate at a CODEPOINT boundary (Array.from iterates codepoints), not a
76
+ // code-unit index: query.slice(0, 20) can split a surrogate pair (emoji,
77
+ // rare CJK), emitting a lone surrogate that renders as � and corrupts the
78
+ // panel's width math downstream.
79
+ const codepoints = Array.from(query);
80
+ const truncatedQuery = codepoints.length > 20 ? codepoints.slice(0, 20).join('') + '...' : query;
81
+ state.quickSliceLabel = `researching: ${truncatedQuery}`;
82
+ addSlice(panelState, state.quickSliceLabel, state.quickSliceLabel, false);
83
+ activateSlice(panelState, state.quickSliceLabel);
84
+ updateSliceStatus(panelState, state.quickSliceLabel, 'starting...', debouncedRefresh);
85
+
86
+ const units = getUnitsPerResearcher();
87
+ panelState.progress = { expected: units, made: 0 };
88
+ }
89
+ // Quick research: researcher is running → steering is acceptable
90
+ panelState.steeringAcceptable = true;
91
+ debouncedRefresh();
92
+ },
93
+
94
+ onPlanningStart: (attempt) => {
95
+ if (attempt === 1) {
96
+ addSlice(panelState, 'coord', `coordinator`, false);
97
+ activateSlice(panelState, 'coord');
98
+ }
99
+ const status = attempt > 1 ? `planning (retry ${attempt - 1})` : 'planning';
100
+ updateSliceStatus(panelState, 'coord', status, debouncedRefresh);
101
+ // Keep steering acceptable: the coordinator LLM cannot be interrupted, but a
102
+ // message entered now is queued (not injected mid-call) and consumed at the
103
+ // next round boundary. Forcing it false here diverted it to a separate pi
104
+ // follow-up turn that never reached this run's evaluator.
105
+ panelState.steeringAcceptable = true;
106
+ debouncedRefresh();
107
+ },
108
+
109
+ onPlanningProgress: (status) => {
110
+ updateSliceStatus(panelState, 'coord', status, debouncedRefresh);
111
+ debouncedRefresh();
112
+ },
113
+
114
+ onPlanningTokens: (tokens, cost) => {
115
+ // Per-slice display only. Run totals are owned by the metrics registry
116
+ // (see research-tool-definition.ts → extractRunStats), not panelState.
117
+ updateSliceTokens(panelState, 'coord', tokens, cost);
118
+ debouncedRefresh();
119
+ },
120
+
121
+ onPlanningSuccess: (plan) => {
122
+ updateSliceStatus(panelState, 'coord', 'ready', debouncedRefresh);
123
+ completeSlice(panelState, 'coord');
124
+ const unitsPerResearcher = getUnitsPerResearcher();
125
+ const count = plan.researchers?.length || 0;
126
+ const units = (count * unitsPerResearcher) + LEAD_EVAL_UNITS;
127
+ panelState.progress = { expected: units, made: 0 };
128
+ panelState.title = plan.title?.trim() || 'Research';
129
+ // Coordinator done — about to start search then researchers → steering acceptable
130
+ panelState.steeringAcceptable = true;
131
+ debouncedRefresh();
132
+ },
133
+
134
+ onRoundStart: (round) => {
135
+ // Mark that we need to clear completed slices once the next round's evaluation or search starts.
136
+ // This keeps previous round findings visible during the evaluation phase.
137
+ if (round > 1) {
138
+ panelState.needsClear = true;
139
+ }
140
+ },
141
+
142
+ onSearchStart: (_queries) => {
143
+ // Do NOT clear the previous round's finished boxes here. They stay visible
144
+ // (greyed) throughout this round's search so the panel never collapses to a
145
+ // single lone box mid-wave. The deferred clear runs atomically in
146
+ // onResearcherStart, so every old box (researchers + evaluator) disappears
147
+ // at once and the new round populates in their place. (needsClear is set by
148
+ // onRoundStart and consumed there.)
149
+
150
+ // Bind the "searching..." status to a real, phase-appropriate box only:
151
+ // - quick mode: the single quick-researcher slice
152
+ // - round 1: the coordinator slice (still present before researchers)
153
+ // - rounds 2+: NONE. The coordinator is gone and the evaluator must NOT
154
+ // be resurrected as a lone search box (that is the regression this
155
+ // guards against). The animated header wave is the search indicator.
156
+ let sliceId: string | null = null;
157
+ if (state.quickSliceLabel) {
158
+ sliceId = state.quickSliceLabel;
159
+ } else if (panelState.slices.has('coord')) {
160
+ sliceId = 'coord';
161
+ }
162
+
163
+ if (sliceId && panelState.slices.has(sliceId)) {
164
+ reactivateSlice(panelState, sliceId);
165
+ activateSlice(panelState, sliceId); // Ensure not queued
166
+ updateSliceStatus(panelState, sliceId, 'searching...', debouncedRefresh);
167
+ }
168
+ panelState.isSearching = true;
169
+
170
+ // Search phase — steering is acceptable (will be consumed at next round boundary)
171
+ panelState.steeringAcceptable = true;
172
+
173
+ // Start wave animation via global pulse subscription.
174
+ // Capture the current global frame as the baseline so this search's wave
175
+ // starts at frame 0 (left edge). Position is frame % PERIOD_FRAMES, and
176
+ // the global counter is never reset, so without this each search would
177
+ // begin at an arbitrary point in the cycle.
178
+ state.waveBaseFrame = tuiPulse.getFrame();
179
+ panelState.waveFrame = 0;
180
+
181
+ if (state.unsubPulse) state.unsubPulse();
182
+ state.unsubPulse = tuiPulse.subscribe((globalFrame) => {
183
+ if (!panelState.isSearching) {
184
+ if (state.unsubPulse) {
185
+ state.unsubPulse();
186
+ state.unsubPulse = null;
187
+ }
188
+ return;
189
+ }
190
+ panelState.waveFrame = globalFrame - state.waveBaseFrame;
191
+ // Animation frame: render immediately. pi-tui's render scheduler
192
+ // (16ms throttle + line-level diff) coalesces and caps these, so the
193
+ // wave updates every frame without the 100ms state-refresh debounce
194
+ // dropping frames.
195
+ renderFrame();
196
+ });
197
+
198
+ debouncedRefresh();
199
+ },
200
+
201
+ onSearchProgress: (count) => {
202
+ // Only the coordinator (round 1) or quick slice carries a textual search
203
+ // count. Rounds 2+ have no search box (header wave only) — see onSearchStart.
204
+ let sliceId: string | null = null;
205
+ if (state.quickSliceLabel) sliceId = state.quickSliceLabel;
206
+ else if (panelState.slices.has('coord')) sliceId = 'coord';
207
+
208
+ const status = `${count} results`;
209
+ if (sliceId) updateSliceStatus(panelState, sliceId, status, debouncedRefresh);
210
+ debouncedRefresh();
211
+ },
212
+
213
+ onSearchComplete: (count) => {
214
+ panelState.isSearching = false;
215
+
216
+ // Stop wave animation
217
+ if (state.unsubPulse) {
218
+ state.unsubPulse();
219
+ state.unsubPulse = null;
220
+ }
221
+ panelState.waveFrame = undefined;
222
+ panelState.waveColors = undefined; // Clear persistent colors for next search
223
+
224
+ // Mirror onSearchStart: only complete a box that actually hosted the search
225
+ // (coordinator in round 1, quick slice in quick mode). Rounds 2+ have none.
226
+ let sliceId: string | null = null;
227
+ if (state.quickSliceLabel) sliceId = state.quickSliceLabel;
228
+ else if (panelState.slices.has('coord')) sliceId = 'coord';
229
+
230
+ if (sliceId) {
231
+ updateSliceStatus(panelState, sliceId, `${count} results`, debouncedRefresh);
232
+ // In quick mode the single researcher keeps working (scraping) after search
233
+ // completes — completing its slice here would mute it and swallow the per-URL
234
+ // scrape flashes/status pops the wiring exists to show. Only the coordinator's
235
+ // round-1 search box is genuinely done at this point; the quick slice is
236
+ // completed later by onResearcherComplete/onResearcherFailure.
237
+ if (sliceId === 'coord') completeSlice(panelState, sliceId);
238
+ }
239
+ debouncedRefresh();
240
+ },
241
+
242
+ onResearcherStart: (id, _name, _goal, _roundNumber) => {
243
+ if (panelState.slices.get('coord')?.completed) removeSlice(panelState, 'coord');
244
+
245
+ // Atomic round transition: this is the single point where the previous
246
+ // round's finished boxes (researchers AND evaluator) are cleared, right as
247
+ // the new round's first researcher appears — so they all vanish together and
248
+ // the new round populates in their place, never leaving a lone box on screen.
249
+ // clearCompletedResearchers already removes the completed 'eval'; the explicit
250
+ // removeSlice is a defensive backstop for the rare path where needsClear is
251
+ // unset (e.g. round 1 has no prior eval, so it is simply a no-op there).
252
+ if (panelState.slices.get('eval')?.completed) removeSlice(panelState, 'eval');
253
+ if (panelState.needsClear) {
254
+ clearCompletedResearchers(panelState);
255
+ panelState.needsClear = false;
256
+ }
257
+
258
+ // Map internal hierarchical ID to sequential display number for TUI
259
+ const sliceId = id === 'quick' ? state.quickSliceLabel : id;
260
+
261
+ if (!panelState.slices.has(sliceId)) {
262
+ let label: string;
263
+ if (id === 'quick') {
264
+ label = state.quickSliceLabel;
265
+ } else {
266
+ if (!state.idToNumberMap.has(id)) {
267
+ state.idToNumberMap.set(id, (state.idToNumberMap.size + 1).toString());
268
+ }
269
+ label = state.idToNumberMap.get(id)!;
270
+ }
271
+ addSlice(panelState, sliceId, label, true);
272
+ }
273
+ activateSlice(panelState, sliceId);
274
+ updateSliceStatus(panelState, sliceId, 'starting...', debouncedRefresh);
275
+ // Researcher phase — steering is acceptable (will be consumed at next round boundary)
276
+ panelState.steeringAcceptable = true;
277
+ debouncedRefresh();
278
+ },
279
+
280
+ onResearcherTokensHint: (id, inputTokens) => {
281
+ const sliceId = id === 'quick' ? state.quickSliceLabel : id;
282
+ const slice = panelState.slices.get(sliceId);
283
+ if (slice && inputTokens > (slice.tokens || 0)) {
284
+ slice.tokens = inputTokens;
285
+ }
286
+ debouncedRefresh();
287
+ },
288
+
289
+ onResearcherProgress: (id, status, tokens, cost) => {
290
+ const sliceId = id === 'quick' ? state.quickSliceLabel : id;
291
+ const unitsPerResearcher = getUnitsPerResearcher();
292
+
293
+ if (status !== undefined) {
294
+ if (status.startsWith('done:')) {
295
+ const toolName = status.slice(5);
296
+ // Only clear status if it matches the current tool or if it's the specific tool that finished
297
+ // For now, we clear it to signal progress, but preserve it if it's null
298
+ updateSliceStatus(panelState, sliceId, undefined, debouncedRefresh);
299
+ if (panelState.progress) {
300
+ const current = progressCredits.get(id) ?? 0;
301
+ // Increment for the first tool call (setup/search) OR any scrape batch
302
+ const shouldIncrement = (current === 0) || (toolName === 'scrape');
303
+ if (shouldIncrement && current + 1 <= unitsPerResearcher) {
304
+ panelState.progress.made += 1;
305
+ progressCredits.set(id, current + 1);
306
+ }
307
+ }
308
+ } else if (status) {
309
+ updateSliceStatus(panelState, sliceId, status, debouncedRefresh);
310
+ }
311
+ }
312
+ if (tokens !== undefined && cost !== undefined) {
313
+ // Per-slice display only; run totals live in the metrics registry.
314
+ updateSliceTokens(panelState, sliceId, tokens, cost);
315
+ }
316
+ debouncedRefresh();
317
+ },
318
+
319
+ onResearcherComplete: (id, _report) => {
320
+ const sliceId = id === 'quick' ? state.quickSliceLabel : id;
321
+ if (panelState.progress) {
322
+ const unitsPerResearcher = getUnitsPerResearcher();
323
+ const current = progressCredits.get(id) ?? 0;
324
+ const remaining = unitsPerResearcher - current;
325
+ if (remaining > 0) {
326
+ panelState.progress.made += remaining;
327
+ progressCredits.set(id, unitsPerResearcher);
328
+ }
329
+ }
330
+ completeSlice(panelState, sliceId);
331
+ debouncedRefresh();
332
+ },
333
+
334
+ onResearcherFailure: (id, _error) => {
335
+ const sliceId = id === 'quick' ? state.quickSliceLabel : id;
336
+ if (panelState.progress) {
337
+ const unitsPerResearcher = getUnitsPerResearcher();
338
+ const current = progressCredits.get(id) ?? 0;
339
+ const remaining = unitsPerResearcher - current;
340
+ if (remaining > 0) {
341
+ panelState.progress.made += remaining;
342
+ progressCredits.set(id, unitsPerResearcher);
343
+ }
344
+ }
345
+ // Terminal failed outcome — completeSlice(failed) marks it so the render draws it
346
+ // distinctly (error color + "failed"), rather than the transient status field which
347
+ // a completed slice suppresses (making a failure look identical to a success).
348
+ completeSlice(panelState, sliceId, true);
349
+ debouncedRefresh();
350
+ },
351
+
352
+ onToolResult: (researcherId, success) => {
353
+ const sliceId = researcherId === 'quick' ? state.quickSliceLabel : researcherId;
354
+ flashSlice(panelState, sliceId, success ? 'green' : 'red', debouncedRefresh);
355
+ },
356
+
357
+ onEvaluationStart: (_round) => {
358
+ addSlice(panelState, 'eval', 'eval', false);
359
+ activateSlice(panelState, 'eval');
360
+ updateSliceStatus(panelState, 'eval', 'evaluating', debouncedRefresh);
361
+ // Keep steering acceptable during evaluation. The running evaluator call
362
+ // already captured its inputs, so a message entered now is queued and acted
363
+ // on at the NEXT round start (the whole point of mid-run steering). Forcing
364
+ // it false here is exactly when users steer — and diverted that steering to
365
+ // a separate pi follow-up turn instead of the current run's next round.
366
+ panelState.steeringAcceptable = true;
367
+ debouncedRefresh();
368
+ },
369
+
370
+ onEvaluationProgress: (status) => {
371
+ updateSliceStatus(panelState, 'eval', status, debouncedRefresh);
372
+ debouncedRefresh();
373
+ },
374
+
375
+ onEvaluationTokens: (tokens, cost) => {
376
+ // Per-slice display only; run totals live in the metrics registry.
377
+ updateSliceTokens(panelState, 'eval', tokens, cost);
378
+ debouncedRefresh();
379
+ },
380
+
381
+ onEvaluationDecision: (action, plan, round) => {
382
+ completeSlice(panelState, 'eval');
383
+ // Only clear completed researchers when returning final synthesis
384
+ // On delegation, researchers stay visible while new round researchers are added
385
+ if (action === 'synthesize') {
386
+ clearCompletedResearchers(panelState);
387
+ }
388
+ if (panelState.progress) {
389
+ const key = `eval.round.${round ?? panelState.slices.size}`;
390
+ if (!progressCredits.has(key)) {
391
+ panelState.progress.made += LEAD_EVAL_UNITS;
392
+ progressCredits.set(key, LEAD_EVAL_UNITS);
393
+ }
394
+ }
395
+ if (action === 'synthesize') {
396
+ if (panelState.progress) panelState.progress.made = panelState.progress.expected;
397
+ // Final synthesis — steering no longer acceptable
398
+ panelState.steeringAcceptable = false;
399
+ } else {
400
+ // Delegation: prepare for new round's search → researchers → steering acceptable
401
+ if (plan?.researchers && plan.researchers.length > 0 && panelState.progress) {
402
+ const unitsPerResearcher = getUnitsPerResearcher();
403
+ panelState.progress.expected += (plan.researchers.length * unitsPerResearcher) + LEAD_EVAL_UNITS;
404
+ }
405
+ panelState.steeringAcceptable = true;
406
+ }
407
+ debouncedRefresh();
408
+ },
409
+
410
+ onSynthesisStart: () => {
411
+ // Final synthesis has begun (deep: forced mustSynthesize call; quick: the
412
+ // researcher session settled and the steering-consume poller stopped).
413
+ // There is NO next round boundary left to consume a queued message, so
414
+ // steering must stop being "acceptable" — otherwise a steer typed now is
415
+ // queued with an affirmative "will steer the next research round" toast
416
+ // and then destroyed at teardown. With the flag off, the input handler's
417
+ // fall-through forwards it to pi with an accurate toast instead.
418
+ panelState.steeringAcceptable = false;
419
+ debouncedRefresh();
420
+ },
421
+
422
+ onComplete: () => {
423
+ if (panelState.progress) panelState.progress.made = panelState.progress.expected;
424
+ panelState.steeringAcceptable = false;
425
+ debouncedRefresh();
426
+ },
427
+
428
+ onError: () => {
429
+ if (panelState.progress) panelState.progress.made = panelState.progress.expected;
430
+ panelState.steeringAcceptable = false;
431
+ debouncedRefresh();
432
+ }
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Create initial observer state
438
+ */
439
+ export function createObserverState(): ObserverState {
440
+ return {
441
+ progressCredits: new Map<string, number>(),
442
+ quickSliceLabel: '',
443
+ idToNumberMap: new Map<string, string>(),
444
+ unsubPulse: null,
445
+ waveBaseFrame: 0,
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Stop wave animation in the observer
451
+ */
452
+ export function stopObserverWaveAnimation(state: ObserverState, panelState: ResearchPanelState): void {
453
+ if (state.unsubPulse) {
454
+ state.unsubPulse();
455
+ state.unsubPulse = null;
456
+ }
457
+ panelState.waveFrame = undefined;
458
+ panelState.waveColors = undefined;
459
+ }