deel-local-cli 1.13.0 → 1.14.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/README.ko.md +32 -26
- package/README.md +32 -26
- package/bin/deel.js +6 -1
- package/package.json +3 -2
- package/src/acp/map.js +367 -367
- package/src/acp/serve.js +830 -810
- package/src/agent/budget.js +156 -156
- package/src/agent/commit.js +534 -534
- package/src/agent/compact.js +441 -441
- package/src/agent/effort.js +308 -308
- package/src/agent/evolve.js +263 -263
- package/src/agent/loop.js +1755 -1728
- package/src/agent/memory.js +156 -156
- package/src/agent/mention.js +210 -210
- package/src/agent/recall.js +222 -222
- package/src/agent/session.js +1056 -1056
- package/src/backend/adapter.js +1355 -1355
- package/src/backend/cachemark.js +223 -223
- package/src/backend/detect.js +327 -327
- package/src/backend/http.js +460 -460
- package/src/backend/mcp.js +407 -407
- package/src/backend/price.js +260 -260
- package/src/backend/probe.js +487 -487
- package/src/backend/quota.js +274 -274
- package/src/backend/tokens.js +59 -59
- package/src/backend/toolfit.js +352 -352
- package/src/backend/wire.js +715 -715
- package/src/cmdnames.js +68 -0
- package/src/commands.js +3116 -3184
- package/src/config.js +283 -283
- package/src/i18n/en.js +545 -545
- package/src/i18n/ja.js +498 -498
- package/src/i18n/ko.js +592 -592
- package/src/i18n/zh.js +498 -498
- package/src/oneshot.js +685 -625
- package/src/pack/selfpack.js +15 -2
- package/src/pack/sheet.en.js +16 -2
- package/src/pack/tar.js +154 -154
- package/src/pack/zip.js +235 -235
- package/src/plugins/manage.js +416 -416
- package/src/repl.js +2616 -2616
- package/src/safety/audit.js +148 -148
- package/src/safety/guard.js +714 -641
- package/src/safety/network.js +200 -200
- package/src/tools/fastgrep.js +229 -229
- package/src/tools/fsutil.js +312 -291
- package/src/tools/index.js +2590 -2560
- package/src/tools/jobs.js +876 -876
- package/src/tools/spawn.js +213 -213
- package/src/tools/verify.js +358 -358
- package/src/tools/webfetch.js +418 -418
- package/src/ui/export.js +233 -233
- package/src/ui/pick.js +115 -115
- package/src/ui/status.js +617 -617
package/src/ui/export.js
CHANGED
|
@@ -1,233 +1,233 @@
|
|
|
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
|
-
// 도구 부름과 글이 어디 실리는지는 규격마다 다르다 — 읽는 규칙은 한 곳뿐이다.
|
|
27
|
-
import { 부른것들, 본문글 } from '../backend/adapter.js';
|
|
28
|
-
import { 말, 언어 } from '../i18n/index.js';
|
|
29
|
-
import { 첫이름 } from '../tools/label.js';
|
|
30
|
-
|
|
31
|
-
/** HTML 로 안전하게. 문서 전체가 이 함수 하나를 지나야 한다. */
|
|
32
|
-
function 글자(s) {
|
|
33
|
-
return String(s ?? '')
|
|
34
|
-
.replace(/&/g, '&')
|
|
35
|
-
.replace(/</g, '<')
|
|
36
|
-
.replace(/>/g, '>')
|
|
37
|
-
.replace(/"/g, '"');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** ANSI 제어문자 걷어내기. 도구 결과에는 색이 섞여 있을 수 있다. */
|
|
41
|
-
const 민글 = (s) => String(s ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
42
|
-
|
|
43
|
-
/** 도구 호출 한 줄 요약. 보고서는 기록이지 재현이 아니다 — 인자 전체를 안 싣는다. */
|
|
44
|
-
function 도구줄(tc) {
|
|
45
|
-
// 부른것들() 이 규격을 이미 펴서 { id, name, args } 로 준다.
|
|
46
|
-
const 이름 = tc?.name ?? '?';
|
|
47
|
-
let 핵심 = '';
|
|
48
|
-
try {
|
|
49
|
-
const a = tc?.args ?? {};
|
|
50
|
-
// 이름 차례는 tools/label.js 한 곳에서 온다. 여기 목록에는 url 이 빠져
|
|
51
|
-
// 있어서, 웹을 읽은 줄이 보고서에서만 빈 괄호로 남았다.
|
|
52
|
-
핵심 = 첫이름(a) ?? a.command ?? '';
|
|
53
|
-
} catch { /* 인자가 잘렸어도 이름은 보여 준다 */ }
|
|
54
|
-
핵심 = String(핵심);
|
|
55
|
-
if (핵심.length > 80) 핵심 = 핵심.slice(0, 80) + '…';
|
|
56
|
-
return 핵심 ? `${이름}(${핵심})` : 이름;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* 보고서 HTML 을 짓는다.
|
|
61
|
-
*
|
|
62
|
-
* @param session Session — messages·changes·usage·conn 을 읽는다. 안 바꾼다.
|
|
63
|
-
* @param {object} o
|
|
64
|
-
* @param {{show:(p:string)=>string}} [o.scope] 경로를 짧게 보일 때
|
|
65
|
-
* @param {object} [o.audit] 증거를 모을 감사기록 (없으면 증거 절이 얕아진다)
|
|
66
|
-
*/
|
|
67
|
-
export function 보고서짓기(session, { scope = null, audit = null } = {}) {
|
|
68
|
-
const 보임 = (p) => { try { return scope?.show ? scope.show(p) : String(p); } catch { return String(p); } };
|
|
69
|
-
|
|
70
|
-
// ── 재료 ──────────────────────────────────────────────────────────────
|
|
71
|
-
const 첫말 = 본문글((session.messages ?? []).find((m) => m.role === 'user'));
|
|
72
|
-
const 제목 = String(첫말 || 말('export.untitled')).split('\n')[0].slice(0, 80);
|
|
73
|
-
|
|
74
|
-
const 흐름 = [];
|
|
75
|
-
for (const m of session.messages ?? []) {
|
|
76
|
-
/*
|
|
77
|
-
* 규격을 안 가린다.
|
|
78
|
-
*
|
|
79
|
-
* 여기가 `typeof m.content === 'string'` 과 `m.tool_calls` 로 읽고 있었다.
|
|
80
|
-
* Anthropic 꼴은 둘 다 안 맞는다 — 모델이 한 말은 블록 배열 안에 있고
|
|
81
|
-
* 부름은 `tool_use` 블록이다. 그래서 그 창구로 나눈 대화를 `/export`
|
|
82
|
-
* 하면 **모델 말과 도구가 통째로 빈** 보고서가 나왔다. 오류는 없다.
|
|
83
|
-
*/
|
|
84
|
-
const 글 = 본문글(m);
|
|
85
|
-
if (m.role === 'user') {
|
|
86
|
-
// Anthropic 꼴에서 사람 차례에 도구 결과가 섞여 온다. 그건 글이 아니다.
|
|
87
|
-
if (글.trim()) 흐름.push({ 갈래: 'user', 글 });
|
|
88
|
-
} else if (m.role === 'assistant') {
|
|
89
|
-
const 부른것 = 부른것들(m).map(도구줄);
|
|
90
|
-
if (부른것.length) 흐름.push({ 갈래: 'tools', 글: 부른것.join(' · ') });
|
|
91
|
-
if (글.trim()) 흐름.push({ 갈래: 'assistant', 글 });
|
|
92
|
-
}
|
|
93
|
-
// 도구 결과 알맹이는 안 싣는다 — 길고, 요점은 답과 증거 절에 이미 있다.
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const 바뀐것 = [...(session.changes ?? new Map()).entries()]
|
|
97
|
-
.map(([p, v]) => ({ 파일: 보임(p), 더함: v.added ?? 0, 뺌: v.removed ?? 0, 몇번: v.times ?? 1 }));
|
|
98
|
-
|
|
99
|
-
const 증거 = (() => {
|
|
100
|
-
try { return 증거모으기(session, { audit }); } catch { return null; }
|
|
101
|
-
})();
|
|
102
|
-
|
|
103
|
-
const u = session.usage ?? {};
|
|
104
|
-
const 쓴것 = [
|
|
105
|
-
session.conn?.model ? `${말('export.model')}: ${session.conn.model}` : '',
|
|
106
|
-
Number.isFinite(u.in) ? `↑${(u.in ?? 0).toLocaleString()} ↓${(u.out ?? 0).toLocaleString()}` : '',
|
|
107
|
-
u.calls ? `${말('export.calls', { 수: u.calls })}` : '',
|
|
108
|
-
].filter(Boolean);
|
|
109
|
-
|
|
110
|
-
// ── 조립 ──────────────────────────────────────────────────────────────
|
|
111
|
-
const 조각 = [];
|
|
112
|
-
const 절 = (이름) => 조각.push(`<h2>${글자(이름)}</h2>`);
|
|
113
|
-
|
|
114
|
-
조각.push(`<header><h1>${글자(제목)}</h1><p class="meta">${글자(쓴것.join(' · '))}</p></header>`);
|
|
115
|
-
|
|
116
|
-
절(말('export.conversation'));
|
|
117
|
-
조각.push('<div class="flow">');
|
|
118
|
-
for (const x of 흐름) {
|
|
119
|
-
if (x.갈래 === 'user') 조각.push(`<div class="u"><span class="who">${글자(말('export.you'))}</span><pre>${글자(x.글)}</pre></div>`);
|
|
120
|
-
else if (x.갈래 === 'tools') 조각.push(`<div class="t">⏺ ${글자(민글(x.글))}</div>`);
|
|
121
|
-
else 조각.push(`<div class="a"><span class="who">deel</span><pre>${글자(민글(x.글))}</pre></div>`);
|
|
122
|
-
}
|
|
123
|
-
if (!흐름.length) 조각.push(`<p class="none">${글자(말('export.nothing'))}</p>`);
|
|
124
|
-
조각.push('</div>');
|
|
125
|
-
|
|
126
|
-
절(말('export.changed'));
|
|
127
|
-
if (바뀐것.length) {
|
|
128
|
-
조각.push(`<table><tr><th>${글자(말('export.file'))}</th><th>+</th><th>−</th><th>${글자(말('export.times'))}</th></tr>`);
|
|
129
|
-
for (const x of 바뀐것) {
|
|
130
|
-
조각.push(`<tr><td>${글자(x.파일)}</td><td class="g">+${x.더함}</td><td class="r">−${x.뺌}</td><td>${x.몇번}</td></tr>`);
|
|
131
|
-
}
|
|
132
|
-
조각.push('</table>');
|
|
133
|
-
} else {
|
|
134
|
-
조각.push(`<p class="none">${글자(말('export.noChanges'))}</p>`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/*
|
|
138
|
-
* 증거 절. 여기가 이 보고서의 요점이다.
|
|
139
|
-
*
|
|
140
|
-
* '증명 안 된 것' 은 항상 절로 세운다 — 비어 있을 때도 "없습니다" 라고
|
|
141
|
-
* 적는다. 절 자체를 숨기면, 읽는 사람은 다 확인된 줄 안다.
|
|
142
|
-
*/
|
|
143
|
-
절(말('export.evidence'));
|
|
144
|
-
const 돌린 = 증거?.돌린것 ?? [];
|
|
145
|
-
if (돌린.length) {
|
|
146
|
-
조각.push('<ul class="ran">');
|
|
147
|
-
for (const x of 돌린) 조각.push(`<li class="${x.됐나 ? 'ok' : 'bad'}">${x.됐나 ? '✓' : '✗'} <code>${글자(민글(x.무엇))}</code>${x.남긴말 ? ` — ${글자(민글(x.남긴말))}` : ''}</li>`);
|
|
148
|
-
조각.push('</ul>');
|
|
149
|
-
} else {
|
|
150
|
-
조각.push(`<p class="none">${글자(말('export.nothingRan'))}</p>`);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
조각.push(`<h3>${글자(말('export.unproven'))}</h3>`);
|
|
154
|
-
const 미증명 = 증거?.증명안된것 ?? [];
|
|
155
|
-
if (미증명.length) {
|
|
156
|
-
조각.push('<ul class="unproven">');
|
|
157
|
-
for (const x of 미증명) 조각.push(`<li><b>${글자(x.파일)}</b> — ${글자(x.왜 ?? '')}</li>`);
|
|
158
|
-
조각.push('</ul>');
|
|
159
|
-
} else if (바뀐것.length) {
|
|
160
|
-
조각.push(`<p class="none">${글자(말('export.allProven'))}</p>`);
|
|
161
|
-
} else {
|
|
162
|
-
조각.push(`<p class="none">${글자(말('export.noChanges'))}</p>`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
조각.push(`<footer>${글자(말('export.footer'))}</footer>`);
|
|
166
|
-
|
|
167
|
-
/*
|
|
168
|
-
* 겉옷. 바깥 주소는 하나도 없다 — 폐쇄망에서 여는 파일이다.
|
|
169
|
-
* 스크립트도 없다. 보고서는 읽는 물건이지 도는 물건이 아니다.
|
|
170
|
-
*/
|
|
171
|
-
return `<!doctype html>
|
|
172
|
-
<html lang="${언어()}">
|
|
173
|
-
<meta charset="utf-8">
|
|
174
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
-
<title>${글자(제목)}</title>
|
|
176
|
-
<style>
|
|
177
|
-
:root{--bg:#fff;--fg:#1a1a1a;--mut:#667;--line:#e2e2e6;--card:#f7f7f9;--g:#0a7d38;--r:#b3261e}
|
|
178
|
-
@media (prefers-color-scheme:dark){:root{--bg:#131316;--fg:#ececf0;--mut:#9a9aa4;--line:#2a2a30;--card:#1c1c21;--g:#4cc38a;--r:#ef7b73}}
|
|
179
|
-
body{margin:0;padding:2rem 1rem 4rem;background:var(--bg);color:var(--fg);
|
|
180
|
-
font:15px/1.65 system-ui,-apple-system,"Segoe UI","Malgun Gothic",sans-serif}
|
|
181
|
-
main{max-width:820px;margin:0 auto}
|
|
182
|
-
h1{font-size:1.35rem;margin:0 0 .2rem}
|
|
183
|
-
.meta{color:var(--mut);font-size:.85rem;margin:0 0 1.6rem}
|
|
184
|
-
h2{font-size:1.02rem;border-top:1px solid var(--line);padding-top:1.1rem;margin:2rem 0 .6rem}
|
|
185
|
-
h3{font-size:.92rem;margin:1.2rem 0 .4rem}
|
|
186
|
-
.flow .u,.flow .a{border:1px solid var(--line);border-radius:8px;padding:.55rem .8rem;margin:.55rem 0}
|
|
187
|
-
.flow .u{background:var(--card)}
|
|
188
|
-
.flow .who{font-size:.75rem;color:var(--mut);display:block;margin-bottom:.15rem}
|
|
189
|
-
.flow pre{margin:0;white-space:pre-wrap;word-break:break-word;font:inherit}
|
|
190
|
-
.flow .t{color:var(--mut);font-size:.85rem;margin:.3rem .2rem;font-family:Consolas,monospace}
|
|
191
|
-
table{border-collapse:collapse;width:100%;font-size:.9rem}
|
|
192
|
-
th,td{text-align:left;padding:.35rem .6rem;border-bottom:1px solid var(--line)}
|
|
193
|
-
th{color:var(--mut);font-weight:600;font-size:.8rem}
|
|
194
|
-
td.g{color:var(--g)} td.r{color:var(--r)}
|
|
195
|
-
ul{padding-left:1.2rem} li{margin:.25rem 0}
|
|
196
|
-
li.ok{color:var(--fg)} li.bad{color:var(--r)}
|
|
197
|
-
.unproven li{color:var(--r)}
|
|
198
|
-
code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.05em .3em;font-size:.85em}
|
|
199
|
-
.none{color:var(--mut)}
|
|
200
|
-
footer{margin-top:2.5rem;color:var(--mut);font-size:.8rem;border-top:1px solid var(--line);padding-top:.8rem}
|
|
201
|
-
</style>
|
|
202
|
-
<main>
|
|
203
|
-
${조각.join('\n')}
|
|
204
|
-
</main>
|
|
205
|
-
</html>
|
|
206
|
-
`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* 파일로 남긴다.
|
|
211
|
-
*
|
|
212
|
-
* .deel/보고서/ 아래에 시각으로 이름을 짓고, 같은 분(分)에 두 번 적으면
|
|
213
|
-
* 번호를 붙인다 — **앞엣것을 절대 덮지 않는다.** 보고서는 남는 것이 일이다.
|
|
214
|
-
*
|
|
215
|
-
* @returns {string|null} 적은 자리. 못 적었으면 null — 못 적어도 대화는 계속된다.
|
|
216
|
-
*/
|
|
217
|
-
export function 보고서적기(root, session, opts = {}) {
|
|
218
|
-
try {
|
|
219
|
-
const 폴더 = join(root, '.deel', 'export');
|
|
220
|
-
mkdirSync(폴더, { recursive: true });
|
|
221
|
-
const t = new Date();
|
|
222
|
-
const 판 = (n) => `${t.getFullYear()}${String(t.getMonth() + 1).padStart(2, '0')}${String(t.getDate()).padStart(2, '0')}`
|
|
223
|
-
+ `-${String(t.getHours()).padStart(2, '0')}${String(t.getMinutes()).padStart(2, '0')}`
|
|
224
|
-
+ (n ? `-${n}` : '') + '.html';
|
|
225
|
-
let 이름 = 판(0);
|
|
226
|
-
for (let n = 1; existsSync(join(폴더, 이름)) && n < 100; n++) 이름 = 판(n);
|
|
227
|
-
const 자리 = join(폴더, 이름);
|
|
228
|
-
writeFileSync(자리, 보고서짓기(session, opts), 'utf8');
|
|
229
|
-
return 자리;
|
|
230
|
-
} catch {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
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
|
+
// 도구 부름과 글이 어디 실리는지는 규격마다 다르다 — 읽는 규칙은 한 곳뿐이다.
|
|
27
|
+
import { 부른것들, 본문글 } from '../backend/adapter.js';
|
|
28
|
+
import { 말, 언어 } from '../i18n/index.js';
|
|
29
|
+
import { 첫이름 } from '../tools/label.js';
|
|
30
|
+
|
|
31
|
+
/** HTML 로 안전하게. 문서 전체가 이 함수 하나를 지나야 한다. */
|
|
32
|
+
function 글자(s) {
|
|
33
|
+
return String(s ?? '')
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** ANSI 제어문자 걷어내기. 도구 결과에는 색이 섞여 있을 수 있다. */
|
|
41
|
+
const 민글 = (s) => String(s ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
42
|
+
|
|
43
|
+
/** 도구 호출 한 줄 요약. 보고서는 기록이지 재현이 아니다 — 인자 전체를 안 싣는다. */
|
|
44
|
+
function 도구줄(tc) {
|
|
45
|
+
// 부른것들() 이 규격을 이미 펴서 { id, name, args } 로 준다.
|
|
46
|
+
const 이름 = tc?.name ?? '?';
|
|
47
|
+
let 핵심 = '';
|
|
48
|
+
try {
|
|
49
|
+
const a = tc?.args ?? {};
|
|
50
|
+
// 이름 차례는 tools/label.js 한 곳에서 온다. 여기 목록에는 url 이 빠져
|
|
51
|
+
// 있어서, 웹을 읽은 줄이 보고서에서만 빈 괄호로 남았다.
|
|
52
|
+
핵심 = 첫이름(a) ?? a.command ?? '';
|
|
53
|
+
} catch { /* 인자가 잘렸어도 이름은 보여 준다 */ }
|
|
54
|
+
핵심 = String(핵심);
|
|
55
|
+
if (핵심.length > 80) 핵심 = 핵심.slice(0, 80) + '…';
|
|
56
|
+
return 핵심 ? `${이름}(${핵심})` : 이름;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 보고서 HTML 을 짓는다.
|
|
61
|
+
*
|
|
62
|
+
* @param session Session — messages·changes·usage·conn 을 읽는다. 안 바꾼다.
|
|
63
|
+
* @param {object} o
|
|
64
|
+
* @param {{show:(p:string)=>string}} [o.scope] 경로를 짧게 보일 때
|
|
65
|
+
* @param {object} [o.audit] 증거를 모을 감사기록 (없으면 증거 절이 얕아진다)
|
|
66
|
+
*/
|
|
67
|
+
export function 보고서짓기(session, { scope = null, audit = null } = {}) {
|
|
68
|
+
const 보임 = (p) => { try { return scope?.show ? scope.show(p) : String(p); } catch { return String(p); } };
|
|
69
|
+
|
|
70
|
+
// ── 재료 ──────────────────────────────────────────────────────────────
|
|
71
|
+
const 첫말 = 본문글((session.messages ?? []).find((m) => m.role === 'user'));
|
|
72
|
+
const 제목 = String(첫말 || 말('export.untitled')).split('\n')[0].slice(0, 80);
|
|
73
|
+
|
|
74
|
+
const 흐름 = [];
|
|
75
|
+
for (const m of session.messages ?? []) {
|
|
76
|
+
/*
|
|
77
|
+
* 규격을 안 가린다.
|
|
78
|
+
*
|
|
79
|
+
* 여기가 `typeof m.content === 'string'` 과 `m.tool_calls` 로 읽고 있었다.
|
|
80
|
+
* Anthropic 꼴은 둘 다 안 맞는다 — 모델이 한 말은 블록 배열 안에 있고
|
|
81
|
+
* 부름은 `tool_use` 블록이다. 그래서 그 창구로 나눈 대화를 `/export`
|
|
82
|
+
* 하면 **모델 말과 도구가 통째로 빈** 보고서가 나왔다. 오류는 없다.
|
|
83
|
+
*/
|
|
84
|
+
const 글 = 본문글(m);
|
|
85
|
+
if (m.role === 'user') {
|
|
86
|
+
// Anthropic 꼴에서 사람 차례에 도구 결과가 섞여 온다. 그건 글이 아니다.
|
|
87
|
+
if (글.trim()) 흐름.push({ 갈래: 'user', 글 });
|
|
88
|
+
} else if (m.role === 'assistant') {
|
|
89
|
+
const 부른것 = 부른것들(m).map(도구줄);
|
|
90
|
+
if (부른것.length) 흐름.push({ 갈래: 'tools', 글: 부른것.join(' · ') });
|
|
91
|
+
if (글.trim()) 흐름.push({ 갈래: 'assistant', 글 });
|
|
92
|
+
}
|
|
93
|
+
// 도구 결과 알맹이는 안 싣는다 — 길고, 요점은 답과 증거 절에 이미 있다.
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const 바뀐것 = [...(session.changes ?? new Map()).entries()]
|
|
97
|
+
.map(([p, v]) => ({ 파일: 보임(p), 더함: v.added ?? 0, 뺌: v.removed ?? 0, 몇번: v.times ?? 1 }));
|
|
98
|
+
|
|
99
|
+
const 증거 = (() => {
|
|
100
|
+
try { return 증거모으기(session, { audit }); } catch { return null; }
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
const u = session.usage ?? {};
|
|
104
|
+
const 쓴것 = [
|
|
105
|
+
session.conn?.model ? `${말('export.model')}: ${session.conn.model}` : '',
|
|
106
|
+
Number.isFinite(u.in) ? `↑${(u.in ?? 0).toLocaleString()} ↓${(u.out ?? 0).toLocaleString()}` : '',
|
|
107
|
+
u.calls ? `${말('export.calls', { 수: u.calls })}` : '',
|
|
108
|
+
].filter(Boolean);
|
|
109
|
+
|
|
110
|
+
// ── 조립 ──────────────────────────────────────────────────────────────
|
|
111
|
+
const 조각 = [];
|
|
112
|
+
const 절 = (이름) => 조각.push(`<h2>${글자(이름)}</h2>`);
|
|
113
|
+
|
|
114
|
+
조각.push(`<header><h1>${글자(제목)}</h1><p class="meta">${글자(쓴것.join(' · '))}</p></header>`);
|
|
115
|
+
|
|
116
|
+
절(말('export.conversation'));
|
|
117
|
+
조각.push('<div class="flow">');
|
|
118
|
+
for (const x of 흐름) {
|
|
119
|
+
if (x.갈래 === 'user') 조각.push(`<div class="u"><span class="who">${글자(말('export.you'))}</span><pre>${글자(x.글)}</pre></div>`);
|
|
120
|
+
else if (x.갈래 === 'tools') 조각.push(`<div class="t">⏺ ${글자(민글(x.글))}</div>`);
|
|
121
|
+
else 조각.push(`<div class="a"><span class="who">deel</span><pre>${글자(민글(x.글))}</pre></div>`);
|
|
122
|
+
}
|
|
123
|
+
if (!흐름.length) 조각.push(`<p class="none">${글자(말('export.nothing'))}</p>`);
|
|
124
|
+
조각.push('</div>');
|
|
125
|
+
|
|
126
|
+
절(말('export.changed'));
|
|
127
|
+
if (바뀐것.length) {
|
|
128
|
+
조각.push(`<table><tr><th>${글자(말('export.file'))}</th><th>+</th><th>−</th><th>${글자(말('export.times'))}</th></tr>`);
|
|
129
|
+
for (const x of 바뀐것) {
|
|
130
|
+
조각.push(`<tr><td>${글자(x.파일)}</td><td class="g">+${x.더함}</td><td class="r">−${x.뺌}</td><td>${x.몇번}</td></tr>`);
|
|
131
|
+
}
|
|
132
|
+
조각.push('</table>');
|
|
133
|
+
} else {
|
|
134
|
+
조각.push(`<p class="none">${글자(말('export.noChanges'))}</p>`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* 증거 절. 여기가 이 보고서의 요점이다.
|
|
139
|
+
*
|
|
140
|
+
* '증명 안 된 것' 은 항상 절로 세운다 — 비어 있을 때도 "없습니다" 라고
|
|
141
|
+
* 적는다. 절 자체를 숨기면, 읽는 사람은 다 확인된 줄 안다.
|
|
142
|
+
*/
|
|
143
|
+
절(말('export.evidence'));
|
|
144
|
+
const 돌린 = 증거?.돌린것 ?? [];
|
|
145
|
+
if (돌린.length) {
|
|
146
|
+
조각.push('<ul class="ran">');
|
|
147
|
+
for (const x of 돌린) 조각.push(`<li class="${x.됐나 ? 'ok' : 'bad'}">${x.됐나 ? '✓' : '✗'} <code>${글자(민글(x.무엇))}</code>${x.남긴말 ? ` — ${글자(민글(x.남긴말))}` : ''}</li>`);
|
|
148
|
+
조각.push('</ul>');
|
|
149
|
+
} else {
|
|
150
|
+
조각.push(`<p class="none">${글자(말('export.nothingRan'))}</p>`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
조각.push(`<h3>${글자(말('export.unproven'))}</h3>`);
|
|
154
|
+
const 미증명 = 증거?.증명안된것 ?? [];
|
|
155
|
+
if (미증명.length) {
|
|
156
|
+
조각.push('<ul class="unproven">');
|
|
157
|
+
for (const x of 미증명) 조각.push(`<li><b>${글자(x.파일)}</b> — ${글자(x.왜 ?? '')}</li>`);
|
|
158
|
+
조각.push('</ul>');
|
|
159
|
+
} else if (바뀐것.length) {
|
|
160
|
+
조각.push(`<p class="none">${글자(말('export.allProven'))}</p>`);
|
|
161
|
+
} else {
|
|
162
|
+
조각.push(`<p class="none">${글자(말('export.noChanges'))}</p>`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
조각.push(`<footer>${글자(말('export.footer'))}</footer>`);
|
|
166
|
+
|
|
167
|
+
/*
|
|
168
|
+
* 겉옷. 바깥 주소는 하나도 없다 — 폐쇄망에서 여는 파일이다.
|
|
169
|
+
* 스크립트도 없다. 보고서는 읽는 물건이지 도는 물건이 아니다.
|
|
170
|
+
*/
|
|
171
|
+
return `<!doctype html>
|
|
172
|
+
<html lang="${언어()}">
|
|
173
|
+
<meta charset="utf-8">
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
+
<title>${글자(제목)}</title>
|
|
176
|
+
<style>
|
|
177
|
+
:root{--bg:#fff;--fg:#1a1a1a;--mut:#667;--line:#e2e2e6;--card:#f7f7f9;--g:#0a7d38;--r:#b3261e}
|
|
178
|
+
@media (prefers-color-scheme:dark){:root{--bg:#131316;--fg:#ececf0;--mut:#9a9aa4;--line:#2a2a30;--card:#1c1c21;--g:#4cc38a;--r:#ef7b73}}
|
|
179
|
+
body{margin:0;padding:2rem 1rem 4rem;background:var(--bg);color:var(--fg);
|
|
180
|
+
font:15px/1.65 system-ui,-apple-system,"Segoe UI","Malgun Gothic",sans-serif}
|
|
181
|
+
main{max-width:820px;margin:0 auto}
|
|
182
|
+
h1{font-size:1.35rem;margin:0 0 .2rem}
|
|
183
|
+
.meta{color:var(--mut);font-size:.85rem;margin:0 0 1.6rem}
|
|
184
|
+
h2{font-size:1.02rem;border-top:1px solid var(--line);padding-top:1.1rem;margin:2rem 0 .6rem}
|
|
185
|
+
h3{font-size:.92rem;margin:1.2rem 0 .4rem}
|
|
186
|
+
.flow .u,.flow .a{border:1px solid var(--line);border-radius:8px;padding:.55rem .8rem;margin:.55rem 0}
|
|
187
|
+
.flow .u{background:var(--card)}
|
|
188
|
+
.flow .who{font-size:.75rem;color:var(--mut);display:block;margin-bottom:.15rem}
|
|
189
|
+
.flow pre{margin:0;white-space:pre-wrap;word-break:break-word;font:inherit}
|
|
190
|
+
.flow .t{color:var(--mut);font-size:.85rem;margin:.3rem .2rem;font-family:Consolas,monospace}
|
|
191
|
+
table{border-collapse:collapse;width:100%;font-size:.9rem}
|
|
192
|
+
th,td{text-align:left;padding:.35rem .6rem;border-bottom:1px solid var(--line)}
|
|
193
|
+
th{color:var(--mut);font-weight:600;font-size:.8rem}
|
|
194
|
+
td.g{color:var(--g)} td.r{color:var(--r)}
|
|
195
|
+
ul{padding-left:1.2rem} li{margin:.25rem 0}
|
|
196
|
+
li.ok{color:var(--fg)} li.bad{color:var(--r)}
|
|
197
|
+
.unproven li{color:var(--r)}
|
|
198
|
+
code{background:var(--card);border:1px solid var(--line);border-radius:4px;padding:.05em .3em;font-size:.85em}
|
|
199
|
+
.none{color:var(--mut)}
|
|
200
|
+
footer{margin-top:2.5rem;color:var(--mut);font-size:.8rem;border-top:1px solid var(--line);padding-top:.8rem}
|
|
201
|
+
</style>
|
|
202
|
+
<main>
|
|
203
|
+
${조각.join('\n')}
|
|
204
|
+
</main>
|
|
205
|
+
</html>
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 파일로 남긴다.
|
|
211
|
+
*
|
|
212
|
+
* .deel/보고서/ 아래에 시각으로 이름을 짓고, 같은 분(分)에 두 번 적으면
|
|
213
|
+
* 번호를 붙인다 — **앞엣것을 절대 덮지 않는다.** 보고서는 남는 것이 일이다.
|
|
214
|
+
*
|
|
215
|
+
* @returns {string|null} 적은 자리. 못 적었으면 null — 못 적어도 대화는 계속된다.
|
|
216
|
+
*/
|
|
217
|
+
export function 보고서적기(root, session, opts = {}) {
|
|
218
|
+
try {
|
|
219
|
+
const 폴더 = join(root, '.deel', 'export');
|
|
220
|
+
mkdirSync(폴더, { recursive: true });
|
|
221
|
+
const t = new Date();
|
|
222
|
+
const 판 = (n) => `${t.getFullYear()}${String(t.getMonth() + 1).padStart(2, '0')}${String(t.getDate()).padStart(2, '0')}`
|
|
223
|
+
+ `-${String(t.getHours()).padStart(2, '0')}${String(t.getMinutes()).padStart(2, '0')}`
|
|
224
|
+
+ (n ? `-${n}` : '') + '.html';
|
|
225
|
+
let 이름 = 판(0);
|
|
226
|
+
for (let n = 1; existsSync(join(폴더, 이름)) && n < 100; n++) 이름 = 판(n);
|
|
227
|
+
const 자리 = join(폴더, 이름);
|
|
228
|
+
writeFileSync(자리, 보고서짓기(session, opts), 'utf8');
|
|
229
|
+
return 자리;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|