deel-local-cli 1.2.0 → 1.4.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 (50) hide show
  1. package/README.en.md +301 -1537
  2. package/README.md +272 -1582
  3. package/bin/deel.js +73 -4
  4. package/package.json +8 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/card.js +129 -0
  9. package/src/agent/compact.js +10 -2
  10. package/src/agent/effort.js +5 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/grade.js +20 -0
  13. package/src/agent/loop.js +134 -13
  14. package/src/agent/models.js +169 -0
  15. package/src/agent/modes.js +47 -1
  16. package/src/agent/pins.js +140 -0
  17. package/src/agent/preset.js +113 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +233 -13
  20. package/src/agent/store.js +31 -0
  21. package/src/backend/adapter.js +14 -0
  22. package/src/commands.js +616 -52
  23. package/src/i18n/en.js +265 -0
  24. package/src/i18n/index.js +126 -0
  25. package/src/i18n/ko.js +252 -0
  26. package/src/lsp/client.js +459 -0
  27. package/src/lsp/diag.js +112 -0
  28. package/src/lsp/rpc.js +84 -0
  29. package/src/lsp/servers.js +218 -0
  30. package/src/oneshot.js +19 -0
  31. package/src/pack/sbom.js +218 -0
  32. package/src/pack/selfpack.js +19 -3
  33. package/src/preview/serve.js +19 -2
  34. package/src/repl.js +193 -8
  35. package/src/safety/secrets.js +205 -0
  36. package/src/safety/undo.js +10 -3
  37. package/src/tools/desc.en.js +221 -0
  38. package/src/tools/docs.js +252 -0
  39. package/src/tools/index.js +190 -6
  40. package/src/tools/lsp.js +327 -0
  41. package/src/tools/task.js +30 -2
  42. package/src/ui/ansi.js +45 -0
  43. package/src/ui/approve.js +25 -21
  44. package/src/ui/banner.js +245 -0
  45. package/src/ui/export.js +217 -0
  46. package/src/ui/inputbox.js +37 -7
  47. package/src/ui/intro.js +206 -0
  48. package/src/ui/level.js +11 -5
  49. package/src/ui/notify.js +101 -0
  50. package/src/ui/status.js +181 -35
@@ -1,4 +1,4 @@
1
- // 도구 6종. 이름과 인자를 Claude Code 와 같게 맞춘다 —
1
+ // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
3
  import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
@@ -14,11 +14,17 @@ import { TODO_TOOL } from './todo.js';
14
14
  import { TASK_TOOL } from './task.js';
15
15
  import { OUTLINE_TOOL } from './outline.js';
16
16
  import { VERIFY_TOOL } from './verify.js';
17
+ import { DEF_TOOL, REFS_TOOL } from './lsp.js';
18
+ import { 편집후진단, 붙이기 as 진단붙이기, 데우기 } from '../lsp/diag.js';
19
+ import { 프로젝트갈래 } from '../lsp/servers.js';
17
20
  import { allow as allowedIn } from '../agent/modes.js';
18
21
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
19
22
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
23
+ import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
20
24
  import { diffLines } from '../ui/diff.js';
21
25
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
26
+ import { 도구설명EN } from './desc.en.js';
27
+ import { 언어 } from '../i18n/index.js';
22
28
 
