axmap-cli 0.0.1

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 (78) hide show
  1. package/.claude/commands/ax-done.md +10 -0
  2. package/.claude/commands/ax-setup.md +31 -0
  3. package/.claude/commands/ax-start.md +15 -0
  4. package/.claude/commands/ax-tell.md +14 -0
  5. package/.claude/commands/ax-update.md +19 -0
  6. package/.claude/commands/ax.md +13 -0
  7. package/CLAUDE.md +309 -0
  8. package/LICENSE +20 -0
  9. package/README.md +207 -0
  10. package/app/README.md +366 -0
  11. package/app/eval/edges.mjs +242 -0
  12. package/app/lib/adjacent.mjs +125 -0
  13. package/app/lib/agentcli.mjs +153 -0
  14. package/app/lib/analyze.mjs +1159 -0
  15. package/app/lib/cochange.mjs +421 -0
  16. package/app/lib/datanodes.mjs +127 -0
  17. package/app/lib/entry.mjs +192 -0
  18. package/app/lib/featuregraph.mjs +389 -0
  19. package/app/lib/features.mjs +645 -0
  20. package/app/lib/fetchrepo-run.mjs +37 -0
  21. package/app/lib/fetchrepo.mjs +164 -0
  22. package/app/lib/flow.mjs +1089 -0
  23. package/app/lib/ladder.mjs +387 -0
  24. package/app/lib/langs.mjs +630 -0
  25. package/app/lib/live.mjs +346 -0
  26. package/app/lib/llm.mjs +594 -0
  27. package/app/lib/newfile.mjs +126 -0
  28. package/app/lib/prdiff.mjs +651 -0
  29. package/app/lib/reveal.mjs +316 -0
  30. package/app/lib/roots.mjs +186 -0
  31. package/app/lib/scope.mjs +342 -0
  32. package/app/lib/session.mjs +389 -0
  33. package/app/lib/slots.mjs +233 -0
  34. package/app/lib/ssot.mjs +277 -0
  35. package/app/lib/teamview.mjs +962 -0
  36. package/app/lib/terms.ko.mjs +169 -0
  37. package/app/server.mjs +1959 -0
  38. package/app/web/shell.css +538 -0
  39. package/app/web/shell.html +197 -0
  40. package/app/web/shell.js +638 -0
  41. package/app/web/stage.js +347 -0
  42. package/app/web/words.js +85 -0
  43. package/bin/axmap.mjs +1918 -0
  44. package/governance/GOVERNANCE.md +433 -0
  45. package/governance/gate.mjs +526 -0
  46. package/governance/vote.mjs +501 -0
  47. package/mcp/README.md +254 -0
  48. package/mcp/SETUP-FOR-AI.md +186 -0
  49. package/mcp/install.ps1 +341 -0
  50. package/mcp/install.sh +339 -0
  51. package/mcp/server.mjs +969 -0
  52. package/package.json +48 -0
  53. package/src/closure.mjs +343 -0
  54. package/src/governance.mjs +839 -0
  55. package/src/invariants.mjs +226 -0
  56. package/src/mrtarget.mjs +284 -0
  57. package/src/promote.mjs +177 -0
  58. package/src/protocol.mjs +423 -0
  59. package/src/repotarget.mjs +81 -0
  60. package/src/update.mjs +177 -0
  61. package/src/version.mjs +186 -0
  62. package/tools/bus.mjs +520 -0
  63. package/tools/cluster-experiment.mjs +256 -0
  64. package/tools/cluster-sweep.mjs +226 -0
  65. package/tools/make-icon.mjs +108 -0
  66. package/tools/mcp-register.mjs +269 -0
  67. package/tools/mr-target.mjs +49 -0
  68. package/tools/persona-bench.mjs +362 -0
  69. package/tools/pick-repo.mjs +229 -0
  70. package/tools/promote.mjs +550 -0
  71. package/tools/reveal-demo.mjs +158 -0
  72. package/tools/run-tests.mjs +42 -0
  73. package/tools/setup.mjs +490 -0
  74. package/tools/shortcut.mjs +121 -0
  75. package/tools/smoke.mjs +166 -0
  76. package/tools/topicgraph.py +154 -0
  77. package/tools/vendor.mjs +382 -0
  78. package/tools/version.mjs +115 -0
