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.
- package/README.en.md +301 -1537
- package/README.md +272 -1582
- package/bin/deel.js +73 -4
- package/package.json +8 -3
- package/src/acp/jsonrpc.js +230 -0
- package/src/acp/map.js +219 -0
- package/src/acp/serve.js +556 -0
- package/src/agent/card.js +129 -0
- package/src/agent/compact.js +10 -2
- package/src/agent/effort.js +5 -0
- package/src/agent/evidence.js +186 -0
- package/src/agent/grade.js +20 -0
- package/src/agent/loop.js +134 -13
- package/src/agent/models.js +169 -0
- package/src/agent/modes.js +47 -1
- package/src/agent/pins.js +140 -0
- package/src/agent/preset.js +113 -0
- package/src/agent/project.js +10 -4
- package/src/agent/session.js +233 -13
- package/src/agent/store.js +31 -0
- package/src/backend/adapter.js +14 -0
- package/src/commands.js +616 -52
- package/src/i18n/en.js +265 -0
- package/src/i18n/index.js +126 -0
- package/src/i18n/ko.js +252 -0
- package/src/lsp/client.js +459 -0
- package/src/lsp/diag.js +112 -0
- package/src/lsp/rpc.js +84 -0
- package/src/lsp/servers.js +218 -0
- package/src/oneshot.js +19 -0
- package/src/pack/sbom.js +218 -0
- package/src/pack/selfpack.js +19 -3
- package/src/preview/serve.js +19 -2
- package/src/repl.js +193 -8
- package/src/safety/secrets.js +205 -0
- package/src/safety/undo.js +10 -3
- package/src/tools/desc.en.js +221 -0
- package/src/tools/docs.js +252 -0
- package/src/tools/index.js +190 -6
- package/src/tools/lsp.js +327 -0
- package/src/tools/task.js +30 -2
- package/src/ui/ansi.js +45 -0
- package/src/ui/approve.js +25 -21
- package/src/ui/banner.js +245 -0
- package/src/ui/export.js +217 -0
- package/src/ui/inputbox.js +37 -7
- package/src/ui/intro.js +206 -0
- package/src/ui/level.js +11 -5
- package/src/ui/notify.js +101 -0
- package/src/ui/status.js +181 -35
package/src/tools/task.js
CHANGED
|
@@ -59,6 +59,27 @@ export const TASK_TOOL = {
|
|
|
59
59
|
type: 'string',
|
|
60
60
|
description: '하위가 일할 방식: code(만들고 고침) · debug(원인 찾기) · ask(읽고 답만). 안 적으면 code.',
|
|
61
61
|
},
|
|
62
|
+
/*
|
|
63
|
+
* 다른 모델에게 떼어 주기.
|
|
64
|
+
*
|
|
65
|
+
* 로컬에서는 모델 하나로 다 하기가 늘 아쉽다. 7B 는 계획을 잘 세우는데
|
|
66
|
+
* 파일 열 개를 고치다 창이 차고, 1.5B 는 창은 넉넉한데 무엇을 할지를
|
|
67
|
+
* 못 정한다. 8GB 램이면 둘 다 올려 두고 나눠 쓰는 것이 실제로 되는
|
|
68
|
+
* 유일한 길이다 (7B q4 + 1.5B q4 ≒ 5.7GB).
|
|
69
|
+
*
|
|
70
|
+
* 설명에 '사람이 설정에 적어 둔 이름' 이라고 못을 박는다. 모델이
|
|
71
|
+
* 주소를 지어내면 그 자리로 나갈 뻔하는데, 그건 이 프로그램이 하는
|
|
72
|
+
* 약속을 그 자리에서 깨는 것이다. 실제 판단은 loop.js 가 한다 —
|
|
73
|
+
* 부탁으로 막지 않는다.
|
|
74
|
+
*/
|
|
75
|
+
모델: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description:
|
|
78
|
+
'이 덩이를 **다른 모델**에게 맡긴다. 사람이 설정에 적어 둔 프로필 이름만 쓸 수 있다'
|
|
79
|
+
+ ' (주소를 지어내지 마라 — 안 먹는다). 안 적으면 지금 쓰는 모델 그대로다.'
|
|
80
|
+
+ ' 잔일(형식 맞추기·반복 수정·짧은 요약)을 작은 모델에게 떼어 주면 네 창이 안 찬다.'
|
|
81
|
+
+ ' 판단이 필요한 일은 네가 직접 해라.',
|
|
82
|
+
},
|
|
62
83
|
},
|
|
63
84
|
required: ['목적', '할일'],
|
|
64
85
|
},
|
|
@@ -106,7 +127,7 @@ export function 하위모드(요청, 부모모드) {
|
|
|
106
127
|
* 못 끝낸 것을 빼면 안 된다. 부모는 그걸 보고 이어서 할지 정한다 —
|
|
107
128
|
* 다 됐다고 알면 안 끝난 일 위에 다음 일을 쌓는다.
|
|
108
129
|
*/
|
|
109
|
-
export function 하위요약({ 목적, 모드, 끝, 글자수, 보인이름 = (p) => p }) {
|
|
130
|
+
export function 하위요약({ 목적, 모드, 끝, 글자수, 모델 = null, 보인이름 = (p) => p }) {
|
|
110
131
|
const 줄 = [];
|
|
111
132
|
const w = workMode(모드);
|
|
112
133
|
const 왜끝났나 = {
|
|
@@ -116,7 +137,14 @@ export function 하위요약({ 목적, 모드, 끝, 글자수, 보인이름 = (p
|
|
|
116
137
|
aborted: '사용자가 중단함 — 다 못 했습니다',
|
|
117
138
|
}[끝?.type] ?? '끝난 이유를 알 수 없음';
|
|
118
139
|
|
|
119
|
-
|
|
140
|
+
/*
|
|
141
|
+
* 다른 모델이 한 일이면 **요약에도 적는다.**
|
|
142
|
+
*
|
|
143
|
+
* 부모 모델이 이 요약만 보고 이어서 일하는데, 누가 한 것인지 모르면 작은
|
|
144
|
+
* 모델이 대충 해 놓은 것을 제가 한 것처럼 믿는다. "1.5B 가 했다" 가 적혀
|
|
145
|
+
* 있으면 미심쩍은 자리를 다시 볼 여지가 생긴다.
|
|
146
|
+
*/
|
|
147
|
+
줄.push(`하위 작업 "${목적}" · ${w.name} 모드${모델 ? ` · ${모델}` : ''} · ${끝?.steps ?? 0}걸음 · ${왜끝났나}`);
|
|
120
148
|
|
|
121
149
|
const 파일들 = (끝?.files ?? []).filter((f) => !f.dir);
|
|
122
150
|
if (파일들.length) {
|
package/src/ui/ansi.js
CHANGED
|
@@ -49,6 +49,15 @@ export const c = {
|
|
|
49
49
|
bgGray: E(100),
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 256색 한 칸. 큰 글자(intro.js)의 깊이를 낼 때 쓴다.
|
|
54
|
+
*
|
|
55
|
+
* 옛 콘솔(TERM=dumb)은 256색을 모른다. 거기서는 번호가 글에 그대로 찍히므로
|
|
56
|
+
* 아무 색도 안 입힌다 — 색이 없는 것보다 숫자가 새는 것이 훨씬 나쁘다.
|
|
57
|
+
*/
|
|
58
|
+
export const 색256가능 = () => process.env.TERM !== 'dumb';
|
|
59
|
+
export const 번호색 = (n) => (s) => (ON && 색256가능() ? `[38;5;${n}m${s}[0m` : String(s));
|
|
60
|
+
|
|
52
61
|
export const cursor = {
|
|
53
62
|
hide: () => ON && process.stdout.write('\x1b[?25l'),
|
|
54
63
|
show: () => ON && process.stdout.write('\x1b[?25h'),
|
|
@@ -148,6 +157,42 @@ export function gauge(ratio, cells = 10) {
|
|
|
148
157
|
return tone('▰'.repeat(filled)) + c.gray('▱'.repeat(cells - filled));
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
/**
|
|
161
|
+
* 눈금이 있는 게이지.
|
|
162
|
+
*
|
|
163
|
+
* 게이지가 차는 것은 보이는데 **언제 무슨 일이 나는지**는 안 보였다. 55% 를
|
|
164
|
+
* 넘으면 오래된 도구 결과를 접기 시작하고, 80% 를 넘으면 대화를 요약한다.
|
|
165
|
+
* 둘 다 사람 눈에는 갑자기 일어나는 일이라 — 어느 날 갑자기 "앞선 대화를
|
|
166
|
+
* 줄였습니다" 가 뜨고, 모델이 방금 읽은 파일을 잊는다.
|
|
167
|
+
*
|
|
168
|
+
* 그래서 그 자리에 눈금을 미리 그어 둔다. 막대가 다가가는 것이 보이면
|
|
169
|
+
* 사람이 먼저 손을 쓸 수 있다 — 못 박아 두거나(/pin), 갈래를 새로 파거나.
|
|
170
|
+
* 지나간 눈금은 안 그린다. 이미 일어난 일을 계속 가리킬 이유가 없고,
|
|
171
|
+
* 남겨 두면 막대가 눈금에 가려 어디까지 찼는지가 흐려진다.
|
|
172
|
+
*
|
|
173
|
+
* @param {number} ratio
|
|
174
|
+
* @param {number} cells
|
|
175
|
+
* @param {number[]} 눈금 0~1 사이 자리들
|
|
176
|
+
*/
|
|
177
|
+
export function 눈금게이지(ratio, cells = 10, 눈금 = []) {
|
|
178
|
+
const n = Number(ratio);
|
|
179
|
+
const r = Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
180
|
+
const filled = Math.round(r * cells);
|
|
181
|
+
const tone = r > 0.8 ? c.hred : r > 0.55 ? c.hyellow : c.hgreen;
|
|
182
|
+
const 눈금칸 = new Set(
|
|
183
|
+
(Array.isArray(눈금) ? 눈금 : [])
|
|
184
|
+
.map((v) => Math.min(cells - 1, Math.max(0, Math.floor(Number(v) * cells))))
|
|
185
|
+
.filter((v) => Number.isFinite(v)),
|
|
186
|
+
);
|
|
187
|
+
let out = '';
|
|
188
|
+
for (let i = 0; i < cells; i++) {
|
|
189
|
+
if (i < filled) out += tone('▰');
|
|
190
|
+
else if (눈금칸.has(i)) out += c.white('┆');
|
|
191
|
+
else out += c.gray('▱');
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
151
196
|
const BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
|
|
152
197
|
|
|
153
198
|
/**
|
package/src/ui/approve.js
CHANGED
|
@@ -13,29 +13,33 @@
|
|
|
13
13
|
* 그래서 세 가지를 준다 — 눈에 띄는 글자, 사람 말 이름, 무엇을 묻는지 한 줄.
|
|
14
14
|
*/
|
|
15
15
|
import { c } from './ansi.js';
|
|
16
|
+
import { 말 } from '../i18n/index.js';
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* 이름과 한 줄 설명은 화면 말(i18n)에서 읽는다. 글자와 색은 여기 남긴다 —
|
|
20
|
+
* ⏵⏵ 는 어느 말로도 ⏵⏵ 이고, 노랑은 어느 말로도 노랑이다.
|
|
21
|
+
*
|
|
22
|
+
* 게터로 둔다. 켤 때 한 번 읽어 굳히면 /lang 으로 바꿔도 상태줄의 승인
|
|
23
|
+
* 표시만 옛 말로 남는데, 하필 그게 안전 표시라 제일 안 어긋나야 할 자리다.
|
|
24
|
+
*/
|
|
25
|
+
/*
|
|
26
|
+
* 펼치기(...)로 붙이면 **그 자리에서 값이 굳는다.** 게터를 펼치면 게터가
|
|
27
|
+
* 아니라 그때 읽은 글자가 복사되기 때문이다. 그러면 /lang 으로 바꿔도
|
|
28
|
+
* 승인 표시만 옛 말로 남는데, 하필 그게 안전 표시라 제일 안 어긋나야 할
|
|
29
|
+
* 자리다. defineProperty 로 게터인 채로 붙인다.
|
|
30
|
+
*/
|
|
31
|
+
function 마디(키, 바탕) {
|
|
32
|
+
const 것 = { ...바탕 };
|
|
33
|
+
for (const [자리, 뒤] of [['이름', 'name'], ['짧은이름', 'short'], ['한줄', 'line']]) {
|
|
34
|
+
Object.defineProperty(것, 자리, { get: () => 말(`approve.${키}.${뒤}`), enumerable: true });
|
|
35
|
+
}
|
|
36
|
+
return 것;
|
|
37
|
+
}
|
|
16
38
|
|
|
17
39
|
export const 승인 = {
|
|
18
|
-
auto: {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
짧은이름: '자동',
|
|
22
|
-
한줄: '안 묻고 고칩니다. 되돌리기(/undo)가 안전망입니다',
|
|
23
|
-
색: c.hyellow,
|
|
24
|
-
},
|
|
25
|
-
confirm: {
|
|
26
|
-
글자: '⏵',
|
|
27
|
-
이름: '위험만 확인',
|
|
28
|
-
짧은이름: '위험만',
|
|
29
|
-
한줄: '되돌릴 수 없는 명령만 물어봅니다. 파일은 안 묻고 고칩니다',
|
|
30
|
-
색: c.white,
|
|
31
|
-
},
|
|
32
|
-
strict: {
|
|
33
|
-
글자: '⏸',
|
|
34
|
-
이름: '모두 확인',
|
|
35
|
-
짧은이름: '모두',
|
|
36
|
-
한줄: '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
|
|
37
|
-
색: c.hgreen,
|
|
38
|
-
},
|
|
40
|
+
auto: 마디('auto', { 글자: '⏵⏵', 색: c.hyellow }),
|
|
41
|
+
confirm: 마디('confirm', { 글자: '⏵', 색: c.white }),
|
|
42
|
+
strict: 마디('strict', { 글자: '⏸', 색: c.hgreen }),
|
|
39
43
|
};
|
|
40
44
|
|
|
41
45
|
export const 차례 = ['auto', 'confirm', 'strict'];
|
package/src/ui/banner.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 켤 때 뜨는 큰 이름.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 남들처럼 안 만들었나 ─────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 요즘 CLI 는 켤 때 큼직한 글자를 띄운다. 무지개 그러데이션이 흔하다.
|
|
7
|
+
* 예쁘긴 한데 그건 **아무 말도 안 한다** — 파랑에서 분홍으로 가는 데 뜻이 없다.
|
|
8
|
+
*
|
|
9
|
+
* 여기서는 그 자리를 뜻으로 채운다. 이 프로그램이 하는 말이 딱 하나인데,
|
|
10
|
+
* **네 소스가 이 컴퓨터 밖으로 안 나간다** 는 것이다. 그래서 —
|
|
11
|
+
*
|
|
12
|
+
* · 글자 밑으로 **선이 닫힌다.** 그 선이 경계다.
|
|
13
|
+
* · 선의 **색이 지금 어디까지 나가는지**를 말한다.
|
|
14
|
+
* 초록 ⌂ 이 컴퓨터 안에서만
|
|
15
|
+
* 노랑 ↗ 사내 게이트웨이로 나간다
|
|
16
|
+
* · 글자의 밝기는 왼쪽이 어둡고 오른쪽이 밝다. 이름이 왼쪽부터 한 자씩
|
|
17
|
+
* 들어오는 것과 같은 방향이라, 자라는 것으로 보인다.
|
|
18
|
+
*
|
|
19
|
+
* 표시(⌂ · ↗)는 상태줄이 쓰는 것과 **같은 것**이다(ui/status.js 의 경계표).
|
|
20
|
+
* 켤 때 본 것이 그대로 아래 줄에 남아 있어야, 그게 장식이 아니라 계기판이 된다.
|
|
21
|
+
*
|
|
22
|
+
* ── 왜 소문자 `deel` 인가 ───────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* 사람이 치는 글자가 그것이다. 큰 대문자로 바꾸면 화면에 뜨는 이름과 손이
|
|
25
|
+
* 치는 이름이 달라진다. 게다가 대문자 배너는 어느 CLI 나 하는 것이라,
|
|
26
|
+
* 소문자 쪽이 오히려 이 프로그램의 얼굴이 된다.
|
|
27
|
+
*
|
|
28
|
+
* ── 폭 ─────────────────────────────────────────────────────────────────
|
|
29
|
+
*
|
|
30
|
+
* 두 크기로 그린다. 넓으면 가로로 두 배 늘려 큼직하게, 좁으면 한 배로.
|
|
31
|
+
* 더 좁으면 아예 안 쓴다 — 그때는 작은 모션(intro.js)이 대신 돈다.
|
|
32
|
+
* 글자가 접히면 무슨 그림인지 알아볼 수가 없어서, 큰 것을 억지로 밀어 넣는
|
|
33
|
+
* 것보다 작은 것을 제대로 보이는 편이 낫다.
|
|
34
|
+
*
|
|
35
|
+
* ── 왜 이 글자들만 쓰나 ─────────────────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* 채움은 █ 하나, 테는 ╭╮╰╯─ 뿐이다. 이모지와 기호는 터미널마다 한 칸이
|
|
38
|
+
* 되기도 두 칸이 되기도 해서, 다시 그릴 때 지울 폭이 어긋나고 앞 그림이
|
|
39
|
+
* 조각으로 남는다. 여기 쓰는 것들은 입력 상자가 이미 쓰고 있는 것이라
|
|
40
|
+
* 이 프로그램이 도는 자리에서는 이미 한 번 검증된 셈이다.
|
|
41
|
+
*/
|
|
42
|
+
import { c, 번호색, 색256가능, width } from './ansi.js';
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* 글꼴. 한 글자가 일곱 줄이다.
|
|
46
|
+
*
|
|
47
|
+
* 값으로 적어 둔다. 그려 놓고 눈으로만 맞추면 나중에 아무도 못 고치고,
|
|
48
|
+
* 검사도 '무엇이 보여야 하나' 를 말할 수가 없다.
|
|
49
|
+
*
|
|
50
|
+
* 소문자라 글자마다 높이가 다르다 — d 와 l 은 위로 솟고(r0 부터), e 는
|
|
51
|
+
* 허리 높이에서 시작한다(r2 부터). 그 들쭉날쭉함이 소문자를 소문자로 보이게
|
|
52
|
+
* 하는 것이라, 높이를 맞추려고 e 를 끌어올리면 안 된다.
|
|
53
|
+
*/
|
|
54
|
+
export const 글꼴 = {
|
|
55
|
+
d: [
|
|
56
|
+
'......##',
|
|
57
|
+
'......##',
|
|
58
|
+
'.#######',
|
|
59
|
+
'##....##',
|
|
60
|
+
'##....##',
|
|
61
|
+
'##....##',
|
|
62
|
+
'.#######',
|
|
63
|
+
],
|
|
64
|
+
e: [
|
|
65
|
+
'........',
|
|
66
|
+
'........',
|
|
67
|
+
'.######.',
|
|
68
|
+
'##....##',
|
|
69
|
+
'########',
|
|
70
|
+
'##......',
|
|
71
|
+
'.######.',
|
|
72
|
+
],
|
|
73
|
+
l: [
|
|
74
|
+
'##...',
|
|
75
|
+
'##...',
|
|
76
|
+
'##...',
|
|
77
|
+
'##...',
|
|
78
|
+
'##...',
|
|
79
|
+
'##...',
|
|
80
|
+
'.####',
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const 이름 = 'deel';
|
|
85
|
+
export const 높이 = 7;
|
|
86
|
+
/*
|
|
87
|
+
* 글자 사이. 한 칸이다.
|
|
88
|
+
*
|
|
89
|
+
* 두 칸으로 뒀더니 `d e e l` 로 읽혔다 — 글꼴 자체가 둥근 획 쪽을 한 칸 안으로
|
|
90
|
+
* 들여 그리기 때문에, 거기에 두 칸을 더 주면 실제로는 네댓 칸이 벌어진다.
|
|
91
|
+
* 큰 글자에서는 이게 낱자 넷으로 보이지 한 낱말로 안 보인다.
|
|
92
|
+
*/
|
|
93
|
+
const 사이 = 1;
|
|
94
|
+
const 들여 = ' ';
|
|
95
|
+
const 채움 = '█';
|
|
96
|
+
|
|
97
|
+
/** 글자 하나가 몇 칸인가 (한 배 기준). */
|
|
98
|
+
export const 글자폭 = (ch) => (글꼴[ch]?.[0]?.length ?? 0);
|
|
99
|
+
|
|
100
|
+
/** 이름 전체가 몇 칸인가. @param 배 가로 배율(1 또는 2) */
|
|
101
|
+
export function 이름폭(배 = 1, 글자수 = 이름.length) {
|
|
102
|
+
const 글자들 = [...이름].slice(0, 글자수);
|
|
103
|
+
if (!글자들.length) return 0;
|
|
104
|
+
const 속 = 글자들.reduce((n, ch) => n + 글자폭(ch), 0) + 사이 * (글자들.length - 1);
|
|
105
|
+
return 속 * 배;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/*
|
|
109
|
+
* 색.
|
|
110
|
+
*
|
|
111
|
+
* 왼쪽이 어둡고 오른쪽이 밝다. 글자가 왼쪽부터 들어오는 것과 같은 방향이라
|
|
112
|
+
* 이름이 **자라는** 것으로 보인다. 무지개를 안 쓰는 이유는 위 머리말에 있다 —
|
|
113
|
+
* 여기 네 칸은 다 같은 색이고 밝기만 다르다. 한 가지 색이라야 그 색이
|
|
114
|
+
* 무언가를 뜻할 수 있다.
|
|
115
|
+
*/
|
|
116
|
+
// 어두운 배경에서 첫 글자도 읽혀야 한다. 어두운 데서 시작하는 폭넓은 층은
|
|
117
|
+
// 그림으로는 예쁜데 왼쪽 글자가 배경에 묻힌다 — 이름이 세 글자로 보인다.
|
|
118
|
+
const 안색 = [34, 40, 46, 82]; // 초록 — 이 컴퓨터 안
|
|
119
|
+
const 밖색 = [172, 214, 220, 226]; // 노랑 — 밖으로 나간다
|
|
120
|
+
|
|
121
|
+
/** 경계 표시. 상태줄(ui/status.js 의 경계표)과 같은 것을 쓴다. */
|
|
122
|
+
export const 경계글 = (바깥) => (바깥 ? '↗' : '⌂');
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 이름을 그린다.
|
|
126
|
+
*
|
|
127
|
+
* @param {number} 글자수 왼쪽부터 몇 글자까지 보일지 (모션이 여기를 늘린다)
|
|
128
|
+
* @param {object} o
|
|
129
|
+
* @param {boolean} [o.바깥]
|
|
130
|
+
* @param {number} [o.배] 가로 배율
|
|
131
|
+
* @param {number} [o.밝힐] 이 번째 글자만 굵게 (방금 들어온 글자)
|
|
132
|
+
* @returns {string[]} 언제나 높이 줄
|
|
133
|
+
*/
|
|
134
|
+
export function 이름줄들(글자수, { 바깥 = false, 배 = 2, 밝힐 = -1 } = {}) {
|
|
135
|
+
const 층 = 바깥 ? 밖색 : 안색;
|
|
136
|
+
const 낱개 = [...이름];
|
|
137
|
+
const 줄들 = [];
|
|
138
|
+
|
|
139
|
+
for (let r = 0; r < 높이; r++) {
|
|
140
|
+
let 줄 = 들여;
|
|
141
|
+
for (let i = 0; i < 낱개.length; i++) {
|
|
142
|
+
const ch = 낱개[i];
|
|
143
|
+
const 폭 = 글자폭(ch) * 배;
|
|
144
|
+
if (i > 0) 줄 += ' '.repeat(사이 * 배);
|
|
145
|
+
if (i >= 글자수) { 줄 += ' '.repeat(폭); continue; }
|
|
146
|
+
|
|
147
|
+
// 한 칸을 배만큼 늘린다. 터미널 한 칸은 세로로 길어서, 가로로 늘려야
|
|
148
|
+
// 글자가 눌린 것처럼 안 보인다.
|
|
149
|
+
const 그림 = [...글꼴[ch][r]].map((x) => (x === '#' ? 채움.repeat(배) : ' '.repeat(배))).join('');
|
|
150
|
+
const 칠 = 색256가능() ? 번호색(층[Math.min(i, 층.length - 1)]) : (바깥 ? c.hyellow : c.hgreen);
|
|
151
|
+
줄 += i === 밝힐 ? c.bold(칠(그림)) : 칠(그림);
|
|
152
|
+
}
|
|
153
|
+
줄들.push(줄);
|
|
154
|
+
}
|
|
155
|
+
return 줄들;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 이름 밑에서 닫히는 선.
|
|
160
|
+
*
|
|
161
|
+
* 왼쪽 끝에서 시작해 오른쪽으로 자란다. 다 자라면 양끝이 위로 꺾여 **닫힌다** —
|
|
162
|
+
* 그 꺾임이 이 그림의 전부다. 아직 자라는 중에는 오른쪽 끝을 안 꺾는다.
|
|
163
|
+
* 안 닫힌 것을 닫힌 것처럼 그리면 그 순간만 거짓말이 된다.
|
|
164
|
+
*
|
|
165
|
+
* @param {number} 몫 0~1. 얼마나 자랐나
|
|
166
|
+
*/
|
|
167
|
+
export function 선줄(몫, { 바깥 = false, 배 = 2, 곁말 = '' } = {}) {
|
|
168
|
+
const 색 = 바깥 ? c.hyellow : c.hgreen;
|
|
169
|
+
const 끝까지 = 이름폭(배);
|
|
170
|
+
const 잰것 = Math.max(0, Math.min(끝까지, Math.round(끝까지 * Math.max(0, Math.min(1, 몫)))));
|
|
171
|
+
if (잰것 <= 0) return '';
|
|
172
|
+
|
|
173
|
+
const 다자람 = 잰것 >= 끝까지;
|
|
174
|
+
// 양끝 꺾임까지 해서 전체 폭을 지킨다 — 꺾임이 붙으면서 줄이 길어지면
|
|
175
|
+
// 다시 그릴 때 지울 폭이 어긋나고 앞 그림이 조각으로 남는다.
|
|
176
|
+
const 속 = '─'.repeat(Math.max(0, 잰것 - 2));
|
|
177
|
+
const 선 = 다자람 ? `╰${속}╯` : `╰${속}${'─'.repeat(Math.min(2, 잰것 - 속.length))}`;
|
|
178
|
+
|
|
179
|
+
const 곁 = 다자람 && 곁말
|
|
180
|
+
? ` ${색(경계글(바깥))} ${c.gray(곁말)}`
|
|
181
|
+
: '';
|
|
182
|
+
return `${들여}${색(선)}${곁}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 한 틀. 언제나 **높이 + 1 줄**이다.
|
|
187
|
+
*
|
|
188
|
+
* 줄 수가 틀마다 달라지면 다시 그릴 때 몇 줄을 올려야 하는지가 틀마다 달라지고,
|
|
189
|
+
* 한 번 어긋나면 앞 그림이 화면에 조각으로 남는다. 그래서 아직 안 그린 선도
|
|
190
|
+
* 빈 줄로 자리를 잡아 둔다. (intro.js 의 작은 모션과 같은 규칙이다)
|
|
191
|
+
*/
|
|
192
|
+
export function 틀(n, { 바깥 = false, 배 = 2, 곁말 = '' } = {}) {
|
|
193
|
+
const f = 차례[Math.max(0, Math.min(차례.length - 1, Math.trunc(Number(n) || 0)))];
|
|
194
|
+
return [
|
|
195
|
+
...이름줄들(f.글자, { 바깥, 배, 밝힐: f.밝힐 }),
|
|
196
|
+
선줄(f.선, { 바깥, 배, 곁말 }),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/*
|
|
201
|
+
* 틀 차례.
|
|
202
|
+
*
|
|
203
|
+
* 1) 이름이 한 자씩 들어온다. 방금 들어온 글자만 굵다 — 다 같은 밝기로 그리면
|
|
204
|
+
* 글자가 '길어지는' 것으로만 보이고, 한 자가 한 틀 동안 도드라졌다
|
|
205
|
+
* 가라앉아야 **찍히는** 것으로 보인다.
|
|
206
|
+
* 2) 한 박자 쉰다. 이 쉼이 다음에 그어지는 선을 눈에 띄게 한다.
|
|
207
|
+
* 3) 선이 그어진다.
|
|
208
|
+
* 4) 닫히고 나서야 곁말이 붙는다. 순서가 뜻이다 — 닫힌 다음에 하는 말이라야
|
|
209
|
+
* 믿을 만하다.
|
|
210
|
+
*/
|
|
211
|
+
function 차례짓기() {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (let i = 1; i <= 이름.length; i++) out.push({ 글자: i, 밝힐: i - 1, 선: 0 });
|
|
214
|
+
out.push({ 글자: 이름.length, 밝힐: -1, 선: 0 });
|
|
215
|
+
for (const 몫 of [0.2, 0.45, 0.7, 0.9]) out.push({ 글자: 이름.length, 밝힐: -1, 선: 몫 });
|
|
216
|
+
out.push({ 글자: 이름.length, 밝힐: -1, 선: 1 });
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const 차례 = 차례짓기();
|
|
221
|
+
|
|
222
|
+
export const 틀수 = 차례.length;
|
|
223
|
+
export const 줄수 = 높이 + 1;
|
|
224
|
+
|
|
225
|
+
/** 다 돈 마지막 모습. 모션을 껐을 때 이것만 찍는다. */
|
|
226
|
+
export function 마지막틀(opts = {}) { return 틀(틀수 - 1, opts); }
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 이 폭에서 큰 이름을 쓸 수 있나. 못 쓰면 0.
|
|
230
|
+
*
|
|
231
|
+
* 억지로 밀어 넣지 않는다. 글자가 접히면 무슨 그림인지 알아볼 수가 없고,
|
|
232
|
+
* 그건 아무것도 안 그린 것보다 나쁘다 — 화면이 깨진 것으로 보인다.
|
|
233
|
+
*
|
|
234
|
+
* @returns {number} 쓸 수 있는 가로 배율 (2 · 1), 못 쓰면 0
|
|
235
|
+
*/
|
|
236
|
+
export function 쓸배율(칸) {
|
|
237
|
+
const w = Number.isFinite(칸) ? 칸 : (process.stdout.columns ?? 80);
|
|
238
|
+
// 들여쓰기 두 칸 + 오른쪽 한 칸 여유.
|
|
239
|
+
if (w >= 이름폭(2) + 4) return 2;
|
|
240
|
+
if (w >= 이름폭(1) + 4) return 1;
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 폭 계산이 어긋나지 않는지 부르는 쪽에서 재 볼 수 있게 열어 둔다.
|
|
245
|
+
export const 잰너비 = (배 = 2) => width(이름줄들(이름.length, { 배 })[3].replace(/\x1b\[[0-9;]*m/g, ''));
|
package/src/ui/export.js
ADDED
|
@@ -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, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|