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,61 @@
1
+ import type { Config } from './types.js'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { globSync } from 'glob'
4
+ import ignore from 'ignore'
5
+
6
+ /** Sort priority for root-level files (lower = earlier) */
7
+ function rootPriority(name: string): number {
8
+ if (name === 'package.json')
9
+ return 0 // tech stack
10
+ if (name.endsWith('.html'))
11
+ return 1 // app entry
12
+ if (name.startsWith('tsconfig'))
13
+ return 2 // TS path aliases
14
+ if (name.startsWith('.env'))
15
+ return 3 // env var names
16
+ return 4
17
+ }
18
+
19
+ const hasGlob = (p: string) => p.includes('*') || p.includes('?') || p.includes('{') || p.includes('[')
20
+
21
+ /** 纯核心:glob 命中的路径 + gitignore 文本 + 显式存在的 include → gitignore 过滤后按优先级排序的路径。 */
22
+ export function filterAndSort(
23
+ matched: string[],
24
+ gitignoreText: string,
25
+ config: Config,
26
+ existingExplicit: string[],
27
+ ): string[] {
28
+ const ig = ignore()
29
+ if (gitignoreText)
30
+ ig.add(gitignoreText)
31
+ if (config.read.ignore.length)
32
+ ig.add(config.read.ignore)
33
+
34
+ // Explicit includes (no glob chars) bypass gitignore
35
+ const explicit = new Set(existingExplicit)
36
+
37
+ return matched.filter(f => explicit.has(f) || !ig.ignores(f)).sort((a, b) => {
38
+ const aRoot = !a.includes('/')
39
+ const bRoot = !b.includes('/')
40
+ if (aRoot !== bRoot)
41
+ return aRoot ? -1 : 1
42
+ if (aRoot) {
43
+ const pa = rootPriority(a)
44
+ const pb = rootPriority(b)
45
+ if (pa !== pb)
46
+ return pa - pb
47
+ }
48
+ return a.localeCompare(b)
49
+ })
50
+ }
51
+
52
+ export function scan(config: Config): string[] {
53
+ const matched = globSync(config.read.include, {
54
+ nodir: true,
55
+ dot: true,
56
+ ignore: ['**/node_modules/**'],
57
+ })
58
+ const gitignoreText = existsSync('.gitignore') ? readFileSync('.gitignore', 'utf-8') : ''
59
+ const existingExplicit = config.read.include.filter(p => !hasGlob(p) && existsSync(p))
60
+ return filterAndSort(matched, gitignoreText, config, existingExplicit)
61
+ }
Binary file
@@ -0,0 +1,171 @@
1
+ import type { FileBlock } from './types.js'
2
+
3
+ interface SecretMatch {
4
+ path: string
5
+ line: number
6
+ pattern: string
7
+ }
8
+
9
+ // ── Char-class predicates (replace regex \d \w [A-Z] etc.) ──
10
+ const isDigit = (c: string) => c >= '0' && c <= '9'
11
+ const isUpper = (c: string) => c >= 'A' && c <= 'Z'
12
+ const isLower = (c: string) => c >= 'a' && c <= 'z'
13
+ const isAlpha = (c: string) => isUpper(c) || isLower(c)
14
+ const isAlphaNum = (c: string) => isAlpha(c) || isDigit(c)
15
+ const isWord = (c: string) => isAlphaNum(c) || c === '_' // regex \w
16
+ const isSpace = (c: string) => c === ' ' || c === '\t' || c === '\f' || c === '\v'
17
+ // bearer token body charset: [\w\-.~+/]
18
+ const isBearerChar = (c: string) => isWord(c) || c === '-' || c === '.' || c === '~' || c === '+' || c === '/'
19
+
20
+ /** Length of the run of chars satisfying `pred` starting at index `i`. */
21
+ function runLen(line: string, i: number, pred: (c: string) => boolean): number {
22
+ let n = 0
23
+ while (i + n < line.length && pred(line[i + n]))
24
+ n++
25
+ return n
26
+ }
27
+
28
+ // ── AWS Key: AKIA followed by exactly 16 [0-9A-Z] ──
29
+ function hasAwsKey(line: string): boolean {
30
+ for (let i = 0; i + 4 <= line.length; i++) {
31
+ if (line.startsWith('AKIA', i)) {
32
+ const run = runLen(line, i + 4, c => isDigit(c) || isUpper(c))
33
+ if (run >= 16)
34
+ return true
35
+ }
36
+ }
37
+ return false
38
+ }
39
+
40
+ // ── Generic Secret: (keyword) \s* [=:] \s* (quote) value{8,} (quote) ── (case-insensitive)
41
+ const SECRET_KEYWORDS = ['secret', 'password', 'passwd', 'token', 'api_key', 'apikey', 'api-key', 'auth']
42
+ function hasGenericSecret(line: string): boolean {
43
+ const lower = line.toLowerCase()
44
+ for (const kw of SECRET_KEYWORDS) {
45
+ let from = 0
46
+ for (;;) {
47
+ const idx = lower.indexOf(kw, from)
48
+ if (idx === -1)
49
+ break
50
+ from = idx + 1
51
+ let j = idx + kw.length
52
+ while (j < line.length && isSpace(line[j])) j++
53
+ if (line[j] !== '=' && line[j] !== ':')
54
+ continue
55
+ j++
56
+ while (j < line.length && isSpace(line[j])) j++
57
+ const quote = line[j]
58
+ if (quote !== '\'' && quote !== '"')
59
+ continue
60
+ j++
61
+ // value: 8+ chars, none of whitespace or quote
62
+ const valStart = j
63
+ while (j < line.length && line[j] !== quote && line[j] !== ' ' && line[j] !== '\t' && line[j] !== '\f' && line[j] !== '\v' && line[j] !== '\'' && line[j] !== '"')
64
+ j++
65
+ if (j - valStart >= 8 && line[j] === quote)
66
+ return true
67
+ }
68
+ }
69
+ return false
70
+ }
71
+
72
+ // ── Private Key: -----BEGIN (optional RSA |EC |DSA )PRIVATE KEY----- ──
73
+ function hasPrivateKey(line: string): boolean {
74
+ const i = line.indexOf('-----BEGIN ')
75
+ if (i === -1)
76
+ return false
77
+ let rest = line.slice(i + '-----BEGIN '.length)
78
+ for (const prefix of ['RSA ', 'EC ', 'DSA ']) {
79
+ if (rest.startsWith(prefix)) {
80
+ rest = rest.slice(prefix.length)
81
+ break
82
+ }
83
+ }
84
+ return rest.startsWith('PRIVATE KEY-----')
85
+ }
86
+
87
+ // ── Bearer Token: "bearer" \s+ [\w\-.~+/]{20,} ── (case-insensitive)
88
+ function hasBearerToken(line: string): boolean {
89
+ const lower = line.toLowerCase()
90
+ let from = 0
91
+ for (;;) {
92
+ const idx = lower.indexOf('bearer', from)
93
+ if (idx === -1)
94
+ return false
95
+ from = idx + 1
96
+ let j = idx + 'bearer'.length
97
+ const spaces = runLen(line, j, isSpace)
98
+ if (spaces === 0)
99
+ continue
100
+ j += spaces
101
+ if (runLen(line, j, isBearerChar) >= 20)
102
+ return true
103
+ }
104
+ }
105
+
106
+ // ── GitHub Token: gh[ps]_ \w{36,} ──
107
+ function hasGitHubToken(line: string): boolean {
108
+ for (let i = 0; i + 4 <= line.length; i++) {
109
+ if (line[i] === 'g' && line[i + 1] === 'h' && (line[i + 2] === 'p' || line[i + 2] === 's') && line[i + 3] === '_') {
110
+ if (runLen(line, i + 4, isWord) >= 36)
111
+ return true
112
+ }
113
+ }
114
+ return false
115
+ }
116
+
117
+ // ── Slack Token: xox[bpors]- [0-9a-zA-Z-]{10,} ──
118
+ const SLACK_KINDS = 'bpors'
119
+ function hasSlackToken(line: string): boolean {
120
+ for (let i = 0; i + 5 <= line.length; i++) {
121
+ if (line.startsWith('xox', i) && SLACK_KINDS.includes(line[i + 3]) && line[i + 4] === '-') {
122
+ if (runLen(line, i + 5, c => isAlphaNum(c) || c === '-') >= 10)
123
+ return true
124
+ }
125
+ }
126
+ return false
127
+ }
128
+
129
+ // ── Generic API Key: (sk|pk|key)- [a-zA-Z0-9]{20,} ──
130
+ const API_PREFIXES = ['sk', 'pk', 'key']
131
+ function hasGenericApiKey(line: string): boolean {
132
+ for (const prefix of API_PREFIXES) {
133
+ let from = 0
134
+ for (;;) {
135
+ const idx = line.indexOf(`${prefix}-`, from)
136
+ if (idx === -1)
137
+ break
138
+ from = idx + 1
139
+ if (runLen(line, idx + prefix.length + 1, isAlphaNum) >= 20)
140
+ return true
141
+ }
142
+ }
143
+ return false
144
+ }
145
+
146
+ const SECRET_PATTERNS: { name: string, match: (line: string) => boolean }[] = [
147
+ { name: 'AWS Key', match: hasAwsKey },
148
+ { name: 'Generic Secret', match: hasGenericSecret },
149
+ { name: 'Private Key', match: hasPrivateKey },
150
+ { name: 'Bearer Token', match: hasBearerToken },
151
+ { name: 'GitHub Token', match: hasGitHubToken },
152
+ { name: 'Slack Token', match: hasSlackToken },
153
+ { name: 'Generic API Key', match: hasGenericApiKey },
154
+ ]
155
+
156
+ export function scanSecrets(blocks: FileBlock[]): SecretMatch[] {
157
+ const matches: SecretMatch[] = []
158
+ for (const block of blocks) {
159
+ if (!block.content)
160
+ continue
161
+ const lines = block.content.split('\n')
162
+ for (let i = 0; i < lines.length; i++) {
163
+ for (const { name, match } of SECRET_PATTERNS) {
164
+ if (match(lines[i])) {
165
+ matches.push({ path: block.path, line: i + 1, pattern: name })
166
+ }
167
+ }
168
+ }
169
+ }
170
+ return matches
171
+ }
@@ -0,0 +1,333 @@
1
+ import type { FileBlock, FileDetailLevel } from './types.js'
2
+ import { stripJsonComments } from './render.js'
3
+ import { execSync } from 'node:child_process'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { dirname, join, relative, resolve } from 'node:path'
6
+ import process from 'node:process'
7
+
8
+ const estimateTokens = (s: string) => Math.ceil(s.length / 4)
9
+
10
+ // ── Git recency ──────────────────────────────────────────
11
+
12
+ /**
13
+ * 纯核心:`git log --name-only --pretty=format: -n 50` 的输出 → 按提交新近度评分。
14
+ * gitLogOutput 按空行分隔成提交块,记录每个文件首次(最新)出现的提交序号;
15
+ * cwdFromRoot 把 git-root 相对路径归一到 cwd 相对路径。2 = 最近 5 次提交内,1 = 20 次内,0 = 更早。
16
+ */
17
+ export function parseRecency(gitLogOutput: string, files: string[], cwdFromRoot: string): Map<string, number> {
18
+ const scores = new Map<string, number>(files.map(f => [f, 0]))
19
+
20
+ const commits: string[][] = []
21
+ let block: string[] = []
22
+ for (const raw of gitLogOutput.split('\n')) {
23
+ const l = raw.trim()
24
+ if (l) {
25
+ block.push(l)
26
+ }
27
+ else if (block.length) {
28
+ commits.push(block)
29
+ block = []
30
+ }
31
+ }
32
+ if (block.length)
33
+ commits.push(block)
34
+
35
+ const firstSeen = new Map<string, number>()
36
+ for (const [i, commitFiles] of commits.entries()) {
37
+ for (const f of commitFiles) {
38
+ if (!firstSeen.has(f))
39
+ firstSeen.set(f, i)
40
+ }
41
+ }
42
+
43
+ for (const [gitPath, commitIdx] of firstSeen) {
44
+ const cwdPath = cwdFromRoot ? relative(cwdFromRoot, gitPath) : gitPath
45
+ if (scores.has(cwdPath))
46
+ scores.set(cwdPath, commitIdx < 5 ? 2 : commitIdx < 20 ? 1 : 0)
47
+ }
48
+ return scores
49
+ }
50
+
51
+ /** Score files by how recently they were committed: 2 = last 5, 1 = last 20, 0 = beyond */
52
+ export function computeRecency(files: string[], cwd = process.cwd()): Map<string, number> {
53
+ try {
54
+ const gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
55
+ const out = execSync('git log --name-only --pretty=format: -n 50', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
56
+ return parseRecency(out, files, relative(gitRoot, resolve(cwd)))
57
+ }
58
+ catch {
59
+ return new Map<string, number>(files.map(f => [f, 0]))
60
+ }
61
+ }
62
+
63
+ // ── Import centrality ────────────────────────────────────
64
+
65
+ const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']
66
+
67
+ export interface PathAlias { prefix: string, target: string }
68
+
69
+ /** Read path aliases from tsconfig compilerOptions.paths */
70
+ function loadPathAliases(cwd: string): PathAlias[] {
71
+ for (const name of ['tsconfig.app.json', 'tsconfig.json']) {
72
+ const p = join(cwd, name)
73
+ if (!existsSync(p))
74
+ continue
75
+ try {
76
+ const stripped = stripJsonComments(readFileSync(p, 'utf-8'))
77
+ const paths = JSON.parse(stripped)?.compilerOptions?.paths ?? {}
78
+ const noStar = (s: string) => s.endsWith('*') ? s.slice(0, -1) : s
79
+ return Object.entries(paths as Record<string, string[]>).map(([key, vals]) => {
80
+ const target = noStar(vals[0]) // "./src/*" → "./src/"
81
+ return {
82
+ prefix: noStar(key), // "@/*" → "@/"
83
+ target: target.startsWith('./') ? target.slice(2) : target, // "./src/" → "src/"
84
+ }
85
+ })
86
+ }
87
+ catch { continue }
88
+ }
89
+ return []
90
+ }
91
+
92
+ function resolveImport(
93
+ fromFile: string,
94
+ specifier: string,
95
+ aliases: PathAlias[],
96
+ diskCache: Map<string, string | null>,
97
+ fileExists: (path: string) => boolean,
98
+ ): string | null {
99
+ let base: string
100
+
101
+ // Try tsconfig path aliases (e.g. "@/lib/utils" → "src/lib/utils")
102
+ const alias = aliases.find(a => specifier.startsWith(a.prefix))
103
+ if (alias) {
104
+ base = alias.target + specifier.slice(alias.prefix.length)
105
+ }
106
+ else if (specifier.startsWith('.')) {
107
+ const dir = dirname(fromFile)
108
+ base = join(dir, specifier)
109
+ }
110
+ else {
111
+ return null // external module
112
+ }
113
+
114
+ const cached = diskCache.get(base)
115
+ if (cached !== undefined)
116
+ return cached
117
+
118
+ for (const ext of EXTENSIONS) {
119
+ if (fileExists(base + ext)) {
120
+ diskCache.set(base, base + ext)
121
+ return base + ext
122
+ }
123
+ const idx = join(base, `index${ext}`)
124
+ if (fileExists(idx)) {
125
+ diskCache.set(base, idx)
126
+ return idx
127
+ }
128
+ }
129
+ diskCache.set(base, null)
130
+ return null
131
+ }
132
+
133
+ /** 纯核心:blocks + tsconfig 别名 + 文件存在谓词 → 反向 import 图(file → 导入它的文件集)。 */
134
+ export function buildImportGraphPure(
135
+ blocks: FileBlock[],
136
+ aliases: PathAlias[],
137
+ fileExists: (path: string) => boolean,
138
+ ): Map<string, Set<string>> {
139
+ const diskCache = new Map<string, string | null>()
140
+ const graph = new Map<string, Set<string>>()
141
+ for (const block of blocks) {
142
+ if (!block.imports?.length)
143
+ continue
144
+ for (const specifier of block.imports) {
145
+ const resolved = resolveImport(block.path, specifier, aliases, diskCache, fileExists)
146
+ if (!resolved)
147
+ continue
148
+ if (!graph.has(resolved))
149
+ graph.set(resolved, new Set())
150
+ graph.get(resolved)!.add(block.path)
151
+ }
152
+ }
153
+ return graph
154
+ }
155
+
156
+ /** Build a reverse import graph: file → Set of files that import it */
157
+ export function buildImportGraph(blocks: FileBlock[], cwd = process.cwd()): Map<string, Set<string>> {
158
+ return buildImportGraphPure(blocks, loadPathAliases(cwd), existsSync)
159
+ }
160
+
161
+ /** Score files by import in-degree: 2 = 20+ importers, 1 = 5–19, 0 = <5 */
162
+ export function computeCentrality(files: string[], graph: Map<string, Set<string>>): Map<string, number> {
163
+ return new Map(files.map((f) => {
164
+ const count = graph.get(f)?.size ?? 0
165
+ return [f, count >= 20 ? 2 : count >= 5 ? 1 : 0]
166
+ }))
167
+ }
168
+
169
+ // ── Top-down budget expansion ────────────────────────────
170
+
171
+ interface ExpandNode {
172
+ path: string
173
+ isFile: boolean
174
+ score: number
175
+ cost: number // tokens to keep this node visible (file: content+entry; dir header: entry)
176
+ children: ExpandNode[]
177
+ }
178
+
179
+ export interface ExpandResult {
180
+ /** Paths (files + dir headers) that fit within budget and stay expanded. */
181
+ kept: Set<string>
182
+ /** Directory prefixes whose whole subtree collapsed to a one-line summary. */
183
+ collapsedDirs: Set<string>
184
+ }
185
+
186
+ /**
187
+ * Top-down expansion: walk the tree breadth-first by signal, charging each kept node's
188
+ * tokens, until the budget runs out. Nodes never reached stay folded — a collapsed dir
189
+ * renders as `dir/ (N files, M dirs)`, leftover files under a kept dir fold to `.ext ×N`.
190
+ *
191
+ * Dir signal = max(subtree file scores), so a single hot file keeps its whole dir reachable.
192
+ * score(file) = recency + centrality (0–4), same scale as allocate().
193
+ */
194
+ export function expandByBudget(
195
+ paths: string[],
196
+ scores: Map<string, number>,
197
+ contentTokens: Map<string, number>,
198
+ budget: number,
199
+ treeEntryTokens = 8,
200
+ ): ExpandResult {
201
+ const root: ExpandNode = { path: '', isFile: false, score: -1, cost: 0, children: [] }
202
+ const byPath = new Map<string, ExpandNode>([['', root]])
203
+ for (const path of paths) {
204
+ const parts = path.split('/')
205
+ let prefix = ''
206
+ let parent = root
207
+ for (let i = 0; i < parts.length; i++) {
208
+ prefix = prefix ? `${prefix}/${parts[i]}` : parts[i]
209
+ let node = byPath.get(prefix)
210
+ if (!node) {
211
+ const isFile = i === parts.length - 1
212
+ node = {
213
+ path: prefix,
214
+ isFile,
215
+ score: isFile ? scores.get(path) ?? 0 : -1,
216
+ cost: isFile ? (contentTokens.get(path) ?? 0) + treeEntryTokens : treeEntryTokens,
217
+ children: [],
218
+ }
219
+ byPath.set(prefix, node)
220
+ parent.children.push(node)
221
+ }
222
+ parent = node
223
+ }
224
+ }
225
+
226
+ // Dir signal = max over subtree files; bottom-up.
227
+ const aggregate = (node: ExpandNode): number => {
228
+ if (node.isFile)
229
+ return node.score
230
+ node.score = node.children.reduce((m, c) => Math.max(m, aggregate(c)), node.score)
231
+ return node.score
232
+ }
233
+ aggregate(root)
234
+
235
+ // Top-down: pop highest-signal frontier node, charge its cost, expand if affordable.
236
+ const kept = new Set<string>()
237
+ const collapsedDirs = new Set<string>()
238
+ const frontier = [...root.children]
239
+ let spent = 0
240
+ while (frontier.length) {
241
+ let bestIdx = 0
242
+ for (let i = 1; i < frontier.length; i++) {
243
+ if (frontier[i].score > frontier[bestIdx].score)
244
+ bestIdx = i
245
+ }
246
+ const node = frontier.splice(bestIdx, 1)[0]
247
+ if (spent + node.cost > budget) {
248
+ if (!node.isFile)
249
+ collapsedDirs.add(node.path)
250
+ continue // leave it folded; keep draining frontier so siblings still get their shot
251
+ }
252
+ spent += node.cost
253
+ kept.add(node.path)
254
+ if (!node.isFile)
255
+ frontier.push(...node.children)
256
+ }
257
+ return { kept, collapsedDirs }
258
+ }
259
+
260
+ // ── Token budget allocation ──────────────────────────────
261
+
262
+ export interface AllocationResult {
263
+ levels: Map<string, FileDetailLevel>
264
+ totalBefore: number
265
+ totalAfter: number
266
+ downgraded: number
267
+ /** Tokens still over budget after compact→tree. >0 means the tree must collapse subtrees. */
268
+ deficit: number
269
+ /** file → recency+centrality (0–4), reused by expandByBudget for top-down collapse. */
270
+ scores: Map<string, number>
271
+ /** file → tokens to keep its content visible, reused by expandByBudget. */
272
+ contentTokens: Map<string, number>
273
+ treeEntryTokens: number
274
+ }
275
+
276
+ const TREE_ENTRY_TOKENS = 8
277
+
278
+ /**
279
+ * Given scored blocks and a token budget, downgrade low-scoring files compact → tree
280
+ * until content fits. What's still over budget surfaces as `deficit` — the tree side
281
+ * (expandByBudget) then collapses whole low-signal subtrees instead of dropping files.
282
+ */
283
+ export function allocate(
284
+ blocks: FileBlock[],
285
+ recency: Map<string, number>,
286
+ centrality: Map<string, number>,
287
+ budget: number,
288
+ pinned?: Set<string>,
289
+ isTreeDisplay?: (path: string) => boolean,
290
+ ): AllocationResult {
291
+ const levels = new Map<string, FileDetailLevel>(blocks.map(b => [b.path, b.level]))
292
+ // Cost = what the file actually contributes to the injected output. A file displayed
293
+ // as tree (only its name ships) costs nothing beyond its tree entry — charging its
294
+ // extracted signature would ration against content that never gets injected.
295
+ const contentTokens = new Map<string, number>(blocks.map(b => [
296
+ b.path,
297
+ isTreeDisplay?.(b.path)
298
+ ? 0
299
+ : estimateTokens(b.content) + estimateTokens(`<file path="${b.path}">\n\n</file>\n\n`),
300
+ ]))
301
+ // Score each file: recency (0–2) + centrality (0–2)
302
+ const scores = new Map<string, number>(
303
+ blocks.map(b => [b.path, (recency.get(b.path) ?? 0) + (centrality.get(b.path) ?? 0)]),
304
+ )
305
+
306
+ let total = blocks.reduce((sum, b) => sum + (contentTokens.get(b.path) ?? 0) + TREE_ENTRY_TOKENS, 0)
307
+ const totalBefore = total
308
+ let downgraded = 0
309
+
310
+ const done = () => ({ levels, totalBefore, totalAfter: total, downgraded, deficit: Math.max(0, total - budget), scores, contentTokens, treeEntryTokens: TREE_ENTRY_TOKENS })
311
+
312
+ if (total <= budget)
313
+ return done()
314
+
315
+ // Sort ascending by score — lowest score = first candidate for downgrade
316
+ const sorted = [...blocks].sort((a, b) => (scores.get(a.path) ?? 0) - (scores.get(b.path) ?? 0))
317
+
318
+ // Phase 1: compact → tree (removes content tokens, tree entry remains).
319
+ // Pinned files (explicit fileDetailLevel) are never demoted.
320
+ for (const block of sorted) {
321
+ if (total <= budget)
322
+ break
323
+ if (pinned?.has(block.path))
324
+ continue
325
+ if (levels.get(block.path) === 'compact') {
326
+ total -= contentTokens.get(block.path) ?? 0
327
+ levels.set(block.path, 'tree')
328
+ downgraded++
329
+ }
330
+ }
331
+
332
+ return done()
333
+ }
@@ -0,0 +1,71 @@
1
+ // 状态显示涌现 — 洗衣机面板的另一半(旋钮是输入端,状态是输出端/可观测量)。
2
+ //
3
+ // 控制论系统 = (旋钮 × 状态) → 转移。panel.ts 抽了旋钮(输入)与转移(旋钮如何改状态),
4
+ // 这里抽**状态空间本身**:系统由哪些可观测状态量构成、各自初值、各自值域(可能的态)。
5
+ //
6
+ // 真理源 = 神经图引擎(stategraph.ts):ts-morph 的 type checker 把 store(返回类型带响应式
7
+ // marker 字段的工厂调用,构造无关)从全量引用流里分类出来,并按中心性(引用密度 × 跨文件辐射)
8
+ // 排序。本文件是它的「读面板」投影:StateNode → StoreState(取 init/domain),按 rank 排序让中心 store 置顶。
9
+ //
10
+ // 旧的 tree-sitter CREATE_QUERY + domainOf 已坍缩进引擎——同一个 store-识别不再写两遍
11
+ // (grow.ts 运行时一份、这里读面板一份)。引擎离线(ts-morph),不进浏览器/热路径。
12
+
13
+ import { relative, resolve } from 'node:path'
14
+ import process from 'node:process'
15
+ import { buildStateGraph, extractStoreFields, openProject } from './stategraph.js'
16
+
17
+ export interface StateField {
18
+ name: string // 状态量名(mode / showSideBar …)
19
+ init: string // 初值表达式原文('chat' / false / null / [] …)
20
+ domain: string[] | null // 可枚举值域(字符串字面量联合 → ['chat','im','workbench']);null = 非枚举(boolean/对象/数组)
21
+ }
22
+
23
+ export interface StoreState {
24
+ store: string // create 绑定名(appUIStore),与 Knob.store join
25
+ filePath: string
26
+ fields: StateField[]
27
+ }
28
+
29
+ // 引擎的 StoreFieldRaw(domain: [])→ 读面板的 StateField(domain: null 表非枚举,保留旧渲染契约)。
30
+ const toStateField = (f: { name: string, init: string, domain: string[] }): StateField => ({
31
+ name: f.name,
32
+ init: f.init,
33
+ domain: f.domain.length ? f.domain : null,
34
+ })
35
+
36
+ // 全仓聚合:整个 App 的状态空间,按中心性排序(中心 store 置顶)。
37
+ // 走引擎的真 Project(tsconfig,跨文件解析返回类型 marker + findReferences 中心性)。filePath 归一成
38
+ // 仓相对路径(与 scan() 给的 paths 同形,cli.ts 的 ★ 文件集 join 才对得上);paths 给定时只留
39
+ // 落在扫描集内的 store(尊重 read.include 范围),不给则全收。marker 默认 '_loading'。
40
+ export async function buildState(paths?: string[], marker: string = '_loading'): Promise<StoreState[]> {
41
+ const cwd = process.cwd()
42
+ const project = await openProject(cwd)
43
+ const inScope = paths ? new Set(paths) : null
44
+ const { nodes } = buildStateGraph(project, abs => relative(cwd, abs), marker)
45
+ const states: StoreState[] = []
46
+ for (const n of nodes) {
47
+ const filePath = n.anchor.slice(0, n.anchor.lastIndexOf(':'))
48
+ if (inScope && !inScope.has(filePath))
49
+ continue
50
+ const decl = project.getSourceFile(resolve(cwd, filePath))?.getVariableDeclarations().find(v => v.getName() === n.store)
51
+ if (!decl)
52
+ continue
53
+ const fields = extractStoreFields(decl, marker)
54
+ if (fields.length)
55
+ states.push({ store: n.store, filePath, fields: fields.map(toStateField) })
56
+ }
57
+ return states
58
+ }
59
+
60
+ // 状态显示渲染:一行一个状态量 `field = init {域}`,按 store 分组(已按中心性排序)。
61
+ export function formatState(states: StoreState[]): string {
62
+ const lines: string[] = []
63
+ for (const s of states) {
64
+ lines.push(`${s.store} (${s.filePath})`)
65
+ for (const f of s.fields) {
66
+ const dom = f.domain ? ` {${f.domain.join('|')}}` : ''
67
+ lines.push(` ${f.name} = ${f.init}${dom}`)
68
+ }
69
+ }
70
+ return lines.join('\n')
71
+ }