aipeek 0.2.4 → 0.2.6

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.
@@ -1,5 +1,5 @@
1
1
  import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
2
- import { compactUrl, truncate } from './util'
2
+ import { appStackFrames, compactUrl, formatValue, truncate } from './util'
3
3
 
4
4
  const SLOW_THRESHOLD = 1000
5
5
 
@@ -262,26 +262,14 @@ export function compactErrors(errors: ErrorEntry[]): string {
262
262
  for (const err of seen.values()) {
263
263
  lines.push(err.message)
264
264
  if (err.stack) {
265
- const frames = filterStack(err.stack)
266
- for (const frame of frames) {
267
- lines.push(` at ${frame}`)
268
- }
265
+ for (const frame of appStackFrames(err.stack, 5))
266
+ lines.push(` ${frame}`)
269
267
  }
270
268
  }
271
269
 
272
270
  return lines.join('\n')
273
271
  }
274
272
 
275
- function filterStack(stack: string): string[] {
276
- return stack
277
- .split('\n')
278
- .map(l => l.trim())
279
- .filter(l => l.startsWith('at '))
280
- .map(l => l.slice(3))
281
- .filter(l => !l.includes('node_modules') && !l.includes('<anonymous>'))
282
- .slice(0, 5)
283
- }
284
-
285
273
  // --- State ---
286
274
 
