deel-local-cli 1.4.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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 대화 → 보고서 한 장 (/export).
3
+ *
4
+ * ── 왜 링크가 아니라 파일인가 ───────────────────────────────────────────
5
+ *
6
+ * 요즘 CLI 는 세션을 링크로 공유한다. 링크는 서버가 있어야 산다 — 이 프로그램이
7
+ * 도는 자리(폐쇄망)에는 그 서버가 없고, 있어도 소스가 그리로 나가면 안 된다.
8
+ *
9
+ * 이 자리의 공유는 원래 파일이다. 결재는 첨부로 돌고, 보고는 한 장으로 한다.
10
+ * 그래서 대화를 **자기완결 HTML 한 장**으로 접는다 — 무엇을 시켰고, 무엇이
11
+ * 바뀌었고, 무엇으로 확인했나. 바깥 주소가 하나도 없어서 어느 망에서든 열린다.
12
+ *
13
+ * ── 지키는 선 ───────────────────────────────────────────────────────────
14
+ *
15
+ * 1. **대화가 코드가 되지 않는다.** 대화에는 <script> 같은 글자가 흔히 든다
16
+ * (코드를 다루는 프로그램이니까). 이스케이프를 한 자리라도 빠뜨리면
17
+ * 보고서를 여는 순간 그 코드가 돈다 — 보고서가 공격 통로가 된다.
18
+ * 2. **증거의 정직함을 그대로 옮긴다.** '증명 안 된 것' 절이 이 보고서의
19
+ * 요점이다. 그 절을 빼고 초록만 남기면 보고서가 아니라 홍보물이다.
20
+ * 3. **화면 말을 따라간다.** /lang en 이면 보고서도 영어로 나간다 —
21
+ * 보고서는 남에게 가는 글이라, 화면보다도 이쪽이 먼저다.
22
+ */
23
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { 증거모으기 } from '../agent/evidence.js';
26
+ import { 말, 언어 } from '../i18n/index.js';
27
+
28
+ /** HTML 로 안전하게. 문서 전체가 이 함수 하나를 지나야 한다. */
29
+ function 글자(s) {
30
+ return String(s ?? '')
31
+ .replace(/&/g, '&amp;')
32
+ .replace(/</g, '&lt;')
33
+ .replace(/>/g, '&gt;')
34
+ .replace(/"/g, '&quot;');
35
+ }
36
+
37
+ /** ANSI 제어문자 걷어내기. 도구 결과에는 색이 섞여 있을 수 있다. */
38
+ const 민글 = (s) => String(s ?? '').replace(/\x1b\[[0-9;]*m/g, '');
39
+
40
+ /** 도구 호출 한 줄 요약. 보고서는 기록이지 재현이 아니다 — 인자 전체를 안 싣는다. */
41
+ function 도구줄(tc) {
42
+ const 이름 = tc?.function?.name ?? tc?.name ?? '?';
43
+ let 핵심 = '';
44
+ try {
45
+ const a = JSON.parse(tc?.function?.arguments ?? '{}');
46
+ 핵심 = a.file_path ?? a.pattern ?? a.command ?? a.name ?? a.목적 ?? '';
47
+ } catch { /* 인자가 잘렸어도 이름은 보여 준다 */ }
48
+ 핵심 = String(핵심);
49
+ if (핵심.length > 80) 핵심 = 핵심.slice(0, 80) + '…';
50
+ return 핵심 ? `${이름}(${핵심})` : 이름;
51
+ }
52
+
53
+ /**
54
+ * 보고서 HTML 을 짓는다.
55
+ *
56
+ * @param session Session — messages·changes·usage·conn 을 읽는다. 안 바꾼다.
57
+ * @param {object} o
58
+ * @param {{show:(p:string)=>string}} [o.scope] 경로를 짧게 보일 때
59
+ * @param {object} [o.audit] 증거를 모을 감사기록 (없으면 증거 절이 얕아진다)
60
+ */
61
+ export function 보고서짓기(session, { scope = null, audit = null } = {}) {
62
+ const 보임 = (p) => { try { return scope?.show ? scope.show(p) : String(p); } catch { return String(p); } };
63
+
64
+ // ── 재료 ──────────────────────────────────────────────────────────────
65
+ const 첫말 = (session.messages ?? []).find((m) => m.role === 'user')?.content;
66
+ const 제목 = String(첫말 ?? 말('export.untitled')).split('\n')[0].slice(0, 80);
67
+
68
+ const 흐름 = [];
69
+ for (const m of session.messages ?? []) {
70
+ if (m.role === 'user' && typeof m.content === 'string' && m.content.trim()) {
71
+ 흐름.push({ 갈래: 'user', 글: m.content });
72
+ } else if (m.role === 'assistant') {
73
+ const 부른것 = (m.tool_calls ?? []).map(도구줄);
74
+ if (부른것.length) 흐름.push({ 갈래: 'tools', 글: 부른것.join(' · ') });
75
+ if (typeof m.content === 'string' && m.content.trim()) 흐름.push({ 갈래: 'assistant', 글: m.content });
76
+ }
77
+ // tool 결과는 안 싣는다 — 길고, 요점은 답과 증거 절에 이미 있다.
78
+ }
79
+
80
+ const 바뀐것 = [...(session.changes ?? new Map()).entries()]
81
+ .map(([p, v]) => ({ 파일: 보임(p), 더함: v.added ?? 0, 뺌: v.removed ?? 0, 몇번: v.times ?? 1 }));
82
+
83
+ const 증거 = (() => {
84
+ try { return 증거모으기(session, { audit }); } catch { return null; }
85
+ })();
86
+
87
+ const u = session.usage ?? {};
88
+ const 쓴것 = [
89
+ session.conn?.model ? `${말('export.model')}: ${session.conn.model}` : '',
90
+ Number.isFinite(u.in) ? `↑${(u.in ?? 0).toLocaleString()} ↓${(u.out ?? 0).toLocaleString()}` : '',
91
+ u.calls ? `${말('export.calls', { 수: u.calls })}` : '',
92
+ ].filter(Boolean);
93
+
94
+ // ── 조립 ──────────────────────────────────────────────────────────────
95
+ const 조각 = [];
96
+ const 절 = (이름) => 조각.push(`<h2>${글자(이름)}</h2>`);
97
+
98
+ 조각.push(`<header><h1>${글자(제목)}</h1><p class="meta">${글자(쓴것.join(' · '))}</p></header>`);
99
+
100
+ 절(말('export.conversation'));
101
+ 조각.push('<div class="flow">');
102
+ for (const x of 흐름) {
103
+ if (x.갈래 === 'user') 조각.push(`<div class="u"><span class="who">${글자(말('export.you'))}</span><pre>${글자(x.글)}</pre></div>`);
104
+ else if (x.갈래 === 'tools') 조각.push(`<div class="t">⏺ ${글자(민글(x.글))}</div>`);
105
+ else 조각.push(`<div class="a"><span class="who">deel</span><pre>${글자(민글(x.글))}</pre></div>`);
106
+ }
107
+ if (!흐름.length) 조각.push(`<p class="none">${글자(말('export.nothing'))}</p>`);
108
+ 조각.push('</div>');
109
+
110
+ 절(말('export.changed'));
111
+ if (바뀐것.length) {
112
+ 조각.push(`<table><tr><th>${글자(말('export.file'))}</th><th>+</th><th>−</th><th>${글자(말('export.times'))}</th></tr>`);
113
+ for (const x of 바뀐것) {
114
+ 조각.push(`<tr><td>${글자(x.파일)}</td><td class="g">+${x.더함}</td><td class="r">−${x.뺌}</td><td>${x.몇번}</td></tr>`);
115
+ }
116
+ 조각.push('</table>');
117
+ } else {
118
+ 조각.push(`<p class="none">${글자(말('export.noChanges'))}</p>`);
119
+ }
120
+
121
+ /*
122
+ * 증거 절. 여기가 이 보고서의 요점이다.
123
+ *
124
+ * '증명 안 된 것' 은 항상 절로 세운다 — 비어 있을 때도 "없습니다" 라고
125
+ * 적는다. 절 자체를 숨기면, 읽는 사람은 다 확인된 줄 안다.
126
+ */
127
+ 절(말('export.evidence'));
128
+ const 돌린 = 증거?.돌린것 ?? [];
129
+ if (돌린.length) {
130
+ 조각.push('<ul class="ran">');
131
+ for (const x of 돌린) 조각.push(`<li class="${x.됐나 ? 'ok' : 'bad'}">${x.됐나 ? '✓' : '✗'} <code>${글자(민글(x.무엇))}</code>${x.남긴말 ? ` — ${글자(민글(x.남긴말))}` : ''}</li>`);
132
+ 조각.push('</ul>');
133
+ } else {
134
+ 조각.push(`<p class="none">${글자(말('export.nothingRan'))}</p>`);
135
+ }
136
+
137
+ 조각.push(`<h3>${글자(말('export.unproven'))}</h3>`);
138
+ const 미증명 = 증거?.증명안된것 ?? [];
139
+ if (미증명.length) {
140
+ 조각.push('<ul class="unproven">');
141
+ for (const x of 미증명) 조각.push(`<li><b>${글자(x.파일)}</b> — ${글자(x.왜 ?? '')}</li>`);
142
+ 조각.push('</ul>');
143
+ } else if (바뀐것.length) {
144
+ 조각.push(`<p class="none">${글자(말('export.allProven'))}</p>`);
145
+ } else {
146
+ 조각.push(`<p class="none">${글자(말('export.noChanges'))}</p>`);
147
+ }
148
+
149
+ 조각.push(`<footer>${글자(말('export.footer'))}</footer>`);
150
+
151
+ /*
152
+ * 겉옷. 바깥 주소는 하나도 없다 — 폐쇄망에서 여는 파일이다.
153
+ * 스크립트도 없다. 보고서는 읽는 물건이지 도는 물건이 아니다.
154
+ */
155
+ return `<!doctype html>
156
+ <html lang="${언어()}">
157
+ <meta charset="utf-8">
158
+ <meta name="viewport" content="width=device-width, initial-scale=1">
159
+ <title>${글자(제목)}</title>
160
+ <style>
161
+ :root{--bg:#fff;--fg:#1a1a1a;--mut:#667;--line:#e2e2e6;--card:#f7f7f9;--g:#0a7d38;--r:#b3261e}
162
+ @media (prefers-color-scheme:dark){:root{--bg:#131316;--fg:#ececf0;--mut:#9a9aa4;--line:#2a2a30;--card:#1c1c21;--g:#4cc38a;--r:#ef7b73}}
163
+ body{margin:0;padding:2rem 1rem 4rem;background:var(--bg);color:var(--fg);
164
+ font:15px/1.65 system-ui,-apple-system,"Segoe UI","Malgun Gothic",sans-serif}
165
+ main{max-width:820px;margin:0 auto}
166
+ h1{font-size:1.35rem;margin:0 0 .2rem}
167
+ .meta{color:var(--mut);font-size:.85rem;margin:0 0 1.6rem}
168
+ h2{font-size:1.02rem;border-top:1px solid var(--line);padding-top:1.1rem;margin:2rem 0 .6rem}
169
+ h3{font-size:.92rem;margin:1.2rem 0 .4rem}
170
+ .flow .u,.flow .a{border:1px solid var(--line);border-radius:8px;padding:.55rem .8rem;margin:.55rem 0}
171
+ .flow .u{background:var(--card)}
172
+ .flow .who{font-size:.75rem;color:var(--mut);display:block;margin-bottom:.15rem}
173
+ .flow pre{margin:0;white-space:pre-wrap;word-break:break-word;font:inherit}
174
+ .flow .t{color:var(--mut);font-size:.85rem;margin:.3rem .2rem;font-family:Consolas,monospace}
175
+ table{border-collapse:collapse;width:100%;font-size:.9rem}
176
+ th,td{text-align:left;padding:.35rem .6rem;border-bottom:1px solid var(--line)}
177
+ th{color:var(--mut);font-weight:600;font-size:.8rem}
178
+ td.g{color:var(--g)} td.r{color:var(--r)}
179
+ ul{padding-left:1.2rem} li{margin:.25rem 0}
180
+ li.ok{color:var(--fg)} li.bad{color:var(--r)}
181
+ .unproven li{color:var(--r)}
182
+ code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.05em .3em;font-size:.85em}
183
+ .none{color:var(--mut)}
184
+ footer{margin-top:2.5rem;color:var(--mut);font-size:.8rem;border-top:1px solid var(--line);padding-top:.8rem}
185
+ </style>
186
+ <main>
187
+ ${조각.join('\n')}
188
+ </main>
189
+ </html>
190
+ `;
191
+ }
192
+
193
+ /**
194
+ * 파일로 남긴다.
195
+ *
196
+ * .deel/보고서/ 아래에 시각으로 이름을 짓고, 같은 분(分)에 두 번 적으면
197
+ * 번호를 붙인다 — **앞엣것을 절대 덮지 않는다.** 보고서는 남는 것이 일이다.
198
+ *
199
+ * @returns {string|null} 적은 자리. 못 적었으면 null — 못 적어도 대화는 계속된다.
200
+ */
201
+ export function 보고서적기(root, session, opts = {}) {
202
+ try {
203
+ const 폴더 = join(root, '.deel', 'export');
204
+ mkdirSync(폴더, { recursive: true });
205
+ const t = new Date();
206
+ const 판 = (n) => `${t.getFullYear()}${String(t.getMonth() + 1).padStart(2, '0')}${String(t.getDate()).padStart(2, '0')}`
207
+ + `-${String(t.getHours()).padStart(2, '0')}${String(t.getMinutes()).padStart(2, '0')}`
208
+ + (n ? `-${n}` : '') + '.html';
209
+ let 이름 = 판(0);
210
+ for (let n = 1; existsSync(join(폴더, 이름)) && n < 100; n++) 이름 = 판(n);
211
+ const 자리 = join(폴더, 이름);
212
+ writeFileSync(자리, 보고서짓기(session, opts), 'utf8');
213
+ return 자리;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
package/src/ui/intro.js CHANGED
@@ -34,6 +34,7 @@
34
34
  */
35
35
  import { c, cursor, width } from './ansi.js';
36
36
  import { 말 } from '../i18n/index.js';
37
+ import * as 큰이름 from './banner.js';
37
38
 
38
39
  const 앞 = 'deel';
39
40
  const 꼬리 = '-local';
@@ -137,32 +138,63 @@ export async function 보이기({
137
138
  주기 = 틀주기,
138
139
  바깥 = false,
139
140
  곁말 = '',
141
+ 칸 = null,
140
142
  쉬기 = (ms) => new Promise((r) => setTimeout(r, ms)),
141
143
  } = {}) {
142
- const 옵션 = { 바깥, 곁말 };
143
- const 돌릴까 = 움직임 ?? (!끔() && !!process.stdout.isTTY);
144
+ const 앞자리 = !!process.stdout.isTTY;
145
+ const 돌릴까 = 움직임 ?? (!끔() && 앞자리);
146
+
147
+ /*
148
+ * 큰 이름을 쓸지 여기서 한 번에 정한다 (ui/banner.js).
149
+ *
150
+ * **앞자리가 터미널일 때만** 쓴다. 파이프·CI·기록으로 나갈 때는 작은 것
151
+ * 한 장이다 — 여덟 줄짜리 그림이 로그 맨 앞에 박히면 그 로그를 읽는
152
+ * 사람에게는 그냥 방해다. 화면에 뜨는 것과 파일에 남는 것이 달라도 되는,
153
+ * 몇 안 되는 자리다.
154
+ *
155
+ * 좁으면 억지로 안 밀어 넣는다. 글자가 접히면 무슨 그림인지 알아볼 수가
156
+ * 없고, 그건 아무것도 안 그린 것보다 나쁘다 — 화면이 깨진 것으로 보인다.
157
+ */
158
+ // 칸을 안 주면 앞자리를 보고 정한다. 터미널이 아니면 폭이 0이라 큰 것은 안 뜬다.
159
+ // 칸을 주면 그 폭으로 판단한다 — 검사가 앞자리 없이도 두 갈래를 다 재는 길이고,
160
+ // 부르는 쪽이 화면을 직접 아는 자리(다른 화면 구현)에서도 그대로 쓴다.
161
+ const 잴칸 = 칸 ?? (앞자리 ? (process.stdout.columns ?? 80) : 0);
162
+ const 배 = 큰이름.쓸배율(잴칸);
163
+ const 큰가 = 배 > 0;
164
+
165
+ const 옵션 = 큰가 ? { 바깥, 곁말, 배 } : { 바깥, 곁말 };
166
+ const 그릴틀 = 큰가 ? 큰이름.틀 : 틀;
167
+ const 그릴수 = 큰가 ? 큰이름.틀수 : 틀수;
168
+ const 줄수 = 큰가 ? 큰이름.줄수 : 2;
144
169
 
145
170
  if (!돌릴까) {
146
171
  // 한 장만. 커서를 안 올리므로 파이프·기록에 제어문자가 안 섞인다.
147
- for (const l of 마지막틀(옵션)) 쓰기(`${l}\n`);
172
+ const 한장 = 큰가 ? 큰이름.마지막틀(옵션) : 마지막틀(옵션);
173
+ for (const l of 한장) 쓰기(`${l}\n`);
148
174
  return 1;
149
175
  }
150
176
 
151
177
  cursor.hide();
152
178
  try {
153
- for (let i = 0; i < 틀수; i++) {
154
- const 줄들 = (i, 옵션);
155
- // 첫 틀은 자리를 새로 잡고, 그 뒤로는 그 두 줄만 지우고 다시 쓴다.
156
- const 앞머리 = i === 0 ? '' : '\x1b[2A';
157
- 쓰기(`${앞머리}\x1b[2K${줄들[0]}\n\x1b[2K${줄들[1]}\n`);
158
- if (i < 틀수 - 1) await 쉬기(주기);
179
+ for (let i = 0; i < 그릴수; i++) {
180
+ const 줄들 = 그릴틀(i, 옵션);
181
+ /*
182
+ * 틀은 자리를 새로 잡고, 뒤로는 줄들만 지우고 다시 쓴다.
183
+ *
184
+ * 올릴 수는 **틀마다 같아야 한다.** 한 번이라도 어긋나면 그 위의
185
+ * 남의 글을 지우거나, 앞 그림이 화면에 조각으로 남는다. 그래서 두 그림
186
+ * 모두 아직 안 그린 줄까지 빈 줄로 자리를 잡아 둔다.
187
+ */
188
+ const 앞머리 = i === 0 ? '' : `\x1b[${줄수}A`;
189
+ 쓰기(앞머리 + 줄들.map((l) => `\x1b[2K${l}\n`).join(''));
190
+ if (i < 그릴수 - 1) await 쉬기(주기);
159
191
  }
160
192
  } finally {
161
193
  // 무슨 일이 있어도 커서는 되돌린다. 여기서 안 되돌리면 그 뒤로 사람이
162
194
  // 치는 글에 커서가 안 보인다 — 프로그램이 멈춘 것처럼 보인다.
163
195
  cursor.show();
164
196
  }
165
- return 틀수;
197
+ return 그릴수;
166
198
  }
167
199
 
168
200
  /** 곁말은 부르는 쪽이 정한다. 여기 있는 것은 기본값이다. */
package/src/ui/status.js CHANGED
@@ -143,7 +143,10 @@ export const SEGMENTS = {
143
143
  get desc() { return 말('seg.think'); },
144
144
  make: (s) => {
145
145
  const p = PROFILES[s.effort] ?? PROFILES.save;
146
- return `${c.gray('◇')} ${c.white(s.think)}${c.gray('·')}${c.magenta(p.name)}`;
146
+ // 배분 이름도 화면 말을 따라간다. 강도(medium) 어느 말에서나 같은
147
+ // 낱말이라, 여기만 한국어로 남으면 `medium·절약` 처럼 반씩 섞인다.
148
+ const 이름 = 언어() === 'en' ? (p.en ?? p.name) : p.name;
149
+ return `${c.gray('◇')} ${c.white(s.think)}${c.gray('·')}${c.magenta(이름)}`;
147
150
  },
148
151
  // 배분은 강도보다 덜 급하다. 좁으면 강도만 남긴다.
149
152
  short: (s) => `${c.gray('◇')} ${c.white(s.think)}`,