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,1118 @@
1
+ import type { Tree } from 'web-tree-sitter'
2
+ import type { Config, FileBlock, FileDetailLevel } from './types.js'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import ignore from 'ignore'
5
+ import { fromMarkdown } from 'mdast-util-from-markdown'
6
+ import { parse as parseHtml } from 'node-html-parser'
7
+ import postcss from 'postcss'
8
+ import postcssLess from 'postcss-less'
9
+ import postcssScss from 'postcss-scss'
10
+ import { Parser } from 'web-tree-sitter'
11
+ import { parse as parseYaml } from 'yaml'
12
+ import { compact, extractImports, getLang } from './compact.js'
13
+
14
+ // ── D2: Classify ────────────────────────────────────────
15
+
16
+ /** Pre-compile fileDetailLevel patterns into a fast per-path classifier. */
17
+ export function buildClassifier(
18
+ detail: Record<string, FileDetailLevel>,
19
+ defaultLevel: FileDetailLevel = 'compact',
20
+ ): (path: string) => FileDetailLevel {
21
+ const matchers: Array<{ level: FileDetailLevel, ig: ReturnType<typeof ignore> }> = []
22
+ for (const level of ['tree', 'full', 'compact'] as const) {
23
+ const globs = Object.keys(detail).filter(p => detail[p] === level)
24
+ if (globs.length)
25
+ matchers.push({ level, ig: ignore().add(globs) })
26
+ }
27
+ return path => matchers.find(m => m.ig.ignores(path))?.level ?? defaultLevel
28
+ }
29
+
30
+ // ── D3: Transform ───────────────────────────────────────
31
+
32
+ // ── Tech stack detection ─────────────────────────────────
33
+
34
+ /** Known skeleton-defining packages (whitelist). Only these appear in stack line. */
35
+ const STACK_INCLUDE: Set<string> = new Set([
36
+ // Platform
37
+ 'electron',
38
+ 'react-native',
39
+ '@tauri-apps/api',
40
+ '@capacitor/core',
41
+ // Meta-framework
42
+ 'next',
43
+ 'nuxt',
44
+ '@remix-run/react',
45
+ 'astro',
46
+ 'gatsby',
47
+ '@sveltejs/kit',
48
+ // UI framework
49
+ 'react',
50
+ 'vue',
51
+ 'svelte',
52
+ 'solid-js',
53
+ '@angular/core',
54
+ // Build
55
+ 'vite',
56
+ 'webpack',
57
+ 'esbuild',
58
+ '@rspack/core',
59
+ 'turbopack',
60
+ // Monorepo
61
+ 'turbo',
62
+ 'nx',
63
+ // Language
64
+ 'typescript',
65
+ // Styling
66
+ 'tailwindcss',
67
+ 'unocss',
68
+ 'styled-components',
69
+ '@emotion/css',
70
+ '@emotion/react',
71
+ '@vanilla-extract/css',
72
+ // Component lib
73
+ 'class-variance-authority',
74
+ '@mui/material',
75
+ 'antd',
76
+ '@chakra-ui/react',
77
+ '@mantine/core',
78
+ '@headlessui/react',
79
+ 'element-plus',
80
+ '@arco-design/web-react',
81
+ '@arco-design/mobile-react',
82
+ 'naive-ui',
83
+ 'vuetify',
84
+ // Icons
85
+ 'lucide-react',
86
+ '@heroicons/react',
87
+ // Routing
88
+ 'react-router-dom',
89
+ '@tanstack/react-router',
90
+ 'vue-router',
91
+ // State
92
+ 'mobx',
93
+ 'zustand',
94
+ 'redux',
95
+ '@reduxjs/toolkit',
96
+ '@tanstack/react-query',
97
+ 'jotai',
98
+ 'pinia',
99
+ 'valtio',
100
+ 'xstate',
101
+ 'swr',
102
+ '@apollo/client',
103
+ // Forms
104
+ 'react-hook-form',
105
+ // Animation
106
+ 'framer-motion',
107
+ '@react-spring/web',
108
+ // Rich text / Editor
109
+ '@tiptap/react',
110
+ '@monaco-editor/react',
111
+ // DnD
112
+ '@dnd-kit/core',
113
+ // Virtualization
114
+ '@tanstack/react-virtual',
115
+ // Charts
116
+ 'recharts',
117
+ '@nivo/core',
118
+ 'd3',
119
+ // Canvas / Visual
120
+ '@xyflow/react',
121
+ 'three',
122
+ '@react-three/fiber',
123
+ // Testing
124
+ 'vitest',
125
+ 'jest',
126
+ '@playwright/test',
127
+ 'cypress',
128
+ // Backend
129
+ 'express',
130
+ 'fastify',
131
+ 'hono',
132
+ '@nestjs/core',
133
+ // ORM / DB
134
+ 'drizzle-orm',
135
+ 'prisma',
136
+ '@prisma/client',
137
+ 'mongoose',
138
+ // BaaS
139
+ '@supabase/supabase-js',
140
+ 'firebase',
141
+ // AI
142
+ 'ai',
143
+ '@langchain/core',
144
+ // API
145
+ '@trpc/server',
146
+ 'graphql',
147
+ // Storage
148
+ 'dexie',
149
+ ])
150
+
151
+ /** Scoped packages → collapsed label (first prefix match wins, avoids duplicates) */
152
+ const STACK_COLLAPSE: Array<{ prefix: string, label: string }> = [
153
+ { prefix: '@dnd-kit/', label: 'dnd-kit' },
154
+ { prefix: '@tiptap/', label: 'tiptap' },
155
+ { prefix: '@tanstack/react-query', label: 'tanstack-query' },
156
+ { prefix: '@tanstack/react-router', label: 'tanstack-router' },
157
+ { prefix: '@tanstack/react-virtual', label: 'tanstack-virtual' },
158
+ { prefix: '@emotion/', label: 'emotion' },
159
+ { prefix: '@mui/', label: 'mui' },
160
+ { prefix: '@chakra-ui/', label: 'chakra' },
161
+ { prefix: '@mantine/', label: 'mantine' },
162
+ { prefix: '@headlessui/', label: 'headless-ui' },
163
+ { prefix: '@heroicons/', label: 'heroicons' },
164
+ { prefix: '@arco-design/', label: 'arco-design' },
165
+ { prefix: '@nestjs/', label: 'nestjs' },
166
+ { prefix: '@supabase/', label: 'supabase' },
167
+ { prefix: '@langchain/', label: 'langchain' },
168
+ { prefix: '@trpc/', label: 'trpc' },
169
+ { prefix: '@prisma/', label: 'prisma' },
170
+ { prefix: '@remix-run/', label: 'remix' },
171
+ { prefix: '@sveltejs/kit', label: 'sveltekit' },
172
+ { prefix: '@angular/', label: 'angular' },
173
+ { prefix: '@tauri-apps/', label: 'tauri' },
174
+ { prefix: '@capacitor/', label: 'capacitor' },
175
+ { prefix: '@xyflow/', label: 'react-flow' },
176
+ { prefix: '@react-three/', label: 'r3f' },
177
+ { prefix: '@react-spring/', label: 'react-spring' },
178
+ { prefix: '@reduxjs/', label: 'redux' },
179
+ { prefix: '@playwright/', label: 'playwright' },
180
+ { prefix: '@rspack/', label: 'rspack' },
181
+ { prefix: '@monaco-editor/', label: 'monaco' },
182
+ { prefix: '@apollo/', label: 'apollo' },
183
+ { prefix: '@nivo/', label: 'nivo' },
184
+ { prefix: '@vanilla-extract/', label: 'vanilla-extract' },
185
+ ]
186
+
187
+ /** Rename map — only for deps whose package name != concept name */
188
+ const STACK_RENAME: Record<string, string> = {
189
+ 'typescript': 'ts',
190
+ 'tailwindcss': 'tailwind',
191
+ 'class-variance-authority': 'shadcn/ui',
192
+ 'ai': 'vercel-ai',
193
+ 'solid-js': 'solid',
194
+ 'drizzle-orm': 'drizzle',
195
+ 'lucide-react': 'lucide',
196
+ }
197
+
198
+ // Priority tiers for stack display order (lower = more important)
199
+ const STACK_PRIORITY: Record<string, number> = {
200
+ // Platform / runtime
201
+ "electron": 0,
202
+ 'react-native': 0,
203
+ "tauri": 0,
204
+ "capacitor": 0,
205
+ // Meta-framework
206
+ "next": 1,
207
+ "nuxt": 1,
208
+ "remix": 1,
209
+ "astro": 1,
210
+ "gatsby": 1,
211
+ "sveltekit": 1,
212
+ // UI framework
213
+ "react": 2,
214
+ "vue": 2,
215
+ "svelte": 2,
216
+ "solid": 2,
217
+ "angular": 2,
218
+ // Bundler + lang
219
+ "vite": 3,
220
+ "webpack": 3,
221
+ "esbuild": 3,
222
+ "rspack": 3,
223
+ "turbopack": 3,
224
+ "ts": 3,
225
+ // Monorepo
226
+ "turbo": 4,
227
+ "nx": 4,
228
+ // State
229
+ "mobx": 5,
230
+ "zustand": 5,
231
+ "redux": 5,
232
+ "jotai": 5,
233
+ "pinia": 5,
234
+ "valtio": 5,
235
+ "xstate": 5,
236
+ // Styling
237
+ "tailwind": 5,
238
+ "unocss": 5,
239
+ 'styled-components': 5,
240
+ "emotion": 5,
241
+ 'vanilla-extract': 5,
242
+ // Component library
243
+ 'shadcn/ui': 6,
244
+ "mui": 6,
245
+ "antd": 6,
246
+ "chakra": 6,
247
+ "mantine": 6,
248
+ 'headless-ui': 6,
249
+ 'element-plus': 6,
250
+ 'arco-design': 6,
251
+ 'naive-ui': 6,
252
+ "vuetify": 6,
253
+ // Data / network
254
+ 'vercel-ai': 7,
255
+ "langchain": 7,
256
+ 'tanstack-query': 7,
257
+ "swr": 7,
258
+ "apollo": 7,
259
+ "trpc": 7,
260
+ "graphql": 7,
261
+ "drizzle": 7,
262
+ "prisma": 7,
263
+ "mongoose": 7,
264
+ "supabase": 7,
265
+ "firebase": 7,
266
+ "dexie": 7,
267
+ // Testing
268
+ "vitest": 8,
269
+ "jest": 8,
270
+ "playwright": 8,
271
+ "cypress": 8,
272
+ // Package manager (detected separately, always first)
273
+ "bun": -1,
274
+ "pnpm": -1,
275
+ "yarn": -1,
276
+ }
277
+
278
+ function detectStack(pkg: any, packageManager: string): string[] {
279
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
280
+ const keys = Object.keys(allDeps)
281
+ const result: string[] = []
282
+
283
+ if (packageManager)
284
+ result.push(packageManager)
285
+
286
+ for (const dep of keys) {
287
+ if (!STACK_INCLUDE.has(dep))
288
+ continue
289
+ const collapse = STACK_COLLAPSE.find(c => dep.startsWith(c.prefix))
290
+ const label = collapse ? collapse.label : (STACK_RENAME[dep] ?? dep)
291
+ if (!result.includes(label))
292
+ result.push(label)
293
+ }
294
+
295
+ return result.sort((a, b) => (STACK_PRIORITY[a] ?? 7) - (STACK_PRIORITY[b] ?? 7))
296
+ }
297
+
298
+ // ── Non-code compressors ─────────────────────────────────
299
+
300
+ /** Probe lock files next to package.json to name the package manager. */
301
+ function detectPackageManager(filePath: string): string {
302
+ const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : './'
303
+ if (existsSync(`${dir}bun.lock`))
304
+ return 'bun'
305
+ if (existsSync(`${dir}pnpm-lock.yaml`))
306
+ return 'pnpm'
307
+ if (existsSync(`${dir}yarn.lock`))
308
+ return 'yarn'
309
+ return ''
310
+ }
311
+
312
+ export function compactPackageJson(content: string, filePath: string, packageManager = detectPackageManager(filePath)): string {
313
+ try {
314
+ const pkg = JSON.parse(content)
315
+ const parts: string[] = []
316
+ const stack = detectStack(pkg, packageManager)
317
+ if (stack.length)
318
+ parts.push(`stack: ${stack.join(', ')}`)
319
+ if (pkg.scripts)
320
+ parts.push(`scripts: ${Object.keys(pkg.scripts).join(', ')}`)
321
+ // Structural metadata
322
+ const meta: string[] = []
323
+ if (pkg.type)
324
+ meta.push(`type: ${pkg.type}`)
325
+ if (pkg.main)
326
+ meta.push(`main: ${pkg.main}`)
327
+ if (pkg.workspaces) {
328
+ const ws = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? []
329
+ if (ws.length)
330
+ meta.push(`workspaces: ${ws.join(', ')}`)
331
+ }
332
+ if (meta.length)
333
+ parts.splice(1, 0, meta.join(', '))
334
+ return parts.join('\n') || ''
335
+ }
336
+ catch { return '' }
337
+ }
338
+
339
+ /** Strip // line comments and block comments from JSONC. */
340
+ export function stripJsonComments(src: string): string {
341
+ let out = ''
342
+ for (let i = 0; i < src.length; i++) {
343
+ if (src[i] === '/' && src[i + 1] === '/') {
344
+ i += 2
345
+ while (i < src.length && src[i] !== '\n') i++
346
+ i-- // keep the newline (loop's i++ re-adds it)
347
+ }
348
+ else if (src[i] === '/' && src[i + 1] === '*') {
349
+ i += 2
350
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++
351
+ i++ // skip the closing '/'
352
+ }
353
+ else {
354
+ out += src[i]
355
+ }
356
+ }
357
+ return out
358
+ }
359
+
360
+ export function compactTsconfig(content: string): string {
361
+ try {
362
+ // strip comments (// and /* */) before parsing
363
+ const stripped = stripJsonComments(content)
364
+ const cfg = JSON.parse(stripped)
365
+ const co = cfg.compilerOptions ?? {}
366
+ const parts: string[] = []
367
+ if (co.target)
368
+ parts.push(`target: ${co.target}`)
369
+ if (co.jsx)
370
+ parts.push(`jsx: ${co.jsx}`)
371
+ if (co.paths) {
372
+ const aliases = Object.entries(co.paths as Record<string, string[]>)
373
+ .map(([k, v]) => `"${k}": "${v[0] ?? ''}"`)
374
+ .join(', ')
375
+ parts.push(`paths: { ${aliases} }`)
376
+ }
377
+ if (cfg.extends)
378
+ parts.push(`extends: ${cfg.extends}`)
379
+ if (cfg.include)
380
+ parts.push(`include: ${cfg.include.join(', ')}`)
381
+ return parts.join(', ') || ''
382
+ }
383
+ catch { return '' }
384
+ }
385
+
386
+ /** YAML: extract top-level keys and first-level sub-keys (jobs, services, on, …) */
387
+ export function compactYaml(content: string): string {
388
+ let doc: unknown
389
+ try {
390
+ doc = parseYaml(content)
391
+ }
392
+ catch { return '' }
393
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
394
+ return ''
395
+ const result: string[] = []
396
+ for (const [key, val] of Object.entries(doc as Record<string, unknown>)) {
397
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
398
+ const sub = Object.keys(val).slice(0, 12).join(', ')
399
+ result.push(sub ? `${key}: ${sub}` : key)
400
+ }
401
+ else if (Array.isArray(val)) {
402
+ result.push(`${key}: ${(val as unknown[]).slice(0, 8).join(', ')}`)
403
+ }
404
+ else if (val != null) {
405
+ result.push(`${key}: ${val}`)
406
+ }
407
+ else {
408
+ result.push(key)
409
+ }
410
+ }
411
+ return result.join('\n')
412
+ }
413
+
414
+ /** Dockerfile: extract FROM / WORKDIR / EXPOSE / ARG / CMD / ENTRYPOINT */
415
+ export function compactDockerfile(content: string): string {
416
+ const KEEP = new Set(['FROM', 'WORKDIR', 'EXPOSE', 'CMD', 'ENTRYPOINT', 'ARG'])
417
+ const parts: string[] = []
418
+ let buf = ''
419
+ for (const raw of content.split('\n')) {
420
+ const line = raw.trim()
421
+ if (!line || line.startsWith('#'))
422
+ continue
423
+ if (line.endsWith('\\')) {
424
+ buf += `${line.slice(0, -1)} `
425
+ continue
426
+ }
427
+ const full = (buf + line).trim()
428
+ buf = ''
429
+ const spaceIdx = full.indexOf(' ')
430
+ if (spaceIdx === -1)
431
+ continue
432
+ const cmd = full.slice(0, spaceIdx).toUpperCase()
433
+ const arg = full.slice(spaceIdx + 1).trim()
434
+ if (!KEEP.has(cmd))
435
+ continue
436
+ const val = cmd === 'ARG' ? arg.slice(0, arg.includes('=') ? arg.indexOf('=') : undefined) : arg
437
+ parts.push(`${cmd} ${val}`)
438
+ }
439
+ return parts.join('\n')
440
+ }
441
+
442
+ /** CSS/SCSS/LESS: extract CSS custom property names via PostCSS AST */
443
+ export function compactCss(content: string, filename: string): string {
444
+ const syntax = filename.endsWith('.scss')
445
+ ? postcssScss
446
+ : filename.endsWith('.less')
447
+ ? postcssLess
448
+ : undefined
449
+ try {
450
+ const root = syntax
451
+ ? postcss().process(content, { syntax }).root
452
+ : postcss.parse(content)
453
+ const seen = new Set<string>()
454
+ root.walkDecls((decl) => {
455
+ if (decl.prop.startsWith('--'))
456
+ seen.add(decl.prop)
457
+ })
458
+ return [...seen].join(', ')
459
+ }
460
+ catch { return '' }
461
+ }
462
+
463
+ /** .env: keep variable names, strip values */
464
+ export function compactEnv(content: string): string {
465
+ const keys = content.split('\n')
466
+ .map(l => l.trim())
467
+ .filter(l => l && !l.startsWith('#') && l.includes('='))
468
+ .map(l => l.slice(0, l.indexOf('=')))
469
+ return keys.length ? keys.join(', ') : ''
470
+ }
471
+
472
+ /** HTML: extract title and entry script, skip boilerplate (favicon, viewport, …) */
473
+ export function compactHtml(content: string): string {
474
+ const root = parseHtml(content)
475
+ const parts: string[] = []
476
+ const title = root.querySelector('title')
477
+ if (title?.text.trim())
478
+ parts.push(`title: ${title.text.trim()}`)
479
+ const SCRIPT_EXTS = ['.tsx', '.ts', '.jsx', '.js', '.mjs']
480
+ const entry = root.querySelectorAll('script[src]')
481
+ .map(n => n.getAttribute('src')!)
482
+ .find(src => SCRIPT_EXTS.some(ext => src.endsWith(ext)))
483
+ if (entry)
484
+ parts.push(`entry: ${entry}`)
485
+ return parts.join(', ') || ''
486
+ }
487
+
488
+ /** Markdown: extract h1/h2/h3 headings as structural outline (AST-based, ignores code blocks) */
489
+ export function compactMarkdown(content: string): string {
490
+ const tree = fromMarkdown(content)
491
+ return tree.children
492
+ .filter(n => n.type === 'heading' && n.depth <= 3)
493
+ .map((n) => {
494
+ const text = (n as import('mdast').Heading).children.map(c => ('value' in c ? c.value : '')).join('')
495
+ return `${'#'.repeat((n as import('mdast').Heading).depth)} ${text}`
496
+ })
497
+ .join('\n')
498
+ }
499
+
500
+ const NON_CODE_COMPRESSORS: Array<{ match: (filename: string) => boolean, compress: (content: string, path: string) => string }> = [
501
+ { match: f => f === 'package.json', compress: compactPackageJson },
502
+ { match: f => f.startsWith('tsconfig') && f.endsWith('.json'), compress: c => compactTsconfig(c) },
503
+ { match: f => f === '.env' || f.startsWith('.env.'), compress: c => compactEnv(c) },
504
+ { match: f => f.endsWith('.html'), compress: c => compactHtml(c) },
505
+ { match: f => f.endsWith('.yml') || f.endsWith('.yaml'), compress: c => compactYaml(c) },
506
+ { match: f => f === 'Dockerfile' || f.startsWith('Dockerfile.'), compress: c => compactDockerfile(c) },
507
+ { match: f => f.endsWith('.css') || f.endsWith('.scss') || f.endsWith('.less'), compress: (c, p) => compactCss(c, p.slice(p.lastIndexOf('/') + 1)) },
508
+ { match: f => f.endsWith('.md'), compress: c => compactMarkdown(c) },
509
+ ]
510
+
511
+ const isWs = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v'
512
+ const isDigit = (c: string) => c >= '0' && c <= '9'
513
+
514
+ /** Find first `port:` (optional whitespace) followed by digits. */
515
+ export function extractPort(src: string): string {
516
+ let from = 0
517
+ for (;;) {
518
+ const idx = src.indexOf('port', from)
519
+ if (idx === -1)
520
+ return ''
521
+ from = idx + 4
522
+ let i = from
523
+ while (i < src.length && isWs(src[i])) i++
524
+ if (src[i] !== ':')
525
+ continue
526
+ i++
527
+ while (i < src.length && isWs(src[i])) i++
528
+ let n = 0
529
+ while (i + n < src.length && isDigit(src[i + n])) n++
530
+ if (n)
531
+ return src.slice(i, i + n)
532
+ }
533
+ }
534
+
535
+ /** Find `alias:` `{...}` block, return quoted keys (text before each `:`). */
536
+ export function extractAliasKeys(src: string): string[] {
537
+ let from = 0
538
+ for (;;) {
539
+ const idx = src.indexOf('alias', from)
540
+ if (idx === -1)
541
+ return []
542
+ from = idx + 5
543
+ let i = from
544
+ while (i < src.length && isWs(src[i])) i++
545
+ if (src[i] !== ':')
546
+ continue
547
+ i++
548
+ while (i < src.length && isWs(src[i])) i++
549
+ if (src[i] !== '{')
550
+ continue
551
+ const close = src.indexOf('}', i)
552
+ if (close === -1)
553
+ continue
554
+ return extractQuotedKeys(src.slice(i + 1, close))
555
+ }
556
+ }
557
+
558
+ /** Inside a block body, collect quoted strings immediately followed by `:`. */
559
+ function extractQuotedKeys(body: string): string[] {
560
+ const keys: string[] = []
561
+ for (let i = 0; i < body.length; i++) {
562
+ const q = body[i]
563
+ if (q !== '\'' && q !== '"')
564
+ continue
565
+ const end = body.indexOf(q, i + 1)
566
+ if (end === -1)
567
+ break
568
+ const key = body.slice(i + 1, end)
569
+ let j = end + 1
570
+ while (j < body.length && isWs(body[j])) j++
571
+ if (body[j] === ':')
572
+ keys.push(key)
573
+ i = end
574
+ }
575
+ return keys
576
+ }
577
+
578
+ export async function buildBlocks(
579
+ paths: string[],
580
+ config: Config,
581
+ wasmDir?: string,
582
+ classifyFn?: (path: string) => FileDetailLevel,
583
+ contents?: Map<string, string>,
584
+ ): Promise<FileBlock[]> {
585
+ await Parser.init()
586
+ const parser = new Parser()
587
+ const classifyPath = classifyFn ?? buildClassifier(config.read.fileDetailLevel)
588
+ const blocks: FileBlock[] = []
589
+ let currentLang: Awaited<ReturnType<typeof getLang>> = null
590
+
591
+ for (const path of paths) {
592
+ const level = classifyPath(path)
593
+
594
+ if (level === 'tree') {
595
+ blocks.push({ path, level, content: '' })
596
+ continue
597
+ }
598
+
599
+ const raw = contents?.get(path) ?? readFileSync(path, 'utf-8')
600
+ if (!raw.trim())
601
+ continue
602
+
603
+ if (level === 'full') {
604
+ blocks.push({ path, level, content: raw })
605
+ continue
606
+ }
607
+
608
+ // compact: load language once per extension, parse once, reuse tree
609
+ const ext = path.slice(path.lastIndexOf('.'))
610
+ const lang = await getLang(ext, wasmDir)
611
+ if (lang && lang !== currentLang) {
612
+ parser.setLanguage(lang)
613
+ currentLang = lang
614
+ }
615
+ let tree: Tree | undefined
616
+ if (lang) {
617
+ tree = parser.parse(raw) ?? undefined
618
+ }
619
+ const imports = lang ? extractImports(raw, parser, lang, tree) : undefined
620
+
621
+ // inline compact content
622
+ const filename = path.slice(path.lastIndexOf('/') + 1)
623
+ let content: string
624
+
625
+ if (filename.startsWith('vite.config.') && lang) {
626
+ const plugins = (imports ?? [])
627
+ .filter(p => !p.startsWith('node:') && !p.startsWith('.')
628
+ && (p.includes('plugin') || p.startsWith('@vitejs/')))
629
+ const info: string[] = []
630
+ if (plugins.length)
631
+ info.push(`plugins: ${plugins.join(', ')}`)
632
+ // Extract server.port
633
+ const port = extractPort(raw)
634
+ if (port)
635
+ info.push(`port: ${port}`)
636
+ // Extract resolve.alias keys
637
+ const aliasKeys = extractAliasKeys(raw)
638
+ if (aliasKeys.length)
639
+ info.push(`alias: ${aliasKeys.join(', ')}`)
640
+ content = info.join(', ')
641
+ }
642
+ else if (lang) {
643
+ content = compact(raw, parser, lang, {
644
+ skipNoParamFn: !config.read.showNoParamFn,
645
+ }, tree)
646
+ }
647
+ else {
648
+ const c = NON_CODE_COMPRESSORS.find(c => c.match(filename))
649
+ content = c ? c.compress(raw, path) : ''
650
+ }
651
+
652
+ tree?.delete()
653
+
654
+ if (content.trim())
655
+ blocks.push({ path, level, content, imports })
656
+ }
657
+
658
+ parser.delete()
659
+ return blocks
660
+ }
661
+
662
+ // ── D4: Format ──────────────────────────────────────────
663
+
664
+ const CODE_EXTS = new Set([
665
+ '.ts',
666
+ '.tsx',
667
+ '.js',
668
+ '.jsx',
669
+ '.mjs',
670
+ '.cjs',
671
+ '.vue',
672
+ '.svelte',
673
+ '.py',
674
+ '.rb',
675
+ '.go',
676
+ '.rs',
677
+ '.java',
678
+ '.c',
679
+ '.cpp',
680
+ '.cs',
681
+ '.swift',
682
+ '.kt',
683
+ ])
684
+
685
+ function getExt(name: string): string {
686
+ return name.includes('.') ? name.slice(name.lastIndexOf('.')) : '(no ext)'
687
+ }
688
+
689
+ const isHex = (c: string) => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
690
+ const isAlpha = (c: string) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
691
+
692
+ /**
693
+ * Char-class signature of a filename stem (extension stripped), run-length compressed.
694
+ * Used to fold machine-generated names (hash/uuid/serial/timestamp) into one pattern.
695
+ * Digit and hex collapse to one class H — for id-folding their distinction is noise
696
+ * (a3f9 vs c8e1 must share a signature). Classes: H=hex/digit, A=alpha, S=separator(-_.).
697
+ */
698
+ function classSeq(stem: string): string {
699
+ let out = ''
700
+ let prev = ''
701
+ for (const c of stem) {
702
+ const cls = isHex(c) ? 'H' : isAlpha(c) ? 'A' : (c === '-' || c === '_' || c === '.') ? 'S' : 'X'
703
+ if (cls !== prev) {
704
+ out += cls
705
+ prev = cls
706
+ }
707
+ }
708
+ return out
709
+ }
710
+
711
+ /** Min siblings/files before a same-shape fold triggers. Below this, list individually. */
712
+ const HOMOGENEOUS_FOLD_MIN = 3
713
+
714
+ export interface TreeOptions {
715
+ /** When a Signatures section already lists every path, drop filenames here — show dir + count. */
716
+ skeletonOnly?: boolean
717
+ /** Files/dir headers that fit the token budget and stay expanded. Empty/undefined = expand all. */
718
+ kept?: Set<string>
719
+ /** Dir prefixes whose whole subtree collapsed to a `name/ (N files, M dirs)` summary line. */
720
+ collapsedDirs?: Set<string>
721
+ /** Paths the control panel references (knob/state filePaths) → marked ★ "关键" and never folded away. */
722
+ starred?: Set<string>
723
+ }
724
+
725
+ export function formatTree(paths: string[], foldThreshold = 8, opts: TreeOptions = {}): string {
726
+ const { skeletonOnly = false, kept, collapsedDirs, starred } = opts
727
+ const star = (fullPath: string) => (starred?.has(fullPath) ? ' ★' : '')
728
+
729
+ // count=1: name tracks the filename so single files show by name instead of "ext ×1"
730
+ interface ExtEntry { count: number, name?: string }
731
+
732
+ interface TreeNode {
733
+ children: Map<string, TreeNode>
734
+ file?: true
735
+ hasCode?: boolean
736
+ subtreeExts?: Map<string, ExtEntry>
737
+ subtreeCount?: number
738
+ subtreeDirCount?: number
739
+ }
740
+
741
+ const root: TreeNode = { children: new Map() }
742
+ for (const path of paths) {
743
+ const parts = path.split('/')
744
+ let node = root
745
+ for (let i = 0; i < parts.length - 1; i++) {
746
+ if (!node.children.has(parts[i]))
747
+ node.children.set(parts[i], { children: new Map() })
748
+ node = node.children.get(parts[i])!
749
+ }
750
+ node.children.set(parts[parts.length - 1], { children: new Map(), file: true })
751
+ }
752
+
753
+ function mergeExt(exts: Map<string, ExtEntry>, ext: string, incoming: ExtEntry): void {
754
+ const existing = exts.get(ext)
755
+ if (!existing) {
756
+ exts.set(ext, { ...incoming })
757
+ return
758
+ }
759
+ const count = existing.count + incoming.count
760
+ // name only kept when exactly one file total — drop it once count > 1
761
+ exts.set(ext, { count })
762
+ }
763
+
764
+ // Pre-pass: annotate each dir node
765
+ function annotate(node: TreeNode): void {
766
+ const exts = new Map<string, ExtEntry>()
767
+ let count = 0
768
+ let dirCount = 0
769
+ let hasCode = false
770
+ for (const [name, child] of node.children) {
771
+ if (child.file) {
772
+ const ext = getExt(name)
773
+ mergeExt(exts, ext, { count: 1, name })
774
+ count++
775
+ if (CODE_EXTS.has(ext))
776
+ hasCode = true
777
+ }
778
+ else {
779
+ annotate(child)
780
+ count += child.subtreeCount ?? 0
781
+ dirCount += 1 + (child.subtreeDirCount ?? 0)
782
+ if (child.hasCode)
783
+ hasCode = true
784
+ for (const [ext, entry] of child.subtreeExts ?? [])
785
+ mergeExt(exts, ext, entry)
786
+ }
787
+ }
788
+ node.hasCode = hasCode
789
+ node.subtreeExts = exts
790
+ node.subtreeCount = count
791
+ node.subtreeDirCount = dirCount
792
+ }
793
+
794
+ annotate(root)
795
+
796
+ const lines: string[] = []
797
+ const FOLD_DIRS = new Set(['ui', 'icons'])
798
+
799
+ /** A leaf dir holds only files (no nested dirs). Its name-set is its structural signature. */
800
+ const leafFileNames = (n: TreeNode): string[] | null => {
801
+ const names: string[] = []
802
+ for (const [name, c] of n.children) {
803
+ if (!c.file)
804
+ return null
805
+ names.push(name)
806
+ }
807
+ return names
808
+ }
809
+
810
+ type Entry = [string, TreeNode]
811
+
812
+ // Fold B: among sibling leaf dirs, group by name-set (or ext-multiset) signature.
813
+ // A group of ≥3 isomorphic dirs collapses to one `[name]/(<files>) ×N` line.
814
+ function foldSiblingDirs(dirEntries: Entry[]): { folded: string[], rest: Entry[] } {
815
+ interface Item { entry: Entry, names: string[] }
816
+ const groups = new Map<string, Item[]>()
817
+ const rest: Entry[] = []
818
+ for (const [name, c] of dirEntries) {
819
+ const names = leafFileNames(c)
820
+ if (!names || !names.length) {
821
+ rest.push([name, c])
822
+ continue
823
+ }
824
+ const sorted = [...names].sort()
825
+ const sig = `N:${sorted.join('|')}` // exact filename set → identical [name]/index.md dirs collapse
826
+ groups.set(sig, [...(groups.get(sig) ?? []), { entry: [name, c], names: sorted }])
827
+ }
828
+ // Below-threshold name groups get a second chance: fold by ext-multiset (names differ, shape same).
829
+ const extGroups = new Map<string, Item[]>()
830
+ for (const [sig, arr] of [...groups]) {
831
+ if (arr.length >= HOMOGENEOUS_FOLD_MIN)
832
+ continue
833
+ groups.delete(sig)
834
+ for (const item of arr) {
835
+ const extSig = `E:${item.names.map(getExt).sort().join('|')}`
836
+ extGroups.set(extSig, [...(extGroups.get(extSig) ?? []), item])
837
+ }
838
+ }
839
+ const folded: string[] = []
840
+ const emit = (arr: Item[], byName: boolean) => {
841
+ const sample = arr[0].names
842
+ const body = byName
843
+ ? sample.join(', ')
844
+ : [...new Set(sample.map(getExt))].map((e) => {
845
+ const n = sample.filter(name => getExt(name) === e).length
846
+ return n > 1 ? `${e} ×${n}` : e
847
+ }).join(', ')
848
+ folded.push(`[name]/ (${body}) ×${arr.length}`)
849
+ }
850
+ for (const arr of groups.values()) {
851
+ if (arr.length >= HOMOGENEOUS_FOLD_MIN)
852
+ emit(arr, true)
853
+ else arr.forEach(i => rest.push(i.entry))
854
+ }
855
+ for (const arr of extGroups.values()) {
856
+ if (arr.length >= HOMOGENEOUS_FOLD_MIN)
857
+ emit(arr, false)
858
+ else arr.forEach(i => rest.push(i.entry))
859
+ }
860
+ return { folded, rest }
861
+ }
862
+
863
+ // Fold C: within one dir, fold ≥3 same-shape *machine-generated* filenames → `[id].ext ×N`.
864
+ // Only pure-id stems (hex/digit/sep, no alpha word) fold — `data0.json`/`fn1.ts` keep their
865
+ // names since the alpha prefix is a real navigational word. Hashes/uuids/serials lose nothing.
866
+ function foldHomogeneousFiles(fileEntries: Entry[]): { folded: string[], rest: Entry[] } {
867
+ const stemOf = (name: string) => {
868
+ const ext = getExt(name)
869
+ return ext === '(no ext)' ? name : name.slice(0, name.length - ext.length)
870
+ }
871
+ const isPureId = (stem: string) => stem.length > 0
872
+ && [...stem].every(c => isHex(c) || c === '-' || c === '_')
873
+ const groups = new Map<string, Entry[]>()
874
+ const rest: Entry[] = []
875
+ for (const [name, c] of fileEntries) {
876
+ const stem = stemOf(name)
877
+ if (!isPureId(stem)) {
878
+ rest.push([name, c])
879
+ continue
880
+ }
881
+ const key = `${classSeq(stem)}|${getExt(name)}` // a3f9.json & c8e1.json fold; log.txt stays separate
882
+ groups.set(key, [...(groups.get(key) ?? []), [name, c]])
883
+ }
884
+ const folded: string[] = []
885
+ for (const entries of groups.values()) {
886
+ if (entries.length >= HOMOGENEOUS_FOLD_MIN) {
887
+ const ext = getExt(entries[0][0])
888
+ folded.push(`[id]${ext === '(no ext)' ? '' : ext} ×${entries.length}`)
889
+ }
890
+ else {
891
+ rest.push(...entries)
892
+ }
893
+ }
894
+ return { folded, rest }
895
+ }
896
+
897
+ function walk(node: TreeNode, prefix: string, dirName?: string, dirPath = '') {
898
+ let rawEntries = Array.from(node.children.entries())
899
+
900
+ // Budget collapse: when `kept` is set, un-kept files in this (kept) dir don't list
901
+ // individually — they fold into a single `.ext ×N` summary. Dirs are untouched here;
902
+ // a fully-collapsed dir is summarized by emitItems via `collapsedDirs`.
903
+ const unkeptExtLines: string[] = []
904
+ if (kept?.size) {
905
+ const unkept = rawEntries.filter(([name, c]) => c.file && !kept.has(dirPath ? `${dirPath}/${name}` : name))
906
+ if (unkept.length) {
907
+ rawEntries = rawEntries.filter(([name, c]) => !(c.file && !kept.has(dirPath ? `${dirPath}/${name}` : name)))
908
+ const extCounts = new Map<string, number>()
909
+ for (const [name] of unkept) extCounts.set(getExt(name), (extCounts.get(getExt(name)) ?? 0) + 1)
910
+ for (const [ext, n] of [...extCounts.entries()].sort((a, b) => b[1] - a[1]))
911
+ unkeptExtLines.push(`${ext} ×${n}`)
912
+ }
913
+ }
914
+
915
+ // Code items: code files + dirs that contain any code (shown individually)
916
+ // Non-code items: data files + data-only dirs (folded when count exceeds threshold)
917
+ const codeItems = rawEntries.filter(([name, c]) => c.file ? CODE_EXTS.has(getExt(name)) : !!c.hasCode)
918
+ const nonCodeItems = rawEntries.filter(([name, c]) => c.file ? !CODE_EXTS.has(getExt(name)) : !c.hasCode)
919
+
920
+ // Count non-code items: each item = 1 (dirs count as 1, not their subtreeCount)
921
+ // This preserves dir names at the parent level — a dir only folds inside itself
922
+ const nonCodeItemCount = nonCodeItems.length
923
+
924
+ const items: Item[] = []
925
+
926
+ // Fold E: a Signatures section already lists every file path, so the tree only needs the
927
+ // dir skeleton + a file count. Recurse into subdirs; collapse direct files to "N files".
928
+ // A leaf dir (only files, no subdirs) renders inline as "Name/ N files".
929
+ if (skeletonOnly) {
930
+ const dirs = rawEntries.filter(([, c]) => !c.file)
931
+ const fileCount = rawEntries.length - dirs.length
932
+ const branchDirs = dirs.filter(([, c]) => [...c.children.values()].some(ch => !ch.file))
933
+ const leafDirs = dirs.filter(([, c]) => ![...c.children.values()].some(ch => !ch.file))
934
+ const { folded, rest } = foldSiblingDirs(leafDirs)
935
+ for (const [name, c] of rest)
936
+ items.push({ display: `${name}/ ${c.children.size} file${c.children.size > 1 ? 's' : ''}` })
937
+ for (const d of folded) items.push({ display: d })
938
+ for (const [name, c] of branchDirs) items.push({ display: name, child: c })
939
+ if (fileCount > 0)
940
+ items.push({ display: `${fileCount} file${fileCount > 1 ? 's' : ''}` })
941
+ return emitItems(items, prefix)
942
+ }
943
+
944
+ // Fold code files in well-known homogeneous directories (e.g. shadcn ui/, icons/)
945
+ const codeFiles = codeItems.filter(([, c]) => c.file)
946
+ const codeDirs = codeItems.filter(([, c]) => !c.file)
947
+ if (dirName && FOLD_DIRS.has(dirName) && codeFiles.length > 1 && codeDirs.length === 0) {
948
+ const ext = getExt(codeFiles[0][0])
949
+ const allSameExt = codeFiles.every(([name]) => getExt(name) === ext)
950
+ if (allSameExt) {
951
+ items.push({ display: `${ext} ×${codeFiles.length}` })
952
+ }
953
+ else {
954
+ const extCounts = new Map<string, number>()
955
+ for (const [name] of codeFiles) {
956
+ const e = getExt(name)
957
+ extCounts.set(e, (extCounts.get(e) ?? 0) + 1)
958
+ }
959
+ for (const [ext, count] of [...extCounts.entries()].sort((a, b) => b[1] - a[1]))
960
+ items.push({ display: `${ext} ×${count}` })
961
+ }
962
+ }
963
+ else {
964
+ // Fold B: collapse ≥3 isomorphic sibling code dirs into one [name]/ pattern line.
965
+ const { folded: foldedDirs, rest: restDirs } = foldSiblingDirs(codeDirs)
966
+ // Fold C: collapse ≥3 same-shape code filenames (hash/serial names) into [id].ext.
967
+ const { folded: foldedFiles, rest: restFiles } = foldHomogeneousFiles(codeFiles)
968
+ for (const [name] of restFiles)
969
+ items.push({ display: name, child: undefined })
970
+ for (const [name, c] of restDirs)
971
+ items.push({ display: name, child: c })
972
+ for (const d of foldedFiles) items.push({ display: d })
973
+ for (const d of foldedDirs) items.push({ display: d })
974
+ }
975
+
976
+ if (nonCodeItemCount > foldThreshold) {
977
+ // Absorb all non-code items into extension summary
978
+ const extCounts = new Map<string, ExtEntry>()
979
+ for (const [name, c] of nonCodeItems) {
980
+ if (c.file) {
981
+ mergeExt(extCounts, getExt(name), { count: 1, name })
982
+ }
983
+ else {
984
+ for (const [ext, entry] of c.subtreeExts ?? []) mergeExt(extCounts, ext, entry)
985
+ }
986
+ }
987
+ // count=1 with known name → show filename; count≥2 → show "ext ×N"
988
+ for (const [ext, entry] of [...extCounts.entries()].sort((a, b) => b[1].count - a[1].count))
989
+ items.push({ display: entry.count === 1 && entry.name ? entry.name : `${ext} ×${entry.count}` })
990
+ }
991
+ else {
992
+ // Fold B/C also apply to non-code dirs/files (e.g. knowledge/[name]/index.md, Logs/[id].json).
993
+ const ncDirs = nonCodeItems.filter(([, c]) => !c.file)
994
+ const ncFiles = nonCodeItems.filter(([, c]) => c.file)
995
+ const { folded: foldedDirs, rest: restDirs } = foldSiblingDirs(ncDirs)
996
+ const { folded: foldedFiles, rest: restFiles } = foldHomogeneousFiles(ncFiles)
997
+ for (const [name] of restFiles)
998
+ items.push({ display: name, child: undefined })
999
+ for (const [name, c] of restDirs)
1000
+ items.push({ display: name, child: c })
1001
+ for (const d of foldedFiles) items.push({ display: d })
1002
+ for (const d of foldedDirs) items.push({ display: d })
1003
+ }
1004
+
1005
+ for (const line of unkeptExtLines)
1006
+ items.push({ display: line })
1007
+
1008
+ return emitItems(items, prefix, dirPath)
1009
+ }
1010
+
1011
+ interface Item { display: string, child?: TreeNode }
1012
+ function emitItems(items: Item[], prefix: string, dirPath = ''): void {
1013
+ for (const { display, child } of items) {
1014
+ if (child) {
1015
+ // Budget collapse: a dir whose whole subtree didn't fit renders as a one-line
1016
+ // `name/ (N files, M dirs)` summary and is not recursed into.
1017
+ const childPath = dirPath ? `${dirPath}/${display}` : display
1018
+ if (collapsedDirs?.has(childPath)) {
1019
+ const files = child.subtreeCount ?? 0
1020
+ const dirs = child.subtreeDirCount ?? 0
1021
+ lines.push(`${prefix}${display}/ (${files} file${files === 1 ? '' : 's'}, ${dirs} dir${dirs === 1 ? '' : 's'})`)
1022
+ continue
1023
+ }
1024
+ // Fold A: collapse single-child dir chains — a/ → b/ → file becomes a/b/file.
1025
+ let label = display
1026
+ let cur = child
1027
+ let fileLeaf = false
1028
+ while (cur.children.size === 1) {
1029
+ const [onlyName, onlyChild] = [...cur.children][0]
1030
+ label += `/${onlyName}`
1031
+ if (onlyChild.file) {
1032
+ lines.push(`${prefix}${label}${star(dirPath ? `${dirPath}/${label}` : label)}`)
1033
+ fileLeaf = true
1034
+ break
1035
+ }
1036
+ cur = onlyChild
1037
+ }
1038
+ if (fileLeaf)
1039
+ continue
1040
+ lines.push(`${prefix}${label}/`)
1041
+ walk(cur, `${prefix} `, label.slice(label.lastIndexOf('/') + 1), dirPath ? `${dirPath}/${label}` : label)
1042
+ }
1043
+ else {
1044
+ lines.push(`${prefix}${display}${star(dirPath ? `${dirPath}/${display}` : display)}`)
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ walk(root, '')
1050
+ return lines.join('\n')
1051
+ }
1052
+
1053
+ // 关键文件版图:tree 只显示 starred(控制面触碰的)文件 + 它们所在的目录骨架。
1054
+ // 非 ★ 的叶子/子树整块折成 `… N more`,让 ★ 不再被全量噪音稀释。与 formatTree 的全量折叠
1055
+ // 模式正交(一个答"全部物理布局",一个答"哪些文件被控制面触碰"),故独立函数不耦合进 walk。
1056
+ export function formatStarredTree(paths: string[], starred: Set<string>): string {
1057
+ interface Node { children: Map<string, Node>, file?: true, fullPath?: string }
1058
+ const root: Node = { children: new Map() }
1059
+ for (const path of paths) {
1060
+ const parts = path.split('/')
1061
+ let node = root
1062
+ for (let i = 0; i < parts.length; i++) {
1063
+ const isLeaf = i === parts.length - 1
1064
+ if (!node.children.has(parts[i]))
1065
+ node.children.set(parts[i], { children: new Map() })
1066
+ node = node.children.get(parts[i])!
1067
+ if (isLeaf) { node.file = true; node.fullPath = path }
1068
+ }
1069
+ }
1070
+
1071
+ // 子树是否含 ★(决定是否展开此目录)。
1072
+ const hasStar = (n: Node): boolean => {
1073
+ if (n.file) return !!(n.fullPath && starred.has(n.fullPath))
1074
+ for (const c of n.children.values()) if (hasStar(c)) return true
1075
+ return false
1076
+ }
1077
+ // 子树里非 ★ 的文件总数(折成 `… N more` 的计数,全子树深算,诚实)。
1078
+ const countNonStar = (n: Node): number => {
1079
+ if (n.file) return n.fullPath && starred.has(n.fullPath) ? 0 : 1
1080
+ let s = 0
1081
+ for (const c of n.children.values()) s += countNonStar(c)
1082
+ return s
1083
+ }
1084
+
1085
+ const lines: string[] = []
1086
+ const walk = (node: Node, prefix: string) => {
1087
+ const entries = [...node.children.entries()]
1088
+ const starDirs = entries.filter(([, c]) => !c.file && hasStar(c))
1089
+ const starFiles = entries.filter(([, c]) => c.file && hasStar(c))
1090
+ // 此目录下被折叠掉的非★文件:直属非★文件 + 不含★的整个子目录里的文件。
1091
+ let hidden = 0
1092
+ for (const [, c] of entries)
1093
+ if (!hasStar(c)) hidden += countNonStar(c)
1094
+
1095
+ for (const [name, c] of starFiles)
1096
+ lines.push(`${prefix}${name} ★`)
1097
+ for (const [name, c] of starDirs) {
1098
+ lines.push(`${prefix}${name}/`)
1099
+ walk(c, `${prefix} `)
1100
+ }
1101
+ if (hidden > 0)
1102
+ lines.push(`${prefix}… ${hidden} more`)
1103
+ }
1104
+ walk(root, '')
1105
+ return lines.join('\n')
1106
+ }
1107
+
1108
+ export function formatXml(blocks: FileBlock[], levels?: Map<string, FileDetailLevel>): string {
1109
+ return blocks
1110
+ .filter((b) => {
1111
+ if (levels && !levels.has(b.path))
1112
+ return false
1113
+ const level = levels?.get(b.path) ?? b.level
1114
+ return level !== 'tree' && b.content.trim()
1115
+ })
1116
+ .map(b => `<file path="${b.path}">\n${b.content}\n</file>`)
1117
+ .join('\n\n')
1118
+ }