aihand 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +136 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-FDS2C2CZ.cjs +651 -0
  25. package/dist/cli-HHRGYPSM.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. package/index.js +0 -2
@@ -0,0 +1,254 @@
1
+ import type { PerformanceData, PerfStat, PerfBucketData } from './types.js'
2
+
3
+ // Verdict noise floors: a delta smaller than these is treated as measurement noise, not a real
4
+ // change. Frame %: ±3pp. Self-time fallback: 10% relative, but at least 5ms absolute.
5
+ const FRAME_NOISE_PCT = 3
6
+ const SELF_NOISE_REL = 0.1
7
+ const SELF_NOISE_MS = 5
8
+
9
+ const avgOf = (samples: number[]) =>
10
+ samples.length > 0 ? samples.reduce((s, v) => s + v, 0) / samples.length : 0
11
+
12
+ // Summarize accumulated stat into display shape. total/n/max are O(1) streaming all-time
13
+ // truths; avg derives from them. No p95 here: it would need a per-key sample buffer, which is
14
+ // near-window while total/n/max are all-time — putting two different sample sets in one row
15
+ // (a row implies one distribution). max already gives the worst case, avg the center; that's
16
+ // enough to decide "is this worth optimizing" without a same-row lie.
17
+ export function summarizeStat(stat: PerfStat) {
18
+ const avg = stat.n > 0 ? stat.total / stat.n : 0
19
+ return {
20
+ total: stat.total,
21
+ n: stat.n,
22
+ avg,
23
+ max: stat.max,
24
+ }
25
+ }
26
+
27
+ // Which buckets a single hit (component/line/frame) writes to: the live view + the __all__
28
+ // aggregate. When the active view IS __all__, return it ONCE — else every hit double-counts
29
+ // into __all__ (n, total, frames all 2×). Mirrors client-patch.ts targetBuckets().
30
+ export function targetBuckets(active: string): string[] {
31
+ return active === '__all__' ? ['__all__'] : [active, '__all__']
32
+ }
33
+
34
+ // One-line summary: 23fps · 18% dropped · 4 longtasks(max 120ms) · hot: ChatList 340ms, Markdown 120ms
35
+ export function compactPerformance(p: PerformanceData): string {
36
+ const all = p.buckets.find(b => b.name === '__all__')
37
+ if (!all) return 'perf: (no data)'
38
+
39
+ const { total, long, samples } = all.frames
40
+ const fps = total > 0 ? Math.round((1000 / avgOf(samples)) * 10) / 10 : 0
41
+ const droppedPct = total > 0 ? Math.round((long / total) * 100) : 0
42
+
43
+ const parts: string[] = [`${fps}fps`, `${droppedPct}% dropped`]
44
+
45
+ if (p.longtasks.count > 0) {
46
+ parts.push(`${p.longtasks.count} longtasks(max ${Math.round(p.longtasks.max)}ms)`)
47
+ }
48
+
49
+ // Top 2 components by total
50
+ const topComps = Object.entries(all.components)
51
+ .map(([name, stat]) => ({ name, total: stat.total }))
52
+ .sort((a, b) => b.total - a.total)
53
+ .slice(0, 2)
54
+
55
+ if (topComps.length > 0 && p.mobxPatched) {
56
+ const hot = topComps.map(c => `${c.name} ${Math.round(c.total)}ms`).join(', ')
57
+ parts.push(`hot: ${hot}`)
58
+ }
59
+
60
+ return `perf: ${parts.join(' · ')}`
61
+ }
62
+
63
+ // Full report adapting litcode __recReport
64
+ export function detailPerformance(p: PerformanceData): string {
65
+ const lines: string[] = []
66
+
67
+ // Banner
68
+ const windowSec = (p.windowMs / 1000).toFixed(1)
69
+ lines.push(`=== Performance (${windowSec}s window) ===`)
70
+ if (p.hiddenFrames > 0) {
71
+ lines.push(`⚠ Tab was backgrounded or unfocused — ${p.hiddenFrames} throttled frames skipped (rAF throttling lies about timing)`)
72
+ lines.push('For accurate profiling: keep tab FOREGROUND + FOCUSED + run /profile/reset before reproducing')
73
+ }
74
+ lines.push(`MobX attribution: ${p.mobxPatched ? 'ON' : 'OFF'}`)
75
+ if (!p.mobxPatched) {
76
+ lines.push('(Component attribution disabled — set window.__AIPEEK_MOBX__={Reaction} in src/lib/mobx.ts to enable)')
77
+ }
78
+ lines.push('')
79
+
80
+ // longtasks
81
+ if (p.longtasks.count > 0) {
82
+ lines.push(`Longtasks (>50ms): ${p.longtasks.count}, max ${Math.round(p.longtasks.max)}ms`)
83
+ lines.push('')
84
+ }
85
+
86
+ // Buckets (always show __all__, then per-view only if >1 bucket)
87
+ const showPerView = p.buckets.length > 1
88
+ for (const bucket of p.buckets) {
89
+ if (bucket.name !== '__all__' && !showPerView) continue
90
+
91
+ lines.push(`--- ${bucket.name} ---`)
92
+
93
+ // Frame stats. total===0 means NO trustworthy frames were sampled (tab unfocused/hidden
94
+ // the whole window). Printing "0 dropped (0.0%)" then reads as "buttery smooth" — the exact
95
+ // inverse of the truth. An empty denominator is not 0%, it's no data: say so plainly.
96
+ const { total, long, max, samples } = bucket.frames
97
+ if (total === 0) {
98
+ lines.push('Frames: none sampled (tab unfocused/hidden — no frame data this window)')
99
+ } else {
100
+ const droppedPct = ((long / total) * 100).toFixed(1)
101
+ const avgFrame = avgOf(samples).toFixed(1)
102
+ lines.push(`Frames: ${total} total, ${long} dropped (${droppedPct}%), max ${Math.round(max)}ms, avg ${avgFrame}ms`)
103
+ }
104
+
105
+ // Components (top 20 by total, desc)
106
+ const comps = Object.entries(bucket.components)
107
+ .map(([name, stat]) => ({ name, ...summarizeStat(stat) }))
108
+ .sort((a, b) => b.total - a.total)
109
+ .slice(0, 20)
110
+
111
+ if (comps.length > 0 && p.mobxPatched) {
112
+ lines.push('Components (by total self-time):')
113
+ for (const c of comps) {
114
+ lines.push(` ${c.name.padEnd(30)} ${Math.round(c.total)}ms / ${c.n}× = ${c.avg.toFixed(1)}ms avg, max ${Math.round(c.max)}ms`)
115
+ }
116
+ } else if (!p.mobxPatched) {
117
+ lines.push('(no component data — attribution OFF)')
118
+ }
119
+
120
+ // Hot lines (top 15 by total time) — babel line-level instrumentation.
121
+ // Orthogonal to component attribution: points at the exact source line, not just the component.
122
+ // INCLUSIVE, not self-time: __line wraps each statement, so an outer statement's timing
123
+ // contains every nested __line below it (`const x = foo(bar())` counts foo+bar). Labeling
124
+ // this "self-time" would lie — you'd optimize a high-level line that just sums its callees.
125
+ const hotLines = Object.entries(bucket.lines ?? {})
126
+ .map(([label, stat]) => ({ label, ...summarizeStat(stat) }))
127
+ .sort((a, b) => b.total - a.total)
128
+ .slice(0, 15)
129
+
130
+ if (hotLines.length > 0) {
131
+ lines.push('Hot lines (by total inclusive time — contains nested calls):')
132
+ for (const l of hotLines) {
133
+ lines.push(` ${l.label.padEnd(36)} ${Math.round(l.total)}ms / ${l.n}× = ${l.avg.toFixed(1)}ms avg, max ${Math.round(l.max)}ms`)
134
+ }
135
+ }
136
+
137
+ lines.push('')
138
+ }
139
+
140
+ return lines.join('\n')
141
+ }
142
+
143
+ // Closed-loop verdict: did my fix work? Compares two snapshots (before/after) by component
144
+ // name + line label, lists per-key total-ms deltas, and a headline verdict driven by the
145
+ // __all__ frame dropped% change. This is what lets an automated fix→measure loop decide
146
+ // "keep it" vs "revert and try again" without eyeballing two reports.
147
+ export function diffPerformance(before: PerformanceData, after: PerformanceData): string {
148
+ const out: string[] = []
149
+
150
+ const beforeAll = before.buckets.find(b => b.name === '__all__')
151
+ const afterAll = after.buckets.find(b => b.name === '__all__')
152
+
153
+ const droppedPct = (b: PerfBucketData | undefined) =>
154
+ b && b.frames.total > 0 ? (b.frames.long / b.frames.total) * 100 : 0
155
+ const beforeDrop = droppedPct(beforeAll)
156
+ const afterDrop = droppedPct(afterAll)
157
+ const dropDelta = afterDrop - beforeDrop
158
+
159
+ // Total self-time across all components — the fallback ground truth when frames weren't
160
+ // exercised (e.g. a fix reproduced via direct calls, not real renders → 0%→0% frames).
161
+ const sumTotal = (b: PerfBucketData | undefined) =>
162
+ b ? Object.values(b.components).reduce((s, c) => s + c.total, 0) : 0
163
+ const beforeSelf = sumTotal(beforeAll)
164
+ const afterSelf = sumTotal(afterAll)
165
+ const selfDelta = afterSelf - beforeSelf
166
+
167
+ // Verdict: frame health is the user-visible truth. But frames are only comparable when BOTH
168
+ // snapshots actually sampled frames AND at least one saw drops. If the after-tab went hidden
169
+ // its frameLoop stops (frames.total→0), which would read as a false "0% dropped" win — so fall
170
+ // back to self-time (MobX self-time keeps recording in the background) whenever a side is blind.
171
+ const bothSampledFrames = (beforeAll?.frames.total ?? 0) > 0 && (afterAll?.frames.total ?? 0) > 0
172
+ const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0
173
+ const framesExercised = bothSampledFrames && sawDrops
174
+
175
+ // Blind-window guard: the after-window must actually REPRODUCE the workload to be diffable.
176
+ // afterSelf===0 means zero components re-rendered. Two shapes both mean "workload not run":
177
+ // (a) hard-blind — frames.total===0 too (rAF frozen, tab fully suspended);
178
+ // (b) soft-blind — frames still ticked (rAF only throttled) but self-time is zero while the
179
+ // before-window had real self-time. Frames alone then describe an idle tab's compositor,
180
+ // not the render workload — a -5% frame delta off a backgrounded tab is NOT a code win.
181
+ // A genuine fix shows frames recovering AND self-time dropping in agreement; "frames still
182
+ // dropping but self-time vanished entirely" is the contradiction that betrays an idle after-tab.
183
+ // The pure-paint case (beforeSelf===0===afterSelf, frames>0) is NOT blind — it goes to frames.
184
+ const afterBlind = afterSelf === 0 && (beforeSelf > 0 || (afterAll?.frames.total ?? 0) === 0)
185
+ let verdict: string
186
+ if (afterBlind) {
187
+ verdict = 'NO DATA'
188
+ } else if (framesExercised) {
189
+ if (dropDelta < -FRAME_NOISE_PCT) verdict = 'IMPROVED'
190
+ else if (dropDelta > FRAME_NOISE_PCT) verdict = 'REGRESSED'
191
+ else verdict = 'UNCHANGED'
192
+ } else {
193
+ const floor = Math.max(SELF_NOISE_MS, beforeSelf * SELF_NOISE_REL)
194
+ if (selfDelta < -floor) verdict = 'IMPROVED'
195
+ else if (selfDelta > floor) verdict = 'REGRESSED'
196
+ else verdict = 'UNCHANGED'
197
+ }
198
+
199
+ out.push(`=== Perf diff: ${verdict} ===`)
200
+ if (afterBlind) {
201
+ const why = (afterAll?.frames.total ?? 0) === 0
202
+ ? 'recorded no frames and no re-renders'
203
+ : `recorded frames but zero re-renders (was ${Math.round(beforeSelf)}ms self-time)`
204
+ out.push(`after-window ${why} — tab backgrounded or workload not reproduced. Re-run with the tab focused and the interaction repeated.`)
205
+ out.push('')
206
+ return out.join('\n').trimEnd()
207
+ }
208
+ out.push(`Dropped frames: ${beforeDrop.toFixed(1)}% → ${afterDrop.toFixed(1)}% (${dropDelta >= 0 ? '+' : ''}${dropDelta.toFixed(1)}%)`)
209
+ if (!framesExercised && Math.round(selfDelta) !== 0) {
210
+ out.push(`Total self-time: ${Math.round(beforeSelf)}ms → ${Math.round(afterSelf)}ms (${selfDelta >= 0 ? '+' : ''}${Math.round(selfDelta)}ms) — frames flat, verdict by self-time`)
211
+ }
212
+ if (before.longtasks.count !== after.longtasks.count) {
213
+ out.push(`Longtasks: ${before.longtasks.count} → ${after.longtasks.count}`)
214
+ }
215
+ out.push('')
216
+
217
+ // Per-component total-ms deltas (union of names), biggest absolute change first.
218
+ const compDelta = keyedTotalDelta(beforeAll?.components ?? {}, afterAll?.components ?? {})
219
+ if (compDelta.length > 0) {
220
+ out.push('Components (Δ total self-time):')
221
+ for (const d of compDelta) out.push(' ' + formatDelta(d))
222
+ out.push('')
223
+ }
224
+
225
+ // Per-line total-ms deltas (only when line instrumentation was on).
226
+ const lineDelta = keyedTotalDelta(beforeAll?.lines ?? {}, afterAll?.lines ?? {})
227
+ if (lineDelta.length > 0) {
228
+ out.push('Hot lines (Δ total inclusive time):')
229
+ for (const d of lineDelta) out.push(' ' + formatDelta(d))
230
+ }
231
+
232
+ return out.join('\n').trimEnd()
233
+ }
234
+
235
+ interface KeyDelta { key: string, before: number, after: number, delta: number, isNew: boolean, gone: boolean }
236
+
237
+ function keyedTotalDelta(a: Record<string, PerfStat>, b: Record<string, PerfStat>): KeyDelta[] {
238
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
239
+ const rows: KeyDelta[] = []
240
+ for (const key of keys) {
241
+ const beforeT = a[key]?.total ?? 0
242
+ const afterT = b[key]?.total ?? 0
243
+ const delta = afterT - beforeT
244
+ if (Math.round(delta) === 0) continue // unchanged keys add noise
245
+ rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] })
246
+ }
247
+ return rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 20)
248
+ }
249
+
250
+ function formatDelta(d: KeyDelta): string {
251
+ const sign = d.delta >= 0 ? '+' : ''
252
+ const tag = d.isNew ? ' (new)' : d.gone ? ' (gone)' : ''
253
+ return `${d.key.padEnd(36)} ${Math.round(d.before)}ms → ${Math.round(d.after)}ms (${sign}${Math.round(d.delta)}ms)${tag}`
254
+ }
@@ -0,0 +1,164 @@
1
+ export interface LogEntry {
2
+ level: 'error' | 'warn' | 'info' | 'debug' | 'log'
3
+ text: string
4
+ timestamp?: number
5
+ source?: string
6
+ }
7
+
8
+ export interface NetworkRequest {
9
+ method: string
10
+ url: string
11
+ status: number
12
+ duration: number
13
+ resourceType: string
14
+ requestHeaders?: Record<string, string>
15
+ responseHeaders?: Record<string, string>
16
+ requestBody?: string
17
+ requestSample?: string
18
+ responseBody?: string
19
+ responseSample?: string
20
+ failed?: boolean
21
+ failureText?: string
22
+ }
23
+
24
+ export interface ErrorEntry {
25
+ message: string
26
+ stack?: string
27
+ source?: string
28
+ line?: number
29
+ column?: number
30
+ }
31
+
32
+ // One semantic page action — a state-machine transition. trusted=real human / real
33
+ // input; !trusted=aihand synthetic. view/modal/focus are the post-settle projection
34
+ // snapshot; the UI change ("dialog opened") is the diff against the prior entry.
35
+ export interface ActionEntry {
36
+ type: string
37
+ target: string
38
+ value?: string
39
+ trusted: boolean
40
+ view?: string
41
+ modal?: string
42
+ focus?: string
43
+ ts: number
44
+ tab?: string // set only in the server's cross-tab /timeline merge; undefined per-tab
45
+ }
46
+
47
+ export interface RawState {
48
+ tab?: string
49
+ url: string
50
+ title?: string
51
+ visible?: boolean
52
+ ui: string
53
+ console: LogEntry[]
54
+ network: NetworkRequest[]
55
+ errors: ErrorEntry[]
56
+ actions?: ActionEntry[]
57
+ actionsDropped?: number // head entries the per-tab ring evicted before this window
58
+ state: Record<string, unknown>
59
+ performance?: PerformanceData
60
+ timestamp: number
61
+ }
62
+
63
+ // --- Performance profiler ---
64
+ //
65
+ // 语义分桶 profiler 的投影。三类正交信号(组件 self-time / 帧 dt / longtask)按 view 分桶,
66
+ // 答「哪个组件在烧帧」。client-patch 采集进 ring buffer,client 投影成此结构,core/perf.ts 纯渲染。
67
+
68
+ // 一段重复计时的累积统计。total/n/max 全是 O(1) 流式全程真值,avg=total/n 派生。
69
+ // 不存 samples:它过去只为 p95,而 p95 是近窗近似、和全程 total 不同源(放同一行即撒谎),已删。
70
+ export interface PerfStat {
71
+ total: number
72
+ n: number
73
+ max: number
74
+ }
75
+
76
+ // 一个语义桶(一个 view,或全局 __all__):组件 self-time map + 帧统计 + 行级 self-time map。
77
+ export interface PerfBucketData {
78
+ name: string
79
+ components: Record<string, PerfStat>
80
+ frames: { total: number, long: number, max: number, samples: number[] }
81
+ lines: Record<string, PerfStat> // "File.tsx:42:varName" → stat,babel 行级插桩
82
+ }
83
+
84
+ export interface PerformanceData {
85
+ windowMs: number // Date.now() - startedAt,采样窗口时长
86
+ hiddenFrames: number // 标签页隐藏期被跳过的帧(后台 rAF 节流会说谎,故不计入 frames)
87
+ mobxPatched: boolean // Reaction.track patch 是否生效(否则无组件归因)
88
+ buckets: PerfBucketData[] // __all__ 在前
89
+ longtasks: { count: number, max: number }
90
+ }
91
+
92
+ // One live client (browser tab / Electron window) seen by the server. lastSeen is the
93
+ // server-side arrival time of its most recent reply — used to address one tab (?tab=)
94
+ // and to age out stale entries from /tabs.
95
+ export interface TabInfo {
96
+ id: string
97
+ url: string
98
+ title: string
99
+ visible: boolean
100
+ lastSeen: number
101
+ }
102
+
103
+ export interface CompactState {
104
+ url: string
105
+ ui: string
106
+ console: string
107
+ network: string
108
+ errors: string
109
+ state: string
110
+ performance?: string
111
+ timestamp: number
112
+ counts: {
113
+ console: number
114
+ network: number
115
+ errors: number
116
+ state: number
117
+ }
118
+ }
119
+
120
+ export interface Assertion {
121
+ name: string
122
+ pass: boolean
123
+ detail?: string
124
+ }
125
+
126
+ export interface CheckResult {
127
+ pass: boolean
128
+ assertions: Assertion[]
129
+ }
130
+
131
+ export interface DiffResult {
132
+ newErrors: LogEntry[]
133
+ newExceptions: ErrorEntry[]
134
+ newFailedRequests: NetworkRequest[]
135
+ uiGone: boolean
136
+ clean: boolean
137
+ }
138
+
139
+ // The state-machine projection at one instant — what a click/fill is diffed against
140
+ // before vs after it settles. view/modal/focus are aihand's generic UI vars; `domain`
141
+ // is the app's own state machine, injected via window.__AIPEEK_SCREEN__() — the few
142
+ // variables that DON'T live in the DOM (是否流式 / 选了哪个模型 / 未读数), which a
143
+ // DOM-only projector can never synthesize. Empty when the app injects nothing.
144
+ // A big structural block of the page (>5% viewport), digested to a comparison-stable key.
145
+ // key = tag + quantized rect + childElementCount — small jitter doesn't change it, wholesale
146
+ // region replacement does. The DOM-only signal behind view-change inference (see diffScreen).
147
+ // label = role||tag + child count — a cheap human name (no text walk) so an *appeared* region
148
+ // (a dropdown/popover that isn't a modal and isn't a nav) can be reported self-sufficiently.
149
+ export interface RegionDigest {
150
+ tag: string
151
+ key: string
152
+ label: string
153
+ }
154
+
155
+ export interface ScreenSnap {
156
+ view: string
157
+ modal: string
158
+ focus: string
159
+ domain: Record<string, unknown>
160
+ // >5% viewport structural blocks. Only consumed when view is double-(unknown) — i.e. the app
161
+ // didn't inject __AIPEEK_VIEW__, so view-change is inferred from region replacement instead.
162
+ // Absent in old fixtures → treated as empty (no DOM fallback signal), zero regression.
163
+ regions: RegionDigest[]
164
+ }
@@ -0,0 +1,221 @@
1
+ import type { ActionEntry, TabInfo } from './types'
2
+
3
+ export function truncate(s: string, max: number): string {
4
+ return s.length > max ? `${s.slice(0, max)}…` : s
5
+ }
6
+
7
+ // unknown → 人类可读字符串。穷举 JS 类型,让 JSON.stringify 的「漏网类型 → {}」失败模式不可能出现。
8
+ // 返回未截断字符串——截断由调用方按各自 max 处理。
9
+ export function formatValue(v: unknown, seen: Set<object> = new Set()): string {
10
+ if (v === null || v === undefined)
11
+ return String(v)
12
+ const t = typeof v
13
+ if (t === 'string')
14
+ return v as string
15
+ if (t === 'number' || t === 'boolean' || t === 'bigint')
16
+ return String(v)
17
+ if (t === 'symbol')
18
+ return (v as symbol).toString()
19
+ if (t === 'function')
20
+ return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
21
+ // 此后 v 是 object
22
+ const obj = v as object
23
+ if (seen.has(obj))
24
+ return '[Circular]'
25
+ if (v instanceof Error)
26
+ return v.stack || `${v.name}: ${v.message}`
27
+ seen.add(obj)
28
+ if (v instanceof Map) {
29
+ const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
30
+ return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
31
+ }
32
+ if (v instanceof Set) {
33
+ const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
34
+ return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
35
+ }
36
+ if (Array.isArray(v)) {
37
+ const items = v.slice(0, 30).map(val => formatValue(val, seen))
38
+ return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
39
+ }
40
+ try {
41
+ return JSON.stringify(v)
42
+ }
43
+ catch {
44
+ // 循环引用 / getter 抛错 → 手动遍历可枚举键,循环处标 [Circular]
45
+ const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
46
+ const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`)
47
+ return `{${parts.join(', ')}}`
48
+ }
49
+ }
50
+
51
+ // stack → 应用栈帧(去 node_modules / <anonymous>),保留 `at ` 前缀。max 与溢出提示由调用方决定。
52
+ export function appStackFrames(stack: string, max: number): string[] {
53
+ return stack
54
+ .split('\n')
55
+ .map(l => l.trim())
56
+ .filter(l => l.startsWith('at ') && !l.includes('node_modules') && !l.includes('<anonymous>'))
57
+ .slice(0, max)
58
+ }
59
+
60
+ // pathname only; pass `search` to append a truncated query string
61
+ export function compactUrl(url: string, search?: number): string {
62
+ try {
63
+ const u = new URL(url)
64
+ if (search && u.search)
65
+ return `${u.pathname}?${truncate(u.search.slice(1), search)}`
66
+ return u.pathname
67
+ }
68
+ catch {
69
+ return truncate(url, 80)
70
+ }
71
+ }
72
+
73
+ // SPA 路由切换判据(出口2 同文档导航回填用):CDP Page.navigatedWithinDocument 对一次真路由切换
74
+ // 之外还会为锚点滚动 / replaceState 状态更新喷一串事件,只有 pathname+search 真变的那一次才是换页。
75
+ // 纯 #hash 变化(同 path 同 search,只 hash 不同)是页内锚点跳转、不是路由切换,排除。URL 解析失败
76
+ // (相对/畸形)退回字符串严格不等——保守:宁可漏判一次少见的畸形 URL 换页,不误判一次锚点为换页。
77
+ export function isRouteChange(fromUrl: string, toUrl: string): boolean {
78
+ try {
79
+ const a = new URL(fromUrl)
80
+ const b = new URL(toUrl)
81
+ return a.pathname !== b.pathname || a.search !== b.search
82
+ }
83
+ catch {
84
+ return fromUrl !== toUrl && stripHash(fromUrl) !== stripHash(toUrl)
85
+ }
86
+ }
87
+ function stripHash(u: string): string {
88
+ const i = u.indexOf('#')
89
+ return i < 0 ? u : u.slice(0, i)
90
+ }
91
+
92
+ // A tab is "live" if it replied within STALE_MS (a closed tab stops replying, so its
93
+ // lastSeen freezes). One definition, shared by the server's roster/guard and formatTabs.
94
+ const STALE_MS = 30_000
95
+ export const isLive = (t: TabInfo, now: number) => now - t.lastSeen < STALE_MS
96
+
97
+ // Render the live-tab roster — shared by GET /tabs and the 409 "which tab?" guard.
98
+ // `now` is server time. The visible one is marked first so the caller can pick.
99
+ // When NO tab is visible (the user is reading the terminal, so every dev tab is
100
+ // backgrounded — the common case), append a note: background is fully operable, so
101
+ // the model never reads `○ background` as "can't act, ask the user to foreground it".
102
+ export function formatTabs(tabs: TabInfo[], now: number): string {
103
+ const live = tabs.filter(t => isLive(t, now))
104
+ if (!live.length)
105
+ return '(no live tabs)'
106
+ const rows = live
107
+ .sort((a, b) => Number(b.visible) - Number(a.visible) || a.id.localeCompare(b.id))
108
+ .map((t) => {
109
+ const age = Math.round((now - t.lastSeen) / 1000)
110
+ const mark = t.visible ? '● visible' : '○ background'
111
+ return `${t.id} ${mark} ${truncate(t.title || '(untitled)', 40)} ${compactUrl(t.url)} ${age}s ago`
112
+ })
113
+ .join('\n')
114
+ if (live.some(t => t.visible))
115
+ return rows
116
+ return `${rows}\n(all backgrounded because the user is looking at the terminal — this is normal. Reads and clicks/fills work on background tabs; do NOT ask the user to foreground a tab. Only /profile needs focus.)`
117
+ }
118
+
119
+ // The single projection π for every "didn't get an answer" path: connection state → one
120
+ // actionable message. Defined once, pure (no closure, no clock), so no failure path can
121
+ // collapse two states that need different fixes (the law: ker π ⊆ ker f*). The five fibers
122
+ // each demand a different action, so each gets its own branch:
123
+ // addressed, live → handler threw / hung / socket dropped mid self-heal (retry, check console)
124
+ // addressed, not live → tab closed or mid self-heal (check /tabs, retry)
125
+ // broadcast, live tabs → handler threw or tab just closed (check /tabs + console)
126
+ // broadcast, sockets>0 → page open but client not injected/wrong server (check the port)
127
+ // broadcast, no socket → no browser pointed here at all (open the app)
128
+ // `socketCount` is the raw HMR-socket count (server.ws.clients.size) — the bit that separates
129
+ // "open but uninjected" from "no browser", which the aihand roster alone can't see.
130
+ export function diagnose(tab: string | undefined, tabs: TabInfo[], now: number, socketCount: number, port: number): string {
131
+ if (tab) {
132
+ const t = tabs.find(x => x.id === tab)
133
+ return t && isLive(t, now)
134
+ ? `tab '${tab}' is connected but didn't answer in time — most likely the HMR socket dropped while the tab was backgrounded and self-heal hasn't reconnected yet (retry in ~2s, it reloads itself), or the handler threw/hung on a bad selector. Reads (/screen /dom) work backgrounded — do NOT ask the user to foreground the tab. Check /__aihand/console.`
135
+ : `tab '${tab}' is not connected — it closed, or the server restarted and it hasn't re-handshaked yet. Check /__aihand/tabs for live ids; if the page is mid self-heal, retry in ~2s.`
136
+ }
137
+ const live = tabs.filter(t => isLive(t, now))
138
+ if (live.length)
139
+ return `${live.length} tab(s) connected but none answered — a handler threw, or they just closed. Check /__aihand/tabs (still live?) and /__aihand/console (errors?).`
140
+ return socketCount > 0
141
+ ? `no aihand tab registered, but ${socketCount} page(s) are connected to this dev server via HMR — the page is open yet the aihand client didn't load. Is it served by THIS vite server on :${port}? A separate vite config / vite preview / production build won't inject it.`
142
+ : `no browser tab connected to this dev server on :${port} — open the app, or check you're hitting the right port.`
143
+ }
144
+
145
+ // Render the action ring as a cause-effect timeline. Each entry is a transition; the UI
146
+ // change (→ dialog opened / closed / view switched) is the diff of this entry's post-settle
147
+ // snapshot against the prior one *on the same tab*. `T`=trusted (human), `S`=synthetic
148
+ // (aihand). With anchorTs set, the matching entry is bracketed by "你当前的行为" dividers so
149
+ // the model reads [earlier] → its own action → [what followed]. Pure: no DOM. Shared by the
150
+ // single-tab `recent actions` tail (client.ts) and the cross-tab GET /timeline (server).
151
+ //
152
+ // dropped = how many head entries the ring evicted before this window (it's a bounded ring,
153
+ // 30 per-tab / 200 server). A causal chain with a silently-cut head reads as "this is the
154
+ // start" → the model anchors on a non-start. Same diagnostic-fiber law diff.ts already obeys
155
+ // for flow frames (a clipped trace must announce the clip): announce the eviction at the head.
156
+ export function formatActions(entries: ActionEntry[], anchorTs?: number, dropped = 0): string {
157
+ if (!entries.length)
158
+ return dropped > 0 ? `┄ … 更早 ${dropped} 条动作已超出窗口(此处非会话起点)` : '(no actions)'
159
+ const sorted = [...entries].sort((a, b) => a.ts - b.ts)
160
+ // Tag lines with their tab only when the stream spans >1 tab (the cross-tab /timeline).
161
+ // Single-tab `recent actions` stays byte-identical — no tab prefix. uiChange diffs per
162
+ // tab, so an interleaved A/B stream doesn't read B's modal as A's transition.
163
+ const multiTab = new Set(sorted.map(e => e.tab).filter(Boolean)).size > 1
164
+ const lines: string[] = []
165
+ if (dropped > 0)
166
+ lines.push(`┄ … 更早 ${dropped} 条动作已超出窗口(下方非会话起点)`)
167
+ const prevByTab = new Map<string | undefined, ActionEntry>()
168
+ let anchored = false
169
+ for (const e of sorted) {
170
+ const isAnchor = anchorTs !== undefined && !anchored && e.ts === anchorTs
171
+ if (isAnchor) {
172
+ lines.push('—————— 你当前的行为 ——————')
173
+ anchored = true
174
+ }
175
+ const who = e.trusted ? 'T' : 'S'
176
+ const tag = multiTab ? `${e.tab ?? '?'} ` : ''
177
+ const val = e.value ? ` value="${e.value}"` : ''
178
+ const change = uiChange(prevByTab.get(e.tab), e)
179
+ lines.push(`${tag}[${who}] ${e.type} ${e.target}${val}${change ? ` → ${change}` : ''}`)
180
+ if (isAnchor)
181
+ lines.push('——————————————————————————')
182
+ prevByTab.set(e.tab, e)
183
+ }
184
+ return lines.join('\n')
185
+ }
186
+
187
+ // Append an action to the server's cross-tab ring, mutating it in place. Mirrors
188
+ // client-patch's input debounce at the aggregation point: a tab fires its hook on every
189
+ // keystroke (rewriting its in-page entry in place), so consecutive same-tab input on the
190
+ // same target rewrites the logged entry instead of appending — else "hello" becomes 5 rows
191
+ // (h/he/hel/…). Any other action ends the run. Ring-bounded by max. Pure logic, fixture-tested.
192
+ export function appendAction(log: ActionEntry[], tab: string, entry: ActionEntry, max: number): void {
193
+ const last = log[log.length - 1]
194
+ if (entry.type === 'input' && last?.type === 'input' && last.tab === tab && last.target === entry.target) {
195
+ log[log.length - 1] = { ...entry, tab }
196
+ return
197
+ }
198
+ log.push({ ...entry, tab })
199
+ if (log.length > max)
200
+ log.shift()
201
+ }
202
+
203
+ function uiChange(prev: ActionEntry | undefined, cur: ActionEntry): string {
204
+ if (cur.modal === undefined)
205
+ return ''
206
+ const prevModal = prev?.modal ?? 'none'
207
+ if (cur.modal !== prevModal) {
208
+ if (cur.modal === 'none')
209
+ return '弹窗关闭'
210
+ if (prevModal === 'none')
211
+ return `弹窗打开「${cur.modal}」`
212
+ return `弹窗切换「${cur.modal}」`
213
+ }
214
+ // Both sides must be captured to call it a transition. A prior entry whose view wasn't
215
+ // snapshotted (screen hook not injected yet) is *unknown*, not "a different view" — rendering
216
+ // `视图 undefined→chat` projects a non-event as a transition (ker π must not collapse
217
+ // unknown-prior into changed). Unknown prior → no change.
218
+ if (prev && cur.view !== undefined && prev.view !== undefined && cur.view !== prev.view)
219
+ return `视图 ${prev.view}→${cur.view}`
220
+ return ''
221
+ }
@@ -0,0 +1,5 @@
1
+ export { check } from './core/check'
2
+ export { diffState } from './core/diff'
3
+ export { emitCheck, emitDiff, emitSummary } from './core/emit'
4
+ export type { CheckResult, CompactState, DiffResult, RawState } from './core/types'
5
+ export { aihandPlugin } from './server/plugin'