axmap-cli 1.3.2 → 1.4.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/CLAUDE.md +15 -9
- package/README.ko.md +89 -85
- package/README.md +90 -86
- package/bin/axmap.mjs +9 -3
- package/mcp/README.md +8 -8
- package/mcp/install.ps1 +6 -3
- package/mcp/install.sh +5 -3
- package/mcp/server.mjs +81 -32
- package/package.json +7 -11
- package/src/agentcli.mjs +19 -0
- package/src/bridge.mjs +245 -0
- package/src/invariants.mjs +1 -1
- package/src/managed-session.mjs +178 -0
- package/src/protocol.mjs +1 -1
- package/src/session-dialogue.mjs +131 -0
- package/src/session-usage.mjs +34 -0
- package/src/state.mjs +8 -0
- package/src/terminal-bridge.mjs +224 -0
- package/src/update.mjs +1 -1
- package/tools/bridge.mjs +85 -0
- package/tools/mcp-register.mjs +22 -3
- package/tools/run-tests.mjs +10 -3
- package/tools/run.mjs +60 -0
- package/tools/setup.mjs +11 -0
- package/app/README.md +0 -366
- package/app/eval/edges.mjs +0 -242
- package/app/lib/adjacent.mjs +0 -125
- package/app/lib/agentcli.mjs +0 -153
- package/app/lib/analyze.mjs +0 -1159
- package/app/lib/cochange.mjs +0 -421
- package/app/lib/datanodes.mjs +0 -127
- package/app/lib/entry.mjs +0 -192
- package/app/lib/featuregraph.mjs +0 -389
- package/app/lib/features.mjs +0 -645
- package/app/lib/fetchrepo-run.mjs +0 -37
- package/app/lib/fetchrepo.mjs +0 -164
- package/app/lib/flow.mjs +0 -1089
- package/app/lib/ladder.mjs +0 -387
- package/app/lib/langs.mjs +0 -630
- package/app/lib/live.mjs +0 -346
- package/app/lib/llm.mjs +0 -594
- package/app/lib/newfile.mjs +0 -126
- package/app/lib/prdiff.mjs +0 -651
- package/app/lib/reveal.mjs +0 -316
- package/app/lib/roots.mjs +0 -186
- package/app/lib/scope.mjs +0 -342
- package/app/lib/session.mjs +0 -389
- package/app/lib/slots.mjs +0 -233
- package/app/lib/ssot.mjs +0 -277
- package/app/lib/teamview.mjs +0 -975
- package/app/lib/terms.ko.mjs +0 -169
- package/app/server.mjs +0 -1959
- package/app/web/shell.css +0 -538
- package/app/web/shell.html +0 -197
- package/app/web/shell.js +0 -638
- package/app/web/stage.js +0 -347
- package/app/web/words.js +0 -85
- package/src/repotarget.mjs +0 -84
- package/tools/cluster-experiment.mjs +0 -256
- package/tools/cluster-sweep.mjs +0 -226
- package/tools/make-icon.mjs +0 -108
- package/tools/persona-bench.mjs +0 -362
- package/tools/pick-repo.mjs +0 -229
- package/tools/reveal-demo.mjs +0 -158
- package/tools/shortcut.mjs +0 -121
- package/tools/smoke.mjs +0 -174
- package/tools/topicgraph.py +0 -154
package/app/eval/edges.mjs
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* 모델 비교 하네스 — 엣지 독해만 잰다.
|
|
4
|
-
*
|
|
5
|
-
* "모델을 바꾸면 이득이 있나" 에 감상 대신 숫자로 답하기 위한 것이다.
|
|
6
|
-
* 요약문 품질은 기계로 잴 수 없다. 엣지 독해는 잴 수 있는데, 정답 세트가 없어도
|
|
7
|
-
* 서로 독립된 판정 기준이 이미 두 개 있기 때문이다.
|
|
8
|
-
*
|
|
9
|
-
* · verified — 모델이 말한 채널이 파일에 문자열로 실재하는가 (llm.mjs, D5)
|
|
10
|
-
* · 정적 추출 — analyze.mjs 의 channelsOf 가 같은 채널을 찾았는가, 방향은 뭐라 했는가
|
|
11
|
-
*
|
|
12
|
-
* 그래서 이 하네스가 재는 것은 "모델이 똑똑한가" 가 아니라
|
|
13
|
-
* **정적 파싱 위에 모델이 무엇을 더 얹어주는가** 다. README 의 표현대로
|
|
14
|
-
* 모델이 더해주는 것은 방향과 문서에만 적힌 흐름이고, 그 둘이 여기서 숫자가 된다.
|
|
15
|
-
*
|
|
16
|
-
* 사용:
|
|
17
|
-
* AXMAP_MODEL=qwen2.5-coder:7b node app/eval/edges.mjs <대상저장소>
|
|
18
|
-
* AXMAP_MODEL=<다른모델> node app/eval/edges.mjs <대상저장소>
|
|
19
|
-
* node app/eval/edges.mjs --compare
|
|
20
|
-
*
|
|
21
|
-
* 캐시는 모델별로 갈리므로(llm.mjs 의 key) 같은 모델을 다시 돌리면 즉시 끝난다.
|
|
22
|
-
* 모델이 다르면 캐시가 겹치지 않는다 — 비교가 오염되지 않는 이유다.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import fs from 'node:fs'
|
|
26
|
-
import path from 'node:path'
|
|
27
|
-
import { fileURLToPath } from 'node:url'
|
|
28
|
-
import { scan, channelsOf } from '../lib/analyze.mjs'
|
|
29
|
-
import { status, warmup, readEdges } from '../lib/llm.mjs'
|
|
30
|
-
|
|
31
|
-
const OUT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'out')
|
|
32
|
-
|
|
33
|
-
const slug = (model) => model.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
34
|
-
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// 한 파일의 측정
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* @param st Map<channel, 'pub'|'sub'|'unknown'|'both'> 정적 추출 결과
|
|
41
|
-
* @param res readEdges 의 반환
|
|
42
|
-
*/
|
|
43
|
-
function measure(file, st, res, ms) {
|
|
44
|
-
const edges = res.edges ?? []
|
|
45
|
-
const verified = edges.filter((e) => e.verified)
|
|
46
|
-
const seen = new Set(verified.map((e) => e.channel))
|
|
47
|
-
|
|
48
|
-
let dirResolved = 0 // 정적이 방향을 확정 못 한 채널에 모델이 방향을 준 것 ← 부가가치
|
|
49
|
-
let dirAgree = 0 // 정적도 확신했고 모델도 같은 방향
|
|
50
|
-
let dirConflict = 0 // 정적은 확신했는데 모델이 반대 ← 둘 중 하나는 틀렸다
|
|
51
|
-
let beyondStatic = 0 // 정적이 아예 못 본 채널인데 파일에 실재 ← 부가가치
|
|
52
|
-
|
|
53
|
-
for (const e of verified) {
|
|
54
|
-
if (!st.has(e.channel)) {
|
|
55
|
-
beyondStatic++
|
|
56
|
-
continue
|
|
57
|
-
}
|
|
58
|
-
const sd = st.get(e.channel)
|
|
59
|
-
// 'both' 은 정적이 한 방향으로 확정하지 못한 상태이므로 unknown 과 같이 센다
|
|
60
|
-
if (sd === 'unknown' || sd === 'both') dirResolved++
|
|
61
|
-
else if (sd === e.direction) dirAgree++
|
|
62
|
-
else dirConflict++
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
path: file.path,
|
|
67
|
-
lines: file.lines,
|
|
68
|
-
staticCount: st.size,
|
|
69
|
-
claimed: edges.length,
|
|
70
|
-
verified: verified.length,
|
|
71
|
-
hallucinated: edges.length - verified.length,
|
|
72
|
-
dirResolved,
|
|
73
|
-
dirAgree,
|
|
74
|
-
dirConflict,
|
|
75
|
-
beyondStatic,
|
|
76
|
-
staticMissed: [...st.keys()].filter((ch) => !seen.has(ch)).length,
|
|
77
|
-
parseFailed: res.parseFailed === true,
|
|
78
|
-
cached: res.cached === true,
|
|
79
|
-
ms,
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const SUM_KEYS = [
|
|
84
|
-
'staticCount', 'claimed', 'verified', 'hallucinated',
|
|
85
|
-
'dirResolved', 'dirAgree', 'dirConflict', 'beyondStatic', 'staticMissed',
|
|
86
|
-
]
|
|
87
|
-
|
|
88
|
-
function totalsOf(rows) {
|
|
89
|
-
const t = Object.fromEntries(SUM_KEYS.map((k) => [k, 0]))
|
|
90
|
-
for (const r of rows) for (const k of SUM_KEYS) t[k] += r[k]
|
|
91
|
-
t.files = rows.length
|
|
92
|
-
t.parseFailedFiles = rows.filter((r) => r.parseFailed).length
|
|
93
|
-
const fresh = rows.filter((r) => !r.cached)
|
|
94
|
-
t.freshFiles = fresh.length
|
|
95
|
-
t.msMedian = fresh.length
|
|
96
|
-
? fresh.map((r) => r.ms).sort((a, b) => a - b)[Math.floor(fresh.length / 2)]
|
|
97
|
-
: null
|
|
98
|
-
return t
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// 실행
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
async function run(root, nFiles) {
|
|
106
|
-
const s = await status()
|
|
107
|
-
// 여기서 통과시키면 "0개 찾음" 이 모델 탓인지 Ollama 탓인지 영영 구분되지 않는다.
|
|
108
|
-
if (!s.available) {
|
|
109
|
-
console.error(`Ollama 에 연결할 수 없습니다 (${s.host}). 켜고 다시 실행하세요.`)
|
|
110
|
-
process.exit(1)
|
|
111
|
-
}
|
|
112
|
-
if (!s.modelReady) {
|
|
113
|
-
console.error(`모델 ${s.model} 이 없습니다. 받은 것: ${s.models.join(', ') || '(없음)'}`)
|
|
114
|
-
console.error(` ollama pull ${s.model}`)
|
|
115
|
-
process.exit(1)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const all = scan(root).filter((f) => !f.backup)
|
|
119
|
-
if (!all.length) {
|
|
120
|
-
console.error(`${root} 에서 분석할 파일을 찾지 못했습니다.`)
|
|
121
|
-
process.exit(1)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// 채널이 많은 파일부터 본다. 재는 대상이 엣지 독해이므로 채널이 0인 파일을
|
|
125
|
-
// 아무리 돌려도 판정에 기여하지 않는다. 대신 몇 개를 안 봤는지 반드시 찍는다.
|
|
126
|
-
const ranked = all
|
|
127
|
-
.map((f) => ({ f, st: channelsOf(f) }))
|
|
128
|
-
.sort((a, b) => b.st.size - a.st.size || a.f.path.localeCompare(b.f.path))
|
|
129
|
-
const picked = ranked.slice(0, nFiles)
|
|
130
|
-
|
|
131
|
-
console.log(`모델 ${s.model}`)
|
|
132
|
-
console.log(`대상 ${root}`)
|
|
133
|
-
console.log(`파일 ${picked.length}개 (전체 ${ranked.length}개 중 채널 많은 순, ${ranked.length - picked.length}개 안 봄)\n`)
|
|
134
|
-
|
|
135
|
-
await warmup() // 콜드 스타트 44초를 첫 파일의 측정치에 섞지 않는다
|
|
136
|
-
|
|
137
|
-
const rows = []
|
|
138
|
-
for (const { f, st } of picked) {
|
|
139
|
-
const t0 = performance.now()
|
|
140
|
-
const res = await readEdges(f.path, f.text)
|
|
141
|
-
rows.push(measure(f, st, res, Math.round(performance.now() - t0)))
|
|
142
|
-
process.stderr.write(`\r ${rows.length}/${picked.length} ${f.path}`.padEnd(78).slice(0, 78))
|
|
143
|
-
}
|
|
144
|
-
process.stderr.write('\r'.padEnd(79) + '\r')
|
|
145
|
-
|
|
146
|
-
report(rows, s.model, root)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function report(rows, model, root) {
|
|
150
|
-
const t = totalsOf(rows)
|
|
151
|
-
|
|
152
|
-
const head = ['파일', '정적', '주장', '실재', '환각', '방향+', '일치', '충돌', '정적밖', '누락', 'ms']
|
|
153
|
-
const w = [38, 4, 4, 4, 4, 5, 4, 4, 6, 4, 6]
|
|
154
|
-
const line = (cells) => cells.map((c, i) => (i === 0 ? String(c).padEnd(w[i]) : String(c).padStart(w[i]))).join(' ')
|
|
155
|
-
|
|
156
|
-
console.log(line(head))
|
|
157
|
-
console.log('-'.repeat(w.reduce((a, b) => a + b + 1, -1)))
|
|
158
|
-
for (const r of rows) {
|
|
159
|
-
const name = r.path.length > w[0] ? '…' + r.path.slice(-(w[0] - 1)) : r.path
|
|
160
|
-
console.log(line([
|
|
161
|
-
name, r.staticCount, r.claimed, r.verified, r.hallucinated,
|
|
162
|
-
r.dirResolved, r.dirAgree, r.dirConflict, r.beyondStatic, r.staticMissed,
|
|
163
|
-
r.cached ? 'cache' : r.ms,
|
|
164
|
-
]) + (r.parseFailed ? ' ← 형식위반' : ''))
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const pct = (n, d) => (d ? `${((n / d) * 100).toFixed(1)}%` : '—')
|
|
168
|
-
console.log(`\n합계 ${t.files}개 파일`)
|
|
169
|
-
console.log(` 주장 ${t.claimed} · 실재 ${t.verified} · 환각 ${t.hallucinated} (${pct(t.hallucinated, t.claimed)})`)
|
|
170
|
-
console.log(` 정적 위에 더한 것 : 방향 채움 ${t.dirResolved} + 정적 밖 채널 ${t.beyondStatic} = ${t.dirResolved + t.beyondStatic}`)
|
|
171
|
-
console.log(` 정적과 충돌 : ${t.dirConflict}`)
|
|
172
|
-
console.log(` 정적이 찾은 걸 놓침: ${t.staticMissed} / ${t.staticCount} (${pct(t.staticMissed, t.staticCount)})`)
|
|
173
|
-
console.log(` 새로 추론한 파일 ${t.freshFiles}개, 중앙값 ${t.msMedian ?? '—'}ms`)
|
|
174
|
-
|
|
175
|
-
if (t.parseFailedFiles) {
|
|
176
|
-
console.log(`\n⚠ JSON 형식 위반 ${t.parseFailedFiles}개 파일.`)
|
|
177
|
-
console.log(` 위 숫자는 믿을 수 없다. 모델이 사고 토큰을 뱉거나 num_predict 한도에 잘렸을 가능성이 크다.`)
|
|
178
|
-
console.log(` 프롬프트/num_predict 를 그 모델에 맞추기 전까지는 품질 비교가 아니라 배관 문제다.`)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
fs.mkdirSync(OUT_DIR, { recursive: true })
|
|
182
|
-
const file = path.join(OUT_DIR, `${slug(model)}.json`)
|
|
183
|
-
fs.writeFileSync(file, JSON.stringify({ model, root, at: new Date().toISOString(), totals: t, rows }, null, 2))
|
|
184
|
-
console.log(`\n기록 ${path.relative(process.cwd(), file)}`)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
// 비교
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
|
|
191
|
-
function compare() {
|
|
192
|
-
let names = []
|
|
193
|
-
try {
|
|
194
|
-
names = fs.readdirSync(OUT_DIR).filter((n) => n.endsWith('.json'))
|
|
195
|
-
} catch {
|
|
196
|
-
/* 아래에서 안내한다 */
|
|
197
|
-
}
|
|
198
|
-
if (names.length < 2) {
|
|
199
|
-
console.error(`비교하려면 리포트가 2개 이상 필요합니다 (지금 ${names.length}개).`)
|
|
200
|
-
console.error(` AXMAP_MODEL=<모델> node app/eval/edges.mjs <대상저장소> 를 모델별로 돌리세요.`)
|
|
201
|
-
process.exit(1)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const reps = names.map((n) => JSON.parse(fs.readFileSync(path.join(OUT_DIR, n), 'utf8')))
|
|
205
|
-
const roots = new Set(reps.map((r) => r.root))
|
|
206
|
-
if (roots.size > 1) {
|
|
207
|
-
// 대상이 다르면 숫자를 나란히 놓는 것 자체가 거짓말이다
|
|
208
|
-
console.error(`대상 저장소가 서로 다릅니다: ${[...roots].join(' / ')}`)
|
|
209
|
-
console.error(`같은 대상으로 다시 돌리거나 app/eval/out/ 을 비우세요.`)
|
|
210
|
-
process.exit(1)
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const rows = reps.map((r) => ({
|
|
214
|
-
model: r.model,
|
|
215
|
-
환각률: r.totals.claimed ? `${((r.totals.hallucinated / r.totals.claimed) * 100).toFixed(1)}%` : '—',
|
|
216
|
-
부가가치: r.totals.dirResolved + r.totals.beyondStatic,
|
|
217
|
-
충돌: r.totals.dirConflict,
|
|
218
|
-
누락: r.totals.staticMissed,
|
|
219
|
-
형식위반: r.totals.parseFailedFiles,
|
|
220
|
-
'ms(중앙)': r.totals.msMedian ?? '—',
|
|
221
|
-
}))
|
|
222
|
-
console.log(`대상 ${reps[0].root} · 파일 ${reps[0].totals.files}개\n`)
|
|
223
|
-
console.table(rows)
|
|
224
|
-
console.log(`부가가치 = 정적이 방향을 확정 못 한 채널에 방향을 준 수 + 정적이 못 본 실재 채널 수`)
|
|
225
|
-
console.log(`형식위반이 0이 아닌 모델의 숫자는 품질이 아니라 배관 문제를 보고 있는 것이다.`)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
const args = process.argv.slice(2)
|
|
231
|
-
if (args.includes('--compare')) {
|
|
232
|
-
compare()
|
|
233
|
-
} else {
|
|
234
|
-
const root = args.find((a) => !a.startsWith('--'))
|
|
235
|
-
if (!root) {
|
|
236
|
-
console.error('사용: AXMAP_MODEL=<모델> node app/eval/edges.mjs <대상저장소> [--files N]')
|
|
237
|
-
console.error(' node app/eval/edges.mjs --compare')
|
|
238
|
-
process.exit(1)
|
|
239
|
-
}
|
|
240
|
-
const i = args.indexOf('--files')
|
|
241
|
-
await run(root, i >= 0 ? Number(args[i + 1]) : 20)
|
|
242
|
-
}
|
package/app/lib/adjacent.mjs
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 기능 인접 경고 — 파일은 안 겹치는데 **기능이 겹칠 때**.
|
|
3
|
-
*
|
|
4
|
-
* v0 프로토콜은 경로가 겹치는지만 본다. 그것으로 못 잡는 상황이 있다.
|
|
5
|
-
*
|
|
6
|
-
* 에이전트 A 가 `album.controller.ts` 를 잡는다 (API 쪽, 앞에서부터)
|
|
7
|
-
* 에이전트 B 가 `album.repository.ts` 를 잡는다 (DB 쪽, 뒤에서부터)
|
|
8
|
-
*
|
|
9
|
-
* 경로가 안 겹치므로 **둘 다 통과한다.** 그런데 같은 기능이다.
|
|
10
|
-
* A 가 응답 형태를 바꾸고 B 가 스키마를 바꾸면, 머지 시점에 텍스트 충돌은
|
|
11
|
-
* 하나도 없는데 기능이 깨진다. 이것이 파일 단위 락의 한계다.
|
|
12
|
-
*
|
|
13
|
-
* 🔴 이것은 **경고이지 거부가 아니다.** 프로토콜을 건드리지 않는다.
|
|
14
|
-
*
|
|
15
|
-
* checkOverlap 은 그대로 두고, 뷰어가 옆에서 알려주기만 한다. 이유가 있다 —
|
|
16
|
-
*
|
|
17
|
-
* 1. 거부로 만들면 통과가 **줄어드는** 게 아니라 판정 기준이 달라진다.
|
|
18
|
-
* "경로가 겹치면 거부" 는 결정론이고 증명 가능하지만(I1), "기능이 겹치면
|
|
19
|
-
* 거부" 는 lift 문턱이라는 조절 가능한 숫자에 기댄다. 그 숫자를 정당화할
|
|
20
|
-
* 근거가 아직 없다 (DECISIONS.md 열린 질문 Q5).
|
|
21
|
-
* 2. 히스토리가 없는 새 저장소에서는 이 신호가 아예 없다. 락이 그런 것에
|
|
22
|
-
* 의존하면 "어떤 저장소에서는 락이 약해진다" 는 상태가 된다.
|
|
23
|
-
*
|
|
24
|
-
* 그래서 게이트는 그대로, 사람에게 정보만 준다 — D4 가 LLM 을 게이트 밖에
|
|
25
|
-
* 둔 것과 같은 배치다.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** 경로 접두사 포함. protocol.mjs 의 겹침 판정과 같은 '/' 경계 규칙을 쓴다. */
|
|
29
|
-
const covers = (claimPath, file) => file === claimPath || file.startsWith(`${claimPath}/`)
|
|
30
|
-
|
|
31
|
-
/** 이 claim 이 덮는 실제 파일들 */
|
|
32
|
-
function filesOf(claim, allPaths) {
|
|
33
|
-
const out = new Set()
|
|
34
|
-
for (const c of claim.paths ?? []) {
|
|
35
|
-
for (const f of allPaths) if (covers(c, f)) out.add(f)
|
|
36
|
-
}
|
|
37
|
-
return out
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* 활성 claim 들 사이에서 "경로는 안 겹치는데 공변경으로 이어진" 쌍을 찾는다.
|
|
42
|
-
*
|
|
43
|
-
* @param {object[]} claims 활성 claim (agent, paths, task, intent…)
|
|
44
|
-
* @param {object[]} coEdges 공변경 엣지 (source, target, support, lift)
|
|
45
|
-
* @param {string[]} allPaths 그래프에 있는 파일 전체
|
|
46
|
-
* @param {object} opts
|
|
47
|
-
* @returns {{pairs: object[], byAgent: object}}
|
|
48
|
-
*/
|
|
49
|
-
export function featureAdjacency(claims, coEdges, allPaths, {
|
|
50
|
-
minLift = 3, minSupport = 4, maxEvidence = 8,
|
|
51
|
-
} = {}) {
|
|
52
|
-
const live = (claims ?? []).filter((c) => !c.broken && (c.paths?.length ?? 0) > 0)
|
|
53
|
-
if (live.length < 2) return { pairs: [], checked: live.length }
|
|
54
|
-
|
|
55
|
-
const owned = live.map((c) => ({ claim: c, files: filesOf(c, allPaths) }))
|
|
56
|
-
|
|
57
|
-
// 파일 → 그 파일을 덮는 claim 들. 같은 파일을 둘이 덮고 있으면 그건
|
|
58
|
-
// 프로토콜이 이미 막았어야 하는 상황이므로 여기서 다루지 않는다.
|
|
59
|
-
const ownerOf = new Map()
|
|
60
|
-
owned.forEach((o, i) => { for (const f of o.files) ownerOf.set(f, i) })
|
|
61
|
-
|
|
62
|
-
const pairKey = (i, j) => (i < j ? `${i}\0${j}` : `${j}\0${i}`)
|
|
63
|
-
const acc = new Map()
|
|
64
|
-
|
|
65
|
-
for (const e of coEdges) {
|
|
66
|
-
if ((e.lift ?? 0) < minLift || (e.support ?? 0) < minSupport) continue
|
|
67
|
-
const a = ownerOf.get(e.source)
|
|
68
|
-
const b = ownerOf.get(e.target)
|
|
69
|
-
if (a === undefined || b === undefined || a === b) continue
|
|
70
|
-
|
|
71
|
-
const k = pairKey(a, b)
|
|
72
|
-
if (!acc.has(k)) acc.set(k, { a: Math.min(a, b), b: Math.max(a, b), evidence: [] })
|
|
73
|
-
acc.get(k).evidence.push({
|
|
74
|
-
from: e.source, to: e.target, lift: e.lift, support: e.support,
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const pairs = [...acc.values()].map((p) => {
|
|
79
|
-
const A = owned[p.a].claim
|
|
80
|
-
const B = owned[p.b].claim
|
|
81
|
-
const ev = p.evidence.sort((x, y) => y.lift - x.lift).slice(0, maxEvidence)
|
|
82
|
-
return {
|
|
83
|
-
agents: [A.agent, B.agent],
|
|
84
|
-
tasks: [A.task ?? null, B.task ?? null],
|
|
85
|
-
intents: [A.intent ?? null, B.intent ?? null],
|
|
86
|
-
paths: [A.paths, B.paths],
|
|
87
|
-
// 세기는 "몇 개의 공변경 엣지가 두 영역을 가로지르나" + 가장 강한 lift.
|
|
88
|
-
// 하나의 점수로 합치지 않는다 — 둘은 다른 것을 뜻한다.
|
|
89
|
-
// (엣지 수 = 얼마나 넓게 얽혔나 / 최고 lift = 얼마나 강하게 얽혔나)
|
|
90
|
-
crossings: p.evidence.length,
|
|
91
|
-
topLift: ev[0]?.lift ?? 0,
|
|
92
|
-
evidence: ev,
|
|
93
|
-
}
|
|
94
|
-
}).sort((x, y) => y.crossings - x.crossings || y.topLift - x.topLift)
|
|
95
|
-
|
|
96
|
-
return { pairs, checked: live.length }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* 사람이 읽을 경고문.
|
|
101
|
-
*
|
|
102
|
-
* 거부 메시지와 **같은 모양**으로 만든다. 이 저장소의 거부 메시지는
|
|
103
|
-
* "누가·무엇을·언제까지" 를 말해서 에이전트가 스스로 방향을 틀 수 있게 한다.
|
|
104
|
-
* 경고도 같은 재료를 줘야 같은 판단을 할 수 있다.
|
|
105
|
-
*/
|
|
106
|
-
export function formatAdjacency(pair) {
|
|
107
|
-
const [a, b] = pair.agents
|
|
108
|
-
const lines = [
|
|
109
|
-
`⚠ 경로는 안 겹치지만 같은 기능일 수 있습니다`,
|
|
110
|
-
``,
|
|
111
|
-
` ${a} (${pair.tasks[0] ?? '작업 미지정'})`,
|
|
112
|
-
` ${pair.paths[0].join(', ')}`,
|
|
113
|
-
pair.intents[0] ? ` 의도: ${pair.intents[0]}` : null,
|
|
114
|
-
``,
|
|
115
|
-
` ${b} (${pair.tasks[1] ?? '작업 미지정'})`,
|
|
116
|
-
` ${pair.paths[1].join(', ')}`,
|
|
117
|
-
pair.intents[1] ? ` 의도: ${pair.intents[1]}` : null,
|
|
118
|
-
``,
|
|
119
|
-
` 근거 — 두 영역을 가로지르는 공변경 ${pair.crossings}건 (최고 lift ${pair.topLift})`,
|
|
120
|
-
...pair.evidence.slice(0, 4).map((e) => ` ${e.from} ↔ ${e.to} lift ${e.lift} · n=${e.support}`),
|
|
121
|
-
``,
|
|
122
|
-
` 이것은 거부가 아닙니다. 두 사람이 서로의 의도를 아는 것으로 충분할 수 있습니다.`,
|
|
123
|
-
]
|
|
124
|
-
return lines.filter((l) => l !== null).join('\n')
|
|
125
|
-
}
|
package/app/lib/agentcli.mjs
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 어떤 AI CLI 가 이 PC 에 있고, 로그인돼 있는가.
|
|
3
|
-
*
|
|
4
|
-
* 🔴 왜 필요한가.
|
|
5
|
-
*
|
|
6
|
-
* 화면에서는 "새 대화" 지만 실제로는 **CLI 프로세스를 하나 띄우는 것**이다.
|
|
7
|
-
* 그 CLI 에 로그인이 안 돼 있으면 프롬프트를 보낸 뒤에야 로그인 화면이 뜨는데,
|
|
8
|
-
* 우리는 터미널을 감춰 놨으므로 사용자에게는 그냥 **아무 일도 안 일어난 것**으로
|
|
9
|
-
* 보인다. 그래서 대화를 시작하기 전에 먼저 물어본다.
|
|
10
|
-
*
|
|
11
|
-
* 세 CLI 모두 같은 모양의 비대화형 모드를 갖고 있어서 한 겹으로 덮을 수 있다.
|
|
12
|
-
*
|
|
13
|
-
* -p / --print 한 번 묻고 끝낸다
|
|
14
|
-
* --output-format stream-json JSONL 로 흘려준다 → 채팅 말풍선으로 다시 그린다
|
|
15
|
-
* --resume / --conversation <id> 앞 대화에 이어붙인다
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { spawnSync } from 'node:child_process'
|
|
19
|
-
import fs from 'node:fs'
|
|
20
|
-
import os from 'node:os'
|
|
21
|
-
import path from 'node:path'
|
|
22
|
-
|
|
23
|
-
const HOME = os.homedir()
|
|
24
|
-
const hasHome = (rel) => { try { return fs.existsSync(path.join(HOME, rel)) } catch { return false } }
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* 🔴 `claude` 는 세션 id 를 **받고**, `agy` 는 **주기만 한다.**
|
|
28
|
-
*
|
|
29
|
-
* 그래서 이어붙이는 방법이 둘로 갈린다.
|
|
30
|
-
* 받는 쪽 — 우리가 uuid 를 만들어 첫 턴에 박고, 그 값으로 resume 한다
|
|
31
|
-
* 주는 쪽 — 첫 턴 응답에서 id 를 주워 두었다가 다음 턴에 쓴다
|
|
32
|
-
* 둘 다 `pickId()` 로 응답에서 id 를 줍는다. 받는 쪽도 주워 두면 우리가 만든
|
|
33
|
-
* 값과 CLI 가 실제로 쓴 값이 어긋났을 때 CLI 쪽을 따라갈 수 있다.
|
|
34
|
-
*/
|
|
35
|
-
export const AGENTS = [
|
|
36
|
-
{
|
|
37
|
-
id: 'claude',
|
|
38
|
-
name: 'Claude Code',
|
|
39
|
-
bin: 'claude',
|
|
40
|
-
creds: ['.claude/.credentials.json', '.claude.json'],
|
|
41
|
-
takesId: true,
|
|
42
|
-
start: (id) => ['-p', '--output-format', 'stream-json', '--verbose', '--session-id', id],
|
|
43
|
-
resume: (cid) => ['-p', '--output-format', 'stream-json', '--verbose', '--resume', cid],
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: 'agy',
|
|
47
|
-
name: 'agy',
|
|
48
|
-
bin: 'agy',
|
|
49
|
-
/**
|
|
50
|
-
* 🔴 여기에 `AppData/Local/agy` 를 넣었다가 뺐다.
|
|
51
|
-
*
|
|
52
|
-
* 그건 **실행 파일이 있는 자리**이지 자격증명이 아니다. 넣어 두면 설치만
|
|
53
|
-
* 해도 "로그인됨" 으로 뜬다 — 확신 있게 틀린 답이고, 이 저장소가 가장
|
|
54
|
-
* 싫어하는 종류의 실패다(D5). 모르면 `unknown` 이 맞는 답이다.
|
|
55
|
-
*/
|
|
56
|
-
creds: ['.agy/auth.json', '.agy/credentials.json', '.config/agy/auth.json'],
|
|
57
|
-
takesId: false,
|
|
58
|
-
start: () => ['-p', '--output-format', 'stream-json'],
|
|
59
|
-
resume: (cid) => ['-p', '--output-format', 'stream-json', '--conversation', cid],
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
id: 'codex',
|
|
63
|
-
name: 'Codex CLI',
|
|
64
|
-
bin: 'codex',
|
|
65
|
-
creds: ['.codex/auth.json'],
|
|
66
|
-
takesId: false,
|
|
67
|
-
start: () => ['exec', '--json', '-'],
|
|
68
|
-
resume: (cid) => ['exec', 'resume', cid, '--json', '-'],
|
|
69
|
-
/**
|
|
70
|
-
* 🔴 이 PC 에 codex 가 없어서 **실물로 확인하지 못했다.**
|
|
71
|
-
* 확인 안 된 것을 확인된 것처럼 화면에 내보내지 않는다 — 화면이 이 표시를
|
|
72
|
-
* 그대로 띄운다. 실제 codex 가 있는 PC 에서 한 번 돌려보고 이 줄을 지운다.
|
|
73
|
-
*/
|
|
74
|
-
unverified: true,
|
|
75
|
-
},
|
|
76
|
-
]
|
|
77
|
-
|
|
78
|
-
export const agentById = (id) => AGENTS.find((a) => a.id === id) ?? null
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 실행 파일의 실제 경로.
|
|
82
|
-
*
|
|
83
|
-
* 🔴 윈도우의 `spawn('claude', …)` 은 확장자 없는 이름을 못 찾는다.
|
|
84
|
-
* `shell: true` 로 우회하면 프롬프트 안의 따옴표가 셸에 먹혀 **조용히 잘린다** —
|
|
85
|
-
* 사용자가 쓴 말이 반쯤 사라진 채로 AI 에게 간다는 뜻이라 최악이다.
|
|
86
|
-
* 그래서 경로를 먼저 풀어서 그 경로로 직접 띄운다. 프롬프트는 stdin 으로 준다.
|
|
87
|
-
*/
|
|
88
|
-
export function resolveBin(bin) {
|
|
89
|
-
const cmd = process.platform === 'win32' ? 'where' : 'which'
|
|
90
|
-
try {
|
|
91
|
-
const r = spawnSync(cmd, [bin], { encoding: 'utf8', windowsHide: true })
|
|
92
|
-
if (r.status !== 0) return null
|
|
93
|
-
return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null
|
|
94
|
-
} catch { return null }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* 로그인 여부는 **모르면 모른다고 한다.**
|
|
99
|
-
*
|
|
100
|
-
* 자격증명이 있는 자리는 CLI 버전마다 바뀌고, macOS 는 키체인에 넣어서 파일이
|
|
101
|
-
* 아예 없다. 없다고 단정하면 멀쩡한 CLI 를 못 쓰게 막고, 있다고 단정하면 첫
|
|
102
|
-
* 프롬프트에서 로그인 화면이 떠 대화가 그냥 멈춘다. 둘 다 조용한 실패다.
|
|
103
|
-
* 그래서 `yes` · `no` · `unknown` 셋으로 답하고, 화면이 그 셋을 다르게 그린다.
|
|
104
|
-
*
|
|
105
|
-
* 🔴 그러므로 `unknown` 을 "아마 되겠지" 로 접지 마라. 그 값이 화면에
|
|
106
|
-
* "로그인 상태를 확인하지 못했습니다" 로 그대로 나가는 것이 설계다 —
|
|
107
|
-
* 터미널을 감춰 놨기 때문에 침묵이 곧 미궁이 된다.
|
|
108
|
-
*/
|
|
109
|
-
function signedIn(a, installed) {
|
|
110
|
-
if (!installed) return 'no'
|
|
111
|
-
return a.creds.some(hasHome) ? 'yes' : 'unknown'
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 이 PC 에서 쓸 수 있는 CLI 목록. 화면이 시작할 때 한 번 묻는다. */
|
|
115
|
-
export function detect() {
|
|
116
|
-
return AGENTS.map((a) => {
|
|
117
|
-
const bin = resolveBin(a.bin)
|
|
118
|
-
return {
|
|
119
|
-
id: a.id,
|
|
120
|
-
name: a.name,
|
|
121
|
-
installed: !!bin,
|
|
122
|
-
path: bin,
|
|
123
|
-
signedIn: signedIn(a, !!bin),
|
|
124
|
-
unverified: !!a.unverified,
|
|
125
|
-
}
|
|
126
|
-
})
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** 설치돼 있는 것 중 첫 번째. 화면이 기본값으로 쓴다. */
|
|
130
|
-
export function preferred(found = detect()) {
|
|
131
|
-
return found.find((a) => a.installed && a.signedIn !== 'no') ?? found.find((a) => a.installed) ?? null
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 이번 턴에 띄울 인자.
|
|
136
|
-
* `cid` 가 있으면 이어붙이고, 없으면 새로 시작한다.
|
|
137
|
-
*/
|
|
138
|
-
export function argvFor(agent, { cid, newId }) {
|
|
139
|
-
return cid ? agent.resume(cid) : agent.start(newId)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* 응답 이벤트에서 대화 id 를 줍는다.
|
|
144
|
-
* CLI 마다 이름이 달라서 아는 이름을 전부 본다. 못 찾으면 null 이고,
|
|
145
|
-
* 그러면 다음 턴은 **이어붙지 않고 새 대화가 된다** — 조용히 섞이는 것보다 낫다.
|
|
146
|
-
*/
|
|
147
|
-
export function pickId(ev) {
|
|
148
|
-
if (!ev || typeof ev !== 'object') return null
|
|
149
|
-
for (const k of ['session_id', 'sessionId', 'conversation_id', 'conversationId', 'thread_id']) {
|
|
150
|
-
if (typeof ev[k] === 'string' && ev[k]) return ev[k]
|
|
151
|
-
}
|
|
152
|
-
return null
|
|
153
|
-
}
|