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,899 @@
1
+ // aihand client patch — synchronous, non-module
2
+ // Injected as inline <script> at head-prepend to patch console/fetch/XHR/errors
3
+ // BEFORE any application modules execute.
4
+
5
+ import type { ActionEntry, ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
6
+
7
+ type NetworkEntry = NetworkRequest
8
+
9
+ // --- Perf buffer (runtime accumulation; client.ts projects it into PerformanceData) ---
10
+ // Internal shape differs from core/types PerformanceData: buckets is a name→bucket map (not
11
+ // array), carries startedAt + active (the live view key). client.ts does the array/windowMs
12
+ // projection on collect.
13
+ interface PerfStatBuf { total: number, n: number, max: number }
14
+ interface PerfBucketBuf {
15
+ components: Record<string, PerfStatBuf>
16
+ frames: { total: number, long: number, max: number, samples: number[] }
17
+ lines: Record<string, PerfStatBuf> // line-level: "File.tsx:42:varName" → stat
18
+ }
19
+ interface PerfBuffer {
20
+ startedAt: number
21
+ hiddenFrames: number
22
+ mobxPatched: boolean
23
+ buckets: Record<string, PerfBucketBuf>
24
+ active: string
25
+ // lastEndAt: when the most recent long task FINISHED (startTime + duration, page-relative ms).
26
+ // A longtask entry only dispatches AFTER the task ends, so this is "the main thread was blocking
27
+ // until at least here". The flow terminus reads it as a third, app-agnostic busy signal: a
28
+ // no-network, no-DOM-mutation compute flow (IndexedDB, a Worker's sync hop, a heavy render pass)
29
+ // still shows up as long tasks while it grinds, so freshness here means "still working".
30
+ longtasks: { count: number, max: number, lastEndAt: number }
31
+ }
32
+
33
+ interface AipeekBuffers {
34
+ consoleLogs: LogEntry[]
35
+ networkRequests: NetworkEntry[]
36
+ errorEntries: ErrorEntry[]
37
+ actionEntries: ActionEntry[]
38
+ perf: PerfBuffer
39
+ actionsDropped: number
40
+ }
41
+
42
+ // --- Ring buffers (persist across HMR) ---
43
+
44
+ const MAX_CONSOLE = 100
45
+ const MAX_NETWORK = 50
46
+ const MAX_ERRORS = 20
47
+ const MAX_ACTIONS = 30
48
+
49
+ const BUFFERS_KEY = '__AIPEEK_BUFFERS__'
50
+ const existing = (window as any)[BUFFERS_KEY] as AipeekBuffers | undefined
51
+ const consoleLogs: LogEntry[] = existing?.consoleLogs ?? []
52
+ const networkRequests: NetworkEntry[] = existing?.networkRequests ?? []
53
+ const errorEntries: ErrorEntry[] = existing?.errorEntries ?? []
54
+ const actionEntries: ActionEntry[] = existing?.actionEntries ?? []
55
+ const perf: PerfBuffer = existing?.perf ?? {
56
+ startedAt: Date.now(),
57
+ hiddenFrames: 0,
58
+ mobxPatched: false,
59
+ buckets: {},
60
+ active: '__all__',
61
+ longtasks: { count: 0, max: 0, lastEndAt: 0 },
62
+ }
63
+ // Monotonic count of action entries the ring evicted from its head — the timeline is a causal
64
+ // chain, so a silently-cut head reads as "this is the start". Survives HMR via the existing buffer.
65
+ let actionsDropped = existing?.actionsDropped ?? 0
66
+ ;(window as any)[BUFFERS_KEY] = { consoleLogs, networkRequests, errorEntries, actionEntries, perf, get actionsDropped() { return actionsDropped } }
67
+
68
+ const PERF_SAMPLE_CAP = 5000
69
+
70
+ function perfBucket(name: string): PerfBucketBuf {
71
+ let b = perf.buckets[name]
72
+ if (!b) {
73
+ b = { components: {}, frames: { total: 0, long: 0, max: 0, samples: [] }, lines: {} }
74
+ perf.buckets[name] = b
75
+ }
76
+ return b
77
+ }
78
+
79
+ // The view bucket + the __all__ aggregate. When active IS __all__, return it once —
80
+ // else every hit double-counts into __all__ (n, total, frames all 2×).
81
+ function targetBuckets(): string[] {
82
+ return perf.active === '__all__' ? ['__all__'] : [perf.active, '__all__']
83
+ }
84
+
85
+ // One timed hit (component render / line exec) → a stat map, accumulating total/n/max.
86
+ // No samples buffer: total/n/max are O(1) streaming all-time truths; p95 was the only sample
87
+ // consumer and is gone (near-window p95 vs all-time total = same-row lie). Dropping the per-hit
88
+ // push/shift also lightens the line-instrumentation hot path (less self-pollution).
89
+ function recordStat(map: Record<string, PerfStatBuf>, key: string, ms: number) {
90
+ let s = map[key]
91
+ if (!s) {
92
+ s = { total: 0, n: 0, max: 0 }
93
+ map[key] = s
94
+ }
95
+ s.total += ms
96
+ s.n += 1
97
+ if (ms > s.max)
98
+ s.max = ms
99
+ }
100
+
101
+ // One component render's self-time → both its live view bucket and the __all__ aggregate.
102
+ function hitComponent(name: string, ms: number) {
103
+ for (const bucketName of targetBuckets())
104
+ recordStat(perfBucket(bucketName).components, name, ms)
105
+ }
106
+
107
+ // One line's execution time → both its live view bucket and the __all__ aggregate.
108
+ // Called by babel-inserted __line(label, fn) wrapper.
109
+ function hitLine(label: string, ms: number) {
110
+ for (const bucketName of targetBuckets())
111
+ recordStat(perfBucket(bucketName).lines, label, ms)
112
+ }
113
+
114
+ // Runtime for babel plugin: wraps one statement, times it, records to perf.lines.
115
+ // Exposed globally so babel-transformed code can call it (client-patch can't be imported).
116
+ ;(window as any).__line = function __line<T>(label: string, fn: () => T): T {
117
+ const start = performance.now()
118
+ try {
119
+ return fn()
120
+ }
121
+ finally {
122
+ hitLine(label, performance.now() - start)
123
+ }
124
+ }
125
+
126
+ // One frame's dt → both live view bucket and __all__. long = dropped (>16.7ms ≈ missed 60fps).
127
+ function hitFrame(dt: number) {
128
+ for (const bucketName of targetBuckets()) {
129
+ const f = perfBucket(bucketName).frames
130
+ f.total += 1
131
+ if (dt > 16.7)
132
+ f.long += 1
133
+ if (dt > f.max)
134
+ f.max = dt
135
+ f.samples.push(dt)
136
+ if (f.samples.length > PERF_SAMPLE_CAP)
137
+ f.samples.shift()
138
+ }
139
+ }
140
+
141
+ // (b) Frame loop — zero-coupling, holds for any app. Throttle guard: a backgrounded OR unfocused
142
+ // window throttles rAF to ~1fps, so each such frame looks like a ~1000ms "dropped" frame and lies.
143
+ // document.hidden only fires when the TAB is switched away; a window that's merely unfocused
144
+ // (another app has focus, minimized, fully occluded) keeps document.hidden=false yet the OS still
145
+ // throttles its rAF — same lie, different probe. document.hasFocus() catches that second case.
146
+ // Don't count dt while throttled; tally hiddenFrames so the report can flag it.
147
+ const WARN_THRESHOLD = 20 // dropped% — push warning when ≥20% frames jank
148
+ const WARN_COOLDOWN = 5000 // ms — don't spam, at most one warn per 5s
149
+ let lastFrame = performance.now()
150
+ let lastWarn = 0
151
+ function frameLoop() {
152
+ const now = performance.now()
153
+ if (document.hidden || !document.hasFocus()) {
154
+ perf.hiddenFrames += 1
155
+ lastFrame = now
156
+ requestAnimationFrame(frameLoop)
157
+ return
158
+ }
159
+ const dt = now - lastFrame
160
+ hitFrame(dt)
161
+ lastFrame = now
162
+
163
+ // Push mode: auto-warn on jank (≥20% dropped), once per 5s
164
+ const bucket = perf.buckets[perf.active]
165
+ if (bucket && bucket.frames.total > 20) { // need ≥20 frames for stable %
166
+ const droppedPct = (bucket.frames.long / bucket.frames.total) * 100
167
+ if (droppedPct >= WARN_THRESHOLD && now - lastWarn > WARN_COOLDOWN) {
168
+ const top3 = Object.entries(bucket.components)
169
+ .sort(([, a], [, b]) => b.total - a.total)
170
+ .slice(0, 3)
171
+ .map(([name, s]) => `${name} ${s.total.toFixed(0)}ms`)
172
+ .join(', ')
173
+ const hot = top3 ? ` · hot: ${top3}` : ''
174
+ console.warn(`[aihand] 🐌 ${droppedPct.toFixed(0)}% frames dropped${hot}`)
175
+ lastWarn = now
176
+ }
177
+ }
178
+
179
+ requestAnimationFrame(frameLoop)
180
+ }
181
+ requestAnimationFrame(frameLoop)
182
+
183
+ // (b) Longtask observer — main-thread blocks >50ms, app-agnostic.
184
+ try {
185
+ new PerformanceObserver((list) => {
186
+ for (const entry of list.getEntries()) {
187
+ perf.longtasks.count += 1
188
+ if (entry.duration > perf.longtasks.max)
189
+ perf.longtasks.max = entry.duration
190
+ const endAt = entry.startTime + entry.duration
191
+ if (endAt > perf.longtasks.lastEndAt)
192
+ perf.longtasks.lastEndAt = endAt
193
+ }
194
+ }).observe({ entryTypes: ['longtask'] })
195
+ }
196
+ catch { /* longtask not supported — frames + components still work */ }
197
+
198
+ // (a) Component self-time via MobX Reaction.track. client-patch can't import mobx, so the app
199
+ // exposes the live singleton at window.__AIPEEK_MOBX__={Reaction} (dev-only seam in src/lib/mobx.ts).
200
+ // mobx-react-lite names every component's Reaction "observer"+name, so name_.slice(8) is the
201
+ // component name. Patch is idempotent (track.__aihand flag) and survives HMR via the shared buffer.
202
+ function patchMobx() {
203
+ const mobx = (window as any).__AIPEEK_MOBX__
204
+ const Reaction = mobx?.Reaction
205
+ const track = Reaction?.prototype?.track
206
+ if (!track || (track as any).__aihand)
207
+ return
208
+ const patched = function (this: any, fn: () => void) {
209
+ const name: string = this.name_ ?? ''
210
+ if (name.indexOf('observer') !== 0)
211
+ return track.call(this, fn)
212
+ const start = performance.now()
213
+ try {
214
+ return track.call(this, fn)
215
+ }
216
+ finally {
217
+ hitComponent(name.slice(8) || '(anon)', performance.now() - start)
218
+ }
219
+ }
220
+ ;(patched as any).__aihand = true
221
+ Reaction.prototype.track = patched
222
+ perf.mobxPatched = true
223
+ }
224
+ patchMobx()
225
+ // The seam may run after this patch (module order). Retry briefly so attribution turns on
226
+ // once src/lib/mobx.ts executes; stops as soon as it succeeds.
227
+ if (!perf.mobxPatched) {
228
+ let tries = 0
229
+ const retry = setInterval(() => {
230
+ patchMobx()
231
+ if (perf.mobxPatched || ++tries > 20)
232
+ clearInterval(retry)
233
+ }, 100)
234
+ }
235
+
236
+ function pushBounded<T>(arr: T[], item: T, max: number) {
237
+ arr.push(item)
238
+ if (arr.length > max)
239
+ arr.shift()
240
+ }
241
+
242
+ // --- Patch console ---
243
+
244
+ /* eslint-disable no-console -- patching console is the point of this block */
245
+ for (const level of ['log', 'info', 'warn', 'error', 'debug'] as const) {
246
+ const orig = console[level]
247
+ console[level] = (...args: unknown[]) => {
248
+ pushBounded(consoleLogs, {
249
+ level,
250
+ text: args.map(a => typeof a === 'string' ? a : formatValue(a)).join(' '),
251
+ timestamp: Date.now(),
252
+ }, MAX_CONSOLE)
253
+ orig.apply(console, args)
254
+ }
255
+ }
256
+ /* eslint-enable no-console */
257
+
258
+ // --- Patch fetch — 透明摄像头:tee body,主流原样返回,副流后台 capture ---
259
+
260
+ const originalFetch = window.fetch
261
+
262
+ // --- Self-heal handshake: reconnect after a dev-server restart without a human reload ---
263
+ // The whole aihand action chain rides the Vite HMR WebSocket. A full server restart kills
264
+ // that socket and Vite's reconnect often gives up — stranding the page on "connection lost".
265
+ // So we poll an HTTP heartbeat (independent of the WS) carrying the server's BOOT_ID; when it
266
+ // changes (server is a new process), the page reloads itself and re-handshakes the new server.
267
+ //
268
+ // The heartbeat MUST run in a Worker, not a main-thread setInterval. Browsers throttle
269
+ // main-thread timers in BACKGROUND tabs to once per minute (Chrome 88+) — and the aihand tab is
270
+ // almost always backgrounded (the operator is watching a terminal, not the page). A main-thread
271
+ // poll therefore took ~60s to notice a restart, forcing a manual reload. Worker timers are NOT
272
+ // throttled (throttling is main-thread only), so the Worker keeps a true ~2s heartbeat while
273
+ // backgrounded. The Worker only times + fetches + posts the BOOT_ID back; ALL decision logic
274
+ // (sessionStorage debounce, wsDead check, location.reload) stays on the main thread, where a
275
+ // Worker can't reach storage/location anyway.
276
+ ;(function keepAlive() {
277
+ const SEEN_KEY = '__aihand_boot__'
278
+ const RELOADING_KEY = '__aihand_reloading__'
279
+
280
+ // Main-thread decision: given a freshly-fetched BOOT_ID, decide whether to reload+re-handshake.
281
+ // Two reload triggers, same fix (reload → re-handshake), both debounced by RELOADING_KEY:
282
+ // 1. BOOT_ID changed → server restarted (the original case).
283
+ // 2. BOOT_ID same but the HMR WebSocket is dead → the socket dropped (Electron window
284
+ // backgrounded/suspended) and Vite's reconnect is gated on visibility, so it never
285
+ // comes back on its own. The action chain rides that socket, so a dead WS = "can't
286
+ // see" even though the server is fine. client.ts sets this flag on vite:ws:disconnect.
287
+ const handleBoot = (id: string) => {
288
+ sessionStorage.removeItem(RELOADING_KEY) // ping succeeded — clear the reload debounce
289
+ const seen = sessionStorage.getItem(SEEN_KEY)
290
+ if (!seen) {
291
+ sessionStorage.setItem(SEEN_KEY, id) // first sighting — remember this server
292
+ return
293
+ }
294
+ const wsDead = (window as { __aihand_ws_dead__?: boolean }).__aihand_ws_dead__ === true
295
+ if (seen !== id || wsDead) {
296
+ sessionStorage.setItem(SEEN_KEY, id)
297
+ if (!sessionStorage.getItem(RELOADING_KEY)) {
298
+ sessionStorage.setItem(RELOADING_KEY, '1')
299
+ location.reload()
300
+ }
301
+ }
302
+ }
303
+
304
+ // Heartbeat source: a Worker timer that survives background throttling. Its whole body is the
305
+ // poll — fetch the BOOT_ID, post it back; on failure post nothing (server not up yet → keep
306
+ // polling, never reload on a mere failure). Inlined as a Blob (client-patch can't import).
307
+ try {
308
+ if (typeof Worker === 'undefined')
309
+ throw new Error('no Worker')
310
+ // A Blob-URL Worker's baseURI is `blob:…`, not the page origin — a relative '/__aihand/ping'
311
+ // fails to parse there. So bake the absolute URL in from the main thread's location.origin.
312
+ const pingUrl = `${location.origin}/__aihand/ping`
313
+ const src = `setInterval(async () => {
314
+ try {
315
+ const r = await fetch(${JSON.stringify(pingUrl)}, { cache: 'no-store' })
316
+ postMessage(await r.text())
317
+ } catch (e) {}
318
+ }, 2000)`
319
+ const worker = new Worker(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })))
320
+ worker.onmessage = e => handleBoot(e.data as string)
321
+ }
322
+ catch {
323
+ // No Worker (or Blob construction blocked) — fall back to a main-thread poll. Throttled in
324
+ // a background tab, but still self-heals eventually; never worse than before this change.
325
+ setInterval(async () => {
326
+ try {
327
+ handleBoot(await (await originalFetch('/__aihand/ping', { cache: 'no-store' })).text())
328
+ }
329
+ catch {}
330
+ }, 2000)
331
+ }
332
+ })()
333
+
334
+ // --- Restore-then-verify: a reload is only a heal if the page comes back rendered ---
335
+ // handleBoot reloads on a server restart, then unconditionally assumes the page is healthy.
336
+ // But a reload can land on a WHITE SCREEN — the new bundle threw on boot, the app mounted
337
+ // nothing into #root. The old heartbeat never notices (BOOT_ID is stable, WS may even connect),
338
+ // so the operator is stranded on a blank page that *looks* healed. Borrowing agent-browser's
339
+ // `--restore-check`: the restore action carries its own verification fiber — if the restored
340
+ // state isn't actually good, don't bank it as success. Here: after a heal-reload, confirm #root
341
+ // rendered; if still empty past a grace window, reload once more (bounded), never claim success.
342
+ // 元真理: one binary "did it render" check, no configurable assertion table — that's all it takes
343
+ // to stop a white screen from masquerading as healed.
344
+ ;(function verifyRestore() {
345
+ const RELOADING_KEY = '__aihand_reloading__' // set by handleBoot right before its location.reload()
346
+ const RETRY_KEY = '__aihand_heal_retry__'
347
+ const MAX_RETRY = 2 // bounded: a genuinely-broken build must NOT reload-loop forever
348
+ if (!sessionStorage.getItem(RELOADING_KEY))
349
+ return // this load wasn't triggered by a self-heal reload — nothing to verify
350
+ const rendered = () => {
351
+ const root = document.getElementById('root') || document.querySelector('#app, [data-reactroot]')
352
+ return !!root && root.childElementCount > 0
353
+ }
354
+ // Grace window: the app needs a beat to mount. Check after the bundle has had time to run.
355
+ setTimeout(() => {
356
+ if (rendered()) {
357
+ sessionStorage.removeItem(RETRY_KEY) // healed for real — reset the retry budget
358
+ return
359
+ }
360
+ const tries = Number(sessionStorage.getItem(RETRY_KEY) || '0')
361
+ if (tries >= MAX_RETRY) {
362
+ // eslint-disable-next-line no-console
363
+ console.warn(`[aihand] self-heal reload left a white screen after ${tries} retries — build likely broken, stopping (not claiming healed)`)
364
+ return // don't reload-loop a broken bundle; leave it visibly broken, not falsely "healed"
365
+ }
366
+ sessionStorage.setItem(RETRY_KEY, String(tries + 1))
367
+ sessionStorage.setItem(RELOADING_KEY, '1') // keep the verify armed for the next attempt
368
+ location.reload()
369
+ }, 3000)
370
+ })()
371
+
372
+ window.fetch = async (input, init) => {
373
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
374
+ const method = init?.method || 'GET'
375
+ const start = performance.now()
376
+
377
+ // 立即推 entry,主线程持有同一引用,capture 完成后写回
378
+ const entry: NetworkEntry = {
379
+ method,
380
+ url,
381
+ status: 0,
382
+ duration: 0,
383
+ resourceType: 'fetch',
384
+ }
385
+ if (init?.headers) {
386
+ const rh: Record<string, string> = {}
387
+ new Headers(init.headers as HeadersInit).forEach((v, k) => {
388
+ rh[k] = v
389
+ })
390
+ entry.requestHeaders = rh
391
+ }
392
+ if (init?.body && typeof init.body === 'string') {
393
+ entry.requestBody = init.body.slice(0, 4000)
394
+ entry.requestSample = jsonSample(init.body)
395
+ }
396
+ pushBounded(networkRequests, entry, MAX_NETWORK)
397
+
398
+ let response: Response
399
+ try {
400
+ response = await originalFetch(input, init)
401
+ }
402
+ catch (err) {
403
+ entry.status = 0
404
+ entry.duration = Math.round(performance.now() - start)
405
+ entry.failed = true
406
+ entry.failureText = err instanceof Error ? err.message : String(err)
407
+ throw err
408
+ }
409
+
410
+ entry.status = response.status
411
+ entry.duration = Math.round(performance.now() - start)
412
+ const respHeaders: Record<string, string> = {}
413
+ response.headers.forEach((v, k) => {
414
+ respHeaders[k] = v
415
+ })
416
+ entry.responseHeaders = respHeaders
417
+
418
+ if (!response.body)
419
+ return response
420
+
421
+ // tee:主流原样给业务,副流后台读 4KB 做 capture
422
+ const [pass, peek] = response.body.tee()
423
+ captureBody(peek, entry).catch(() => {})
424
+
425
+ const proxied = new Response(pass, {
426
+ status: response.status,
427
+ statusText: response.statusText,
428
+ headers: response.headers,
429
+ })
430
+ Object.defineProperty(proxied, 'url', { value: response.url })
431
+ Object.defineProperty(proxied, 'type', { value: response.type })
432
+ Object.defineProperty(proxied, 'redirected', { value: response.redirected })
433
+ return proxied
434
+ }
435
+
436
+ async function captureBody(stream: ReadableStream<Uint8Array>, entry: NetworkEntry, MAX = 4000) {
437
+ const reader = stream.getReader()
438
+ const decoder = new TextDecoder()
439
+ let body = ''
440
+ try {
441
+ while (body.length < MAX) {
442
+ const { done, value } = await reader.read()
443
+ if (done)
444
+ break
445
+ body += decoder.decode(value, { stream: true })
446
+ }
447
+ }
448
+ catch { /* ignore */ }
449
+ finally {
450
+ try {
451
+ await reader.cancel()
452
+ }
453
+ catch {}
454
+ }
455
+ entry.responseBody = body.slice(0, MAX)
456
+ entry.responseSample = jsonSample(body)
457
+ }
458
+
459
+ // --- Patch XHR ---
460
+
461
+ const XHRProto = XMLHttpRequest.prototype
462
+ const origOpen = XHRProto.open
463
+ const origSend = XHRProto.send
464
+
465
+ XHRProto.open = function (method: string, url: string | URL, ...rest: unknown[]) {
466
+ (this as any).__aihand = { method, url: String(url), start: 0 }
467
+ return (origOpen as any).apply(this, [method, url, ...rest])
468
+ }
469
+
470
+ XHRProto.send = function (...args: unknown[]) {
471
+ const meta = (this as any).__aihand
472
+ if (meta) {
473
+ meta.start = performance.now()
474
+ this.addEventListener('loadend', () => {
475
+ const responseHeaders: Record<string, string> = {}
476
+ const rawHeaders = this.getAllResponseHeaders()
477
+ for (const line of rawHeaders.trim().split('\r\n')) {
478
+ const idx = line.indexOf(': ')
479
+ if (idx > 0)
480
+ responseHeaders[line.slice(0, idx)] = line.slice(idx + 2)
481
+ }
482
+ pushBounded(networkRequests, {
483
+ method: meta.method,
484
+ url: meta.url,
485
+ status: this.status,
486
+ duration: Math.round(performance.now() - meta.start),
487
+ resourceType: 'xhr',
488
+ responseHeaders,
489
+ }, MAX_NETWORK)
490
+ })
491
+ }
492
+ return (origSend as any).apply(this, args)
493
+ }
494
+
495
+ // --- Patch errors ---
496
+
497
+ // Extract a useful message+stack from any thrown value. The single chokepoint shared by the
498
+ // 'error' and 'unhandledrejection' listeners so they can never diverge: an Error gives both;
499
+ // a plain object with a .message (a common throw shape) gives that message; anything else
500
+ // stringifies. `{}` → "[object Object]" is the honest floor — there is genuinely no message.
501
+ function throwMeta(val: unknown): { message: string, stack: string | undefined } {
502
+ if (val instanceof Error)
503
+ return { message: val.message, stack: val.stack }
504
+ if (val == null)
505
+ return { message: '', stack: undefined }
506
+ const obj = val as { message?: unknown, stack?: unknown }
507
+ const message = typeof obj.message === 'string' && obj.message ? obj.message : String(val)
508
+ return { message, stack: typeof obj.stack === 'string' ? obj.stack : undefined }
509
+ }
510
+
511
+ window.addEventListener('error', (e) => {
512
+ if (e.target && e.target !== window) {
513
+ const el = e.target as HTMLElement
514
+ const src = (el as HTMLImageElement).src || (el as HTMLScriptElement).src || (el as HTMLLinkElement).href || ''
515
+ pushBounded(errorEntries, {
516
+ message: `Resource load failed: ${el.tagName.toLowerCase()} ${src}`,
517
+ source: src,
518
+ }, MAX_ERRORS)
519
+ return
520
+ }
521
+ // e.message can be empty — cross-origin "Script error.", or a non-Error throw
522
+ // (`throw undefined` / `throw {}` / `throw {code,detail}`). The real thrown value rides on
523
+ // e.error, so fall back to it instead of recording a blank "Uncaught" (a dead-end the consumer
524
+ // can't act on — 诊断纤维必须细分到根因,不可悲观坍缩成空). throwMeta is the shared extractor
525
+ // (Error → .message, {message} → .message, else String) so error & rejection can't drift.
526
+ const meta = throwMeta(e.error)
527
+ pushBounded(errorEntries, {
528
+ message: e.message || meta.message || 'Uncaught (no message — likely cross-origin script error)',
529
+ source: e.filename,
530
+ line: e.lineno,
531
+ column: e.colno,
532
+ stack: meta.stack,
533
+ }, MAX_ERRORS)
534
+ }, true)
535
+
536
+ window.addEventListener('unhandledrejection', (e) => {
537
+ const reason = e.reason
538
+ let message: string
539
+ let stack: string | undefined
540
+ if (reason instanceof Event) {
541
+ // A rejected resource-load event carries no message of its own — describe it by tag+src.
542
+ const target = reason.target as any
543
+ const tag = target?.tagName?.toLowerCase() || ''
544
+ const src = target?.src || target?.href || target?.currentSrc || ''
545
+ if (tag && src)
546
+ message = `Unhandled rejection: ${tag} load failed ${src}`
547
+ else if (tag)
548
+ message = `Unhandled rejection: ${tag} ${reason.type} event`
549
+ else
550
+ message = `Unhandled rejection: ${reason.type} event`
551
+ }
552
+ else {
553
+ const meta = throwMeta(reason)
554
+ message = meta.message
555
+ stack = meta.stack
556
+ }
557
+ pushBounded(errorEntries, { message, stack }, MAX_ERRORS)
558
+ })
559
+
560
+ // --- Patch EventSource (SSE) ---
561
+
562
+ const OrigEventSource = window.EventSource
563
+ if (OrigEventSource) {
564
+ window.EventSource = new Proxy(OrigEventSource, {
565
+ construct(Target, args) {
566
+ const es = new Target(...args as ConstructorParameters<typeof EventSource>)
567
+ const url = typeof args[0] === 'string' ? args[0] : String(args[0])
568
+ const start = performance.now()
569
+
570
+ pushBounded(networkRequests, {
571
+ method: 'GET',
572
+ url,
573
+ status: 0,
574
+ duration: 0,
575
+ resourceType: 'eventsource',
576
+ }, MAX_NETWORK)
577
+
578
+ es.addEventListener('open', () => {
579
+ const entry = networkRequests.find(r => r.url === url && r.resourceType === 'eventsource' && r.status === 0)
580
+ if (entry) {
581
+ entry.status = 200
582
+ entry.duration = Math.round(performance.now() - start)
583
+ }
584
+ })
585
+
586
+ es.addEventListener('error', () => {
587
+ pushBounded(networkRequests, {
588
+ method: 'GET',
589
+ url,
590
+ status: es.readyState === EventSource.CLOSED ? 0 : es.readyState,
591
+ duration: Math.round(performance.now() - start),
592
+ resourceType: 'eventsource',
593
+ failed: true,
594
+ failureText: es.readyState === EventSource.CLOSED ? 'connection closed' : 'connection error',
595
+ }, MAX_NETWORK)
596
+ })
597
+
598
+ return es
599
+ },
600
+ })
601
+ }
602
+
603
+ // --- User action timeline ---
604
+ //
605
+ // One document capture listener catches every semantic action — both the user's real
606
+ // events (isTrusted=true) and aihand's synthetic performAction events (isTrusted=false),
607
+ // since both bubble through document. Each entry stamps its post-settle UI projection
608
+ // (view/modal/focus) so a later diff against the prior entry reveals "dialog opened".
609
+
610
+ const KEYS = new Set(['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
611
+ let lastInputTarget: EventTarget | null = null
612
+
613
+ // inline mirror of client.ts visibleText/inspPath — client-patch can't import (see formatValue note)
614
+ function describe(el: Element): string {
615
+ // aria-label → own direct text → title → descendant text. Mirrors core elementLabel's chain
616
+ // order (candidates.ts): title sits BEFORE textContent — icon-only buttons (搜索/设置) carry
617
+ // their name in title=, with empty text, so without it they log as nameless <button>.
618
+ // client-patch can't import elementLabel (no-import constraint), so the chain is inlined.
619
+ // Flatten newlines → space then collapse runs: a multi-line tooltip/text
620
+ // (深度思考\n当前状态:关闭\n…) would otherwise spill the entry across 3 lines, breaking the
621
+ // one-line-per-action contract every reader (and /timeline) relies on. Literal split, not regex
622
+ // (codebase idiom — see line 427 / client.ts:483).
623
+ const flat = (el.getAttribute('aria-label') || directText(el) || el.getAttribute('title') || el.textContent || '')
624
+ .split('\n').join(' ').split('\t').join(' ')
625
+ const label = flat.split(' ').filter(Boolean).join(' ').slice(0, 40)
626
+ const tag = el.tagName.toLowerCase()
627
+ const name = label || `<${tag}>`
628
+ const raw = el.getAttribute('data-insp-path')
629
+ let loc = ''
630
+ if (raw) {
631
+ const [file, line] = raw.split(':')
632
+ const tail = file.split('/').slice(-2).join('/')
633
+ loc = line ? ` @${tail}:${line}` : ` @${tail}`
634
+ }
635
+ return `${name} [${tag}]${loc}`
636
+ }
637
+
638
+ function directText(el: Element): string {
639
+ let t = ''
640
+ for (const node of el.childNodes) {
641
+ if (node.nodeType === 3)
642
+ t += node.textContent
643
+ }
644
+ return t.trim()
645
+ }
646
+
647
+ // Light projection — just the variables that move when UI changes. NOT the full
648
+ // collectScreen (knobs etc.) which lives in client.ts; this only needs the deltas.
649
+ function stampScreen(entry: ActionEntry) {
650
+ let view = '(unknown)'
651
+ try { view = (window as any).__AIPEEK_VIEW__?.() ?? '(unknown)' } catch { /* app hook threw */ }
652
+ entry.view = view
653
+ perf.active = view === '(unknown)' ? '__all__' : view
654
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
655
+ entry.modal = modalEl
656
+ ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || 'untitled').trim().slice(0, 40)
657
+ : 'none'
658
+ const active = document.activeElement
659
+ entry.focus = active && active !== document.body ? describe(active) : 'none'
660
+ }
661
+
662
+ // Enqueue + stamp in one place — every listener funnels here so the trusted/pushBounded/
663
+ // settle-then-stamp triple isn't repeated five times.
664
+ function record(e: Event, type: string, target: Element, value?: string) {
665
+ const entry: ActionEntry = {
666
+ type,
667
+ target: describe(target),
668
+ value: value === undefined ? undefined : value.slice(0, 60),
669
+ trusted: e.isTrusted,
670
+ ts: Date.now(),
671
+ }
672
+ actionEntries.push(entry)
673
+ if (actionEntries.length > MAX_ACTIONS) {
674
+ actionEntries.shift()
675
+ actionsDropped++ // head evicted — timeline must announce the clip (diagnostic-fiber law)
676
+ }
677
+ // Stamp view/modal/focus after the first React commit settles (<20ms). Must land before the
678
+ // action response ships, or attachTimeline reads the entry without its `→ ui change` suffix —
679
+ // so this stays under waitForStable's quiet (now 50ms), with ~10ms headroom.
680
+ setTimeout(() => stampScreen(entry), 40)
681
+ }
682
+
683
+ // e.target is the innermost leaf actually clicked — the <svg> icon *inside* the button, not
684
+ // the button. A human perceives clicking "AI 对话", so record what they aimed at: climb to the
685
+ // nearest interactive ancestor (same INTERACTIVE set as command dispatch; inlined — client-patch
686
+ // can't import). The leaf often has no name (svg/span) → describe() falls back to <svg>, breaking
687
+ // the action↔feedback morphism. Falls back to the leaf if no interactive ancestor (rare).
688
+ const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
689
+ const aimed = (el: Element) => el.closest(INTERACTIVE) ?? el
690
+ document.addEventListener('click', e => e.target && record(e, 'click', aimed(e.target as Element)), true)
691
+ document.addEventListener('submit', e => e.target && record(e, 'submit', e.target as Element), true)
692
+ document.addEventListener('change', (e) => {
693
+ if (!e.target)
694
+ return
695
+ lastInputTarget = null
696
+ record(e, 'change', e.target as Element, (e.target as HTMLInputElement).value)
697
+ }, true)
698
+
699
+ document.addEventListener('keydown', (e) => {
700
+ if (KEYS.has(e.key))
701
+ record(e, `key:${e.key}`, (e.target as Element | null) ?? document.body)
702
+ }, true)
703
+
704
+ document.addEventListener('input', (e) => {
705
+ const el = e.target as HTMLInputElement | null
706
+ if (!el)
707
+ return
708
+ const value = el.value ?? (el.isContentEditable ? (el.textContent ?? '') : '')
709
+ // Debounce same-target keystrokes: rewrite the last entry instead of appending one
710
+ // per character (the timeline wants "input value=hello", not h/he/hel/…).
711
+ const last = actionEntries[actionEntries.length - 1]
712
+ if (lastInputTarget === el && last?.type === 'input') {
713
+ last.value = value.slice(0, 60)
714
+ last.trusted = e.isTrusted
715
+ last.ts = Date.now()
716
+ setTimeout(() => stampScreen(last), 180)
717
+ return
718
+ }
719
+ lastInputTarget = el
720
+ record(e, 'input', el, value)
721
+ }, true)
722
+
723
+ // --- 一体化反向态射最后一跳:Alt+Click 页面元素 → 浮层直接显示代码原因 ---
724
+ // FUSION §7。Alt+Click 任一元素 → 沿 DOM 找最近 data-insp-path → fetch /source → 浮层渲染
725
+ // 符号 + callers/callees(每条可点继续向上跳调用链)。镜像 read/locate.ts 的 sourcePanelHtml
726
+ // (client-patch 不能 import),core 单测钉契约,这里靠 aihand 真页面验证字节一致。
727
+
728
+ function srcEsc(s: string): string {
729
+ return s.split('&').join('&amp;').split('<').join('&lt;').split('>').join('&gt;').split('"').join('&quot;')
730
+ }
731
+
732
+ // 与 read/locate.ts sourcePanelHtml 同一渲染(LocateResult → HTML),逐字镜像。
733
+ function srcPanelHtml(r: any): string {
734
+ const at = (s: any) => `${s.filePath}:${s.startLine + 1}`
735
+ const row = (c: any) => `<div class="aihand-src-edge" data-src-path="${srcEsc(at(c))}">${srcEsc(c.name)} <span class="aihand-src-at">${srcEsc(at(c))}</span></div>`
736
+ const out = [`<div class="aihand-src-head">${srcEsc(r.symbol.name)} <span class="aihand-src-kind">${srcEsc(r.symbol.kind)}</span> <span class="aihand-src-at">${srcEsc(at(r.symbol))}</span></div>`]
737
+ if (r.callers.length)
738
+ out.push(`<div class="aihand-src-sec">Callers (${r.callers.length})</div>`, ...r.callers.map(row))
739
+ if (r.callees.length)
740
+ out.push(`<div class="aihand-src-sec">Callees (${r.callees.length})</div>`, ...r.callees.map(row))
741
+ if (!r.callers.length && !r.callees.length)
742
+ out.push(`<div class="aihand-src-empty">(no call edges found)</div>`)
743
+ if (r.impact)
744
+ out.push(`<div class="aihand-src-impact">Impact: ${srcEsc(r.impact.risk)} (${r.impact.upstreamCount} upstream)</div>`)
745
+ return out.join('')
746
+ }
747
+
748
+ let srcPanelEl: HTMLDivElement | null = null
749
+ function closeSrcPanel() {
750
+ srcPanelEl?.remove()
751
+ srcPanelEl = null
752
+ }
753
+
754
+ async function showSrcPanel(path: string, x: number, y: number) {
755
+ let r: any
756
+ try {
757
+ const resp = await originalFetch(`/__aihand/source?path=${encodeURIComponent(path)}&impact&json`, { cache: 'no-store' })
758
+ if (!resp.ok) {
759
+ r = null
760
+ }
761
+ else {
762
+ r = await resp.json()
763
+ }
764
+ }
765
+ catch {
766
+ r = null
767
+ }
768
+ closeSrcPanel()
769
+ const panel = document.createElement('div')
770
+ panel.className = 'aihand-src-panel'
771
+ panel.style.cssText = `position:fixed;left:${Math.min(x, window.innerWidth - 340)}px;top:${Math.min(y, window.innerHeight - 200)}px;z-index:2147483647;max-width:320px;max-height:60vh;overflow:auto;background:#1e1e1e;color:#ddd;font:12px/1.5 ui-monospace,monospace;border:1px solid #444;border-radius:6px;padding:8px 10px;box-shadow:0 8px 30px rgba(0,0,0,.5)`
772
+ panel.innerHTML = r
773
+ ? srcPanelHtml(r)
774
+ : `<div class="aihand-src-empty">no symbol owns ${srcEsc(path)}</div>`
775
+ panel.dataset.x = String(x)
776
+ panel.dataset.y = String(y)
777
+ document.body.appendChild(panel)
778
+ srcPanelEl = panel
779
+ }
780
+
781
+ // 监听挂 window capture,不是 document capture:本 app 在 window↔document 之间有一层 capture
782
+ // 监听对「带修饰键的 click」(alt/meta/ctrl/shift 全部)stopPropagation,导致 document 级永远收不到
783
+ // Alt+Click(实测:plain 到 document,任何修饰键都被吞)。window capture 是事件最外层,先于那层吞噬者。
784
+ window.addEventListener('click', (e) => {
785
+ if (!(e.altKey && e.shiftKey))
786
+ return
787
+ const el = (e.target as Element | null)?.closest('[data-insp-path]') as HTMLElement | null
788
+ e.preventDefault()
789
+ e.stopPropagation()
790
+ if (!el) {
791
+ closeSrcPanel()
792
+ return
793
+ }
794
+ const raw = el.getAttribute('data-insp-path')!
795
+ // insp-path 形态 `rel:line` 或 `rel:line:col:Name` — 取 file:line 两段喂 /source(端点自会再 parse)。
796
+ const seg = raw.split(':')
797
+ const path = seg.length <= 2 ? raw : `${seg[0]}:${seg[1]}`
798
+ showSrcPanel(path, e.clientX + 4, e.clientY + 4)
799
+ }, true)
800
+
801
+ // 浮层内点 caller/callee 边 → 继续向上跳调用链。同样挂 window capture(本 app 吞掉冒泡到
802
+ // 浮层的 click,panel.addEventListener 收不到),委托判定 target 落在边上。
803
+ window.addEventListener('click', (e) => {
804
+ if ((e.altKey && e.shiftKey) || !srcPanelEl)
805
+ return
806
+ const edge = (e.target as Element | null)?.closest('[data-src-path]') as HTMLElement | null
807
+ if (edge && srcPanelEl.contains(edge)) {
808
+ e.preventDefault()
809
+ e.stopPropagation()
810
+ showSrcPanel(edge.getAttribute('data-src-path')!, Number(srcPanelEl.dataset.x), Number(srcPanelEl.dataset.y))
811
+ }
812
+ }, true)
813
+
814
+ window.addEventListener('keydown', (e) => {
815
+ if (e.key === 'Escape')
816
+ closeSrcPanel()
817
+ }, true)
818
+ // 点浮层外关闭(普通点击,非 Alt+Shift 触发手势)。
819
+ window.addEventListener('click', (e) => {
820
+ if (!(e.altKey && e.shiftKey) && srcPanelEl && !srcPanelEl.contains(e.target as Node))
821
+ closeSrcPanel()
822
+ }, true)
823
+
824
+ // --- Utils ---
825
+
826
+ function jsonSample(text: string): string | undefined {
827
+ if (!text || (text[0] !== '{' && text[0] !== '['))
828
+ return undefined
829
+ try {
830
+ return JSON.stringify(sampleOf(JSON.parse(text), 0))
831
+ }
832
+ catch {
833
+ return undefined
834
+ }
835
+ }
836
+
837
+ function sampleOf(v: unknown, d: number): unknown {
838
+ if (d > 4)
839
+ return null
840
+ if (v === null || v === undefined)
841
+ return v
842
+ if (typeof v === 'string')
843
+ return v.length > 50 ? v.slice(0, 50) : v
844
+ if (typeof v === 'number' || typeof v === 'boolean')
845
+ return v
846
+ if (Array.isArray(v))
847
+ return v.length ? [sampleOf(v[0], d + 1)] : []
848
+ if (typeof v === 'object') {
849
+ const result: Record<string, unknown> = {}
850
+ for (const [k, val] of Object.entries(v as Record<string, unknown>).slice(0, 15)) {
851
+ if (typeof val === 'function')
852
+ continue
853
+ result[k] = sampleOf(val, d + 1)
854
+ }
855
+ return result
856
+ }
857
+ return null
858
+ }
859
+
860
+ // client-patch 是自包含内联脚本(esbuild transformSync,无模块解析)——不能 import core/util。
861
+ // 这是 core/util.ts formatValue 的镜像;两处契约由各自单测守,client 侧靠运行时验证。
862
+ function formatValue(v: unknown, seen: Set<object> = new Set()): string {
863
+ if (v === null || v === undefined)
864
+ return String(v)
865
+ const t = typeof v
866
+ if (t === 'string')
867
+ return v as string
868
+ if (t === 'number' || t === 'boolean' || t === 'bigint')
869
+ return String(v)
870
+ if (t === 'symbol')
871
+ return (v as symbol).toString()
872
+ if (t === 'function')
873
+ return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
874
+ const obj = v as object
875
+ if (seen.has(obj))
876
+ return '[Circular]'
877
+ if (v instanceof Error)
878
+ return v.stack || `${v.name}: ${v.message}`
879
+ seen.add(obj)
880
+ if (v instanceof Map) {
881
+ const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
882
+ return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
883
+ }
884
+ if (v instanceof Set) {
885
+ const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
886
+ return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
887
+ }
888
+ if (Array.isArray(v)) {
889
+ const items = v.slice(0, 30).map(val => formatValue(val, seen))
890
+ return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
891
+ }
892
+ try {
893
+ return JSON.stringify(v)
894
+ }
895
+ catch {
896
+ const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
897
+ return `{${entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`).join(', ')}}`
898
+ }
899
+ }