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,253 @@
1
+ import type { DiffResult, ErrorEntry, LogEntry, NetworkRequest, RawState, RegionDigest, ScreenSnap } from './types'
2
+ import { isSecretKey, redactSecretValue } from './candidates'
3
+
4
+ // The DOM-only view-change judge. A human knows "the page changed" without reading any store —
5
+ // they see the page's main body get replaced. before/after are the >5% viewport region digests.
6
+ //
7
+ // What separates "navigated to another view" from "opened a modal" is GONE, not appeared. A modal
8
+ // overlays the page: the old regions all REMAIN underneath (gone≈0), it only ADDS. Navigation
9
+ // replaces part of the body: some old regions vanish. Live-measured on chat (the outer shell —
10
+ // html/body/sidebar/main container — is shared across views, so it's never all-or-nothing):
11
+ // chat→im gone 9/23=0.39, appeared 3 → view change
12
+ // im→chat gone 3/13=0.23, appeared 9 → view change
13
+ // open 设置 gone 1/23=0.04, appeared 15 → modal (NOT a view change — gone stays ~0)
14
+ // So: gone/before past a low floor (the shared shell keeps it modest, 0.15 sits in the safe gap
15
+ // between modal's 0.04 and navigation's 0.23) AND appeared>0 (guards pure removal — closing a
16
+ // panel drops regions with nothing new). Full-page scroll isn't defended — it's a human gesture,
17
+ // never inside an action's before/after. The first cut required a symmetric 30%-both gate and漏判
18
+ // the asymmetric real shape; the second over-corrected to gone≥50% and漏判 the shared-shell dilution.
19
+ const GONE_FRAC = 0.15
20
+ export function regionsChanged(before: RegionDigest[], after: RegionDigest[]): boolean {
21
+ if (!before.length || !after.length)
22
+ return false
23
+ const afterKeys = new Set(after.map(r => r.key))
24
+ const beforeKeys = new Set(before.map(r => r.key))
25
+ const gone = before.filter(r => !afterKeys.has(r.key)).length
26
+ const appeared = after.filter(r => !beforeKeys.has(r.key)).length
27
+ return gone >= before.length * GONE_FRAC && appeared > 0
28
+ }
29
+
30
+ // A big region appeared (or vanished) WITHOUT a navigation or a modal — an autocomplete dropdown,
31
+ // a popover, an inline expansion, a toast. regionsChanged only fires on navigation (gone≥15%); a
32
+ // pure ADD has gone≈0, so it slips through every diff branch and the action reads as "no state
33
+ // change" even though a 11-item suggestions list is now on screen (live: Wikipedia fill → dropdown
34
+ // at ~40ms, 30% viewport, reported nothing). A human watching would say "a list appeared" — this
35
+ // recovers exactly that. Returns the appeared region's cheap label (role/tag + child count), or the
36
+ // vanished one's, so the delta is self-sufficient (the agent learns there's a thing to click /
37
+ // dismiss without a second /screen). Caller only consults this when nav & modal both said nothing.
38
+ // A child-bearing region (≥2 blocks) is a structural layer — a list, menu, card stack. A bare or
39
+ // single-child region that merely changed size is reflow jitter (an input growing, a wrapper
40
+ // resizing), keyed differently but not a new layer. Require structure to fire.
41
+ const POPOVER_MIN_CHILDREN = 2
42
+ export function popoverChange(before: RegionDigest[], after: RegionDigest[]): string | undefined {
43
+ const beforeKeys = new Set(before.map(r => r.key))
44
+ const afterKeys = new Set(after.map(r => r.key))
45
+ const appeared = after.filter(r => !beforeKeys.has(r.key))
46
+ const gone = before.filter(r => !afterKeys.has(r.key))
47
+ // Most-childful changed region = the meaningful one (a list/menu, not a reflowed wrapper).
48
+ const top = (rs: RegionDigest[]) => rs.reduce<RegionDigest | undefined>((m, r) => !m || childCount(r) > childCount(m) ? r : m, undefined)
49
+ // Net add (a true overlay adds a layer; a reflow swaps one key for another, appeared≈gone) — and
50
+ // the layer must have structure. Together these reject the size-jitter false positive.
51
+ const add = top(appeared)
52
+ if (appeared.length > gone.length && add && childCount(add) >= POPOVER_MIN_CHILDREN)
53
+ return `panel: appeared (${add.label})`
54
+ const rm = top(gone)
55
+ if (gone.length > appeared.length && rm && childCount(rm) >= POPOVER_MIN_CHILDREN)
56
+ return `panel: dismissed (${rm.label})`
57
+ return undefined
58
+ }
59
+
60
+ // childcount is the `#N` suffix the region key already carries — parse it back for ranking.
61
+ function childCount(r: RegionDigest): number {
62
+ const i = r.key.lastIndexOf('#')
63
+ return i < 0 ? 0 : Number(r.key.slice(i + 1)) || 0
64
+ }
65
+
66
+ // The change a single action caused: the state-machine transition (view/modal/focus)
67
+ // plus any errors/failed requests it triggered. AI reads "what moved", then drills into
68
+ // /ui or /dom for detail if it needs more — the response carries the delta, not a snapshot.
69
+ // Pure: caller passes before/after snaps and the already-diffed error/request deltas.
70
+ export function diffScreen(
71
+ before: ScreenSnap,
72
+ after: ScreenSnap,
73
+ newErrors: LogEntry[],
74
+ newExceptions: ErrorEntry[],
75
+ newFailedRequests: NetworkRequest[],
76
+ ): string[] {
77
+ const lines: string[] = []
78
+ // Track whether a body/modal transition was named — a popover appearance is the fallback only
79
+ // when nav & modal said nothing (else it'd double-report a body replacement or a dialog open).
80
+ let bodyOrModalNamed = false
81
+ if (after.view !== before.view) {
82
+ lines.push(`view: ${before.view} → ${after.view}`)
83
+ bodyOrModalNamed = true
84
+ }
85
+ // DOM-only fallback: the app didn't inject __AIPEEK_VIEW__ (both frames '(unknown)'), so the
86
+ // store-projected view name is blind. Infer view-change from the page body being replaced —
87
+ // honest precision: we can't name the new view, only report it changed. Skipped when the app
88
+ // DID name the view (the branch above already showed chat→im) — store name wins, no dup.
89
+ // Also skipped while a modal is open (after.modal !== 'none'): an open dialog overlays and
90
+ // reflows the body, and its content keeps rendering for several frames after it appears — every
91
+ // such frame churns big regions (live: settle frames mid-open cross the 0.15 floor) but that's
92
+ // the modal, not navigation. A human with a dialog open reads the dialog, never says "the page
93
+ // changed". The `modal: opened` line names it. View-change inference only has meaning with no
94
+ // modal covering the page. A more specific transition wins (ker 诊断 ⊆ ker 动作).
95
+ else if (before.view === '(unknown)' && after.modal === 'none' && regionsChanged(before.regions ?? [], after.regions ?? [])) {
96
+ lines.push('view: changed (DOM 区域指纹)')
97
+ bodyOrModalNamed = true
98
+ }
99
+ if (after.modal !== before.modal) {
100
+ if (after.modal === 'none')
101
+ lines.push(`modal: closed (${before.modal})`)
102
+ else if (before.modal === 'none')
103
+ lines.push(`modal: opened ${after.modal}`)
104
+ else
105
+ lines.push(`modal: ${before.modal} → ${after.modal}`)
106
+ bodyOrModalNamed = true
107
+ }
108
+ // Popover/dropdown/toast: a big region appeared or vanished that's neither a nav nor a modal.
109
+ // Only the fallback — nav & modal are more specific and already named the body change.
110
+ if (!bodyOrModalNamed) {
111
+ const pop = popoverChange(before.regions ?? [], after.regions ?? [])
112
+ if (pop)
113
+ lines.push(pop)
114
+ }
115
+ if (after.focus !== before.focus)
116
+ lines.push(`focus: ${after.focus}`)
117
+ // The app's domain state machine (from __AIPEEK_SCREEN__) — these transitions are
118
+ // the ones a DOM-only projector can't see (流式 false → true never touches the DOM).
119
+ const beforeDomain = before.domain ?? {}
120
+ const afterDomain = after.domain ?? {}
121
+ for (const key of new Set([...Object.keys(beforeDomain), ...Object.keys(afterDomain)])) {
122
+ const b = redactDomain(key, beforeDomain[key])
123
+ const a = redactDomain(key, afterDomain[key])
124
+ if (b !== a)
125
+ lines.push(`${key}: ${b} → ${a}`)
126
+ }
127
+ for (const e of newErrors)
128
+ lines.push(`+error: ${e.text}`)
129
+ for (const e of newExceptions)
130
+ lines.push(`+exception: ${e.message}`)
131
+ for (const r of newFailedRequests)
132
+ lines.push(`+failed: ${r.method} ${r.url} → ${r.status || 'failed'}`)
133
+ return lines
134
+ }
135
+
136
+ // An async action (send a message → think → stream → done) is a *trajectory* through the
137
+ // state machine, not a single transition. diffScreen has a 2-point domain — it collapses the
138
+ // whole trajectory to its first and last frame. traceFlow lifts that to N frames: it applies
139
+ // diffScreen to each adjacent pair and stamps the elapsed time, so the output reads as
140
+ // @0ms 流式中: false → true
141
+ // ╎ 230ms
142
+ // @230ms 思考中: false → true
143
+ // ╎ 870ms
144
+ // @1100ms 思考中: true → false
145
+ // i.e. the state changes AND the Δt between them. It owns no diff logic of its own — strip the
146
+ // time stamping and it IS diffScreen. Frames are pre-sampled by the caller (waitForStable's
147
+ // MutationObserver, one per real DOM-change burst), each carrying ms since the action started.
148
+ //
149
+ // Returns [] unless the trajectory has at least TWO distinct timed transitions. A flow whose whole
150
+ // movement happens in a single burst (a dialog opening: one @t group, no ╎ Δt) is exactly what the
151
+ // 2-frame `--- changed ---` already shows — emitting it again as a degenerate one-line "flow" is
152
+ // pure noise. The Δt between transitions IS the reason flow exists; with fewer than two timed
153
+ // transitions there's no Δt to show, so it degrades to "no flow" and the caller omits the section.
154
+ const MAX_FLOW_FRAMES = 40
155
+
156
+ export function traceFlow(frames: { snap: ScreenSnap, t: number }[]): string[] {
157
+ // Bound the trajectory: a very long stream can emit many domain-change frames. Keep the
158
+ // head (where it starts) and the tail (where it lands), drop the dense middle — but say so,
159
+ // never silently truncate (the diagnostic-fiber law: a clipped trace must announce the clip).
160
+ let kept = frames
161
+ let dropped = 0
162
+ if (frames.length > MAX_FLOW_FRAMES) {
163
+ const head = Math.ceil(MAX_FLOW_FRAMES / 2)
164
+ const tail = MAX_FLOW_FRAMES - head
165
+ dropped = frames.length - MAX_FLOW_FRAMES
166
+ kept = [...frames.slice(0, head), ...frames.slice(frames.length - tail)]
167
+ }
168
+
169
+ const lines: string[] = []
170
+ let transitions = 0
171
+ let prevT = kept[0]?.t ?? 0
172
+ for (let i = 0; i < kept.length - 1; i++) {
173
+ const moved = diffScreen(kept[i].snap, kept[i + 1].snap, [], [], [])
174
+ if (!moved.length)
175
+ continue
176
+ const at = `@${Math.round(kept[i + 1].t)}ms`
177
+ // The first emitted transition prints alone; each subsequent one is preceded by the gap
178
+ // from the previous *emitted* transition's time. transitions>0 == we've emitted before.
179
+ if (transitions)
180
+ lines.push(` ╎ ${Math.round(kept[i + 1].t - prevT)}ms`)
181
+ for (let j = 0; j < moved.length; j++)
182
+ lines.push(`${j === 0 ? at.padEnd(9) : ' '.repeat(9)} ${moved[j]}`)
183
+ transitions++
184
+ prevT = kept[i + 1].t
185
+ // Mark the seam where the dense middle was dropped, right after the head's last frame.
186
+ if (dropped && i === Math.ceil(MAX_FLOW_FRAMES / 2) - 1)
187
+ lines.push(` ┄ … ${dropped} 帧省略`)
188
+ }
189
+ // Fewer than two timed transitions == no Δt to show == the `--- changed ---` 2-frame diff
190
+ // already covers it. Degrade to no-flow so the caller omits a redundant single-line section.
191
+ return transitions >= 2 ? lines : []
192
+ }
193
+
194
+ // One-line, bounded, comparison-stable stringification of a domain value. Used both to
195
+ // detect change (string equality) and to render the transition. Objects compare by their
196
+ // JSON — good enough for the shallow domain maps __AIPEEK_SCREEN__ returns.
197
+ function stringifyDomain(v: unknown): string {
198
+ if (v === null || v === undefined)
199
+ return String(v)
200
+ if (typeof v === 'object')
201
+ return JSON.stringify(v).slice(0, 80)
202
+ return String(v).slice(0, 80)
203
+ }
204
+
205
+ // stringifyDomain, but redact the value when the field KEY names a secret (sessionToken,
206
+ // apiKey, *_token…). The domain map is `store.field → value`; an app's __AIPEEK_SCREEN__ can
207
+ // surface a credential there, and both echo points (/screen's domain block AND every
208
+ // --- changed --- diff) must mask it. Length-preserving so "empty vs set / roughly how long"
209
+ // stays observable. The single key-level chokepoint — see candidates.isSecretKey.
210
+ export function redactDomain(key: string, v: unknown): string {
211
+ const s = stringifyDomain(v)
212
+ if (!isSecretKey(key))
213
+ return s
214
+ const raw = v === null || v === undefined ? '' : String(v)
215
+ return raw ? redactSecretValue(raw) : s
216
+ }
217
+
218
+ export function diffState(prev: RawState | null, curr: RawState): DiffResult {
219
+ if (!prev) {
220
+ return {
221
+ newErrors: curr.console.filter(l => l.level === 'error'),
222
+ newExceptions: [...curr.errors],
223
+ newFailedRequests: curr.network.filter(r => r.status >= 400 || r.failed),
224
+ uiGone: false,
225
+ clean: curr.console.filter(l => l.level === 'error').length === 0
226
+ && curr.errors.length === 0
227
+ && curr.network.filter(r => r.status >= 400 || r.failed).length === 0,
228
+ }
229
+ }
230
+
231
+ const prevErrorTexts = new Set(prev.console.filter(l => l.level === 'error').map(l => l.text))
232
+ const newErrors = curr.console.filter(l => l.level === 'error' && !prevErrorTexts.has(l.text))
233
+
234
+ const prevExceptionMsgs = new Set(prev.errors.map(e => e.message))
235
+ const newExceptions = curr.errors.filter(e => !prevExceptionMsgs.has(e.message))
236
+
237
+ const prevFailedKeys = new Set(
238
+ prev.network.filter(r => r.status >= 400 || r.failed).map(r => `${r.method}|${r.url}|${r.status}`),
239
+ )
240
+ const newFailedRequests = curr.network
241
+ .filter(r => r.status >= 400 || r.failed)
242
+ .filter(r => !prevFailedKeys.has(`${r.method}|${r.url}|${r.status}`))
243
+
244
+ const uiGone = prev.ui.trim().length > 0 && curr.ui.trim().length === 0
245
+
246
+ return {
247
+ newErrors,
248
+ newExceptions,
249
+ newFailedRequests,
250
+ uiGone,
251
+ clean: newErrors.length === 0 && newExceptions.length === 0 && newFailedRequests.length === 0 && !uiGone,
252
+ }
253
+ }
@@ -0,0 +1,198 @@
1
+ import type { CheckResult, CompactState, DiffResult, PerformanceData, RawState } from './types'
2
+ import pc from 'picocolors'
3
+ import { nameOf } from './compact'
4
+ import { compactPerformance } from './perf'
5
+ import { compactUrl, truncate } from './util'
6
+
7
+ // --- Full emit (for ?full) ---
8
+
9
+ const SECTIONS = ['ui', 'console', 'network', 'errors', 'state', 'performance'] as const
10
+ const COUNTED_SECTIONS: Record<string, keyof CompactState['counts']> = {
11
+ console: 'console',
12
+ network: 'network',
13
+ errors: 'errors',
14
+ state: 'state',
15
+ }
16
+
17
+ export function emit(state: CompactState): string {
18
+ const sections: string[] = []
19
+
20
+ for (const key of SECTIONS) {
21
+ if (state[key]) {
22
+ const countKey = COUNTED_SECTIONS[key]
23
+ const count = countKey ? state.counts?.[countKey] ?? 0 : 0
24
+ const attr = count ? ` count="${count}"` : ''
25
+ sections.push(`<${key}${attr}>\n${state[key]}\n</${key}>`)
26
+ }
27
+ }
28
+
29
+ if (!sections.length) {
30
+ sections.push('<empty/>')
31
+ }
32
+
33
+ return `<aihand url="${state.url}">\n\n${sections.join('\n\n')}\n\n</aihand>\n\ndetail: GET /__aihand/{section}/{index}?full`
34
+ }
35
+
36
+ // --- Summary emit (default, high-density) ---
37
+
38
+ export function emitSummary(raw: RawState): string {
39
+ const consoleErrors = raw.console.filter(l => l.level === 'error')
40
+ const consoleWarns = raw.console.filter(l => l.level === 'warn')
41
+ const failedReqs = raw.network.filter(r => r.status >= 400 || r.failed)
42
+ const hasIssues = consoleErrors.length > 0 || raw.errors.length > 0 || failedReqs.length > 0
43
+
44
+ const lines: string[] = []
45
+
46
+ // ui — always 1-line summary
47
+ if (raw.ui.trim()) {
48
+ lines.push(`ui: ${summarizeUI(raw.ui)}`)
49
+ }
50
+
51
+ // console — 1-line if clean, expand errors/warns if dirty
52
+ if (raw.console.length) {
53
+ if (consoleErrors.length || consoleWarns.length) {
54
+ const parts: string[] = []
55
+ for (const e of consoleErrors) parts.push(` [error] ${truncate(e.text, 150)}`)
56
+ for (const w of consoleWarns) parts.push(` [warn] ${truncate(w.text, 150)}`)
57
+ const rest = raw.console.length - consoleErrors.length - consoleWarns.length
58
+ if (rest > 0)
59
+ parts.push(` … ${rest} more`)
60
+ lines.push(`console (${raw.console.length}):`)
61
+ lines.push(...parts)
62
+ }
63
+ else {
64
+ lines.push(`console: ${raw.console.length} logs`)
65
+ }
66
+ }
67
+
68
+ // network — 1-line if clean, expand failures if dirty
69
+ if (raw.network.length) {
70
+ if (failedReqs.length) {
71
+ lines.push(`network (${raw.network.length}):`)
72
+ for (const r of failedReqs) {
73
+ const body = r.responseBody ? ` "${truncate(r.responseBody, 80)}"` : ''
74
+ lines.push(` ${r.method} ${compactUrl(r.url)} ${r.status}${body}`)
75
+ }
76
+ const ok = raw.network.length - failedReqs.length
77
+ if (ok > 0)
78
+ lines.push(` … ${ok} ok`)
79
+ }
80
+ else {
81
+ lines.push(`network: ${raw.network.length} ok`)
82
+ }
83
+ }
84
+
85
+ // errors — always expand (these are uncaught exceptions)
86
+ if (raw.errors.length) {
87
+ lines.push(`errors (${raw.errors.length}):`)
88
+ for (const e of raw.errors) lines.push(` ${truncate(e.message, 150)}`)
89
+ }
90
+
91
+ // perf — only show when jank (dropped >10%)
92
+ if (raw.performance) {
93
+ const all = raw.performance.buckets.find(b => b.name === '__all__')
94
+ if (all) {
95
+ const droppedPct = all.frames.total > 0 ? (all.frames.long / all.frames.total) * 100 : 0
96
+ if (droppedPct > 10) {
97
+ lines.push(compactPerformance(raw.performance))
98
+ }
99
+ }
100
+ }
101
+
102
+ // state — 1-line: store names + key counts
103
+ const storeNames = Object.keys(raw.state)
104
+ if (storeNames.length) {
105
+ const parts = storeNames.map((n) => {
106
+ const v = raw.state[n]
107
+ const keys = typeof v === 'object' && v !== null ? Object.keys(v).length : 0
108
+ return keys > 0 ? `${n}(${keys})` : n
109
+ })
110
+ lines.push(`state: ${parts.join(', ')}`)
111
+ }
112
+
113
+ if (!lines.length)
114
+ return '<aihand>empty</aihand>'
115
+
116
+ const status = hasIssues
117
+ ? `${consoleErrors.length + raw.errors.length + failedReqs.length} issues`
118
+ : 'ok'
119
+
120
+ return `<aihand url="${raw.url}" status="${status}">\n${lines.join('\n')}\n</aihand>${hasIssues ? '\n\ndetail: GET /__aihand/{section}/{index}' : ''}`
121
+ }
122
+
123
+ function summarizeUI(tree: string): string {
124
+ // extract top-level component names (depth 0-1)
125
+ const components: string[] = []
126
+ const counts = new Map<string, number>()
127
+
128
+ for (const line of tree.split('\n')) {
129
+ const trimmed = line.trimStart()
130
+ if (!trimmed)
131
+ continue
132
+ const indent = line.length - trimmed.length
133
+ if (indent > 2)
134
+ continue // only depth 0-1
135
+
136
+ const name = nameOf(trimmed)
137
+ if (!name)
138
+ continue
139
+
140
+ // extract state markers
141
+ const focused = trimmed.includes('[focused]')
142
+ const generating = trimmed.includes('[generating]')
143
+ const loading = trimmed.includes('[loading]')
144
+
145
+ const existing = counts.get(name) || 0
146
+ if (existing > 0) {
147
+ counts.set(name, existing + 1)
148
+ continue
149
+ }
150
+ counts.set(name, 1)
151
+
152
+ let label = name
153
+ if (focused)
154
+ label += '[focused]'
155
+ if (generating)
156
+ label += '[generating]'
157
+ if (loading)
158
+ label += '[loading]'
159
+ components.push(label)
160
+ }
161
+
162
+ // apply counts
163
+ const result = components.map((c) => {
164
+ const name = c.split('[')[0]
165
+ const count = counts.get(name) || 1
166
+ return count > 1 ? `${c}(×${count})` : c
167
+ })
168
+
169
+ return result.join(', ') || 'empty'
170
+ }
171
+
172
+ // --- Check emit ---
173
+
174
+ export function emitCheck(result: CheckResult): string {
175
+ const lines = result.assertions.map(a =>
176
+ a.pass
177
+ ? `✓ ${a.name}`
178
+ : `✗ ${a.name}${a.detail ? `: ${a.detail}` : ''}`,
179
+ )
180
+ return `<aihand-check pass="${result.pass}">\n${lines.join('\n')}\n</aihand-check>`
181
+ }
182
+
183
+ // --- Diff emit (terminal, colored) ---
184
+
185
+ export function emitDiff(diff: DiffResult): string {
186
+ const issues: string[] = []
187
+ for (const e of diff.newErrors) issues.push(pc.red(` [error] ${e.text}`))
188
+ for (const e of diff.newExceptions) issues.push(pc.red(` [exception] ${e.message}`))
189
+ for (const r of diff.newFailedRequests) issues.push(pc.yellow(` [network] ${r.method} ${r.url} ${r.status}`))
190
+ if (diff.uiGone)
191
+ issues.push(pc.magenta(' [ui] component tree disappeared'))
192
+
193
+ if (!issues.length)
194
+ return ''
195
+
196
+ const count = issues.length
197
+ return `${pc.bold('[aihand]')} ${pc.red(`✗ ${count} issue${count > 1 ? 's' : ''} after HMR`)}\n${issues.join('\n')}`
198
+ }
@@ -0,0 +1,137 @@
1
+ // 绿色通道执行器 — 把 build 时静态投影出的旋钮态射(KnobProjection.ops)变成运行时可执行算子。
2
+ //
3
+ // 对照「模拟人工」(/click /fill 走 DOM):此处直接写 store 字段,绕过选择器解析 / disabled / 遮挡门控。
4
+ // 92 个旋钮零手挂——main.tsx 不再逐个抄函数,executeKnob 按 ops 的 kind 分派即可覆盖。
5
+ //
6
+ // 纯函数铁律:不读 window。stores 由调用方注入(client.ts 传 window.__AIPEEK_STORES__,
7
+ // vitest 传 mock store)→ 行为可单测钉死。
8
+
9
+ import type { KnobProjection } from '../server/knob-schema'
10
+
11
+ // 字面量原文 → 运行时值。剥引号 / 解析 true|false|null|数字;绝不 eval(安全 + 确定性)。
12
+ export function parseLiteral(s: string): unknown {
13
+ if (s === 'true') return true
14
+ if (s === 'false') return false
15
+ if (s === 'null') return null
16
+ if (s.length >= 2) {
17
+ const q = s[0]
18
+ if ((q === '\'' || q === '"' || q === '`') && s[s.length - 1] === q)
19
+ return s.slice(1, -1)
20
+ }
21
+ // 纯数字
22
+ let numeric = s.length > 0
23
+ let dot = 0
24
+ for (let i = 0; i < s.length; i++) {
25
+ const ch = s[i]
26
+ if (ch === '-' && i === 0) continue
27
+ if (ch === '.') { dot++; if (dot > 1) { numeric = false; break } continue }
28
+ if (ch < '0' || ch > '9') { numeric = false; break }
29
+ }
30
+ if (numeric && s !== '-' && s !== '.') return Number(s)
31
+ return s // 兜底:原样返回(理论上 classify 已挡住非字面量进 assign/nullary)
32
+ }
33
+
34
+ // miss 时的恢复纤维(ker 诊断 ⊆ ker 动作):列出的 available 必须全是「能当 ?knob= 传的合法 label」。
35
+ // 两类垃圾要剔/规整,否则 AI 拿去重试继续 miss:
36
+ // ① `<Tag>` 形 —— 纯图标旋钮无文本,keyOf 退回 `<button>`/`<div>` 当 join 键(/screen 的 DOM 侧也这么 fallback,
37
+ // forward join 需要它)。但它不是可读 label,传它寻址要么再 miss 要么命中无意义巨桶 —— 从恢复表剔除。
38
+ // ② 多行 label —— 源码 title 写成 `'深度研究\n多步搜索…'`,tree-sitter 读原始文本(labelOf 不解转义),
39
+ // 故 schema 里是字面两字符反斜杠+n(非真换行)。available 是 ` | ` 单行拼接,在字面 `\n` 处截首行守一行一项契约。
40
+ // 排序去重,稳定可测。
41
+ const NL = '\\n' // 字面反斜杠 + n(两字符),不是真换行 —— 见 labelOf 读原始源码文本
42
+ export function availableLabels(sem: Record<string, KnobProjection>): string[] {
43
+ const out = new Set<string>()
44
+ for (const key of Object.keys(sem)) {
45
+ const label = key.slice(0, key.lastIndexOf(' '))
46
+ if (label.startsWith('<') && label.endsWith('>')) continue // 组件名 fallback,非可调 label
47
+ const nl = label.indexOf(NL)
48
+ out.add(nl === -1 ? label : label.slice(0, nl))
49
+ }
50
+ return [...out].sort()
51
+ }
52
+
53
+ // join 表里挑出 label 命中的投影。key 形如 `label file`(knob-schema.keyOf)。
54
+ // 0 命中 → 列可用 label;1 → 命中;>1 且无 file → 列候选让 AI 区分;>1 有 file → file 末段子串唯一化。
55
+ export function resolveKnob(
56
+ sem: Record<string, KnobProjection>,
57
+ label: string,
58
+ file?: string,
59
+ ): { ok: true, proj: KnobProjection, key: string } | { ok: false, error: string } {
60
+ const prefix = `${label} `
61
+ const hits = Object.keys(sem).filter(k => k.startsWith(prefix))
62
+ if (hits.length === 0)
63
+ return { ok: false, error: `no knob labeled "${label}". available: ${availableLabels(sem).join(' | ')}` }
64
+ if (hits.length === 1)
65
+ return { ok: true, proj: sem[hits[0]], key: hits[0] }
66
+ // 多命中:用 file 末段子串消歧。
67
+ if (file) {
68
+ const narrowed = hits.filter(k => k.slice(prefix.length).includes(file))
69
+ if (narrowed.length === 1)
70
+ return { ok: true, proj: sem[narrowed[0]], key: narrowed[0] }
71
+ }
72
+ const candidates = hits.map(k => ` ${k.slice(prefix.length)} → ${sem[k].store}.{ ${sem[k].transitions} }`)
73
+ return { ok: false, error: `"${label}" is ambiguous (${hits.length} matches). pass &file= to disambiguate:\n${candidates.join('\n')}` }
74
+ }
75
+
76
+ // @kN 可寻址 ref → 命中的投影。纯函数:refs 表(@kN→{label,files})由调用方注入(client.ts 传
77
+ // window.__aihandKnobRefs,vitest 传 mock),不读 window。两步:
78
+ // ① 翻译 —— @kN 形 → 查表得 {label,files};表里没有(号过期/没发过)→ 响亮列可用号,绝不静默把
79
+ // 裸 @k 当 label 去 prefix 匹配(那必 miss,把可恢复态坍缩成死局,违 ker 诊断 ⊆ ker 动作)。
80
+ // 非 @k 形 → 原样当 label(/action?knob=群聊 直传仍走老路)。
81
+ // ② 消歧 —— files 是 leaf+callsite 两候选,runtime 分不清 handler 写在哪,逐个喂 resolveKnob 命中任一。
82
+ export function resolveKnobRef(
83
+ sem: Record<string, KnobProjection>,
84
+ knob: string,
85
+ refs: Record<string, { label: string, files: string[] }> | undefined,
86
+ file?: string,
87
+ ): { ok: true, proj: KnobProjection, key: string } | { ok: false, error: string } {
88
+ let label = knob
89
+ let files = file ? [file] : []
90
+ if (knob.startsWith('@k')) {
91
+ const hit = refs?.[knob]
92
+ if (!hit) {
93
+ const avail = refs ? Object.keys(refs).sort((a, b) => Number(a.slice(2)) - Number(b.slice(2))) : []
94
+ return { ok: false, error: `${knob} 已过期或未编号(先读 /screen 取新号)。当前可用: ${avail.join(' ') || '(无,/screen 未采集)'}` }
95
+ }
96
+ label = hit.label
97
+ files = hit.files
98
+ }
99
+ let resolved = resolveKnob(sem, label, files[0])
100
+ for (let i = 1; i < files.length && !resolved.ok; i++)
101
+ resolved = resolveKnob(sem, label, files[i])
102
+ return resolved
103
+ }
104
+
105
+ // 执行一个旋钮的全部 ops,直接写注入的 store。返回写过的字段名(给反馈用)。
106
+ // executable:false / 缺 value / store 缺失 都 throw 明确文案,绝不静默假成功。
107
+ export function executeKnob(
108
+ proj: Pick<KnobProjection, 'ops' | 'arity' | 'store' | 'executable'>,
109
+ value: unknown | undefined,
110
+ stores: Record<string, any>,
111
+ ): { wrote: string[] } {
112
+ if (!proj.executable) {
113
+ const ctx = proj.ops.find(o => o.kind === 'context')
114
+ throw new Error(`knob 依赖运行时上下文(${ctx?.reason ?? '未知'}),无法字面 replay。用 /fill 或 /click 走模拟人工。`)
115
+ }
116
+ const store = stores[proj.store]
117
+ if (!store)
118
+ throw new Error(`store "${proj.store}" 未注册(window.__AIPEEK_STORES__ 缺它)`)
119
+ if (proj.arity === 'param' && value === undefined)
120
+ throw new Error(`此旋钮需要参数,带 ?value= 调用(如 ?value=8)`)
121
+
122
+ const wrote: string[] = []
123
+ for (const op of proj.ops) {
124
+ if (op.kind === 'assign') {
125
+ store[op.field] = op.arity === 'param' ? value : parseLiteral(op.to ?? '')
126
+ }
127
+ else if (op.kind === 'toggle') {
128
+ store[op.field] = !store[op.field]
129
+ }
130
+ else if (op.kind === 'call') {
131
+ const method = op.field.slice(0, -2) // 去尾 '()'
132
+ store[method]()
133
+ }
134
+ wrote.push(op.field)
135
+ }
136
+ return { wrote }
137
+ }