@@ -0,0 +1,594 @@
1
+ /**
2
+ * 로컬 LLM (Ollama) — 선택적이다.
3
+ *
4
+ * docs/DECISIONS.md D4·D5 의 원칙을 지킨다.
5
+ * · LLM 은 판정하지 않는다. 설명하고 후보를 제안할 뿐이다.
6
+ * · LLM 이 말한 것은 기계적으로 검증한다. 검증 못 하면 그렇게 표시한다.
7
+ *
8
+ * Ollama 가 없으면 조용히 비활성화된다. 결정론적 기능은 전부 그대로 동작한다.
9
+ * 폐쇄망에서 LLM 을 못 쓰는 상황이 곧 이 도구를 못 쓰는 상황이 되면 안 된다.
10
+ */
11
+
12
+ import crypto from 'node:crypto'
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { outline } from './analyze.mjs'
17
+
18
+ const HOST = process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434'
19
+ const MODEL = process.env.AXMAP_MODEL ?? 'qwen2.5-coder:7b'
20
+ const CACHE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.cache')
21
+
22
+ let statusCache = null
23
+
24
+ export async function status() {
25
+ if (statusCache && Date.now() - statusCache.at < 10_000) return statusCache
26
+ try {
27
+ const res = await fetch(`${HOST}/api/tags`, { signal: AbortSignal.timeout(1500) })
28
+ const body = await res.json()
29
+ const models = (body.models ?? []).map((m) => m.name)
30
+ const modelReady = models.some((m) => m === MODEL || m.startsWith(MODEL.split(':')[0]))
31
+ statusCache = {
32
+ at: Date.now(),
33
+ available: true,
34
+ host: HOST,
35
+ model: MODEL,
36
+ models,
37
+ modelReady,
38
+ thinking: modelReady ? await thinkingCapability() : false,
39
+ }
40
+ } catch {
41
+ statusCache = { at: Date.now(), available: false, host: HOST, model: MODEL, models: [] }
42
+ }
43
+ return statusCache
44
+ }
45
+
46
+ /**
47
+ * 이 모델이 사고 과정을 뱉는 종류인지 Ollama 에 물어본다.
48
+ *
49
+ * 이름으로 짐작하면 새 모델이 나올 때마다 틀린다. /api/show 의 capabilities 가
50
+ * 답을 갖고 있다. 실패하면 false — 없는 기능을 있다고 보는 쪽이 더 위험하다.
51
+ */
52
+ async function thinkingCapability() {
53
+ try {
54
+ const res = await fetch(`${HOST}/api/show`, {
55
+ method: 'POST',
56
+ headers: { 'content-type': 'application/json' },
57
+ body: JSON.stringify({ model: MODEL }),
58
+ signal: AbortSignal.timeout(3000),
59
+ })
60
+ return ((await res.json()).capabilities ?? []).includes('thinking')
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 사고 과정을 끈다.
68
+ *
69
+ * 여기서 하는 일은 추출이지 추론이 아니다. 그리고 이 파이프라인에서는 사고를
70
+ * 켤 방법이 아예 없다 — qwen3.5:9b 로 실측한 결과다.
71
+ * · 출력 문법을 걸면 첫 토큰부터 JSON 만 허용되어 사고할 자리가 없고,
72
+ * 모델의 답이 통째로 thinking 필드로 가서 response 는 빈 문자열이 된다.
73
+ * · 문법을 풀면 사고가 끝나지 않는다. 컨텍스트 16384 · 예산 8192 로도
74
+ * 사고에만 31,540자를 쓰고 287초 뒤 답 없이 잘렸다.
75
+ * 둘 사이에 안전지대가 없으므로 끄는 것이 유일하게 답이 나오는 설정이다.
76
+ *
77
+ * 지원하지 않는 모델에 이 필드를 보내면 Ollama 가 요청을 거부하므로
78
+ * capabilities 로 확인된 경우에만 붙인다.
79
+ */
80
+ const thinkOpt = (s) => (s.thinking ? { think: false } : {})
81
+
82
+ /**
83
+ * 모델별 샘플링.
84
+ *
85
+ * temperature 0.1 은 qwen2.5-coder:7b 에 맞춰 실측으로 굳은 값이다. 다른 모델에
86
+ * 그대로 씌우면 배관 문제를 모델 성능으로 오해하게 된다 (thinking 계열은 기본
87
+ * temperature 가 1 이고 낮은 값에서 반복에 빠지는 것으로 알려져 있다).
88
+ * 그래서 아는 모델만 덮어쓰고 나머지는 모델 자신의 기본값에 맡긴다.
89
+ *
90
+ * presence_penalty 만은 모델과 무관하게 0 으로 못박는다. JSON 은 `{"channel":`
91
+ * 같은 키를 반복해야 하는 형식인데 qwen3.5 의 기본값은 1.5 로 그 반복을 억제하는
92
+ * 방향으로 작용한다. 이건 취향이 아니라 형식 요구다.
93
+ */
94
+ const TUNED = { 'qwen2.5-coder': { temperature: 0.1 } }
95
+
96
+ /**
97
+ * JSON 출력의 길이 한도.
98
+ *
99
+ * 512 였는데 실측에서 20개 중 2개가 잘렸다 (채널 8개짜리 파일들, 41 tok/s 로
100
+ * 10~11초 = 500토큰 부근). 잘린 JSON 은 파싱에 실패하고 그 파일의 엣지가 통째로
101
+ * 사라진다. 하필 채널이 가장 많은 파일에서 터지므로 손실이 가장 큰 곳에서 조용히 진다.
102
+ * format:'json' 이 문법은 강제해도 길이는 강제하지 못한다.
103
+ */
104
+ const JSON_PREDICT = 1536
105
+
106
+ function sampling(json) {
107
+ return {
108
+ ...(TUNED[MODEL.split(':')[0]] ?? {}),
109
+ ...(json ? { presence_penalty: 0 } : {}),
110
+ num_predict: json ? JSON_PREDICT : 256,
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 모델을 미리 메모리에 올린다.
116
+ *
117
+ * 서버가 뜰 때 한 번 부른다. 사용자가 첫 노드를 클릭할 즈음이면 이미 올라가 있어
118
+ * 44초짜리 콜드 스타트를 만나지 않는다. 실패해도 무시한다 — 없어도 되는 기능이다.
119
+ */
120
+ export async function warmup() {
121
+ const s = await status()
122
+ if (!s.available || !s.modelReady) return s
123
+ try {
124
+ await fetch(`${HOST}/api/generate`, {
125
+ method: 'POST',
126
+ headers: { 'content-type': 'application/json' },
127
+ body: JSON.stringify({ model: MODEL, prompt: 'ok', stream: false, keep_alive: KEEP_ALIVE, options: { num_predict: 1 } }),
128
+ signal: AbortSignal.timeout(180_000),
129
+ })
130
+ } catch {
131
+ /* 미리 올리기 실패는 치명적이지 않다 */
132
+ }
133
+ return s
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // 캐시 — content hash 기준. 파일이 안 바뀌었으면 다시 돌리지 않는다.
138
+ // 로컬 추론은 느리므로 캐시가 성능의 대부분이다.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * 엣지 독해의 출력 문법.
143
+ *
144
+ * format:'json' 은 문법만 강제하고 길이는 강제하지 못한다. 실측에서 채널 10개짜리
145
+ * 파일에 같은 채널을 방향만 바꿔가며 97줄 반복하다 num_predict 에 잘려 죽는 일이
146
+ * 40파일 중 1~2건씩 났다 (temperature 를 0.4 까지 올려도 재현됨 — 확률로는 못 막는다).
147
+ *
148
+ * maxItems 를 주면 문법이 배열을 닫도록 강제하므로 그 실패가 원천적으로 사라진다.
149
+ * 24 는 관측된 파일당 최대 채널 수(14)의 두 배 가까운 여유다. 실제 채널이 이보다
150
+ * 많은 파일이 생기면 잘리는데, 그때는 잘림이 아니라 파일을 쪼갤 신호로 읽는 게 맞다.
151
+ */
152
+ const EDGE_SCHEMA = {
153
+ type: 'object',
154
+ properties: {
155
+ edges: {
156
+ type: 'array',
157
+ maxItems: 24,
158
+ items: {
159
+ type: 'object',
160
+ properties: {
161
+ channel: { type: 'string' },
162
+ direction: { type: 'string', enum: ['pub', 'sub'] },
163
+ },
164
+ required: ['channel', 'direction'],
165
+ },
166
+ },
167
+ },
168
+ required: ['edges'],
169
+ }
170
+
171
+ /**
172
+ * 핵심 줄 고르기의 출력 문법.
173
+ *
174
+ * EDGE_SCHEMA 와 같은 이유다. 프롬프트가 "최대 3군데"라고 말해도 그건 부탁이고,
175
+ * maxItems 는 강제다. 엣지 독해에서 실제로 터진 반복 루프가 여기라고 안 터질
176
+ * 이유가 없다 — 여기는 사용자가 노드를 클릭한 화면 경로라 30초 멈춤으로 나타난다.
177
+ *
178
+ * 줄 범위가 파일 길이 안에 드는지는 문법으로 표현할 수 없으므로 아래에서 잘라낸다.
179
+ */
180
+ const KEY_SCHEMA = {
181
+ type: 'object',
182
+ properties: {
183
+ key: {
184
+ type: 'array',
185
+ maxItems: 3,
186
+ items: {
187
+ type: 'object',
188
+ properties: {
189
+ from: { type: 'integer' },
190
+ to: { type: 'integer' },
191
+ why: { type: 'string' },
192
+ },
193
+ required: ['from', 'to', 'why'],
194
+ },
195
+ },
196
+ },
197
+ required: ['key'],
198
+ }
199
+
200
+ /** 종류별 출력 형식. 답을 바꾸는 입력이므로 캐시 키에도 들어간다. */
201
+ // `cls` 의 문법은 대분류 목록에 따라 런타임에 만들어지므로 여기 둘 수 없다.
202
+ // 하지만 키 계산은 "JSON 문법을 썼는가"를 알아야 하고(디코딩 설정이 달라진다),
203
+ // 목록 자체는 classify() 가 키 텍스트에 넣는다.
204
+ const FORMAT = { sum: undefined, keys: KEY_SCHEMA, edge: EDGE_SCHEMA, cls: 'dynamic-enum' }
205
+
206
+ /**
207
+ * 캐시 키 = 모델 + 디코딩 설정 + 내용.
208
+ *
209
+ * 네 함수 모두 readCache 가 status() 보다 먼저 돈다. 키가 파일 내용만 해싱하면
210
+ * AXMAP_MODEL 을 바꿔도 이미 캐시된 파일은 옛 모델의 답을 그대로 돌려주고,
211
+ * 화면에는 새 모델 이름이 붙는다. 모델을 비교하는 순간 이것이 결과를 조용히 오염시킨다.
212
+ *
213
+ * 디코딩 설정까지 넣는 이유도 같다. num_predict 를 512 에서 올려도 캐시가 남아 있으면
214
+ * 잘린 옛 답이 그대로 나온다. 답을 바꾸는 입력은 전부 키에 들어가야 한다.
215
+ */
216
+ function key(kind, text) {
217
+ const cfg = JSON.stringify({ format: FORMAT[kind], options: sampling(FORMAT[kind] != null) })
218
+ return `${kind}-${crypto.createHash('sha1').update(`${MODEL}\n${cfg}\n${text}`).digest('hex').slice(0, 16)}`
219
+ }
220
+
221
+ function readCache(k) {
222
+ try {
223
+ return JSON.parse(fs.readFileSync(path.join(CACHE_DIR, `${k}.json`), 'utf8'))
224
+ } catch {
225
+ return null
226
+ }
227
+ }
228
+
229
+ function writeCache(k, value) {
230
+ try {
231
+ fs.mkdirSync(CACHE_DIR, { recursive: true })
232
+ fs.writeFileSync(path.join(CACHE_DIR, `${k}.json`), JSON.stringify(value))
233
+ } catch {
234
+ /* 캐시 실패는 치명적이지 않다 */
235
+ }
236
+ }
237
+
238
+ async function generate(prompt, { format, timeoutMs = 120_000 } = {}) {
239
+ const s = await status() // 10초 캐시라 사실상 공짜다. think 지원 여부가 여기서 온다
240
+ const res = await fetch(`${HOST}/api/generate`, {
241
+ method: 'POST',
242
+ headers: { 'content-type': 'application/json' },
243
+ body: JSON.stringify({
244
+ model: MODEL,
245
+ prompt,
246
+ stream: false,
247
+ keep_alive: KEEP_ALIVE,
248
+ format,
249
+ ...thinkOpt(s),
250
+ options: sampling(format != null),
251
+ }),
252
+ signal: AbortSignal.timeout(timeoutMs),
253
+ })
254
+ if (!res.ok) throw new Error(`ollama ${res.status}`)
255
+ return (await res.json()).response ?? ''
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // 요약 — 노드를 클릭했을 때
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * 모델에 넘길 내용의 크기 조절.
264
+ *
265
+ * 실측: 생성은 41 tok/s 로 일정한데(3~4초) 프롬프트 평가가 파일 크기에 비례해
266
+ * 최대 9초까지 간다. 병목은 "얼마나 쓰느냐"가 아니라 "얼마나 읽히느냐"다.
267
+ *
268
+ * 그렇다고 고정된 줄 수로 자르면 정확도를 잃는다. 실측에서 222줄짜리 파일을
269
+ * 160줄로 자르자 뒤쪽 62줄에 있던 채널 두 개를 통째로 놓쳤다.
270
+ *
271
+ * 그래서 파일 크기에 따라 다르게 넣는다 (D6 적응형 노드와 같은 원리).
272
+ * 작은 파일 → 전체. 어차피 프롬프트가 짧아 빠르다
273
+ * 큰 파일 → 앞부분 + 함수 목록. 본문 대신 구조를 준다
274
+ */
275
+ const WHOLE_UNDER = 260
276
+ const HEAD_LINES = 130
277
+
278
+ /**
279
+ * 모델을 메모리에 붙잡아 두는 시간.
280
+ *
281
+ * 기본값은 5분이다. 잠깐 다른 일을 하고 돌아와 노드를 누르면 4.7GB 를 다시 올리느라
282
+ * 44초를 기다리게 된다(실측). 사용 중에는 도구가 "가끔 엄청 느린" 물건이 되면 안 된다.
283
+ */
284
+ const KEEP_ALIVE = process.env.AXMAP_KEEP_ALIVE ?? '30m'
285
+
286
+ /** 파일 크기에 맞춰 모델에 보여줄 내용을 고른다. */
287
+ function context(relPath, text) {
288
+ const lines = text.split('\n')
289
+ if (lines.length <= WHOLE_UNDER) {
290
+ return { body: text, note: `파일 ${relPath} 전체 (${lines.length}줄)` }
291
+ }
292
+ const lang = /\.py$/.test(relPath) ? 'python' : 'js'
293
+ const list = outline(text, lang)
294
+ .map((o) => ` ${o.kind} ${o.name} (${o.line}-${o.endLine})`)
295
+ .join('\n')
296
+ return {
297
+ body: `${lines.slice(0, HEAD_LINES).join('\n')}\n\n# ... 생략 ...\n\n# 이 파일의 전체 구성:\n${list}`,
298
+ note: `파일 ${relPath} 의 앞 ${HEAD_LINES}줄과 전체 구성 (총 ${lines.length}줄)`,
299
+ }
300
+ }
301
+
302
+ function summaryPrompt(relPath, text) {
303
+ const { body, note } = context(relPath, text)
304
+ // 작은 모델은 지시가 앞에 멀리 있으면 흘린다. 코드를 먼저 주고 지시를 뒤에 붙인다.
305
+ return `\`\`\`
306
+ ${body}
307
+ \`\`\`
308
+
309
+ 위는 ${note} 이다. 한국어로 두 문장만 써라.
310
+ 1번 문장: 이 파일이 맡은 책임.
311
+ 2번 문장: 다른 코드와 어떻게 이어지는지 (입출력, 채널 이름, 호출 관계).
312
+ 코드에 없는 것은 쓰지 마라. 인사말·머리말·목록 없이 두 문장만 출력하라.`
313
+ }
314
+
315
+ export async function summarize(relPath, text) {
316
+ const k = key('sum', relPath + text)
317
+ const hit = readCache(k)
318
+ if (hit) return { ...hit, cached: true }
319
+
320
+ const s = await status()
321
+ if (!s.available) return { available: false }
322
+
323
+ const out = { available: true, summary: (await generate(summaryPrompt(relPath, text))).trim(), model: s.model }
324
+ writeCache(k, out)
325
+ return out
326
+ }
327
+
328
+ /**
329
+ * 토큰이 나오는 대로 흘려보낸다.
330
+ *
331
+ * 로컬 추론은 한 파일에 수 초가 걸린다. 다 끝날 때까지 빈 화면을 보여주면
332
+ * 실제 속도와 무관하게 도구가 느리게 느껴진다. 첫 글자가 빨리 나오는 것이
333
+ * 전체가 빨리 끝나는 것보다 체감에 크게 작용한다.
334
+ *
335
+ * @param onChunk (text) => void
336
+ * @returns {{available, cached, model}} 본문은 onChunk 로 나갔다
337
+ */
338
+ export async function summarizeStream(relPath, text, onChunk) {
339
+ const k = key('sum', relPath + text)
340
+
341
+ const hit = readCache(k)
342
+ if (hit) {
343
+ onChunk(hit.summary)
344
+ return { available: true, cached: true, model: hit.model }
345
+ }
346
+
347
+ const s = await status()
348
+ if (!s.available) return { available: false }
349
+
350
+ const res = await fetch(`${HOST}/api/generate`, {
351
+ method: 'POST',
352
+ headers: { 'content-type': 'application/json' },
353
+ body: JSON.stringify({
354
+ model: MODEL,
355
+ prompt: summaryPrompt(relPath, text),
356
+ stream: true,
357
+ keep_alive: KEEP_ALIVE,
358
+ ...thinkOpt(s),
359
+ options: sampling(false),
360
+ }),
361
+ signal: AbortSignal.timeout(180_000),
362
+ })
363
+ if (!res.ok) throw new Error(`ollama ${res.status}`)
364
+
365
+ // Ollama 스트림은 줄 단위 JSON 이다. 청크 경계가 줄 중간일 수 있으므로 버퍼링한다.
366
+ const reader = res.body.getReader()
367
+ const dec = new TextDecoder()
368
+ let buf = ''
369
+ let full = ''
370
+ for (;;) {
371
+ const { done, value } = await reader.read()
372
+ if (done) break
373
+ buf += dec.decode(value, { stream: true })
374
+ const lines = buf.split('\n')
375
+ buf = lines.pop()
376
+ for (const line of lines) {
377
+ if (!line.trim()) continue
378
+ try {
379
+ const j = JSON.parse(line)
380
+ if (j.response) {
381
+ full += j.response
382
+ onChunk(j.response)
383
+ }
384
+ } catch {
385
+ /* 부분 줄은 다음 청크에서 이어진다 */
386
+ }
387
+ }
388
+ }
389
+
390
+ writeCache(k, { available: true, summary: full.trim(), model: s.model })
391
+ return { available: true, cached: false, model: s.model }
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // 핵심 코드 — 전체를 보여주는 것은 "핵심만 읽게 한다"는 목적에 반한다.
396
+ //
397
+ // 파일이 짧아도 그 안에서 정말 봐야 할 곳은 몇 줄뿐인 경우가 많다.
398
+ // 어느 줄이 핵심인지는 기계적으로 정하기 어려우므로 LLM 이 고르되,
399
+ // 돌려준 줄 범위는 파일 길이로 잘라내 검증한다 (없는 줄을 가리키면 버린다).
400
+ // ---------------------------------------------------------------------------
401
+
402
+ export async function keyLines(relPath, text) {
403
+ const k = key('keys', relPath + text)
404
+ const hit = readCache(k)
405
+ if (hit) return { ...hit, cached: true }
406
+
407
+ const s = await status()
408
+ if (!s.available) return { available: false, key: [] }
409
+
410
+ const lines = text.split('\n')
411
+ const numbered = lines
412
+ .slice(0, 400)
413
+ .map((l, i) => `${String(i + 1).padStart(4)}| ${l}`)
414
+ .join('\n')
415
+
416
+ const prompt = `\`\`\`
417
+ ${numbered}
418
+ \`\`\`
419
+
420
+ 위는 ${relPath} 이고 각 줄 앞에 줄 번호가 붙어 있다.
421
+
422
+ 이 파일을 이해하려면 **반드시 읽어야 할 곳**을 최대 3군데 골라라.
423
+ - 핵심 규칙·분기·상태 전이가 있는 곳을 고르고, import 나 상용구는 고르지 마라.
424
+ - 한 군데는 20줄을 넘기지 마라.
425
+ - why 는 한국어 한 문장으로, 왜 그 부분이 핵심인지 적어라.
426
+
427
+ JSON 으로만 답하라:
428
+ {"key":[{"from":10,"to":24,"why":"..."}]}`
429
+
430
+ let parsed = { key: [] }
431
+ try {
432
+ parsed = JSON.parse(await generate(prompt, { format: FORMAT.keys }))
433
+ } catch {
434
+ /* 형식을 어기면 빈 결과 */
435
+ }
436
+
437
+ const out = {
438
+ available: true,
439
+ model: s.model,
440
+ key: (parsed.key ?? [])
441
+ .map((r) => ({
442
+ from: Math.max(1, Math.min(lines.length, Number(r.from) | 0)),
443
+ to: Math.max(1, Math.min(lines.length, Number(r.to) | 0)),
444
+ why: typeof r.why === 'string' ? r.why.trim() : '',
445
+ }))
446
+ .filter((r) => r.to >= r.from && r.to - r.from <= 60)
447
+ .slice(0, 3),
448
+ }
449
+ writeCache(k, out)
450
+ return out
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // 파일 분류 — 자연어 단위로 노드를 다시 세우기 위한 색인
455
+ //
456
+ // 🔴 역할 분담이 이 설계의 전부다.
457
+ //
458
+ // 큰 모델 (Claude) 저장소를 한 번 보고 **대분류를 만든다** — 열린 생성
459
+ // 작은 모델 (로컬) 파일마다 그 목록에서 **고르기만 한다** — 좁은 선택
460
+ //
461
+ // 이번 프로젝트에서 작은 모델에게 열린 생성을 시켜 실패한 적이 세 번 있다
462
+ // (기능 이름 짓기, 제목 검증, 이름 참조 정밀도). 반대로 후보를 주고 고르게
463
+ // 하면 잘한다. 실측: 40개 파일에서 **형식위반 0 · 클러스터 내부 일치도 99%**,
464
+ // 파일당 1.7초.
465
+ //
466
+ // enum 을 문법(format)으로 강제하므로 목록 밖의 답이 **원천적으로** 나올 수
467
+ // 없다. `verified` 로 환각을 문자열 대조하던 것보다 강한 보장이다.
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /** 모델에 넣을 분량. 분류는 요약보다 짧게 봐도 된다 — 무엇인지만 알면 된다. */
471
+ const CLASSIFY_HEAD = 120
472
+ const CLASSIFY_WHOLE_UNDER = 160
473
+
474
+ export async function classify(relPath, text, categories) {
475
+ if (!categories?.length) return { available: false }
476
+
477
+ const lines = text.split('\n')
478
+ const body = lines.length <= CLASSIFY_WHOLE_UNDER
479
+ ? text
480
+ : `${lines.slice(0, CLASSIFY_HEAD).join('\n')}\n\n# ... 생략 ...`
481
+
482
+ // 분류 목록이 바뀌면 답이 바뀐다. 캐시 키에 반드시 들어가야 한다 —
483
+ // 안 넣으면 대분류를 고친 뒤에도 옛 답이 그대로 나온다.
484
+ const k = key('cls', `${categories.join('')}\n${relPath}${text}`)
485
+ const hit = readCache(k)
486
+ if (hit) return { ...hit, cached: true }
487
+
488
+ const s = await status()
489
+ if (!s.available) return { available: false }
490
+
491
+ const schema = {
492
+ type: 'object',
493
+ properties: {
494
+ category: { type: 'string', enum: categories },
495
+ role: { type: 'string' },
496
+ },
497
+ required: ['category', 'role'],
498
+ }
499
+
500
+ const prompt = `\`\`\`
501
+ ${body}
502
+ \`\`\`
503
+
504
+ 위는 파일 ${relPath} 이다.
505
+
506
+ 이 파일이 속하는 분류를 아래에서 **하나만** 고르라:
507
+ ${categories.map((c) => `- ${c}`).join('\n')}
508
+
509
+ 그리고 이 파일이 맡은 역할을 한국어 한 문장으로 적으라 (30자 이내).
510
+
511
+ JSON 으로만 답하라: {"category":"...","role":"..."}`
512
+
513
+ let out = { available: true, category: null, role: '', model: s.model }
514
+ try {
515
+ const j = JSON.parse(await generate(prompt, { format: schema, timeoutMs: 60_000 }))
516
+ // 문법이 보장하지만 한 번 더 본다. 모델을 바꾸면 이 가정이 깨질 수 있다.
517
+ out = {
518
+ available: true,
519
+ category: categories.includes(j.category) ? j.category : null,
520
+ role: typeof j.role === 'string' ? j.role.trim().slice(0, 60) : '',
521
+ model: s.model,
522
+ }
523
+ } catch {
524
+ out.parseFailed = true
525
+ }
526
+ writeCache(k, out)
527
+ return out
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // 엣지 독해 — D5
532
+ //
533
+ // 정적 파싱은 파라미터를 통한 간접 배선을 놓친다 (실측으로 확인됨).
534
+ // LLM 은 그것을 읽지만 환각할 수 있다.
535
+ // 그래서 LLM 이 말한 채널이 파일에 문자열로 실제 존재하는지 확인한다.
536
+ // 없으면 버리지 않고 verified:false 로 남겨 화면에서 구분되게 한다.
537
+ // ---------------------------------------------------------------------------
538
+
539
+ export async function readEdges(relPath, text) {
540
+ const k = key('edge', relPath + text)
541
+ const hit = readCache(k)
542
+ if (hit) return { ...hit, cached: true }
543
+
544
+ const s = await status()
545
+ if (!s.available) return { available: false, edges: [] }
546
+
547
+ const { body, note } = context(relPath, text)
548
+ const prompt = `\`\`\`
549
+ ${body}
550
+ \`\`\`
551
+
552
+ 위는 ${note} 이다.
553
+ 이 파일이 발행(publish)하거나 구독(subscribe)하는 채널/토픽 이름을 모두 찾아라.
554
+ 파라미터 기본값, docstring 의 흐름도, 주석에 적힌 것도 포함하라.
555
+ 반드시 코드에 실제로 등장하는 문자열만 쓰라.
556
+
557
+ JSON 으로만 답하라:
558
+ {"edges":[{"channel":"/이름","direction":"pub"|"sub"}]}`
559
+
560
+ let parsed = { edges: [] }
561
+ let parseFailed = false
562
+ try {
563
+ parsed = JSON.parse(await generate(prompt, { format: FORMAT.edge }))
564
+ } catch {
565
+ // "형식을 어겼다" 와 "채널이 하나도 없다" 는 결과가 똑같이 빈 배열이라 구분이 안 된다.
566
+ // 모델을 바꿨을 때 조용히 망가지는 지점이 정확히 여기다 (사고 토큰이 num_predict 를
567
+ // 먹어 JSON 이 잘리는 경우 등). 화면에는 그냥 "엣지 없음"으로 보인다. 플래그로 드러낸다.
568
+ parseFailed = true
569
+ }
570
+
571
+ // 문법은 배열 길이를 묶어주지만 같은 채널을 두 번 쓰는 것은 막지 못한다.
572
+ // 한 파일이 같은 채널을 같은 방향으로 두 번 배선하는 일은 없으므로 중복은 곧 잡음이고,
573
+ // 그대로 두면 화면에 같은 선을 겹쳐 그리고 세는 쪽에서는 개수를 부풀린다.
574
+ const seen = new Set()
575
+ const edges = (parsed.edges ?? [])
576
+ .filter((e) => e && typeof e.channel === 'string')
577
+ .map((e) => ({
578
+ channel: e.channel,
579
+ direction: e.direction === 'pub' ? 'pub' : 'sub',
580
+ // ★ 여기가 작은 모델을 쓸 수 있게 해주는 지점 — 환각을 문자열 대조로 거른다
581
+ verified: text.includes(e.channel),
582
+ source: 'llm',
583
+ }))
584
+ .filter((e) => {
585
+ const k = `${e.channel}\u0000${e.direction}`
586
+ if (seen.has(k)) return false
587
+ seen.add(k)
588
+ return true
589
+ })
590
+
591
+ const out = { available: true, edges, model: s.model, parseFailed }
592
+ writeCache(k, out)
593
+ return out
594
+ }