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.
- package/README.md +136 -2
- package/dist/chunk-2NTK7H4W.js +10 -0
- package/dist/chunk-3X4FTHLC.cjs +369 -0
- package/dist/chunk-BXVNR4E2.js +399 -0
- package/dist/chunk-C7DGE6MY.cjs +1456 -0
- package/dist/chunk-DUUCVLC3.cjs +254 -0
- package/dist/chunk-FAHI53KO.cjs +125 -0
- package/dist/chunk-G7KVJ7NF.js +369 -0
- package/dist/chunk-GNEUSRGP.js +52 -0
- package/dist/chunk-IGNEAOLT.cjs +130 -0
- package/dist/chunk-IS5XFUDB.js +125 -0
- package/dist/chunk-JLYC76XL.js +2448 -0
- package/dist/chunk-KQOABC2O.cjs +52 -0
- package/dist/chunk-OVMK33AC.cjs +104 -0
- package/dist/chunk-OWYK2IGV.js +250 -0
- package/dist/chunk-PQSQN4CN.js +126 -0
- package/dist/chunk-QF6AG3M5.cjs +410 -0
- package/dist/chunk-QSAMLXML.js +1456 -0
- package/dist/chunk-VEKYRKPF.cjs +399 -0
- package/dist/chunk-Y6H7W7PI.cjs +2451 -0
- package/dist/chunk-YKSYW77R.js +410 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/chunk-ZJQRNIK7.js +104 -0
- package/dist/cli-FDS2C2CZ.cjs +651 -0
- package/dist/cli-HHRGYPSM.js +649 -0
- package/dist/cli-JQEIE7RQ.js +120 -0
- package/dist/cli-K3OS2QQH.cjs +122 -0
- package/dist/cli-OSYG6LJD.cjs +89 -0
- package/dist/cli-TXRW5PG6.js +89 -0
- package/dist/cli.cjs +81 -0
- package/dist/cli.js +81 -0
- package/dist/config-5KEQLN6L.cjs +13 -0
- package/dist/config-PJPYKDLQ.js +13 -0
- package/dist/graph-IH56SCPK.js +8 -0
- package/dist/graph-ZUXXCJ5A.cjs +8 -0
- package/dist/index.cjs +481 -0
- package/dist/index.d.cts +461 -0
- package/dist/index.d.ts +461 -0
- package/dist/index.js +479 -0
- package/dist/locate-5XFSXJ5J.cjs +15 -0
- package/dist/locate-NKSUGL3A.js +15 -0
- package/dist/refactor-5FWSZIBN.cjs +19 -0
- package/dist/refactor-BOB3SZSA.js +19 -0
- package/dist/scan-4R7GQG2W.cjs +9 -0
- package/dist/scan-VF54GAAX.js +9 -0
- package/dist/ui/probe/server.cjs +505 -0
- package/dist/ui/probe/server.js +507 -0
- package/dist/vite.cjs +12 -0
- package/dist/vite.d.cts +12 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +12 -0
- package/package.json +82 -9
- package/src/cli.ts +107 -0
- package/src/index.ts +54 -0
- package/src/read/cli.ts +650 -0
- package/src/read/compact.ts +286 -0
- package/src/read/config.ts +62 -0
- package/src/read/graph.ts +182 -0
- package/src/read/index.ts +12 -0
- package/src/read/inject.ts +121 -0
- package/src/read/locate.ts +104 -0
- package/src/read/panel.ts +335 -0
- package/src/read/pipeline.ts +78 -0
- package/src/read/refactor.ts +576 -0
- package/src/read/render.ts +1118 -0
- package/src/read/scan.ts +61 -0
- package/src/read/seam.ts +0 -0
- package/src/read/security.ts +171 -0
- package/src/read/signals.ts +333 -0
- package/src/read/state.ts +71 -0
- package/src/read/stategraph.ts +205 -0
- package/src/read/types.ts +162 -0
- package/src/read/vite.ts +77 -0
- package/src/ui/babel/line-profiler.ts +197 -0
- package/src/ui/babel/source-loc.ts +68 -0
- package/src/ui/bridge/cdp-bridge.ts +138 -0
- package/src/ui/bridge/compile-probe.ts +80 -0
- package/src/ui/bridge/transport.ts +26 -0
- package/src/ui/bridge/vite-bridge.ts +116 -0
- package/src/ui/client/client-patch.ts +899 -0
- package/src/ui/client/client.ts +2562 -0
- package/src/ui/core/action.ts +747 -0
- package/src/ui/core/candidates.ts +348 -0
- package/src/ui/core/canvas.ts +305 -0
- package/src/ui/core/check.ts +34 -0
- package/src/ui/core/compact.ts +314 -0
- package/src/ui/core/detail.ts +244 -0
- package/src/ui/core/diff.ts +253 -0
- package/src/ui/core/emit.ts +198 -0
- package/src/ui/core/knob-exec.ts +137 -0
- package/src/ui/core/perf.ts +254 -0
- package/src/ui/core/types.ts +164 -0
- package/src/ui/core/util.ts +221 -0
- package/src/ui/index.ts +5 -0
- package/src/ui/probe/cli.ts +139 -0
- package/src/ui/probe/server.ts +468 -0
- package/src/ui/self/act.ts +47 -0
- package/src/ui/self/discover.ts +101 -0
- package/src/ui/self/grow.ts +121 -0
- package/src/ui/self/install.ts +100 -0
- package/src/ui/self/probe.ts +105 -0
- package/src/ui/self/screen-hook.ts +44 -0
- package/src/ui/self/self.ts +48 -0
- package/src/ui/self/store-refs.ts +123 -0
- package/src/ui/self/store-schema.ts +65 -0
- package/src/ui/self/synth.ts +37 -0
- package/src/ui/server/cli.ts +102 -0
- package/src/ui/server/dispatch.ts +276 -0
- package/src/ui/server/help-text.ts +237 -0
- package/src/ui/server/knob-schema.ts +87 -0
- package/src/ui/server/plugin.ts +1151 -0
- package/src/vite.ts +39 -0
- 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
|
+
}
|