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