287
275
  export function compactState(state: Record<string, unknown>): string {
@@ -293,7 +281,7 @@ export function compactState(state: Record<string, unknown>): string {
293
281
  lines.push(`${name}:`)
294
282
  if (typeof value === 'object' && value !== null) {
295
283
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
296
- lines.push(` ${k}: ${formatValue(v)}`)
284
+ lines.push(` ${k}: ${truncate(formatValue(v), 120)}`)
297
285
  }
298
286
  }
299
287
  else {
@@ -303,20 +291,6 @@ export function compactState(state: Record<string, unknown>): string {
303
291
  return lines.join('\n')
304
292
  }
305
293
 
306
- function formatValue(v: unknown): string {
307
- if (v === null || v === undefined)
308
- return String(v)
309
- if (typeof v === 'string')
310
- return v
311
- if (typeof v === 'number' || typeof v === 'boolean')
312
- return String(v)
313
- if (typeof v === 'object') {
314
- const s = JSON.stringify(v)
315
- return s.length > 120 ? `${s.slice(0, 120)}…` : s
316
- }
317
- return String(v)
318
- }
319
-
320
294
  // --- Main ---
321
295
 
322
296
  export function compact(raw: RawState): CompactState {
@@ -1,6 +1,6 @@
1
1
  import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
2
2
  import { compactUI } from './compact'
3
- import { truncate } from './util'
3
+ import { appStackFrames, formatValue, truncate } from './util'
4
4
 
5
5
  export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
6
6
  switch (section) {
@@ -130,15 +130,10 @@ function detailError(errors: ErrorEntry[], index: string | undefined, full: bool
130
130
 
131
131
  const lines = [err.message]
132
132
  if (err.stack) {
133
- const appFrames = err.stack.split('\n')
134
- .map(l => l.trim())
135
- .filter(l => l.startsWith('at ') && !l.includes('node_modules'))
136
- .slice(0, 3)
137
- if (appFrames.length)
138
- lines.push(...appFrames)
139
- const totalApp = err.stack.split('\n').filter(l => l.trim().startsWith('at ') && !l.includes('node_modules')).length
140
- if (totalApp > 3)
141
- lines.push(` ... ${totalApp - 3} more app frames`)
133
+ const all = appStackFrames(err.stack, Infinity)
134
+ lines.push(...all.slice(0, 3))
135
+ if (all.length > 3)
136
+ lines.push(` ... ${all.length - 3} more app frames`)
142
137
  }
143
138
  if (err.line != null)
144
139
  lines.push(`location: ${err.source || ''}:${err.line}:${err.column ?? 0}`)
@@ -159,10 +154,10 @@ function detailState(state: Record<string, unknown>, name: string | undefined, f
159
154
  const value = state[name]
160
155
  if (full) {
161
156
  try {
162
- return JSON.stringify(value, null, 2)
157
+ return JSON.stringify(value, null, 2) ?? formatValue(value)
163
158
  }
164
159
  catch {
165
- return String(value)
160
+ return formatValue(value) // 循环引用 / Error / Map → 不再 {}
166
161
  }
167
162
  }
168
163
  if (typeof value !== 'object' || value === null)
@@ -182,20 +177,9 @@ function isArraySentinel(v: string): boolean {
182
177
  }
183
178
 
184
179
  function formatSummaryValue(v: unknown): string {
185
- if (v === null || v === undefined)
186
- return String(v)
187
- if (typeof v === 'string') {
188
- if (isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
189
- return v
190
- return v.length > 80 ? `${v.slice(0, 80)}…` : v
191
- }
192
- if (typeof v === 'number' || typeof v === 'boolean')
193
- return String(v)
194
- if (typeof v === 'object') {
195
- const s = JSON.stringify(v)
196
- return s.length > 80 ? `${s.slice(0, 80)}…` : s
197
- }
198
- return String(v)
180
+ if (typeof v === 'string' && isArraySentinel(v)) // "Array(N)" from boundedSnapshot — leave untruncated
181
+ return v
182
+ return truncate(formatValue(v), 80)
199
183
  }
200
184
 
201
185
  // --- JSON schema fallback ---
package/src/core/util.ts CHANGED
@@ -2,6 +2,59 @@ export function truncate(s: string, max: number): string {
2
2
  return s.length > max ? `${s.slice(0, max)}…` : s
3
3
  }
4
4
 
5
+ // unknown → 人类可读字符串。穷举 JS 类型,让 JSON.stringify 的「漏网类型 → {}」失败模式不可能出现。
6
+ // 返回未截断字符串——截断由调用方按各自 max 处理。
7
+ export function formatValue(v: unknown, seen: Set<object> = new Set()): string {
8
+ if (v === null || v === undefined)
9
+ return String(v)
10
+ const t = typeof v
11
+ if (t === 'string')
12
+ return v as string
13
+ if (t === 'number' || t === 'boolean' || t === 'bigint')
14
+ return String(v)
15
+ if (t === 'symbol')
16
+ return (v as symbol).toString()
17
+ if (t === 'function')
18
+ return `[Function: ${(v as { name?: string }).name || 'anonymous'}]`
19
+ // 此后 v 是 object
20
+ const obj = v as object
21
+ if (seen.has(obj))
22
+ return '[Circular]'
23
+ if (v instanceof Error)
24
+ return v.stack || `${v.name}: ${v.message}`
25
+ seen.add(obj)
26
+ if (v instanceof Map) {
27
+ const items = [...v.entries()].slice(0, 15).map(([k, val]) => `${formatValue(k, seen)} => ${formatValue(val, seen)}`)
28
+ return `Map(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
29
+ }
30
+ if (v instanceof Set) {
31
+ const items = [...v.values()].slice(0, 15).map(val => formatValue(val, seen))
32
+ return `Set(${v.size}) {${items.join(', ')}${v.size > 15 ? ', …' : ''}}`
33
+ }
34
+ if (Array.isArray(v)) {
35
+ const items = v.slice(0, 30).map(val => formatValue(val, seen))
36
+ return `[${items.join(', ')}${v.length > 30 ? ', …' : ''}]`
37
+ }
38
+ try {
39
+ return JSON.stringify(v)
40
+ }
41
+ catch {
42
+ // 循环引用 / getter 抛错 → 手动遍历可枚举键,循环处标 [Circular]
43
+ const entries = Object.entries(v as Record<string, unknown>).slice(0, 15)
44
+ const parts = entries.map(([k, val]) => `${k}: ${formatValue(val, seen)}`)
45
+ return `{${parts.join(', ')}}`
46
+ }
47
+ }
48
+
49
+ // stack → 应用栈帧(去 node_modules / <anonymous>),保留 `at ` 前缀。max 与溢出提示由调用方决定。
50
+ export function appStackFrames(stack: string, max: number): string[] {
51
+ return stack
52
+ .split('\n')
53
+ .map(l => l.trim())
54
+ .filter(l => l.startsWith('at ') && !l.includes('node_modules') && !l.includes('<anonymous>'))
55
+ .slice(0, max)
56
+ }
57
+
5
58
  // pathname only; pass `search` to append a truncated query string
6
59
  export function compactUrl(url: string, search?: number): string {
7
60
  try {
@@ -21,6 +21,12 @@ function readBody(req: { on: (e: string, cb: (c: unknown) => void) => void }): P
21
21
  })
22
22
  }
23
23
 
24
+ // 所有端点都回文本响应——writeHead(Content-Type) + end 二连写了 15 遍(且有 2 处漏 charset)。收敛成一处。
25
+ function send(res: { writeHead: (s: number, h: Record<string, string>) => void, end: (b: string) => void }, status: number, body: string) {
26
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' })
27
+ res.end(body)
28
+ }
29
+
24
30
  const __dirname = dirname(fileURLToPath(import.meta.url))
25
31
  // client/core sources are read at runtime (client.ts served via Vite transform,
26
32
  // client-patch.ts compiled by esbuild). Two layouts: source consumption
@@ -107,73 +113,30 @@ aipeek auto-detects errors after HMR and prints them to the terminal — watch f
107
113
  `
108
114
  }
109
115
 
110
- // normalize a line so cosmetic diffs (indent, port number) don't break matching
111
- function norm(line: string): string {
112
- const t = line.trim()
113
- const i = t.indexOf('localhost:')
114
- if (i === -1)
115
- return t
116
- let j = i + 'localhost:'.length
117
- while (j < t.length && t[j] >= '0' && t[j] <= '9') j++
118
- return `${t.slice(0, i)}localhost:PORT${t.slice(j)}`
119
- }
116
+ export const START_TAG = '<!-- AIPEEK:START -->'
117
+ export const END_TAG = '<!-- AIPEEK:END -->'
120
118
 
121
- // strip every existing aipeek block by simulating a human reading line-by-line:
122
- // scan downward; once enough recent lines belong to the snippet we're INSIDE a
123
- // block; once they stop belonging we're OUTSIDE again. A run whose lines are
124
- // >50% snippet-content is dropped. Content-fuzzy survives wording/port edits.
125
- function stripBlocks(content: string, snippet: string): string {
126
- const known = new Set(snippet.split('\n').map(norm).filter(l => l.length > 3))
127
- const lines = content.split('\n')
128
- const keep: string[] = []
129
- let inside = false
130
- let buf: string[] = []
131
- let hits = 0
132
-
133
- const flush = () => {
134
- // settle the buffered run: drop it if it reads as snippet, else keep it
135
- if (buf.length && hits / buf.length <= 0.5)
136
- keep.push(...buf)
137
- buf = []
138
- hits = 0
139
- inside = false
140
- }
141
-
142
- for (const line of lines) {
143
- const isKnown = known.has(norm(line))
144
- if (!inside) {
145
- if (isKnown) {
146
- inside = true
147
- buf = [line]
148
- hits = 1
149
- }
150
- else {
151
- keep.push(line)
152
- }
153
- continue
154
- }
155
- // INSIDE: keep accumulating until we drift away from the snippet
156
- buf.push(line)
157
- if (isKnown)
158
- hits++
159
- // a run of unknown lines means the block ended — settle it
160
- else if (buf.slice(-3).every(l => !known.has(norm(l))))
161
- flush()
162
- }
163
- flush()
164
- return keep.join('\n')
165
- }
166
-
167
- function injectClaudeMd(root: string, port: number) {
119
+ // Marker-based injection the block lives between START_TAG and END_TAG, so
120
+ // re-injection is a deterministic splice (find markers, replace between) rather
121
+ // than fuzzy line matching. New file write markers + snippet. Existing markers
122
+ // replace their contents. No markers yet append a fresh marked block.
123
+ export function injectClaudeMd(root: string, port: number) {
168
124
  const path = resolve(root, 'CLAUDE.md')
169
- const snippet = aipeekSnippet(port)
125
+ const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
170
126
  try {
171
127
  if (!existsSync(path)) {
172
- writeFileSync(path, snippet.trimStart())
128
+ writeFileSync(path, block)
129
+ return
130
+ }
131
+ const content = readFileSync(path, 'utf-8')
132
+ const si = content.indexOf(START_TAG)
133
+ const ei = content.indexOf(END_TAG)
134
+ if (si !== -1 && ei !== -1) {
135
+ writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length))
173
136
  return
174
137
  }
175
- const stripped = stripBlocks(readFileSync(path, 'utf-8'), snippet).trimEnd()
176
- writeFileSync(path, `${stripped}\n${snippet}`)
138
+ const sep = content.endsWith('\n') ? '' : '\n'
139
+ writeFileSync(path, `${content}${sep}\n${block}`)
177
140
  }
178
141
  catch {}
179
142
  }
@@ -374,13 +337,11 @@ export function aipeekPlugin(): Plugin {
374
337
  if (!code && req.method === 'POST')
375
338
  code = await readBody(req)
376
339
  if (!code) {
377
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
378
- res.end('eval needs ?code= or a POST body')
340
+ send(res, 400, 'eval needs ?code= or a POST body')
379
341
  return
380
342
  }
381
343
  const r = await evalInClient(code)
382
- res.writeHead(r.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
383
- res.end(r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
344
+ send(res, r.ok ? 200 : 422, r.ok ? (r.value ?? 'undefined') : `error: ${r.error}`)
384
345
  return
385
346
  }
386
347
 
@@ -390,16 +351,14 @@ export function aipeekPlugin(): Plugin {
390
351
  url.searchParams.get('scope') || undefined,
391
352
  url.searchParams.get('sel') || undefined,
392
353
  )
393
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
394
- res.end(dom || '(empty)')
354
+ send(res, 200, dom || '(empty)')
395
355
  return
396
356
  }
397
357
 
398
358
  // /__aipeek/screen — state-machine projection {view, modal, focus, knobs}
399
359
  if (parts[0] === 'screen') {
400
360
  const screen = await collectScreenFromClient()
401
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
402
- res.end(screen || '(empty)')
361
+ send(res, 200, screen || '(empty)')
403
362
  return
404
363
  }
405
364
 
@@ -415,8 +374,7 @@ export function aipeekPlugin(): Plugin {
415
374
  throw new Error('body must be a JSON array')
416
375
  }
417
376
  catch (e) {
418
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
419
- res.end(`invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
377
+ send(res, 400, `invalid chain body: ${e instanceof Error ? e.message : String(e)}`)
420
378
  return
421
379
  }
422
380
  lastRaw = null
@@ -445,8 +403,7 @@ export function aipeekPlugin(): Plugin {
445
403
  break
446
404
  }
447
405
  }
448
- res.writeHead(allOk ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
449
- res.end(lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
406
+ send(res, allOk ? 200 : 422, lastUi ? `${lines.join('\n')}\n\n--- ui after ---\n${lastUi}` : lines.join('\n'))
450
407
  return
451
408
  }
452
409
 
@@ -463,8 +420,7 @@ export function aipeekPlugin(): Plugin {
463
420
  }
464
421
  const check = resolveAction(parts[0], args)
465
422
  if (!check.valid) {
466
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
467
- res.end(check.error)
423
+ send(res, 400, check.error ?? 'invalid action')
468
424
  return
469
425
  }
470
426
  const result = await sendAction(parts[0], args)
@@ -475,13 +431,11 @@ export function aipeekPlugin(): Plugin {
475
431
  const name = q.get('out') || `shot-${result.dataUrl.length}.png`
476
432
  const file = resolve(dir, name)
477
433
  writeFileSync(file, Buffer.from(result.dataUrl.split(',')[1], 'base64'))
478
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
479
- res.end(`saved: ${file}`)
434
+ send(res, 200, `saved: ${file}`)
480
435
  return
481
436
  }
482
- res.writeHead(result.ok ? 200 : 422, { 'Content-Type': 'text/plain; charset=utf-8' })
483
437
  const head = result.ok ? (result.detail || 'ok') : `${result.error}${result.detail ? `\n\nclickable: ${result.detail}` : ''}`
484
- res.end(result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
438
+ send(res, result.ok ? 200 : 422, result.ui ? `${head}\n\n--- ui after ---\n${result.ui}` : head)
485
439
  return
486
440
  }
487
441
 
@@ -491,8 +445,7 @@ export function aipeekPlugin(): Plugin {
491
445
  lastRaw = raw
492
446
  const result = check(raw)
493
447
  const output = emitCheck(result)
494
- res.writeHead(result.pass ? 200 : 417, { 'Content-Type': 'text/plain; charset=utf-8' })
495
- res.end(output)
448
+ send(res, result.pass ? 200 : 417, output)
496
449
  return
497
450
  }
498
451
 
@@ -502,12 +455,10 @@ export function aipeekPlugin(): Plugin {
502
455
  lastRaw = await collectFromClient()
503
456
  const result = detail(lastRaw, parts[0], parts[1], full)
504
457
  if (result !== null) {
505
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
506
- res.end(result)
458
+ send(res, 200, result)
507
459
  return
508
460
  }
509
- res.writeHead(404, { 'Content-Type': 'text/plain' })
510
- res.end(`not found: ${parts.join('/')}`)
461
+ send(res, 404, `not found: ${parts.join('/')}`)
511
462
  return
512
463
  }
513
464
 
@@ -516,19 +467,14 @@ export function aipeekPlugin(): Plugin {
516
467
  lastRaw = raw
517
468
  if (full) {
518
469
  const compacted = compact(raw)
519
- const output = emit(compacted)
520
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
521
- res.end(output)
470
+ send(res, 200, emit(compacted))
522
471
  }
523
472
  else {
524
- const output = emitSummary(raw)
525
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
526
- res.end(output)
473
+ send(res, 200, emitSummary(raw))
527
474
  }
528
475
  }
529
476
  catch (err) {
530
- res.writeHead(504, { 'Content-Type': 'text/plain' })
531
- res.end(err instanceof Error ? err.message : 'unknown error')
477
+ send(res, 504, err instanceof Error ? err.message : 'unknown error')
532
478
  }
533
479
  })
534
480
  },