23
29
  /*
24
30
  * 한 번에 돌려줄 양은 **모델에 맞춰** 정한다 (agent/budget.js).
@@ -180,6 +186,26 @@ function 줄수(abs, 인코딩) {
180
186
  } catch { return 0; }
181
187
  }
182
188
 
189
+ /**
190
+ * 그 줄과 앞뒤 몇 줄. Edit 이 빗나갔을 때 보여 줄 것.
191
+ *
192
+ * 줄 번호를 같이 붙인다 — 모델이 "몇 번 줄" 로 세어 다시 잡을 수 있어야 한다.
193
+ * 한 줄만 보여 주던 것을 넓힐 때, 넓힌 만큼 자리를 먹으니 짧게 자른다.
194
+ */
195
+ function 둘레(text, 줄번호, 보일줄 = 1) {
196
+ const 줄들 = String(text ?? '').split('\n');
197
+ const 가운데 = Math.max(1, Number(줄번호) || 1);
198
+ const 반 = Math.floor(Math.max(1, 보일줄) / 2);
199
+ const 처음 = Math.max(1, 가운데 - 반);
200
+ const 끝 = Math.min(줄들.length, 처음 + Math.max(1, 보일줄) - 1);
201
+ const out = [];
202
+ for (let i = 처음; i <= 끝; i++) {
203
+ const 표 = i === 가운데 ? '→' : ' ';
204
+ out.push(` ${표} ${String(i).padStart(5)} | ${줄들[i - 1].slice(0, 120)}`);
205
+ }
206
+ return out.join('\n');
207
+ }
208
+
183
209
  function 엑셀은못고침(보인이름) {
184
210
  return `엑셀 파일은 이 도구로 고칠 수 없습니다: ${보인이름}\n`
185
211
  + ' 읽기만 됩니다 (CSV 로 바꿔서 보여줍니다). 서식·수식·차트가 든 파일을\n'
@@ -210,6 +236,28 @@ async function 엑셀읽기(abs, args, ctx) {
210
236
  }
211
237
 
212
238
 
239
+ /**
240
+ * 문서(hwpx·docx·pptx)를 글로 읽어 돌려준다.
241
+ *
242
+ * 엑셀읽기와 같은 규칙 — ctx.seen 에 안 넣는다. 넣으면 Edit 이 '이 파일 고칠
243
+ * 수 있다' 고 오해한다. 문서는 이 도구로 고치는 물건이 아니다.
244
+ */
245
+ function 문서읽기(abs, ctx) {
246
+ const r = readDoc(abs);
247
+ if (!r.ok) return { error: r.error };
248
+ const { text, 잘림 } = docText(r.덩이들);
249
+ return {
250
+ content: clip(
251
+ `${text || '(빈 문서입니다 — 글이 없습니다.)'}
252
+
253
+ (${r.갈래} 문서를 글로 바꿔서 보여준 것입니다. 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
254
+ + (잘림.length ? `
255
+ (${잘림.join(' · ')})` : ''),
256
+ ),
257
+ summary: docSummary(r) + (잘림.length ? ' · 일부만' : ''),
258
+ };
259
+ }
260
+
213
261
  /**
214
262
  * 파일 하나를 쓴다 — Write 의 알맹이.
215
263
  *
@@ -227,6 +275,9 @@ function 한파일쓰기(args, ctx) {
227
275
  // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
228
276
  // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
229
277
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
278
+ // 문서(hwpx·docx·pptx)도 같은 이유로 또렷하게 거절한다. 일반 '바이너리'
279
+ // 오류로 넘기면 왜 안 되는지가 안 실려서, 모델이 우회로를 찾는다.
280
+ if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
230
281
  // 엑셀만 막아서는 모자란다. hwp·pdf·png·zip 도 똑같이 그 순간 끝난다.
231
282
  // 게다가 이런 파일은 되돌리기가 내용을 떠 놓지 못하는 종류라 되살릴 길이 없다.
232
283
  // 확장자로 고르지 않고 실제 내용으로 본다 — 사내 파일은 확장자가 제각각이다.
@@ -324,6 +375,7 @@ function 한군데고치기(args, ctx) {
324
375
  // 엑셀 파일은 Read 로 읽히긴 하지만 고칠 수 있는 물건이 아니다.
325
376
  // '먼저 Read 로 읽어야 합니다' 라고만 하면 이미 읽은 쪽은 계속 헛돈다.
326
377
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
378
+ if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
327
379
  if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
328
380
  if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
329
381
 
@@ -335,8 +387,16 @@ function 한군데고치기(args, ctx) {
335
387
  if (m.reason === 'ambiguous') {
336
388
  return { error: `${m.count}군데에서 발견됐습니다 (${TIER_LABELS[m.tier]}). 앞뒤로 더 넓게 잡아 하나만 가리키거나 replace_all 을 쓰세요.` };
337
389
  }
390
+ /*
391
+ * 비슷한 자리를 몇 줄이나 보여 줄까는 **이 모델을 겪어 본 만큼** 정한다
392
+ * (agent/card.js). Edit 이 자주 빗나가는 모델에는 한 줄만 보여 줘 봐야
393
+ * 다음 시도도 빗나간다 — 앞뒤를 같이 보여 주면 옮겨 담을 것이 분명해진다.
394
+ * 겪은 것이 모자라면 여태처럼 한 줄이다. 자리를 괜히 먹지 않는다.
395
+ */
396
+ const 보일줄 = Math.max(1, ctx?.카드?.조정?.빗나갔을때보일줄 ?? 1);
338
397
  const hint = m.near
339
- ? `\n 파일의 ${m.near.line}번 줄이 가장 비슷합니다:\n ${m.near.text.trim().slice(0, 120)}\n 이 줄을 그대로 옮겨 담아 다시 시도하세요.`
398
+ ? `\n 파일의 ${m.near.line}번 줄이 가장 비슷합니다:\n${둘레(text, m.near.line, 보일줄)}`
399
+ + '\n 이 줄을 그대로 옮겨 담아 다시 시도하세요.'
340
400
  : '\n Read 로 다시 읽어 실제 내용을 확인하세요.';
341
401
  return { error: `찾지 못했습니다.${hint}` };
342
402
  }
@@ -435,7 +495,8 @@ export const TOOLS = {
435
495
  name: 'Read',
436
496
  description: '파일 하나를 읽는다. 줄 번호가 붙어 돌아온다. 고치기 전에는 반드시 먼저 읽어야 한다.'
437
497
  + ' 엑셀 파일(.xlsx/.xlsm/.xls)도 그대로 읽을 수 있다 — 시트별 CSV 로 바꿔서 돌려준다.'
438
- + ' 사용자에게 CSV 내보내 달라고 필요가 없다. 다만 엑셀 파일은 읽기만 되고 고칠 수는 없다.',
498
+ + ' 한글·워드·파워포인트 문서(.hwpx/.docx/.pptx)도 그대로 읽는다 글로 바꿔서 돌려준다.'
499
+ + ' 사용자에게 다른 형식으로 내보내 달라고 할 필요가 없다. 다만 이런 파일들은 읽기만 되고 고칠 수는 없다.',
439
500
  parameters: {
440
501
  type: 'object',
441
502
  properties: {
@@ -457,6 +518,19 @@ export const TOOLS = {
457
518
  // 엑셀 파일은 글이 아니라 압축 꾸러미다. 그냥 읽으면 '바이너리' 로 끝난다.
458
519
  // 여기서 표로 바꿔 돌려준다 — 사람이 손으로 CSV 로 내보낼 일이 없게.
459
520
  if (isExcelPath(abs)) return 엑셀읽기(abs, args, ctx);
521
+ // hwpx·docx·pptx 도 같다 — 속이 ZIP+XML 이라 글로 바꿔 돌려준다 (docs.js).
522
+ if (isDocPath(abs)) return 문서읽기(abs, ctx);
523
+ /*
524
+ * 구형 hwp 는 '바이너리' 로 끝내지 않는다. 그 오류에는 길이 없어서
525
+ * 모델이 우회로(새로 쓰기)를 찾는다 — 실제로 그렇게 원본이 죽은 적이
526
+ * 있다. 여기서는 hwpx 로 저장하면 읽힌다는 길을 같이 준다.
527
+ */
528
+ if (abs.toLowerCase().endsWith('.hwp')) {
529
+ try {
530
+ const 머리 = readFileSync(abs);
531
+ if (looksOldHwp(abs, 머리)) return { error: 옛hwp안내(ctx.scope.show(abs)) };
532
+ } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
533
+ }
460
534
 
461
535
  const 읽음 = readTextFull(abs);
462
536
  // 무엇으로 읽었는지 기억해 둔다. 나중에 고칠 때 같은 것으로 되돌려 써야 한다.
@@ -1111,6 +1185,16 @@ export const TOOLS = {
1111
1185
 
1112
1186
  // 뒤에서 도는 명령 보기·끝내기. Bash(background) 와 짝이다 — jobs.js 머리말 참고.
1113
1187
  Jobs: JOBS_TOOL,
1188
+
1189
+ /*
1190
+ * 언어 서버에게 묻는 둘. Grep·Outline 을 밀어내지 않고 **더한다** —
1191
+ * tools/lsp.js 머리말 참고.
1192
+ *
1193
+ * 언어 서버가 이 자리에 없으면 toolSchemas 가 목록에서 뺀다. 못 쓰는 도구를
1194
+ * 세워 두면 모델은 그걸 부르고, 실패를 받고, 또 부른다.
1195
+ */
1196
+ Def: DEF_TOOL,
1197
+ Refs: REFS_TOOL,
1114
1198
  };
1115
1199
 
1116
1200
  /**
@@ -1181,14 +1265,49 @@ export function 설명줄이기(schema, 한도) {
1181
1265
  };
1182
1266
  }
1183
1267
 
1268
+ /**
1269
+ * 도구 설명을 지금 화면 말에 맞춘다.
1270
+ *
1271
+ * 표에 없는 도구·인자는 한글 설명이 그대로 나간다 — 화면 말과 같은 규칙이다.
1272
+ * 빈 설명을 내보내지 않는다. 설명 없는 도구는 모델이 언제 쓰는지 모른 채로
1273
+ * 목록에만 서 있게 되는데, 그건 없는 것보다 나쁘다.
1274
+ */
1275
+ function 영어설명(schema, 이름) {
1276
+ if (언어() !== 'en') return schema;
1277
+ const 것 = 도구설명EN[이름];
1278
+ if (!것) return schema;
1279
+
1280
+ const p = schema.parameters ?? {};
1281
+ const 새속성 = {};
1282
+ for (const [인자, 값] of Object.entries(p.properties ?? {})) {
1283
+ const 글 = 것.params?.[인자];
1284
+ 새속성[인자] = 글 ? { ...값, description: 글 } : 값;
1285
+ }
1286
+ return {
1287
+ ...schema,
1288
+ description: 것.desc ?? schema.description,
1289
+ parameters: { ...p, properties: 새속성 },
1290
+ };
1291
+ }
1292
+
1184
1293
  // 모델에게 넘길 도구 정의 목록.
1185
1294
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
1186
- export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null } = {}) {
1295
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false } = {}) {
1187
1296
  let list = names ?? Object.keys(TOOLS).filter((n) => {
1188
1297
  if (n === 'Skill') return hasSkills;
1189
1298
  if (n === 'WebFetch') return web;
1299
+ // 언어 서버가 없는 자리에서는 Def·Refs 를 아예 안 보여 준다.
1300
+ //
1301
+ // 웹 도구를 오프라인에서 숨기는 것과 같은 이유다. 못 쓰는 도구를 목록에
1302
+ // 세워 두면 모델은 그걸 부르고, "없습니다" 를 받고, 또 부른다. 그 왕복이
1303
+ // 도구 설명으로 나가는 자리보다 비싸다.
1304
+ if (n === 'Def' || n === 'Refs') return !!lsp;
1190
1305
  return true;
1191
1306
  });
1307
+ // 이름을 직접 준 경우(하위 작업이 부모 것을 물려받을 때)에도 같은 규칙을 건다.
1308
+ // 부모에게 있던 것이 하위에서 갑자기 못 쓰게 되지는 않지만, 시험·일회성 호출이
1309
+ // 이름을 통째로 넘기는 길이 있어서 여기서 한 겹 더 막는다.
1310
+ if (names && !lsp) list = list.filter((n) => n !== 'Def' && n !== 'Refs');
1192
1311
  // 작업 모드가 정해져 있으면 그 모드가 쓰는 것만 남긴다.
1193
1312
  //
1194
1313
  // 설계·계획·묻기 모드에서 파일을 바꾸면 안 된다고 프롬프트로 부탁할 수도 있다.
@@ -1202,7 +1321,20 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
1202
1321
  * 이름과 인자는 그대로 남으므로 할 수 있는 일은 똑같다.
1203
1322
  */
1204
1323
  const 한도 = 설명길이(ctx);
1205
- const 우리것 = list.map((n) => ({ type: 'function', function: 설명줄이기(TOOLS[n].schema, 한도) }));
1324
+ /*
1325
+ * 화면 말이 영어면 도구 설명도 영어로 갈아 끼운다 (tools/desc.en.js).
1326
+ *
1327
+ * 줄이기 **전에** 갈아 끼운다. 순서가 반대면 한글 설명을 한도에 맞춰 자른
1328
+ * 다음 영어로 통째로 바꾸는 셈이라, 자른 것이 아무 뜻이 없어지고 영어 글은
1329
+ * 한도를 넘긴 채로 실린다.
1330
+ *
1331
+ * 이름과 인자 이름은 안 건드린다 — 그건 식별자다. Task 의 목적·할일처럼
1332
+ * 한글로 된 인자 이름을 바꾸면 그 도구가 아예 안 불린다.
1333
+ */
1334
+ const 우리것 = list.map((n) => ({
1335
+ type: 'function',
1336
+ function: 설명줄이기(영어설명(TOOLS[n].schema, n), 한도),
1337
+ }));
1206
1338
 
1207
1339
  /*
1208
1340
  * 밖에서 붙인 도구(MCP)를 뒤에 붙인다.
@@ -1215,6 +1347,58 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
1215
1347
  return 우리것;
1216
1348
  }
1217
1349
 
1350
+ // 파일을 바꾸는 도구들. 이것만 고친 뒤 진단을 본다.
1351
+ const 고치는도구 = new Set(['Write', 'Append', 'Edit']);
1352
+ // 한 번에 볼 파일 수. 여덟 개를 한꺼번에 만들었다고 여덟 번 기다릴 수는 없다.
1353
+ const 진단볼파일 = 3;
1354
+
1355
+ /**
1356
+ * 고친 직후에 그 파일이 성한지 본다 — lsp/diag.js 머리말 참고.
1357
+ *
1358
+ * 여기서 절대 죽으면 안 되고, 늦어서도 안 된다. 파일은 이미 고쳐졌다.
1359
+ * 진단은 **덤**이지 이 도구가 성공했는지의 판단 근거가 아니다. 그래서
1360
+ * 통째로 감싸고, 이미 떠 있는 서버가 없으면 아무것도 안 하고 그냥 지나간다.
1361
+ */
1362
+ async function 고친뒤진단(name, r, ctx) {
1363
+ try {
1364
+ if (!고치는도구.has(name) || !r || r.error) return r;
1365
+ /*
1366
+ * **부른 쪽이 켠 자리에서만** 한다. 기본은 꺼짐이다.
1367
+ *
1368
+ * 여기서 하는 일은 남의 프로세스를 하나 띄우는 것이다. 그건 띄운 쪽이
1369
+ * 거둘 줄 알아야 한다 — 안 거두면 그 서버가 작업 폴더를 cwd 로 물고 있어서
1370
+ * 윈도우에서는 폴더 이름조차 못 바꾼다. 그래서 끄는 자리를 갖춘 쪽(repl·
1371
+ * oneshot)만 ctx.lsp.켬 을 켠다. 도구를 직접 부르는 자리는 안 켜진다.
1372
+ */
1373
+ if (ctx.lsp?.켬 !== true) return r;
1374
+ const 뿌리 = ctx.scope?.root;
1375
+ if (!뿌리) return r;
1376
+
1377
+ const 바뀐 = r.changed
1378
+ ? [r.changed]
1379
+ : (Array.isArray(r.여럿) ? r.여럿.filter((x) => x?.ok && x.path).map((x) => x.path) : []);
1380
+ const 볼것 = [...new Set(바뀐)].slice(0, 진단볼파일);
1381
+ if (!볼것.length) return r;
1382
+
1383
+ // 처음 고칠 때 뒤에서 하나 데워 둔다. 이번 것은 못 받아도 다음부터 받는다.
1384
+ 데우기(뿌리, 볼것[0]);
1385
+
1386
+ const 것들 = await Promise.all(볼것.map((abs) => 편집후진단(뿌리, abs)));
1387
+ let 답 = r;
1388
+ for (let i = 0; i < 볼것.length; i++) {
1389
+ 답 = 진단붙이기(답, 것들[i], ctx.scope.show(볼것[i]));
1390
+ }
1391
+ return 답;
1392
+ } catch {
1393
+ return r; // 진단 보다 터져서 편집이 실패로 보이는 일은 없어야 한다
1394
+ }
1395
+ }
1396
+
1397
+ /** 이 폴더에서 언어 서버를 쓸 수 있나. repl 이 켤 때 한 번 물어본다. */
1398
+ export function 언어서버있나(뿌리) {
1399
+ try { return !!프로젝트갈래(뿌리); } catch { return false; }
1400
+ }
1401
+
1218
1402
  export async function runTool(name, args, ctx) {
1219
1403
  // 밖에서 붙인 도구(MCP)는 이름 앞머리로 갈린다.
1220
1404
  //
@@ -1228,7 +1412,7 @@ export async function runTool(name, args, ctx) {
1228
1412
  try {
1229
1413
  const r = await t.run(args ?? {}, ctx);
1230
1414
  ctx.audit.tool(name, args, r);
1231
- return r;
1415
+ return await 고친뒤진단(name, r, ctx);
1232
1416
  } catch (err) {
1233
1417
  const r = { error: err.message };
1234
1418
  ctx.audit.tool(name, args, r);
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Def · Refs — 언어 서버에게 "이게 어디 있나 / 어디서 쓰나" 를 묻는다.
3
+ *
4
+ * ── Grep 을 밀어내는 것이 아니다 ────────────────────────────────────────
5
+ *
6
+ * Grep 은 남는다. 언어 서버가 안 깔린 자리가 더 많고(사내망이 대개 그렇다),
7
+ * 깔려 있어도 못 읽는 파일이 있고, 무엇보다 Grep 은 **주석·설정·문서까지**
8
+ * 찾는다. 이름을 바꿀 때 정말 필요한 것은 그쪽까지다.
9
+ *
10
+ * 이 둘이 더해 주는 것은 딱 하나, **틀린 자리를 안 준다는 것**이다.
11
+ * `run` 을 Grep 으로 찾으면 수백 줄이 나오고 그중 진짜는 몇 개다. 모델은 그
12
+ * 수백 줄을 다 읽을 자리가 없어서 앞의 몇 개만 보고 고치기 시작한다. 놓친
13
+ * 자리는 돌려 본 뒤에야 드러나고, 그때는 이미 다른 것도 같이 고쳐 놓은 뒤다.
14
+ *
15
+ * ── 왜 자리(줄·칸)가 아니라 이름을 받나 ─────────────────────────────────
16
+ *
17
+ * LSP 는 "이 파일 이 줄 이 칸에 있는 것" 을 묻는 규약이다. 그런데 모델은
18
+ * 칸 번호를 모른다. 알려면 파일을 먼저 Read 해야 하는데, 그러면 이 도구를
19
+ * 쓰는 값(파일을 안 읽고도 안다)이 통째로 사라진다.
20
+ *
21
+ * 그래서 이름을 받아 **workspace/symbol 로 자리를 먼저 찾고**, 그 자리로
22
+ * 다시 묻는다. 사람이 하는 것과 같은 순서다. 이름이 여럿이면 그 목록을
23
+ * 그대로 보여 주고 고르게 한다 — 하나를 골라 주고 아닌 척하지 않는다.
24
+ */
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+ import { 얻기, 색인중일까 } from '../lsp/client.js';
28
+ import { 갈래, 프로젝트갈래 } from '../lsp/servers.js';
29
+ import { 찾을개수 } from '../agent/budget.js';
30
+
31
+ /** 한 자리를 사람이 읽을 한 줄로. 그 줄의 글까지 붙여야 열어 보지 않고도 안다. */
32
+ function 한줄(scope, uri, 범위) {
33
+ let abs;
34
+ try { abs = fileURLToPath(uri); } catch { abs = String(uri); }
35
+ const 줄번호 = (범위?.start?.line ?? 0) + 1;
36
+ let 글 = '';
37
+ try {
38
+ const 줄들 = readFileSync(abs, 'utf8').split(/\r?\n/);
39
+ 글 = (줄들[줄번호 - 1] ?? '').trim();
40
+ } catch { /* 못 읽으면 자리만 준다 */ }
41
+ const 보일 = scope?.show ? (() => { try { return scope.show(abs); } catch { return abs; } })() : abs;
42
+ return { 파일: 보일, 줄: 줄번호, 글: 글.length > 160 ? 글.slice(0, 160) + '…' : 글, abs };
43
+ }
44
+
45
+ /** LSP 의 답은 하나일 수도, 목록일 수도, LocationLink 일 수도 있다. 다 같은 모양으로 편다. */
46
+ function 자리들펴기(값) {
47
+ if (!값) return [];
48
+ const 목록 = Array.isArray(값) ? 값 : [값];
49
+ return 목록.map((it) => {
50
+ if (!it) return null;
51
+ if (it.targetUri) return { uri: it.targetUri, range: it.targetSelectionRange ?? it.targetRange };
52
+ if (it.uri) return { uri: it.uri, range: it.range };
53
+ if (it.location) return { uri: it.location.uri, range: it.location.range };
54
+ return null;
55
+ }).filter(Boolean);
56
+ }
57
+
58
+ /**
59
+ * 이름이 그 줄 어디쯤에 있는지.
60
+ *
61
+ * 낱말 경계를 본다. `run` 을 찾을 때 `runner` 를 짚으면 서버는 runner 의
62
+ * 정의를 준다 — 틀린 답인데 맞는 답처럼 생겨서 제일 나쁘다.
63
+ */
64
+ function 칸찾기(줄글, 이름) {
65
+ if (!줄글 || !이름) return -1;
66
+ let i = 0;
67
+ for (;;) {
68
+ const p = 줄글.indexOf(이름, i);
69
+ if (p < 0) return -1;
70
+ const 앞 = 줄글[p - 1] ?? ' ';
71
+ const 뒤 = 줄글[p + 이름.length] ?? ' ';
72
+ const 낱말 = (ch) => /[\p{L}\p{N}_$]/u.test(ch);
73
+ if (!낱말(앞) && !낱말(뒤)) return p;
74
+ i = p + 1;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 이름으로 자리를 찾는다.
80
+ *
81
+ * @returns {{자리: {uri, position}, 후보: object[]}|{오류: string}}
82
+ */
83
+ async function 자리잡기(서버, scope, { 이름, 파일, 줄 }) {
84
+ // 1) 파일과 줄을 준 경우. 그게 제일 정확하다 — 모델이 방금 Read 했거나
85
+ // Grep 으로 좁혀 온 자리다.
86
+ if (파일) {
87
+ let abs;
88
+ try { abs = scope.resolve(파일); } catch (e) { return { 오류: e.message }; }
89
+ let 줄들;
90
+ try { 줄들 = readFileSync(abs, 'utf8').split(/\r?\n/); } catch { return { 오류: `못 읽었습니다: ${파일}` }; }
91
+ 서버.보여주기(abs, 줄들.join('\n'));
92
+
93
+ const 볼줄 = Number.isFinite(줄) && 줄 > 0 ? [줄 - 1] : 줄들.map((_, i) => i);
94
+ for (const i of 볼줄) {
95
+ const 칸 = 칸찾기(줄들[i] ?? '', 이름);
96
+ if (칸 >= 0) return { 자리: { uri: pathToFileURL(abs).href, position: { line: i, character: 칸 } } };
97
+ }
98
+ return { 오류: `${파일}${Number.isFinite(줄) ? `:${줄}` : ''} 에서 ${이름} 을 못 찾았습니다` };
99
+ }
100
+
101
+ /*
102
+ * 2) 이름만 준 경우. 프로젝트 전체에서 그 이름을 찾는다.
103
+ *
104
+ * 방금 켠 서버는 빈손으로 답한다. 없어서가 아니라 아직 프로젝트를 다 못
105
+ * 훑어서다 — 악수는 몇십 ms 면 끝나지만 색인은 몇 초씩 걸린다. 실제로 켠 지
106
+ * 0.2초 만에 물었더니 없다고 했고, 0.5초 뒤에 물으니 나왔다.
107
+ *
108
+ * 이 둘을 구별 안 하면 "그런 이름 없습니다" 가 되고, 모델은 그 말을 믿고
109
+ * 이미 있는 것을 새로 만든다. 그래서 **켠 지 얼마 안 됐을 때만** 몇 번 더
110
+ * 물어본다. 오래 돈 서버에서 빈손이면 그건 정말 없는 것이라 안 기다린다.
111
+ */
112
+ let 답 = await 서버.물어보기('workspace/symbol', { query: 이름 });
113
+ for (const 쉼 of [400, 800, 1500]) {
114
+ if (답.오류 || (Array.isArray(답.값) && 답.값.length)) break;
115
+ if (!색인중일까(서버)) break;
116
+ // 이 시계는 unref 하지 않는다. 여기는 도구가 도는 한가운데라,
117
+ // 놔 버리면 기다리는 사이에 프로그램이 그냥 끝나 버린다.
118
+ await new Promise((r) => setTimeout(r, 쉼));
119
+ 답 = await 서버.물어보기('workspace/symbol', { query: 이름 });
120
+ }
121
+ if (답.오류) return { 오류: 답.오류 };
122
+ const 것들 = (Array.isArray(답.값) ? 답.값 : []).filter((s) => s?.name);
123
+ // 이름이 똑같은 것만. 서버는 대개 부분 일치까지 준다.
124
+ const 딱맞는 = 것들.filter((s) => s.name === 이름);
125
+ const 쓸것 = 딱맞는.length ? 딱맞는 : 것들;
126
+ if (!쓸것.length) {
127
+ return {
128
+ 오류: `${이름} 을(를) 못 찾았습니다`
129
+ + (색인중일까(서버) ? ' (언어 서버가 아직 프로젝트를 훑는 중일 수 있습니다)' : '')
130
+ + '. Grep 으로 한 번 더 보세요.',
131
+ };
132
+ }
133
+
134
+ const 후보 = 쓸것.map((s) => {
135
+ const loc = s.location ?? {};
136
+ return { 이름: s.name, 갈래: s.kind, uri: loc.uri, range: loc.range ?? null, 컨테이너: s.containerName ?? '' };
137
+ }).filter((x) => x.uri);
138
+ if (!후보.length) return { 오류: `${이름} 의 자리를 못 받았습니다` };
139
+
140
+ const 첫 = 후보[0];
141
+ // WorkspaceSymbol 은 range 없이 오기도 한다. 그럼 파일을 열어 직접 짚는다.
142
+ let position = 첫.range?.start;
143
+ if (!position) {
144
+ try {
145
+ const abs = fileURLToPath(첫.uri);
146
+ const 줄들 = readFileSync(abs, 'utf8').split(/\r?\n/);
147
+ for (let i = 0; i < 줄들.length; i++) {
148
+ const 칸 = 칸찾기(줄들[i], 이름);
149
+ if (칸 >= 0) { position = { line: i, character: 칸 }; break; }
150
+ }
151
+ } catch { /* 아래에서 걸린다 */ }
152
+ }
153
+ if (!position) return { 오류: `${이름} 의 자리를 못 짚었습니다` };
154
+
155
+ // 정확히 이름 글자 위를 짚어야 한다. 정의 줄 맨 앞(`export function`)을 짚으면
156
+ // 서버가 아무것도 못 준다.
157
+ try {
158
+ const abs = fileURLToPath(첫.uri);
159
+ const 줄글 = readFileSync(abs, 'utf8').split(/\r?\n/)[position.line] ?? '';
160
+ const 칸 = 칸찾기(줄글, 이름);
161
+ if (칸 >= 0) position = { line: position.line, character: 칸 };
162
+ 서버.보여주기(abs);
163
+ } catch { /* 그대로 간다 */ }
164
+
165
+ return { 자리: { uri: 첫.uri, position }, 후보 };
166
+ }
167
+
168
+ /** 두 도구가 같은 앞머리를 쓴다 — 서버 얻고 자리 잡는 데까지. */
169
+ async function 채비(args, ctx) {
170
+ const 이름 = String(args.name ?? '').trim();
171
+ if (!이름) return { 오류: 'name 이 비었습니다' };
172
+
173
+ const 파일 = args.file_path ? String(args.file_path) : null;
174
+ /*
175
+ * 어느 언어 서버에게 물을지.
176
+ *
177
+ * 파일을 줬으면 그 파일의 언어다. 안 줬으면 이 폴더에서 제일 많은 언어로
178
+ * 간다 — 모델은 `handleClick 어디 있어` 처럼 이름만 알고 부르는 것이 보통이라,
179
+ * 매번 파일을 요구하면 이 도구를 쓰는 값이 사라진다.
180
+ */
181
+ const 볼것 = 파일 ?? 프로젝트갈래(ctx.scope.root)?.대표파일 ?? null;
182
+ if (!볼것) return { 오류: '이 폴더에서 쓸 수 있는 언어 서버가 없습니다. Grep · Outline 을 쓰세요.' };
183
+ if (!갈래(볼것)) return { 오류: `언어 서버가 없는 갈래입니다: ${볼것}` };
184
+
185
+ const 서버 = await 얻기(ctx.scope.root, 볼것);
186
+ if (!서버) return { 오류: '언어 서버가 이 자리에 없습니다. Grep · Outline 을 쓰세요.' };
187
+
188
+ const 잡음 = await 자리잡기(서버, ctx.scope, {
189
+ 이름, 파일, 줄: Number.isFinite(args.line) ? Number(args.line) : null,
190
+ });
191
+ if (잡음.오류) return { 오류: 잡음.오류 };
192
+ return { 이름, 서버, 자리: 잡음.자리, 후보: 잡음.후보 ?? [] };
193
+ }
194
+
195
+ /** 여러 곳에 같은 이름이 있으면 그대로 알려 준다. 하나를 골라 주고 아닌 척하지 않는다. */
196
+ function 여럿이면(후보, scope) {
197
+ if (!Array.isArray(후보) || 후보.length < 2) return null;
198
+ const 파일들 = new Set(후보.map((c) => { try { return scope.show(fileURLToPath(c.uri)); } catch { return c.uri; } }));
199
+ if (파일들.size < 2) return null;
200
+ return `같은 이름이 ${파일들.size}곳에 있습니다: ${[...파일들].slice(0, 5).join(' · ')}`
201
+ + `${파일들.size > 5 ? ' …' : ''}. 다른 것을 뜻했다면 file_path 로 짚어 주세요.`;
202
+ }
203
+
204
+ export const DEF_TOOL = {
205
+ schema: {
206
+ name: 'Def',
207
+ description:
208
+ '이름 하나가 **어디에 정의돼 있는지** 언어 서버에게 묻는다. 파일을 안 읽고도 자리를 안다.'
209
+ + ' Grep 과 다른 점은 틀린 자리를 안 준다는 것이다 — 주석에 든 같은 이름, 남의 라이브러리의'
210
+ + ' 같은 이름, 문자열 안의 같은 이름을 안 섞어 준다.'
211
+ + ' 남이 쓴 코드를 고치기 전에 이걸 먼저 불러라. 자리를 안 다음에 그 파일만 Read 하면 된다.'
212
+ + ' 이름이 여러 곳에 있으면 그 목록을 준다. file_path 로 어느 것인지 짚어 주면 된다.',
213
+ parameters: {
214
+ type: 'object',
215
+ properties: {
216
+ name: { type: 'string', description: '찾을 이름 (함수·클래스·변수)' },
217
+ file_path: { type: 'string', description: '그 이름이 쓰인 파일. 같은 이름이 여럿일 때 짚어 준다' },
218
+ line: { type: 'number', description: 'file_path 안에서 그 이름이 쓰인 줄 번호 (1부터)' },
219
+ },
220
+ required: ['name'],
221
+ },
222
+ },
223
+
224
+ async run(args, ctx) {
225
+ const 준비 = await 채비(args, ctx);
226
+ if (준비.오류) return { error: 준비.오류 };
227
+ const { 이름, 서버, 자리, 후보 } = 준비;
228
+
229
+ const 답 = await 서버.물어보기('textDocument/definition', {
230
+ textDocument: { uri: 자리.uri },
231
+ position: 자리.position,
232
+ });
233
+ if (답.오류) return { error: `언어 서버: ${답.오류}` };
234
+
235
+ let 곳들 = 자리들펴기(답.값).map((x) => 한줄(ctx.scope, x.uri, x.range));
236
+ // 서버가 정의를 못 주면(선언만 있는 자리 등) 심볼 검색으로 잡은 자리를 준다.
237
+ // 빈손으로 돌려보내는 것보다 낫고, 어디서 온 값인지 같이 말해 준다.
238
+ let 어디서 = 'definition';
239
+ if (!곳들.length && 후보.length) {
240
+ 곳들 = 후보.map((c) => 한줄(ctx.scope, c.uri, c.range));
241
+ 어디서 = 'workspace/symbol';
242
+ }
243
+ if (!곳들.length) return { summary: `${이름}: 정의를 못 찾았습니다. Grep 으로 찾아보세요.`, found: 0 };
244
+
245
+ const 여럿 = 여럿이면(후보, ctx.scope);
246
+ return {
247
+ summary: `${이름} — 정의 ${곳들.length}곳${여럿 ? `\n${여럿}` : ''}`,
248
+ found: 곳들.length,
249
+ source: 어디서,
250
+ locations: 곳들,
251
+ content: 곳들.map((l) => `${l.파일}:${l.줄} ${l.글}`).join('\n'),
252
+ };
253
+ },
254
+ };
255
+
256
+ export const REFS_TOOL = {
257
+ schema: {
258
+ name: 'Refs',
259
+ description:
260
+ '이름 하나를 **어디서 쓰는지** 언어 서버에게 다 묻는다. 이름을 바꾸거나 함수를 고치기 전에'
261
+ + ' 불러라 — 몇 군데를 같이 고쳐야 하는지가 여기서 나온다.'
262
+ + ' Grep 이 주는 수백 줄과 달리 진짜 그것을 쓰는 자리만 나온다. 대신 Grep 은 주석·설정·문서까지'
263
+ + ' 찾으니, 이름을 통째로 바꿀 때는 이걸로 코드를 잡고 Grep 으로 나머지를 훑어라.',
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ name: { type: 'string', description: '찾을 이름 (함수·클래스·변수)' },
268
+ file_path: { type: 'string', description: '그 이름이 정의된 파일. 같은 이름이 여럿일 때 짚어 준다' },
269
+ line: { type: 'number', description: 'file_path 안에서 그 이름이 있는 줄 번호 (1부터)' },
270
+ include_declaration: { type: 'boolean', description: '정의한 자리도 넣을지. 기본 false' },
271
+ },
272
+ required: ['name'],
273
+ },
274
+ },
275
+
276
+ async run(args, ctx) {
277
+ const 준비 = await 채비(args, ctx);
278
+ if (준비.오류) return { error: 준비.오류 };
279
+ const { 이름, 서버, 자리, 후보 } = 준비;
280
+
281
+ const 답 = await 서버.물어보기('textDocument/references', {
282
+ textDocument: { uri: 자리.uri },
283
+ position: 자리.position,
284
+ context: { includeDeclaration: args.include_declaration === true },
285
+ });
286
+ if (답.오류) return { error: `언어 서버: ${답.오류}` };
287
+
288
+ const 곳들 = 자리들펴기(답.값).map((x) => 한줄(ctx.scope, x.uri, x.range));
289
+ if (!곳들.length) {
290
+ return {
291
+ summary: `${이름}: 쓰는 자리가 없습니다.`
292
+ + ' 정말 안 쓰는 것일 수도 있고, 언어 서버가 아직 색인 중일 수도 있습니다 —'
293
+ + ' 지우기 전에 Grep 으로 한 번 더 보세요.',
294
+ found: 0,
295
+ };
296
+ }
297
+
298
+ // 창에 맞춰 자른다. 자른 것은 자랐다고 말한다 — 조용히 자르면 모델은
299
+ // 그게 전부인 줄 알고 나머지 자리를 안 고친다.
300
+ const 한도 = 찾을개수(ctx.모델컨텍스트 ?? null);
301
+ const 보일것 = 곳들.slice(0, 한도);
302
+ const 남은 = 곳들.length - 보일것.length;
303
+
304
+ // 파일별로 묶어야 읽힌다. 같은 파일 열 줄이 흩어져 있으면 몇 파일을
305
+ // 고쳐야 하는지가 안 보인다.
306
+ const 묶음 = new Map();
307
+ for (const l of 보일것) {
308
+ if (!묶음.has(l.파일)) 묶음.set(l.파일, []);
309
+ 묶음.get(l.파일).push(l);
310
+ }
311
+ const 글 = [...묶음.entries()]
312
+ .map(([f, 줄들]) => `${f} (${줄들.length})\n` + 줄들.map((l) => ` ${l.줄}: ${l.글}`).join('\n'))
313
+ .join('\n');
314
+
315
+ const 여럿 = 여럿이면(후보, ctx.scope);
316
+ return {
317
+ summary: `${이름} — 쓰는 자리 ${곳들.length}곳 · 파일 ${묶음.size}개`
318
+ + (남은 ? ` (${남은}곳은 자리가 모자라 안 실었습니다)` : '')
319
+ + (여럿 ? `\n${여럿}` : ''),
320
+ found: 곳들.length,
321
+ files: 묶음.size,
322
+ truncated: 남은 > 0,
323
+ locations: 보일것,
324
+ content: 글,
325
+ };
326
+ },
327
+ };