deel-local-cli 1.20.12 → 2.0.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 (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -42,24 +42,79 @@ const 요청남음 = [
42
42
  *
43
43
  * 남음과 한도를 **짝으로** 묶어 두고, 남은 것이 가장 적은 짝을 쓴다.
44
44
  * 짝으로 안 묶으면 화면이 「출력 0 / 입력 한도 100만」 같은 소리를 한다.
45
+ *
46
+ * 셋째 칸은 **그 통이 다시 차는 시계**다. 통마다 시계가 따로라, 바닥난 통의
47
+ * 시계를 써야 한다 (아래 할당량읽기).
45
48
  */
46
49
  const 토큰통 = [
47
- ['x-ratelimit-remaining-tokens', 'x-ratelimit-limit-tokens'],
48
- ['ratelimit-remaining-tokens', 'ratelimit-limit-tokens'],
49
- ['anthropic-ratelimit-tokens-remaining', 'anthropic-ratelimit-tokens-limit'],
50
- ['anthropic-ratelimit-input-tokens-remaining', 'anthropic-ratelimit-input-tokens-limit'],
51
- ['anthropic-ratelimit-output-tokens-remaining', 'anthropic-ratelimit-output-tokens-limit'],
50
+ ['x-ratelimit-remaining-tokens', 'x-ratelimit-limit-tokens', 'x-ratelimit-reset-tokens'],
51
+ ['ratelimit-remaining-tokens', 'ratelimit-limit-tokens', 'ratelimit-reset'],
52
+ ['anthropic-ratelimit-tokens-remaining', 'anthropic-ratelimit-tokens-limit', 'anthropic-ratelimit-tokens-reset'],
53
+ ['anthropic-ratelimit-input-tokens-remaining', 'anthropic-ratelimit-input-tokens-limit', 'anthropic-ratelimit-input-tokens-reset'],
54
+ ['anthropic-ratelimit-output-tokens-remaining', 'anthropic-ratelimit-output-tokens-limit', 'anthropic-ratelimit-output-tokens-reset'],
52
55
  ];
53
- const 요청한도 = ['x-ratelimit-limit-requests', 'ratelimit-limit-requests', 'anthropic-ratelimit-requests-limit'];
56
+ /*
57
+ * 접미사 없는 `x-ratelimit-limit`·`x-ratelimit-reset` 도 같이 읽는다.
58
+ *
59
+ * `요청남음` 에는 접미사 없는 `x-ratelimit-remaining` 이 진작 있는데 그 **짝**인 한도와
60
+ * 시계만 어느 목록에도 없었다. 그 이름으로 주는 게이트웨이(Groq·Together 계열)에서는
61
+ * 남은 수만 뜨고 한도도 시계도 영영 null 이었다 — 한도를 모르면 10% 검사가 안 돌아
62
+ * 남은 수 바닥값으로만 떨어지고, 시계를 모르면 화면에 「몇 초 뒤 풀림」 이 안 뜬다.
63
+ * 아래 하나풀림 의 주석 예시(`x-ratelimit-reset: 1773538800`)가 바로 그 이름이다.
64
+ */
65
+ const 요청한도 = ['x-ratelimit-limit-requests', 'ratelimit-limit-requests', 'x-ratelimit-limit', 'anthropic-ratelimit-requests-limit'];
66
+ const 요청차는때 = ['x-ratelimit-reset-requests', 'anthropic-ratelimit-requests-reset', 'x-ratelimit-reset', 'ratelimit-reset'];
54
67
  /*
55
68
  * 풀리는 때. Anthropic 은 초가 아니라 **날짜(RFC 3339)** 로 준다 —
56
69
  * `언제풀리나` 가 Date.parse 로 받아 지금과의 차이를 초로 바꾼다.
70
+ *
71
+ * ── 「다시 오라」 와 「통이 다시 찬다」 는 다른 말이다 ────────────────────
72
+ *
73
+ * 여태 이 둘을 한 목록에 담아 두고 앞에서부터 있는 것 하나를 집었다. 그런데
74
+ * `Retry-After` 는 **서버가 우리한테 시킨 것**이고, `x-ratelimit-reset-*` 는
75
+ * 그냥 **통이 언제 다시 차는지 적어 둔 시계**다. 뒤엣것은 막을 때만 오는 것이
76
+ * 아니라 **잘 된 응답마다** 실려 온다.
77
+ *
78
+ * 섞어 두었더니 이런 일이 났다. 429 를 맞았는데 `Retry-After` 는 없고
79
+ * `x-ratelimit-reset-tokens: 6m0s` 만 있는 자리에서, 미리기다릴까() 가 그 6분을
80
+ * 서버가 시킨 것으로 읽고 1분(미리기다림상한)을 붙들었다. 화면은 그 1분을
81
+ * 서버 탓으로 적었는데 서버는 그런 말을 한 적이 없다. 아무 말도 못 들은
82
+ * 자리의 답은 막힘띄움(5초)인데, reset 머리가 하나라도 있으면 그 갈래에는
83
+ * 영영 못 들어갔다.
84
+ *
85
+ * 그래서 따로 읽는다. **시킨 것만 시킨 것으로 친다.** 시계는 시계로 두고,
86
+ * 「남은 것이 0」 이라고 서버가 스스로 적어 보냈을 때만 그 시계를 쓴다.
57
87
  */
58
- const 다시언제 = [
59
- 'retry-after', 'x-ratelimit-reset-requests', 'x-ratelimit-reset-tokens', 'ratelimit-reset',
88
+ const 서버가부른때 = ['retry-after'];
89
+ /*
90
+ * 통이 **바닥나기 전**에 화면에 적을 시계다. 바닥난 뒤에는 가장빠듯한통 이 그 통의
91
+ * 시계를 따로 챙기지만(바닥시계), 바닥나기 전 — 경고가 뜨는 바로 그 자리 — 에는 이
92
+ * 목록에 있는 이름만 읽힌다. 여기에 Anthropic 입력·출력 통의 리셋 이름이 빠져 있어서,
93
+ * 「토큰 200/100,000」 이라고 적으면서 언제 차는지는 못 적었다. 미리 알려 주자고 만든
94
+ * 파일인데 미리 볼 때만 시계가 없었던 셈이다 (위 토큰통 의 셋째 칸과 같은 이름들이다).
95
+ */
96
+ const 통차는때 = [
97
+ 'x-ratelimit-reset-requests', 'x-ratelimit-reset-tokens', 'x-ratelimit-reset', 'ratelimit-reset',
60
98
  'anthropic-ratelimit-requests-reset', 'anthropic-ratelimit-tokens-reset',
99
+ 'anthropic-ratelimit-input-tokens-reset', 'anthropic-ratelimit-output-tokens-reset',
61
100
  ];
62
101
 
102
+ /*
103
+ * 이름 여럿 중 **늦게 차는 쪽**. 바닥난 통이 없을 때 화면에 적을 시계다.
104
+ *
105
+ * `골라` 는 목록에서 처음 있는 것 하나를 집는다. 그런데 진짜 Anthropic 응답은 요청·입력
106
+ * 토큰·출력 토큰 시계를 **한꺼번에** 실어 보낸다. 그러면 목록 차례상 늘 요청 시계가 집혀,
107
+ * 화면이 「토큰 200/100,000 · 1초 뒤 풀림」 처럼 **적힌 통과 딴 통의 시계**를 나란히 적었다.
108
+ * 뒤에 이름을 보태도 앞엣것에 가려 한 번도 안 걸린다.
109
+ *
110
+ * 규칙은 바닥시계 와 같게 둔다 — **다 차야 보낼 수 있다.** 이른 쪽을 적으면 「풀렸다」 고
111
+ * 해 놓고 또 막힌다.
112
+ */
113
+ function 늦은시계(머리, 이름들) {
114
+ const 아는것 = 이름들.map((k) => 언제풀리나(골라(머리, [k]))).filter((x) => x !== null);
115
+ return 아는것.length ? Math.max(...아는것) : null;
116
+ }
117
+
63
118
  function 골라(머리, 이름들) {
64
119
  if (!머리) return null;
65
120
  const 보기 = (k) => (typeof 머리.get === 'function' ? 머리.get(k) : 머리[k] ?? 머리[k.toLowerCase()]);
@@ -78,8 +133,17 @@ function 골라(머리, 이름들) {
78
133
  */
79
134
  function 숫자(v) {
80
135
  if (v === null) return null;
81
- const n = Number(String(v).replace(/[,_\s]/g, ''));
82
- return Number.isFinite(n) ? n : null;
136
+ /*
137
+ * 같은 머리가 두 번 오면 Headers 가 「5, 5」 로 이어 붙인다(노드 http 의 머리 객체도 같다).
138
+ * 게이트웨이와 윗단이 같은 머리를 둘 다 실으면 생긴다. 쉼표만 지우고 읽었더니 **55** 가 돼서,
139
+ * 남은 5 가 넉넉한 55 로 읽혀 경고가 안 떴다 (6회차 Gemini 할당6 QA1).
140
+ *
141
+ * 이어 붙인 자리에는 쉼표 **뒤에 빈칸**이 온다. 천 단위 쉼표(`12,500`)에는 빈칸이 없다.
142
+ * 그래서 「쉼표+빈칸」 으로만 가르고, 가른 것 중 **제일 적은 값**을 쓴다 — 두 층이 서로
143
+ * 다른 수를 말하면 먼저 막히는 쪽이 곧 막히는 쪽이다.
144
+ */
145
+ const 낱 = String(v).split(/,\s+/).map((x) => x.replace(/[,_\s]/g, '')).filter(Boolean).map(Number).filter(Number.isFinite);
146
+ return 낱.length ? Math.min(...낱) : null;
83
147
  }
84
148
 
85
149
  /*
@@ -89,40 +153,112 @@ function 숫자(v) {
89
153
  export function 언제풀리나(v) {
90
154
  if (v === null || v === undefined) return null;
91
155
  const s = String(v).trim();
156
+ // 빈 머리말은 못 읽은 것이다 — Number('') 가 0 이라 「지금 바로 풀림」 이 됐다 (2.0.0 6회차 · Gemini 백엔드5역).
157
+ if (!s) return null;
158
+ /*
159
+ * 같은 머리가 두 번 와 이어 붙은 것(`5, 5` · `20ms, 6m0s`). 통째로 읽으면 `Date.parse('5, 5')` 가
160
+ * **2001년 날짜**를 내서 「이미 지남 = 0초」 가 됐고, 시계 둘은 아예 못 읽었다 (6회차 Gemini 할당6 QA1).
161
+ * 하나씩 읽어 **늦은 쪽**을 쓴다 — 둘 다 풀려야 보낼 수 있다.
162
+ */
163
+ /*
164
+ * HTTP 날짜의 요일 뒤 쉼표(`Wed, 21 Oct 2015 …`)도 여기서 갈리는데 그래도 된다 — 요일 조각은 못 읽어
165
+ * 빠지고, 나머지 `21 Oct 2015 07:28:00 GMT` 는 그대로 날짜로 읽힌다(quota.test 6-할당6 이 잰다).
166
+ */
167
+ const 조각 = s.split(/,\s+/);
168
+ if (조각.length > 1) {
169
+ const 읽은것 = 조각.map(하나풀림).filter((x) => x !== null);
170
+ return 읽은것.length ? Math.max(...읽은것) : null;
171
+ }
172
+ return 하나풀림(s);
173
+ }
174
+
175
+ function 하나풀림(s) {
92
176
  const n = Number(s);
93
- if (Number.isFinite(n)) return Math.max(0, Math.round(n));
177
+ if (Number.isFinite(n)) {
178
+ /*
179
+ * 풀리는 **시각**을 에포크로 주는 게이트웨이가 있다(`x-ratelimit-reset: 1773538800`). 그 수를
180
+ * 기다릴 초로 읽으면 56년 뒤에 풀린다고 적는다. 10억 초(2001년)를 넘는 수는 기다릴 초일 수가
181
+ * 없으니 시각으로 보고 지금과의 차이를 낸다. 1조를 넘으면 밀리초 시각이다.
182
+ */
183
+ if (n >= 1e12) return Math.max(0, Math.round((n - Date.now()) / 1000));
184
+ if (n >= 1e9) return Math.max(0, Math.round(n - Date.now() / 1000));
185
+ return Math.max(0, Math.round(n));
186
+ }
94
187
  const t = Date.parse(s);
95
188
  if (Number.isFinite(t)) return Math.max(0, Math.round((t - Date.now()) / 1000));
96
- // `1m30s` 같은 꼴을 주는 게이트웨이가 있다.
97
- const m = /^(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/.exec(s);
98
- if (m && (m[1] || m[2])) return Math.round((Number(m[1] ?? 0) * 60) + Number(m[2] ?? 0));
189
+ /*
190
+ * `1m30s` 같은 꼴을 주는 게이트웨이가 있다. OpenAI 는 여기에 **시(h)와 밀리초(ms)** 도
191
+ * 섞어 준다 — `x-ratelimit-reset-requests: 20ms`, `x-ratelimit-reset-tokens: 1h2m`.
192
+ * 여기가 m·s 만 읽어서 둘 다 null 이었고, 그러면 「남은 것 0」 이라고 적힌 응답에서도
193
+ * 언제 풀리는지를 몰라 미리 비키지 못하고 429 를 한 번 맞았다 (사냥5 B5-11).
194
+ *
195
+ * 차례는 h · m · s · ms 로 고정한다. `m` 은 뒤에 `s` 가 붙지 않을 때만 분이다 —
196
+ * 안 그러면 `20ms` 가 「20분 + 모르는 s」 로 읽힌다. 1초가 안 되는 것은 반올림한다
197
+ * (숫자로 준 `0.4` 와 같은 규칙).
198
+ */
199
+ const m = /^(?:(\d+(?:\.\d+)?)h)?(?:(\d+(?:\.\d+)?)m(?!s))?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/.exec(s);
200
+ if (m && (m[1] || m[2] || m[3] || m[4])) {
201
+ return Math.round(Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) + Number(m[4] ?? 0) / 1000);
202
+ }
99
203
  return null;
100
204
  }
101
205
 
102
- /** 토큰 통 중 **가장 적게 남은** 짝. 없으면 둘 다 null. */
206
+ /*
207
+ * 토큰 통 중 **가장 빠듯한** 짝, 그리고 바닥난 통마다 제 시계.
208
+ *
209
+ * 수만 견주면 입력 5,000/200,000(2.5%) 과 출력 2,000/4,000(50%) 에서 출력 통을 골라
210
+ * 「절반 남음」 이라 하고 경고도 안 띄웠다. 곧 막히는 쪽은 입력 통이다 (6회차 Gemini
211
+ * 할당6 QA3). 둘 다 한도를 알면 **비율**로, 하나라도 모르면 수로 견준다.
212
+ */
103
213
  function 가장빠듯한통(머리) {
104
214
  let 고른것 = { 토큰: null, 토큰한도: null };
105
- for (const [남음이름, 한도이름] of 토큰통) {
215
+ const 바닥시계 = [];
216
+ for (const [남음이름, 한도이름, 시계이름] of 토큰통) {
106
217
  const 남 = 숫자(골라(머리, [남음이름]));
107
218
  if (남 === null) continue;
108
- if (고른것.토큰 === null || 남 < 고른것.토큰) {
109
- 고른것 = { 토큰: 남, 토큰한도: 숫자(골라(머리, [한도이름])) };
110
- }
219
+ const 한도 = 숫자(골라(머리, [한도이름]));
220
+ if (남 <= 0) 바닥시계.push(언제풀리나(골라(머리, [시계이름])));
221
+ const 더빠듯 = 한도 > 0 && 고른것.토큰한도 > 0 ? 남 / 한도 < 고른것.토큰 / 고른것.토큰한도 : 남 < 고른것.토큰;
222
+ if (고른것.토큰 === null || 더빠듯) 고른것 = { 토큰: 남, 토큰한도: 한도 };
111
223
  }
112
- return 고른것;
224
+ return { 고른것, 바닥시계 };
113
225
  }
114
226
 
115
227
  /**
116
228
  * 응답 머리에서 할당량을 읽는다.
117
229
  *
118
- * @returns {{요청:number|null, 요청한도:number|null, 토큰:number|null, 토큰한도:number|null, 풀림:number|null, 있나:boolean}}
230
+ * `서버말` 은 **서버가 「이때 다시 오라」 고 시킨 것**(Retry-After)이고,
231
+ * `풀림` 은 그 말이 없으면 통이 다시 차는 시계까지 넓혀 본 값이다. 화면은
232
+ * 넓은 쪽을 쓰고(할당량말), 기다릴지 말지는 시킨 쪽만 보고 정한다.
233
+ *
234
+ * @returns {{요청:number|null, 요청한도:number|null, 토큰:number|null, 토큰한도:number|null, 서버말:number|null, 풀림:number|null, 있나:boolean}}
119
235
  */
120
236
  export function 할당량읽기(머리) {
237
+ const 서버말 = 언제풀리나(골라(머리, 서버가부른때));
238
+ const 요청 = 숫자(골라(머리, 요청남음));
239
+ const { 고른것, 바닥시계 } = 가장빠듯한통(머리);
240
+ if (요청 !== null && 요청 <= 0) 바닥시계.push(언제풀리나(골라(머리, 요청차는때)));
241
+ /*
242
+ * ── 바닥난 통의 시계를 쓴다 ────────────────────────────────────────────
243
+ *
244
+ * 사람이 본 것: 요청은 4,999 남고 토큰이 0 인데 보내자마자 429 였다. 기다리지도 않았다.
245
+ *
246
+ * 실제로 있었던 일: 시계를 목록 차례로 하나 집었고, 맨 앞이 요청 시계(`20ms`)였다.
247
+ * 토큰 통은 6분 뒤에야 차는데 「곧 풀림(0초)」 으로 읽고 곧장 보냈다. Anthropic 출력 통이
248
+ * 바닥났을 때도 요청 시계를 집었다 — 출력 시계 이름은 목록에 아예 없었다
249
+ * (6회차 Gemini 할당6 QA4).
250
+ *
251
+ * 이제 지키는 규칙: 바닥난 통이 있으면 **그 통들의 시계 중 늦은 쪽**(다 차야 보낼 수 있다).
252
+ * 바닥난 통이 제 시계를 안 실어 보냈거나 바닥난 통이 없으면, 전처럼 있는 시계 하나를 쓴다
253
+ * — 화면에 적을 값이다.
254
+ */
255
+ const 아는시계 = 바닥시계.filter((x) => x !== null);
121
256
  const 것 = {
122
- 요청: 숫자(골라(머리, 요청남음)),
257
+ 요청,
123
258
  요청한도: 숫자(골라(머리, 요청한도)),
124
- ...가장빠듯한통(머리),
125
- 풀림: 언제풀리나(골라(머리, 다시언제)),
259
+ ...고른것,
260
+ 서버말,
261
+ 풀림: 서버말 ?? (아는시계.length ? Math.max(...아는시계) : 늦은시계(머리, 통차는때)),
126
262
  };
127
263
  것.있나 = 것.요청 !== null || 것.토큰 !== null || 것.풀림 !== null;
128
264
  return 것;
@@ -140,7 +276,27 @@ export const 토큰바닥 = 20000;
140
276
 
141
277
  export function 아슬아슬한가(것) {
142
278
  if (!것?.있나) return false;
143
- if (것.풀림 !== null && 것.풀림 > 0) return true;
279
+ /*
280
+ * ── 이 한 줄이 경고를 **늘 켜 놓고** 있었다 ────────────────────────────
281
+ *
282
+ * 사람이 본 것: OpenAI 를 쓰는 내내 상태줄에 「⚠ 서버 할당량 …」 이 떠
283
+ * 있었다. 요청이 5,000 중 4,999 남았을 때도 떴다. 늘 떠 있으니 아무도 안
284
+ * 읽었고, 정작 진짜로 바닥난 날에도 안 읽혔다 — 바로 위 머리말이 하지
285
+ * 말자고 적어 둔 그것이다.
286
+ *
287
+ * 실제로 있었던 일: 여기서 보던 `풀림` 은 「막혔다」 는 표시가 아니라
288
+ * **통이 다시 차는 시계**였고(위 통차는때), OpenAI 는 그 머리를 **성공한
289
+ * 응답마다** 실어 보낸다. 그래서 조건이 매 턴 참이 됐다.
290
+ *
291
+ * 왜 아무도 못 봤나: 아래 10% 검사와 바닥값 검사가 **한 번도 안 돌았다.**
292
+ * 여기서 이미 true 로 나가 버리니, 그 두 검사가 틀렸어도 알 길이 없었다.
293
+ * 조용한 채로 죽어 있던 코드다.
294
+ *
295
+ * 이제 지키는 규칙: **서버가 우리를 밀어냈을 때만**(Retry-After) 그 한 줄로
296
+ * 띄운다. 시계는 「얼마나 남았나」 를 말해 주지 않는다 — 남은 양은 아래
297
+ * 두 검사가 센다.
298
+ */
299
+ if (것.서버말 !== null && 것.서버말 > 0) return true;
144
300
  if (것.요청 !== null) {
145
301
  if (것.요청한도) { if (것.요청 / 것.요청한도 <= 0.1) return true; }
146
302
  else if (것.요청 <= 요청바닥) return true;
@@ -324,11 +480,51 @@ export function 미리기다릴까(것, 지금 = Date.now()) {
324
480
  if (!(것.때 > 0) || 지금 - 것.때 > 낡은값) return null;
325
481
 
326
482
  const 바닥난것 = (것.요청 !== null && 것.요청 <= 0) || (것.토큰 !== null && 것.토큰 <= 0);
327
- // 서버가 언제 오라고 말해 줬으면 그 말이 먼저다. 바닥났다고 했거나 방금 막혔거나.
328
- if ((바닥난것 || 것.막힘) && 것.풀림 > 0) {
329
- // 그 응답을 받은 뒤로 흐른 만큼은 빼 준다.
330
- const 남은초 = 것.풀림 - Math.floor((지금 - 것.때) / 1000);
331
- if (남은초 > 0) return Math.min(남은초 * 1000, 미리기다림상한);
483
+ /*
484
+ * ── 서버가 시킨 것과 우리가 읽은 시계를 갈라 본다 ──────────────────────
485
+ *
486
+ * 사람이 본 것: 429 한 번에 화면이 「서버가 60초 뒤에 오라고 했습니다」 하고
487
+ * 1분을 붙들었다. 그런데 그 응답에 `Retry-After` 는 없었다. 서버가 말한 적
488
+ * 없는 1분을 서버 이름으로 적은 것이다.
489
+ *
490
+ * 실제로 있었던 일: `풀림` 이 `x-ratelimit-reset-tokens: 6m0s` 에서 온
491
+ * 값이었다. 6분을 상한에서 잘라 1분이 됐다. 「아무 말도 못 들었을 때는 5초만
492
+ * 띄운다」 는 아래 갈래는, reset 머리를 주는 서버에서는 **닿을 수가 없었다.**
493
+ *
494
+ * 이제 지키는 규칙:
495
+ * · **시킨 것**(서버말 = Retry-After) 은 언제나 이긴다. 바닥났다고 했든
496
+ * 방금 막혔든 그 말을 지킨다.
497
+ * · **시계**(풀림) 는 서버가 「남은 것이 0」 이라고 스스로 적어 보냈을
498
+ * 때만 쓴다. 그때는 짐작이 아니다 — 그 0 이 언제 0 이 아니게 되는지를
499
+ * 같은 응답이 적어 준 것이다.
500
+ * · 429 만 맞고 남은 수도 Retry-After 도 못 받은 자리는 시계를 안 본다.
501
+ * 거기서 우리가 아는 것은 「방금 막혔다」 하나뿐이고, 그 답은 아래
502
+ * 막힘띄움이다.
503
+ */
504
+ /*
505
+ * 시킨 것은 **0 초라도, 이미 지났어도** 시킨 것이다. 여태 `서버말 > 0` 만 시킨 것으로 쳐서
506
+ * `Retry-After: 0` 과 「1초라더니 1.5초가 지남」 이 둘 다 아래 막힘띄움(5초)으로 떨어졌다 —
507
+ * 서버가 곧장 오라고 한 자리를 우리가 3.5~5초 붙든 것이다 (6회차 Gemini 할당6 QA5·QB1).
508
+ */
509
+ const 시킨때 = Number.isFinite(것.서버말) ? 것.서버말 : null;
510
+ const 볼것 = 시킨때 ?? (바닥난것 && 것.풀림 > 0 ? 것.풀림 : null);
511
+ if ((바닥난것 || 것.막힘) && 볼것 !== null) {
512
+ /*
513
+ * 그 응답을 받은 뒤로 흐른 만큼은 빼 준다 — **밀리초로.** 초로 버려 빼면 1초 시계를
514
+ * 900ms 뒤에 물어도 1초를 통째로 더 기다렸다 (QB3).
515
+ */
516
+ const 남은 = 볼것 * 1000 - (지금 - 것.때);
517
+ if (남은 > 0) return Math.min(남은, 미리기다림상한);
518
+ /*
519
+ * 그 때가 **이미 지났으면 기다릴 것이 없다** — 시킨 것이든 서버가 적어 준 시계든.
520
+ *
521
+ * 여기가 `시킨때 !== null` 일 때만 끝냈다. 그래서 「남은 것 0 · 1초 뒤 참」 이라고
522
+ * 적어 보낸 응답을 1.5초 뒤에 물으면, 서버가 이미 「찼다」 고 말해 준 자리에서
523
+ * 아래 막힘띄움(5초)으로 떨어져 3.5초를 더 붙들었다. 같은 응답인데 Retry-After 가
524
+ * 있고 없고에 따라 0초와 3.5초로 갈렸다 — 아래 갈래는 **아무 말도 못 들은** 자리의
525
+ * 답이지, 들은 말이 지난 자리의 답이 아니다.
526
+ */
527
+ return null;
332
528
  }
333
529
 
334
530
  /*
@@ -190,14 +190,54 @@ export function 기다리기(ms, signal = null) {
190
190
  });
191
191
  }
192
192
 
193
+ /*
194
+ * ── 안 부르는 까닭이 둘인데 화면은 한 가지로 말했다 ──────────────────────
195
+ *
196
+ * 사람이 본 것: 사내 게이트웨이에서 429 가 잦아 `retry.막힘최대` 를 8 로
197
+ * 올려 뒀는데, 화면은 여전히 `5번 불렀지만 계속 막혔습니다 (HTTP 429)` 에서
198
+ * 멎었다. 여덟 번 참으라고 적어 둔 값이 **아무 일도 안 하는 것처럼** 보였다.
199
+ *
200
+ * 실제로 있었던 일: 여덟 번을 채우기 전에 **우리 쪽 시간 울타리**(총상한
201
+ * 5분)가 먼저 닫혔다. `Retry-After` 가 실려 오면 한 번에 최대 60초까지
202
+ * 기다리니(기다릴시간 의 상한), 대여섯 번이면 5분이 찬다.
203
+ *
204
+ * 왜 몰랐나: 이 함수가 **두 경우에 똑같이 null 을 돌려줬다.** 「다시 부를
205
+ * 것이 아니다·횟수를 다 썼다」 와 「우리 예산을 다 썼다」 는 사람이 할 일이
206
+ * 아주 다르다 — 앞은 서버를 봐야 하고, 뒤는 우리 설정을 올리면 되는 일이다.
207
+ * 부르는 쪽은 그 둘을 가를 길이 없어 서버 탓으로만 적었다.
208
+ *
209
+ * 이제 지키는 규칙: 돌려주는 모양은 그대로 두고(부르는 데가 여럿이다),
210
+ * **까닭을 적어 줄 자리**를 인자로 받는다 — detect.js 의 막은것적기 와 같은
211
+ * 자세다. 안 주면 여태와 한 글자도 다르지 않게 돈다.
212
+ */
213
+ /** 왜 안 부르나. 화면 문구가 여기서 갈린다. */
214
+ export const 못부른까닭 = { 안될것: '안될것', 예산: '예산' };
215
+
216
+ /**
217
+ * 우리 쪽 기다림 예산을 다 썼다는 말. 서버가 한 말과 **섞이면 안 된다** —
218
+ * 이 줄에는 사람이 올릴 수 있는 설정 이름이 적혀 있어야 한다.
219
+ */
220
+ export function 못부른말(적힌것) {
221
+ if (적힌것?.까닭 !== 못부른까닭.예산) return null;
222
+ const 초 = Math.round((적힌것.총상한 ?? 0) / 1000);
223
+ return `우리 쪽 기다림 예산 ${초}초를 다 썼습니다 (retry.총상한)`
224
+ + ' — 서버가 계속 막은 것이 아니라 여기서 그만둔 것입니다. 더 참게 하려면 그 값을 올리세요.';
225
+ }
226
+
193
227
  /**
194
228
  * 실패한 응답 하나를 보고 "기다렸다 다시 부른다" 알림을 만든다. 안 부를 것이면 null.
195
229
  * 화면·기록이 이 한 덩이를 그대로 쓴다 — 여기 없는 숫자는 화면에도 없다.
230
+ *
231
+ * @param {{까닭?:string}} [적을곳] 안 부를 때 **왜**를 채워 준다 (위 머리말)
196
232
  */
197
- export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0) {
233
+ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0, 적을곳 = null) {
198
234
  const status = r?.status ?? 0;
199
235
  const code = r?.code ?? null;
200
- if (!다시부를까({ status, code, attempt }, 정책)) return null;
236
+ const 적기 = (것) => { if (적을곳) Object.assign(적을곳, 것); };
237
+ if (!다시부를까({ status, code, attempt }, 정책)) {
238
+ 적기({ 까닭: 못부른까닭.안될것, status, code, attempt });
239
+ return null;
240
+ }
201
241
  const retryAfter = r?.headers?.get?.('retry-after') ?? r?.res?.headers?.get?.('retry-after') ?? null;
202
242
  const wait = 기다릴시간({ attempt, retryAfter, status }, 정책);
203
243
  /*
@@ -208,7 +248,10 @@ export function 다시부를지(r, attempt, 정책 = 기본정책(), 쌓인 = 0)
208
248
  * 둘 다 있어야 한다.
209
249
  */
210
250
  const 총상한 = 정책.총상한 ?? Infinity;
211
- if (Number.isFinite(총상한) && 쌓인 + wait > 총상한) return null;
251
+ if (Number.isFinite(총상한) && 쌓인 + wait > 총상한) {
252
+ 적기({ 까닭: 못부른까닭.예산, status, code, attempt, 총상한, 쌓인, 다음대기: wait });
253
+ return null;
254
+ }
212
255
  return {
213
256
  type: 'backoff',
214
257
  status,
@@ -8,6 +8,7 @@
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { req, headersFor } from './http.js';
11
+ import { 모델목록모양 } from './detect.js';
11
12
  import { allowTemporarily } from '../safety/network.js';
12
13
 
13
14
  // 포트는 겹칠 수 있다(llama.cpp 와 LocalAI 가 둘 다 8080). 그래서 포트로 단정하지 않고
@@ -117,18 +118,43 @@ async function probeOllama(origin, timeout) {
117
118
  // 죽은 포트에서는 첫 요청이 바로 실패하므로 훑는 시간에는 거의 영향이 없다.
118
119
  const BASES = ['/v1', '', '/api/v1', '/openai/v1', '/api', '/llm/v1', '/proxy/v1'];
119
120
 
121
+ /*
122
+ * 열쇠를 달라는 **OpenAI 식** 거절인가 (사냥5 B5-08).
123
+ *
124
+ * 몸에 `error` 칸이 있거나(OpenAI · LiteLLM · vLLM · llama.cpp 가 다 이 모양이다)
125
+ * `WWW-Authenticate: Bearer` 로 열쇠를 달라고 할 때만 그렇다고 본다. `Basic realm=…` 에
126
+ * 글자 「Unauthorized」 는 공유기·NAS 관리 화면의 말투다.
127
+ */
128
+ function 열쇠달라는거절(r) {
129
+ const h = r.headers;
130
+ const 머리 = String((typeof h?.get === 'function' ? h.get('www-authenticate') : h?.['www-authenticate']) ?? '');
131
+ if (/^\s*bearer\b/i.test(머리)) return true;
132
+ const j = r.json;
133
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return false;
134
+ return typeof j.error === 'string' ? j.error.length > 0 : (!!j.error && typeof j.error === 'object');
135
+ }
136
+
120
137
  async function probeOpenAI(origin, timeout, key) {
121
138
  for (const base of BASES.map((b) => `${origin}${b}`)) {
122
139
  const r = await req(`${base}/models`, { headers: headersFor(key ? 'bearer' : 'none', key), timeout });
123
- if (!r.ok || !r.json) {
124
- // 규격은 맞는데 키가 없어 막힌 경우 — 서버가 있다는 사실은 알려 준다.
125
- if (r.status === 401 || r.status === 403) {
140
+ if (!r.ok) {
141
+ /*
142
+ * 규격은 맞는데 키가 없어 막힌 경우 — 서버가 있다는 사실은 알려 준다.
143
+ *
144
+ * 다만 **401 한 줄만으로는 이 규격이라고 못 한다** (사냥5 B5-08). 공유기·NAS 관리
145
+ * 화면은 모든 길에 401 을 준다. 그걸 「잠김 — deel scan --key <키>」 로 올리면 남의
146
+ * 프로그램에 우리 열쇠를 보내라고 권하는 셈이다. 그래서 거절 모양이 OpenAI 식일
147
+ * 때만 센다. 놓치는 쪽으로 틀린다 — 못 찾은 서버는 주소를 직접 적으면 붙지만,
148
+ * 보낸 열쇠는 못 돌려받는다.
149
+ */
150
+ if ((r.status === 401 || r.status === 403) && 열쇠달라는거절(r)) {
126
151
  return { kind: 'openai', runtime: null, base, auth: null, models: [], locked: true, ms: r.ms };
127
152
  }
128
153
  continue;
129
154
  }
130
- const list = r.json.data ?? r.json.models ?? [];
131
- if (!Array.isArray(list)) continue;
155
+ // 목록 모양이 아니면 이 규격이 아니다 — 설치(detect.js)와 같은 잣대.
156
+ const list = 모델목록모양(r.json);
157
+ if (!list) continue;
132
158
  return {
133
159
  kind: 'openai',
134
160
  runtime: null,
@@ -171,7 +197,10 @@ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200
171
197
  // 직접 세운 프록시는 알려진 자리에 없기 때문이다. 이 PC 안에서만 한다 —
172
198
  // 남의 컴퓨터 포트를 훑는 것은 이 도구가 할 일이 아니다.
173
199
  const 잘린것 = [];
174
- if (listening && (host === '127.0.0.1' || host === 'localhost' || host === '::1')) {
200
+ // IPv6 주소는 괄호를 씌워야 주소가 된다. 안 씌우면 `http://::1:11434` 가 Invalid URL 로
201
+ // **훑기 전체를** 던졌다 — `deel scan --host ::1` 한 줄로 (2.0.0 6회차 Gemini 훑기6bf).
202
+ const 주소host = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
203
+ if (listening && (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]')) {
175
204
  const 열린것 = listeningPorts().filter((p) => !list.some((x) => x.port === p));
176
205
  const 볼것 = 열린것.slice(0, maxListening);
177
206
  if (열린것.length > 볼것.length) 잘린것.push(...열린것.slice(maxListening));
@@ -181,7 +210,7 @@ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200
181
210
  }
182
211
 
183
212
  const jobs = list.map(async ({ port, hint }) => {
184
- const origin = `http://${host}:${port}`;
213
+ const origin = `http://${주소host}:${port}`;
185
214
  // 훑는 동안만 그 자리를 연다. 끝나면 바로 닫아서 자물쇠를 원래대로 둔다.
186
215
  const close = allowTemporarily(origin);
187
216
  let hit;
@@ -228,8 +257,8 @@ export async function scanLocal({ host = '127.0.0.1', ports = [], timeout = 1200
228
257
  export function toProfiles(found, existing = []) {
229
258
  const out = [];
230
259
  for (const f of found) {
231
- for (const m of f.models.length ? f.models : [{ id: null }]) {
232
- if (!m.id) continue;
260
+ for (const m of f.models) {
261
+ if (!m.id) continue; // 이름 없는 것은 프로필이 못 된다 (모델 0대인 서버도 여기서 끝)
233
262
  const id = `${slugRuntime(f.runtime)}-${String(m.id).replace(/[^a-zA-Z0-9._-]+/g, '-')}`.slice(0, 60).toLowerCase();
234
263
  const prev = existing.find((p) => p.baseUrl === f.base && p.model === m.id);
235
264
  out.push({
@@ -237,7 +266,15 @@ export function toProfiles(found, existing = []) {
237
266
  name: `${f.runtime} · ${m.id}`,
238
267
  kind: f.kind,
239
268
  baseUrl: f.base,
240
- auth: f.auth ?? 'none',
269
+ /*
270
+ * 잠금 방식은 **열쇠와 같이 움직인다.**
271
+ *
272
+ * 아래에서 `apiKey` 를 되쓰면서 여기만 훑은 값으로 덮고 있었다. 훑기는
273
+ * 열쇠 없이 두드리므로 열쇠를 받는 서버도 `auth:'none'` 으로 보인다 —
274
+ * 그래서 열쇠는 그대로 남고 방식만 none 이 되어, 쓰던 프로필이 열쇠를
275
+ * 안 싣고 나가 조용히 401 로 돌아선다. 되쓰는 자리는 되쓴다.
276
+ */
277
+ auth: prev?.auth ?? f.auth ?? 'none',
241
278
  model: m.id,
242
279
  apiKey: prev?.apiKey ?? '',
243
280
  ctx: prev?.ctx ?? null,
@@ -9,10 +9,26 @@ import { load, save, upsert } from '../config.js';
9
9
  // 코딩 에이전트로 쓰려면 도구 호출이 되어야 한다. 진단 전이라 확실히는 모르니,
10
10
  // 이름에 드러난 단서로 짐작해서 권한다. 틀릴 수 있다는 것을 문구에 밝힌다.
11
11
  const 도구잘함 = /(qwen|llama-?3|llama3|mistral|devstral|hermes|command-?r|firefunction|granite|gpt-?oss|glm|kimi|minimax|seed-?oss)/i;
12
- const 코딩 = /(cod(e|er|ing)|devstral|starcoder|deepseek|qwen.*cod|granite.*cod)/i;
13
- const 작음 = /(0\.5b|1\.5b|1b|2b|3b|tiny|mini|small)/i;
12
+ // `deepseek` 도 `qwen`·`granite` 와 같이 `.*cod` 를 요구한다. 통째로 넣어 두었더니
13
+ // `deepseek-r1`(추론)·`deepseek-chat`(대화)에 「코딩용 모델」 이 붙고, 그 3점이
14
+ // 진짜 코딩 모델을 밀어내고 추천 자리를 가져갔다.
15
+ const 코딩 = /(cod(e|er|ing)|devstral|starcoder|deepseek.*cod|qwen.*cod|granite.*cod)/i;
16
+ /*
17
+ * 크기 토막은 **통째로** 맞아야 한다.
18
+ *
19
+ * `3b` 를 글 아무 데서나 찾으면 `codellama:13b`(13B) 가 걸리고 `2b` 는
20
+ * `qwen2.5-coder:32b`(32B) 가 걸린다 — 이 PC 에서 제일 많이 쓰는 코딩 모델이
21
+ * 「작은 모델」 로 세어져 점수가 깎이고 「도구 호출이 불안할 수 있습니다」 라는
22
+ * 거짓 경고가 붙었다. 앞에 숫자·점이 없을 때만 크기로 친다.
23
+ *
24
+ * `mini` 도 같은 탈이었다. 글 아무 데서나 찾으니 `minimax-m2`(230B) 와
25
+ * `gemini` 가 걸렸다 — minimax 는 바로 위 `도구잘함` 이 「도구 호출을 잘하는
26
+ * 계열」 로 올려 둔 것이라, 한 줄 안에서 추어올리고 깎아내렸다. 앞뒤가 글자가
27
+ * 아닐 때만 크기 낱말로 친다 (`gpt-4o-mini` · `qwen3-mini` 는 그대로 걸린다).
28
+ */
29
+ const 작음 = /(?<![\d.])(?:0\.5|1\.5|1|2|3)b(?!\d)|tiny|(?<![a-z])mini(?![a-z])|small/i;
14
30
 
15
- function recommend(found) {
31
+ export function recommend(found) {
16
32
  const all = [];
17
33
  for (const f of found) for (const m of f.models) all.push({ ...f, model: m.id, note: m.note ?? '' });
18
34
  if (!all.length) return null;
@@ -26,7 +42,15 @@ function recommend(found) {
26
42
  return s;
27
43
  };
28
44
  const best = all.map((x) => ({ x, s: 점수(x) })).sort((a, b) => b.s - a.s)[0];
29
- if (!best || best.s < 2) return null;
45
+ if (!best) return null;
46
+ /*
47
+ * 이름에 단서가 하나도 없으면 **안 권한다.**
48
+ *
49
+ * 비작음(+1)과 ollama(+1)만으로 2점이 차서, 임베딩 전용 모델(nomic-embed-text)
50
+ * 까지 추천으로 올라왔다. 이유 줄에는 「실제로 되는지는 diagnose 로 확인하세요」
51
+ * 한 마디만 남는다 — 짐작의 근거가 이름인데 그 이름이 아무 말도 안 하는 판이다.
52
+ */
53
+ if (best.s < 2 || (!코딩.test(best.x.model) && !도구잘함.test(best.x.model))) return null;
30
54
 
31
55
  const 이유 = [];
32
56
  if (코딩.test(best.x.model)) 이유.push('코딩용 모델');
@@ -36,6 +60,31 @@ function recommend(found) {
36
60
  return { ...best.x, why: 이유.join(' · ') };
37
61
  }
38
62
 
63
+ /**
64
+ * 고르기 목록의 이 줄이 **권한 그것**인가.
65
+ *
66
+ * 표(`추천`)를 붙이는 잣대와 커서 기본값의 잣대가 갈려 있었다 — 표는 포트까지
67
+ * 보고 기본값은 모델 이름만 봤다. 같은 모델이 두 서버에 떠 있으면 둘째를 권해
68
+ * 놓고 커서는 첫째에 앉아, 엔터를 치면 권하지 않은 쪽이 등록된다. 잣대를 하나로 둔다.
69
+ *
70
+ * 그 잣대가 `baseUrl.includes('80')` 이었다 — 8080·8000·11434 가 전부 80 추천에
71
+ * 걸린다. 막으려던 사고를 막았다고 적어 놓고 같은 줄에서 되살린 셈이다.
72
+ * 포트는 글자로 찾지 말고 포트 자리에서 읽는다.
73
+ */
74
+ const 주소포트 = (u) => {
75
+ const m = /:(\d+)(?=[/?#]|$)/.exec(String(u ?? ''));
76
+ return m ? Number(m[1]) : null;
77
+ };
78
+
79
+ export function 추천인가(p, 추천) {
80
+ return !!추천 && p?.model === 추천.model && 주소포트(p?.baseUrl) === Number(추천.port);
81
+ }
82
+
83
+ /** 고르개의 기본 자리. 권한 것이 없으면 첫째. */
84
+ export function 추천자리(profiles, 추천) {
85
+ return Math.max(0, (profiles ?? []).findIndex((p) => 추천인가(p, 추천)));
86
+ }
87
+
39
88
  export async function runScan(flags = {}) {
40
89
  const host = flags.host ? String(flags.host) : '127.0.0.1';
41
90
  const ports = String(flags.ports ?? '')
@@ -66,7 +115,8 @@ export async function runScan(flags = {}) {
66
115
  // ── 찾은 것 표로 ─────────────────────────────────────────────────────
67
116
  const wRun = Math.max(10, ...found.map((f) => width(f.runtime)));
68
117
  for (const f of found) {
69
- const 자리 = `${f.host}:${f.port}`;
118
+ // IPv6 는 괄호를 씌운다 — `::1:11434` 는 어디까지가 주소인지 안 보인다.
119
+ const 자리 = `${String(f.host).includes(':') && !String(f.host).startsWith('[') ? `[${f.host}]` : f.host}:${f.port}`;
70
120
  const 규격 = 규격이름(f.kind);
71
121
  const 표시 = f.guessed ? c.gray(' (추정)') : '';
72
122
  say(` ${c.hcyan('◆')} ${c.bold(pad(f.runtime, wRun))}${표시} ${c.gray(pad(자리, 22))}${c.gray(pad(규격, 14))}${c.gray(f.ms + 'ms')}`);
@@ -114,10 +164,10 @@ export async function runScan(flags = {}) {
114
164
  if (flags.pick && profiles.length > 1) {
115
165
  const items = profiles.map((p) => ({
116
166
  label: `${pad(clip(p.name, 44), 46)}${c.gray(p.note ?? '')}`,
117
- note: 추천 && p.model === 추천.model && p.baseUrl.includes(String(추천.port)) ? '추천' : '',
167
+ note: 추천인가(p, 추천) ? '추천' : '',
118
168
  }));
119
169
  const i = await pick('어느 것을 쓰시겠습니까', items, {
120
- def: Math.max(0, profiles.findIndex((p) => 추천 && p.model === 추천.model)),
170
+ def: 추천자리(profiles, 추천),
121
171
  });
122
172
  profiles = [profiles[i]];
123
173
  cfg.active = null; // 고른 것을 지금 쓰는 것으로