deel-local-cli 1.15.1 → 1.16.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/src/commands.js CHANGED
@@ -16,6 +16,7 @@ import { load, save, 저장시도, resolveKey, upsert, 열쇠보관, configPath
16
16
  import { 지금상태 as 지금열쇠상태, 받기설정, 잊기 as 받은열쇠잊기 } from './safety/authcmd.js';
17
17
  // 열쇠받기 명령을 정책이 못박아 뒀을 수 있다 — 받기설정 이 그 값을 같이 본다.
18
18
  import { 정책읽기 } from './safety/policy.js';
19
+ import { 걸음수 } from './agent/budget.js';
19
20
  import { 제공자고르기 } from './providers/index.js';
20
21
  import { 종, 알릴만한초 } from './ui/notify.js';
21
22
  /*
@@ -1057,7 +1058,7 @@ export async function handle(line, session, ctx) {
1057
1058
  case 'work':
1058
1059
  case 'auto':
1059
1060
  case 'code': case 'plan': case 'architect':
1060
- case 'debug': case 'ask': case 'orchestrator': {
1061
+ case 'debug': case 'ask': case 'inspect': case 'orchestrator': {
1061
1062
  // 인자 없이 /work 만 치면 지금 모드와 고를 수 있는 것을 보여 준다.
1062
1063
  if (name === 'work' && !arg.trim()) { showWork(session); return { handled: true }; }
1063
1064
 
@@ -1080,13 +1081,15 @@ export async function handle(line, session, ctx) {
1080
1081
  session.routed = null;
1081
1082
  const w = getWork(골라진);
1082
1083
  say('');
1083
- say(` ${c.hcyan(w.glyph)} ${c.bold(보일이름(w.id))} ${c.gray('(' + w.en + ')')} ${c.gray(보일한줄(w.id))}`);
1084
- say(` ${c.gray('도구')} ${canWrite(골라진) ? c.yellow('읽기 + 파일 바꾸기') : c.green('읽기만 — 파일을 못 바꿉니다')}`);
1085
- say(` ${c.gray('생각')} ${c.white(w.think ?? session.think)}${c.gray('·')}${c.magenta(w.effort)} ${c.gray('최대 ' + w.steps + '걸음')}`);
1084
+ // 영어 화면에서 이름과 영문 이름은 같은 글자다 — 「Inspect (Inspect)」 를 안 만든다.
1085
+ const 곁이름 = 언어() === 'en' ? '' : ` ${c.gray(`(${w.en})`)}`;
1086
+ say(` ${c.hcyan(w.glyph)} ${c.bold(보일이름(w.id))}${곁이름} ${c.gray(보일한줄(w.id))}`);
1087
+ say(` ${c.gray(pad(말('work.tools'), 6))} ${canWrite(골라진) ? c.yellow(말('work.toolsWrite')) : c.green(말('work.toolsRead'))}`);
1088
+ say(` ${c.gray(pad(말('work.think'), 6))} ${c.white(w.think ?? session.think)}${c.gray('·')}${c.magenta(w.effort)} ${c.gray(말('work.steps', { n: 걸음수(w.id, session.conn?.ctx) }))}`);
1086
1089
  if (골라진 === 'auto') {
1087
- say(` ${c.gray('앞으로는 한마디마다 알맞은 모드로 저절로 옮겨 갑니다.')}`);
1090
+ say(` ${c.gray(말('work.autoOn'))}`);
1088
1091
  } else {
1089
- say(` ${c.gray('직접 고르셨으므로 저절로 바뀌지 않습니다. 다시 맡기려면')} ${c.cyan('/work 종합')}`);
1092
+ say(` ${c.gray(말('work.pinned'))} ${c.cyan(`/work ${보일이름('auto')}`)}`);
1090
1093
  }
1091
1094
  say('');
1092
1095
  return { handled: true };
@@ -3145,18 +3148,33 @@ const INIT_TEMPLATE = `# DEEL.md
3145
3148
 
3146
3149
  // /work 를 인자 없이 부르면 지금 모드와 고를 수 있는 것들을 보여 준다.
3147
3150
  function showWork(session) {
3148
- rule('작업 모드', 70);
3151
+ rule(말('work.title'), 70);
3152
+ /*
3153
+ * 이름 칸을 **제일 긴 이름에 맞춰** 잡는다.
3154
+ *
3155
+ * 8칸으로 못 박아 뒀더니 Orchestrator(12자)가 칸을 넘어 다음 칸을 밀었다.
3156
+ * 화면에 `OrchestratorOrchestrator` 라고 붙어 나왔다 — 사진을 찍어 보고서야
3157
+ * 알았다. 목록의 값은 칸이 맞아야 목록이다.
3158
+ */
3159
+ const 이름들 = WORK_ORDER.map((k) => 보일이름(WORK_MODES[k].id));
3160
+ const 칸너비 = Math.max(...이름들.map((n) => width(n))) + 2;
3161
+ // 영어 화면에서는 이름과 영문 이름이 같은 글자다. 같은 것을 두 번 적지 않는다.
3162
+ const 영문칸 = 언어() === 'en' ? 0 : 14;
3163
+
3149
3164
  for (const k of WORK_ORDER) {
3150
3165
  const w = WORK_MODES[k];
3151
3166
  const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
3152
3167
  const 표 = 지금 ? c.hgreen('●') : c.gray('·');
3153
- const 보임 = 보일이름(w.id);
3154
- const 이름 = 지금 ? c.bold(c.white(pad(보임, 8))) : c.gray(pad(보임, 8));
3155
- say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(보일한줄(w.id))}`);
3156
- say(` ${canWrite(k) ? c.gray('파일 바꿈') : c.green('읽기만')}${c.gray(' · 생각 ' + (w.think ?? '그대로') + '·' + w.effort + ' · 최대 ' + w.steps + '걸음')}`);
3168
+ const 보임 = pad(보일이름(w.id), 칸너비);
3169
+ const 이름 = 지금 ? c.bold(c.white(보임)) : c.gray(보임);
3170
+ say(` ${표} ${c.hcyan(w.glyph)} ${이름}${영문칸 ? c.gray(pad(w.en, 영문칸)) : ''}${c.gray(보일한줄(w.id))}`);
3171
+ const 강도 = `${w.think ?? ''}·${w.effort}`;
3172
+ const 걸음 = 말('work.steps', { n: 걸음수(k, session.conn?.ctx) });
3173
+ say(` ${canWrite(k) ? c.gray(말('work.canEdit')) : c.green(말('work.readOnly'))}`
3174
+ + `${c.gray(` · ${말('work.think')} ${강도} · ${걸음}`)}`);
3157
3175
  }
3158
3176
  say('');
3159
- say(` ${c.gray('바꾸려면')} ${c.cyan('/plan')} ${c.cyan('/code')} ${c.cyan('/debug')} ${c.gray('… 또는')} ${c.cyan('Ctrl+O')} ${c.gray('로 차례로')}`);
3160
- say(` ${c.gray('이건 승인 정책(')}${c.cyan('/mode')}${c.gray(')과 다른 축입니다 — 무엇을 하느냐 / 얼마나 물어보냐.')}`);
3177
+ say(` ${c.gray(말('work.howTo', { a: c.cyan('/plan'), b: c.cyan('/code'), c: c.cyan('/debug'), 키: c.cyan('Ctrl+O') }))}`);
3178
+ say(` ${c.gray(말('work.axis', { 명령: c.cyan('/mode') }))}`);
3161
3179
  say('');
3162
3180
  }
package/src/completion.js CHANGED
@@ -39,6 +39,7 @@ export const 명령들 = [
39
39
  { 이름: 'doctor', 뜻: 'diagnose 와 같다', en: 'same as diagnose' },
40
40
  { 이름: 'pack', 뜻: '오프라인 반입용으로 묶는다', en: 'bundle for offline transfer' },
41
41
  { 이름: 'audit', 뜻: '무엇을 했는지 기록을 본다', en: 'show the record of what it did' },
42
+ { 이름: 'doc2md', 뜻: '문서·시안을 마크다운으로 (hwpx·docx·pptx·xlsx·pdf·fig)', en: 'document or design to Markdown (hwpx/docx/pptx/xlsx/pdf/fig)' },
42
43
  { 이름: 'sbom', 뜻: '무엇이 들었는지 목록을 낸다', en: 'list what is inside (SBOM)' },
43
44
  { 이름: 'scan', 뜻: '게이트웨이에 어떤 모델이 있나 훑는다', en: 'scan the gateway for available models' },
44
45
  { 이름: 'sessions', 뜻: '지난 대화 목록', en: 'list past conversations' },
package/src/i18n/en.js CHANGED
@@ -70,6 +70,7 @@ export const en = {
70
70
  'cmd.architect.desc': 'work mode → architect (shapes the structure)',
71
71
  'cmd.debug.desc': 'work mode → debug (finds the cause)',
72
72
  'cmd.ask.desc': 'work mode → ask (explains only)',
73
+ 'cmd.inspect.desc': 'work mode → inspect (audits for defects, changes nothing)',
73
74
  'cmd.orchestrator.desc': 'work mode → orchestrator (splits a big job up)',
74
75
  'cmd.undo.desc': 'undo the last turn — files and the conversation together',
75
76
  'cmd.undo.arg': '[turns]',
@@ -314,6 +315,18 @@ export const en = {
314
315
  'approve.strict.name': 'ask always',
315
316
  'approve.strict.short': 'always',
316
317
  'approve.strict.line': 'asks before every file edit and every command',
318
+ 'work.title': 'Work modes',
319
+ 'work.canEdit': 'edits files',
320
+ 'work.readOnly': 'read-only',
321
+ 'work.think': 'thinking',
322
+ 'work.steps': 'up to {n} steps',
323
+ 'work.tools': 'tools',
324
+ 'work.toolsWrite': 'read + change files',
325
+ 'work.toolsRead': 'read only — cannot change files',
326
+ 'work.howTo': 'To switch: {a} {b} {c} … or {키} to cycle',
327
+ 'work.axis': 'A different axis from the approval policy ({명령}) — what you do / how much it asks.',
328
+ 'work.autoOn': 'From here it moves to the right mode by itself, message by message.',
329
+ 'work.pinned': 'You chose this, so it will not change by itself. To hand it back:',
317
330
  'level.beginner.name': 'beginner',
318
331
  'level.beginner.hint': 'start on the recommended settings',
319
332
  'level.developer.name': 'developer',
package/src/i18n/ja.js CHANGED
@@ -57,6 +57,7 @@ export const ja = {
57
57
  'cmd.architect.desc': '作業モード → architect (構造を決める)',
58
58
  'cmd.debug.desc': '作業モード → debug (原因を突き止める)',
59
59
  'cmd.ask.desc': '作業モード → ask (説明だけ)',
60
+ 'cmd.inspect.desc': '作業モード → inspect (欠陥を探す。直さない)',
60
61
  'cmd.orchestrator.desc': '作業モード → orchestrator (大きな仕事を分ける)',
61
62
  'cmd.undo.desc': '直前のターンを戻す — ファイルと会話をまとめて',
62
63
  'cmd.undo.arg': '[ターン数]',
@@ -281,6 +282,18 @@ export const ja = {
281
282
  'approve.strict.name': '毎回訊く',
282
283
  'approve.strict.short': '毎回',
283
284
  'approve.strict.line': 'ファイルを直すたび、コマンドを打つたびに訊きます',
285
+ 'work.title': '作業モード',
286
+ 'work.canEdit': 'ファイルを変更',
287
+ 'work.readOnly': '読むだけ',
288
+ 'work.think': '思考',
289
+ 'work.steps': '最大 {n} ステップ',
290
+ 'work.tools': 'ツール',
291
+ 'work.toolsWrite': '読み取り + ファイル変更',
292
+ 'work.toolsRead': '読み取りのみ — ファイルは変更できません',
293
+ 'work.howTo': '切り替え: {a} {b} {c} … または {키} で順に',
294
+ 'work.axis': '承認ポリシー({명령})とは別の軸です — 何をするか / どれだけ聞くか。',
295
+ 'work.autoOn': 'これからは一言ごとに適したモードへ自動で移ります。',
296
+ 'work.pinned': 'ご自分で選んだので自動では変わりません。任せ直すには',
284
297
  'level.beginner.name': 'かんたん',
285
298
  'level.beginner.hint': '勧められた設定のまま始めます',
286
299
  'level.developer.name': '開発者',
package/src/i18n/ko.js CHANGED
@@ -59,6 +59,7 @@ export const ko = {
59
59
  'cmd.architect.desc': '작업 모드 → 설계 (구조를 짠다)',
60
60
  'cmd.debug.desc': '작업 모드 → 디버그 (원인을 찾는다)',
61
61
  'cmd.ask.desc': '작업 모드 → 묻기 (설명만)',
62
+ 'cmd.inspect.desc': '작업 모드 → 점검 (결함을 찾는다. 안 고침)',
62
63
  'cmd.orchestrator.desc': '작업 모드 → 총괄 (큰 일을 쪼개서)',
63
64
  'cmd.undo.desc': '직전 작업 되돌리기 — 파일과 대화를 같이',
64
65
  'cmd.undo.arg': '[턴수]',
@@ -303,6 +304,18 @@ export const ko = {
303
304
  'approve.strict.name': '모두 확인',
304
305
  'approve.strict.short': '모두',
305
306
  'approve.strict.line': '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
307
+ 'work.title': '작업 모드',
308
+ 'work.canEdit': '파일 바꿈',
309
+ 'work.readOnly': '읽기만',
310
+ 'work.think': '생각',
311
+ 'work.steps': '최대 {n}걸음',
312
+ 'work.tools': '도구',
313
+ 'work.toolsWrite': '읽기 + 파일 바꾸기',
314
+ 'work.toolsRead': '읽기만 — 파일을 못 바꿉니다',
315
+ 'work.howTo': '바꾸려면 {a} {b} {c} … 또는 {키} 로 차례로',
316
+ 'work.axis': '이건 승인 정책({명령})과 다른 축입니다 — 무엇을 하느냐 / 얼마나 물어보냐.',
317
+ 'work.autoOn': '앞으로는 한마디마다 알맞은 모드로 저절로 옮겨 갑니다.',
318
+ 'work.pinned': '직접 고르셨으므로 저절로 바뀌지 않습니다. 다시 맡기려면',
306
319
  'level.beginner.name': '쉬움',
307
320
  'level.beginner.hint': '권장값으로 바로 시작',
308
321
  'level.developer.name': '개발자',
package/src/i18n/zh.js CHANGED
@@ -57,6 +57,7 @@ export const zh = {
57
57
  'cmd.architect.desc': '工作模式 → architect (定结构)',
58
58
  'cmd.debug.desc': '工作模式 → debug (找原因)',
59
59
  'cmd.ask.desc': '工作模式 → ask (只解释)',
60
+ 'cmd.inspect.desc': '工作模式 → inspect (查找缺陷,不修改)',
60
61
  'cmd.orchestrator.desc': '工作模式 → orchestrator (把大活儿拆开)',
61
62
  'cmd.undo.desc': '撤销上一轮 — 文件和对话一起',
62
63
  'cmd.undo.arg': '[轮数]',
@@ -281,6 +282,18 @@ export const zh = {
281
282
  'approve.strict.name': '每次都问',
282
283
  'approve.strict.short': '每次',
283
284
  'approve.strict.line': '每改一个文件、每跑一条命令都问',
285
+ 'work.title': '工作模式',
286
+ 'work.canEdit': '会改文件',
287
+ 'work.readOnly': '只读',
288
+ 'work.think': '思考',
289
+ 'work.steps': '最多 {n} 步',
290
+ 'work.tools': '工具',
291
+ 'work.toolsWrite': '读取 + 修改文件',
292
+ 'work.toolsRead': '只读 — 不能修改文件',
293
+ 'work.howTo': '切换:{a} {b} {c} … 或用 {키} 依次切换',
294
+ 'work.axis': '这与审批策略({명령})是两条不同的轴 — 做什么 / 问多少。',
295
+ 'work.autoOn': '之后会按每句话自动切到合适的模式。',
296
+ 'work.pinned': '你自己选的,不会自动改变。要交回自动请用',
284
297
  'level.beginner.name': '新手',
285
298
  'level.beginner.hint': '按推荐设置开始',
286
299
  'level.developer.name': '开发者',
@@ -0,0 +1,266 @@
1
+ /**
2
+ * 문서 → 마크다운.
3
+ *
4
+ * ── 왜 글이 아니라 마크다운인가 ─────────────────────────────────────────
5
+ *
6
+ * deel 은 이미 hwpx·docx·pptx·xlsx·pdf 와 Figma `.fig` 를 제 손으로 읽는다
7
+ * (docs.js · xlsx.js · pdf.js · fig.js). 다만 돌려주던 것이 **평평한 글**이었다.
8
+ * 표는 `이름 | 값 | 비고` 한 줄로 펴져 나갔고, 장 구분은 `--- 3장 ---` 이었다.
9
+ *
10
+ * 그게 왜 손해인가 — 읽는 쪽이 사람이 아니라 모델이기 때문이다. 마크다운
11
+ * 표는 모델이 「첫 줄이 머리글이고 셋째 칸이 비고」 라는 것을 그냥 안다.
12
+ * 평평한 줄에서는 그걸 매번 다시 짐작해야 하고, 칸이 빈 행이 하나 섞이면
13
+ * 거기서부터 어긋난다. 같은 바이트로 더 많이 전하는 쪽이 마크다운이다.
14
+ *
15
+ * 그리고 사람도 그대로 쓴다. `.md` 로 떨어뜨려 두면 그게 곧 문서다.
16
+ *
17
+ * ── 무엇을 새로 들이지 않나 ─────────────────────────────────────────────
18
+ *
19
+ * 아무것도. 읽는 일은 이미 있는 읽개들이 그대로 하고, 이 파일은 그것이
20
+ * 돌려준 것을 **그리기만** 한다. 의존성 0개는 그대로다.
21
+ *
22
+ * ── 표를 언제 표로 안 그리나 ────────────────────────────────────────────
23
+ *
24
+ * 칸이 열두 개를 넘으면 마크다운 표는 읽는 것이 아니라 가로로 흐르는 벽이
25
+ * 된다. 그때는 CSV 울타리로 떨어뜨리고 **왜 그랬는지 적는다.** 조용히
26
+ * 모양을 바꾸면, 표가 안 나오는 것이 파일 탓인지 우리 탓인지 알 길이 없다.
27
+ */
28
+ import { basename, extname } from 'node:path';
29
+
30
+ import { isDocPath, readDoc, summarize as docSummary } from './docs.js';
31
+ import { isPdfPath, readPdf, summarize as pdfSummary } from './pdf.js';
32
+ import { isFigPath, readFig, summarize as figSummary } from './fig.js';
33
+ import { readXlsx, toCsv } from './xlsx.js';
34
+ import { readFileSync } from 'node:fs';
35
+
36
+ /** 엑셀 확장자. tools/index.js 가 쓰는 것과 같은 목록이다. */
37
+ const 엑셀 = new Set(['.xlsx', '.xlsm']);
38
+
39
+ /** 이 길로 마크다운을 만들 수 있는 파일인가. */
40
+ export function 바꿀수있나(경로) {
41
+ const p = String(경로 ?? '');
42
+ return isDocPath(p) || isPdfPath(p) || isFigPath(p) || 엑셀.has(extname(p).toLowerCase());
43
+ }
44
+
45
+ /** 우리가 직접 읽는 갈래 목록 — 화면과 도움말이 같은 곳에서 가져간다. */
46
+ export const 읽는갈래 = ['hwpx', 'docx', 'pptx', 'xlsx', 'pdf', 'fig'];
47
+
48
+ // 다 실어 봐야 창만 찬다. 글로 낼 때와 같은 상한을 쓴다(docs.js · pdf.js).
49
+ const 최대글자 = 60000;
50
+ // 이보다 칸이 많으면 마크다운 표로 안 그린다 — 가로로 흐르는 벽이 된다.
51
+ const 최대칸 = 12;
52
+
53
+ /** 마크다운 표 칸 하나. `|` 와 줄바꿈은 표를 부수므로 바꿔 준다. */
54
+ function 칸(v) {
55
+ return String(v ?? '')
56
+ .replace(/\|/g, '\\|')
57
+ .replace(/\r?\n/g, '<br>')
58
+ .trim();
59
+ }
60
+
61
+ /** 이 행들이 다 비었나. 빈 표를 그리면 머리글만 있는 유령 표가 남는다. */
62
+ const 다비었나 = (행들) => 행들.every((r) => r.every((c) => !String(c ?? '').trim()));
63
+
64
+ /**
65
+ * 행 목록을 마크다운 표로. 첫 행이 머리글이다.
66
+ *
67
+ * 칸 수가 행마다 다른 문서가 흔하다(합쳐진 칸). 제일 넓은 행에 맞춰 채운다 —
68
+ * 안 채우면 마크다운이 그 행부터 표로 안 읽는다.
69
+ */
70
+ export function 표그리기(행들) {
71
+ const 있는것 = (행들 ?? []).filter((r) => Array.isArray(r) && r.length);
72
+ if (!있는것.length || 다비었나(있는것)) return '';
73
+
74
+ const 폭 = Math.max(...있는것.map((r) => r.length));
75
+ if (폭 > 최대칸) {
76
+ return ['```csv', toCsv(있는것.map((r) => [...r, ...Array(폭 - r.length).fill('')])), '```',
77
+ `(칸이 ${폭}개라 마크다운 표 대신 CSV 로 냈습니다 — 표로 그리면 가로로 흘러 못 읽습니다.)`,
78
+ ].join('\n');
79
+ }
80
+
81
+ const 채워 = (r) => [...r.map(칸), ...Array(폭 - r.length).fill('')];
82
+ const [머리, ...몸] = 있는것;
83
+ return [
84
+ `| ${채워(머리).join(' | ')} |`,
85
+ `|${' --- |'.repeat(폭)}`,
86
+ ...몸.map((r) => `| ${채워(r).join(' | ')} |`),
87
+ ].join('\n');
88
+ }
89
+
90
+ /**
91
+ * 글자 수를 세면서 조각을 담는 자. 넘치면 **넘쳤다고 말하고** 멈춘다.
92
+ *
93
+ * 조용히 자르면 모델은 그게 전부인 줄 알고 "문서에 그런 내용 없다" 고 답한다.
94
+ * 이 파일이 읽개들에서 그대로 물려받는 규칙이다.
95
+ */
96
+ function 담개(maxChars) {
97
+ const 조각 = [];
98
+ const 말 = [];
99
+ let 셈 = 0;
100
+ let 찼나 = false;
101
+ return {
102
+ 담기(글) {
103
+ if (찼나) return false;
104
+ const s = String(글 ?? '');
105
+ if (셈 + s.length > maxChars) {
106
+ 찼나 = true;
107
+ 말.push(`${maxChars.toLocaleString('en-US')}자에서 잘랐습니다 — 뒷부분은 안 실렸습니다`);
108
+ return false;
109
+ }
110
+ 조각.push(s);
111
+ 셈 += s.length + 1;
112
+ return true;
113
+ },
114
+ get 찼나() { return 찼나; },
115
+ 끝내기: () => ({ md: 조각.join('\n\n'), 말 }),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * 덩이 하나(장·구획·쪽)를 담는다.
121
+ *
122
+ * 표 행({행:[...]})이 잇달아 나오면 한 표로 묶는다. 사이에 글이 끼면 거기서
123
+ * 표가 끊긴 것이다 — 문서에서 표가 두 개로 나뉜 자리가 정확히 그 모양이다.
124
+ */
125
+ function 덩이담기(담, 문단들, 머리글) {
126
+ if (머리글 && !담.담기(머리글)) return;
127
+ let 표 = [];
128
+ const 표비우기 = () => {
129
+ if (!표.length) return true;
130
+ const 그림 = 표그리기(표);
131
+ 표 = [];
132
+ return 그림 ? 담.담기(그림) : true;
133
+ };
134
+ for (const 문단 of 문단들 ?? []) {
135
+ if (문단 && typeof 문단 === 'object' && Array.isArray(문단.행)) { 표.push(문단.행); continue; }
136
+ if (!표비우기()) return;
137
+ if (!담.담기(String(문단))) return;
138
+ }
139
+ 표비우기();
140
+ }
141
+
142
+ /** 마크다운 제목에 파일 이름을 그대로 쓴다 — 어느 파일에서 나온 글인지가 남아야 한다. */
143
+ const 제목 = (경로) => `# ${basename(String(경로 ?? ''))}`;
144
+
145
+ function 문서를마크다운(경로, maxChars) {
146
+ const r = readDoc(경로, { 표를따로: true });
147
+ if (!r.ok) return { ok: false, error: r.error, 끝났다: r.끝났다 };
148
+
149
+ const 담 = 담개(maxChars);
150
+ 담.담기(제목(경로));
151
+ const 여럿 = r.덩이들.length > 1;
152
+ for (const d of r.덩이들) {
153
+ 덩이담기(담, d.문단들, 여럿 ? `## ${d.이름}` : '');
154
+ if (담.찼나) break;
155
+ }
156
+ const { md, 말 } = 담.끝내기();
157
+ return { ok: true, 갈래: r.갈래, md, 말, summary: docSummary(r) };
158
+ }
159
+
160
+ function 엑셀을마크다운(경로, maxChars) {
161
+ let r;
162
+ try {
163
+ r = readXlsx(readFileSync(경로));
164
+ } catch (err) {
165
+ return { ok: false, error: `엑셀을 읽지 못했습니다 — ${err.message}` };
166
+ }
167
+ const 담 = 담개(maxChars);
168
+ 담.담기(제목(경로));
169
+ for (const s of r.sheets) {
170
+ // 숨긴 시트도 낸다. 다만 숨겨져 있었다고 적는다 — 그 사실이 값을 읽는
171
+ // 방식을 바꾼다(옛 판·임시 계산용인 일이 많다).
172
+ 담.담기(`## ${s.name}${s.hidden ? ' (숨김 시트)' : ''}`);
173
+ const 그림 = 표그리기(s.rows);
174
+ if (!담.담기(그림 || '(빈 시트입니다.)')) break;
175
+ }
176
+ const { md, 말 } = 담.끝내기();
177
+ const 시트말 = `xlsx · 시트 ${r.sheets.length}개`;
178
+ return { ok: true, 갈래: 'xlsx', md, 말: [...말, ...(r.notes ?? [])], summary: 시트말 };
179
+ }
180
+
181
+ /**
182
+ * Figma 시안을 마크다운으로.
183
+ *
184
+ * 쪽마다 `## 쪽이름` 이 되고, 그 밑이 그대로 마크다운 목록이 된다 — 시안의
185
+ * 짜임이 곧 목록의 들여쓰기라, 옮겨 그릴 것이 없다.
186
+ */
187
+ function fig를마크다운(경로, maxChars) {
188
+ const r = readFig(경로);
189
+ if (!r.ok) return { ok: false, error: r.error, 끝났다: r.끝났다 };
190
+
191
+ const 담 = 담개(maxChars);
192
+ 담.담기(제목(경로));
193
+ for (const d of r.덩이들) {
194
+ if (!담.담기(`## ${d.이름}`)) break;
195
+ /*
196
+ * 나무 줄은 **붙여서** 한 덩이로 낸다.
197
+ *
198
+ * 여기만 다른 읽개와 다르다. 문서의 문단은 사이가 벌어져야 문단인데,
199
+ * 목록은 사이가 벌어지면 들여쓰기가 뜻을 잃는다. 그렇다고 쪽 하나를
200
+ * 통째로 한 덩이로 만들면, 넘칠 때 그 쪽이 통째로 사라진다 — 조용히
201
+ * 빠진 자리를 안 만드는 것이 이 파일의 규칙이라 그럴 수는 없다.
202
+ * 그래서 8천 자쯤에서 끊는다. 자를 자리가 줄 단위로 남는다.
203
+ */
204
+ let 뭉치 = [];
205
+ let 셈 = 0;
206
+ const 비우기 = () => {
207
+ if (!뭉치.length) return true;
208
+ const 글 = 뭉치.join('\n');
209
+ 뭉치 = [];
210
+ 셈 = 0;
211
+ return 담.담기(글);
212
+ };
213
+ for (const 줄 of d.문단들 ?? []) {
214
+ 뭉치.push(String(줄));
215
+ 셈 += String(줄).length + 1;
216
+ if (셈 >= 8000 && !비우기()) break;
217
+ }
218
+ if (!비우기()) break;
219
+ }
220
+ const { md, 말 } = 담.끝내기();
221
+ return { ok: true, 갈래: 'fig', md, 말: [...(r.말 ?? []), ...말], summary: figSummary(r) };
222
+ }
223
+
224
+ function pdf를마크다운(경로, maxChars) {
225
+ const r = readPdf(경로);
226
+ if (!r.ok) return { ok: false, error: r.error, 끝났다: r.끝났다 };
227
+
228
+ const 담 = 담개(maxChars);
229
+ 담.담기(제목(경로));
230
+ const 왜표 = new Map((r.못읽은쪽 ?? []).map((x) => [x.번호, x]));
231
+ for (const d of r.덩이들) {
232
+ const 번호 = Number((d.이름.match(/^(\d+)/) ?? [])[1]);
233
+ 담.담기(`## ${d.이름}`);
234
+ const 못 = 왜표.get(번호);
235
+ // 못 읽은 쪽을 조용히 건너뛰면 **없는 문서**가 된다. 쪽 자리에 왜 비었는지 적는다.
236
+ if (못 && !못.일부) { 담.담기(`> 이 쪽은 글로 못 읽었습니다 — ${못.왜}`); continue; }
237
+ 덩이담기(담, d.문단들, '');
238
+ if (못?.일부) 담.담기(`> 이 쪽은 일부만 읽었습니다 — ${못.왜}`);
239
+ if (담.찼나) break;
240
+ }
241
+ const { md, 말 } = 담.끝내기();
242
+ return { ok: true, 갈래: 'pdf', md, 말, summary: pdfSummary(r) };
243
+ }
244
+
245
+ /**
246
+ * 문서 하나를 마크다운으로.
247
+ *
248
+ * 던지지 않는다. 깨진 파일은 도구 실행 한가운데서 만나는 것이라, 예외가 나면
249
+ * 「문서가 깨졌다」 가 「도구가 터졌다」 로 보고된다 — 읽개들이 다 지키는 규칙이다.
250
+ *
251
+ * @returns {{ok:true, 갈래:string, md:string, 말:string[], summary:string}
252
+ * |{ok:false, error:string, 끝났다?:boolean}}
253
+ */
254
+ export function 마크다운(경로, { maxChars = 최대글자 } = {}) {
255
+ const p = String(경로 ?? '');
256
+ if (엑셀.has(extname(p).toLowerCase())) return 엑셀을마크다운(p, maxChars);
257
+ if (isPdfPath(p)) return pdf를마크다운(p, maxChars);
258
+ if (isFigPath(p)) return fig를마크다운(p, maxChars);
259
+ if (isDocPath(p)) return 문서를마크다운(p, maxChars);
260
+ return {
261
+ ok: false,
262
+ error: `마크다운으로 바꿀 수 있는 갈래가 아닙니다: ${basename(p) || p}\n`
263
+ + ` 직접 읽는 것: ${읽는갈래.join(' · ')}\n`
264
+ + ' 옛 형식(.ppt·.doc·.xls·.hwp)은 이 PC 에 LibreOffice 가 있으면 Read 가 빌려 읽습니다.',
265
+ };
266
+ }
package/src/tools/docs.js CHANGED
@@ -162,7 +162,14 @@ const 끝이름 = (name) => {
162
162
  * "이름값가1" 같은 덩어리가 되는데, 그건 뜻이 사라진 글이라 안 읽은 것보다
163
163
  * 나쁘다 — 모델이 그걸 근거로 답한다.
164
164
  */
165
- export function 문단뽑기(xml, 갈래) {
165
+ /**
166
+ * @param {object} [옵션]
167
+ * @param {boolean} [옵션.표를따로] 표 행을 { 행: [칸...] } 로 돌려준다.
168
+ * 기본값은 여태와 똑같이 `a | b` 한 줄이다. 마크다운으로 낼 때만 켠다
169
+ * (tools/doc2md.js) — 마크다운 표는 칸이 몇 개인지 알아야 그릴 수 있고,
170
+ * 이어 붙인 한 줄에서 그걸 되짚으면 글에 든 | 하나에 표가 어긋난다.
171
+ */
172
+ export function 문단뽑기(xml, 갈래, { 표를따로 = false } = {}) {
166
173
  const 표기 = 이름표[갈래];
167
174
  const 문단들 = [];
168
175
 
@@ -195,7 +202,10 @@ export function 문단뽑기(xml, 갈래) {
195
202
  if (이름 === 표기.표) { 표깊이 += t.closing ? -1 : (t.selfClosing ? 0 : 1); continue; }
196
203
  if (표깊이 > 0 && 이름 === 표기.행) {
197
204
  if (t.closing) {
198
- if (행칸들) 문단들.push(행칸들.map((x) => x.trim()).join(' | '));
205
+ if (행칸들) {
206
+ const 칸 = 행칸들.map((x) => x.trim());
207
+ 문단들.push(표를따로 ? { 행: 칸 } : 칸.join(' | '));
208
+ }
199
209
  행칸들 = null;
200
210
  } else {
201
211
  행칸들 = [];
@@ -237,7 +247,7 @@ const 알맹이 = {
237
247
  * 던지지 않는다 — 깨진 파일은 도구 실행 한가운데서 만나는 것이라,
238
248
  * 예외가 나면 "문서가 깨졌다" 가 "도구가 터졌다" 로 보고된다.
239
249
  */
240
- export function readDoc(경로또는버퍼) {
250
+ export function readDoc(경로또는버퍼, { 표를따로 = false } = {}) {
241
251
  const 갈래 = Buffer.isBuffer(경로또는버퍼) ? null : 종류(경로또는버퍼);
242
252
  let buf;
243
253
  try {
@@ -270,7 +280,7 @@ export function readDoc(경로또는버퍼) {
270
280
 
271
281
  const 덩이들 = 찾은.map((s, i) => ({
272
282
  이름: 구획이름(i, s.번호),
273
- 문단들: 문단뽑기(s.몸.toString('utf8'), 갈래),
283
+ 문단들: 문단뽑기(s.몸.toString('utf8'), 갈래, { 표를따로 }),
274
284
  }));
275
285
  return { ok: true, 갈래, 덩이들 };
276
286
  }