deel-local-cli 0.5.0 → 0.8.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/bin/deel.js CHANGED
@@ -1,11 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  // deel 진입점. 외부 의존성 없음 — Node 표준 기능만 씁니다.
3
- import { c, say } from '../src/ui/ansi.js';
3
+ import { join } from 'node:path';
4
+ import { c, say, mark, rule } from '../src/ui/ansi.js';
4
5
  import { runSetup, runDiagnose, showStatus, banner } from '../src/setup.js';
5
6
  import { chatLoop } from '../src/repl.js';
7
+ import { packSelf, audit, reviewSheet } from '../src/pack/selfpack.js';
8
+ import { runScan } from '../src/backend/scanui.js';
9
+ import { runSessions } from '../src/agent/sessionui.js';
6
10
 
7
11
  const MIN_NODE = 20;
8
12
 
13
+ // 사내 반입용 묶음 만들기.
14
+ function runPack(flags) {
15
+ const out = flags.out ? String(flags.out) : join(process.cwd(), 'deel-반입.zip');
16
+ const r = packSelf(out);
17
+ const a = r.audit;
18
+ say('');
19
+ rule('반입 묶음', 70);
20
+ say(` ${mark.ok} ${c.bold(r.out)}`);
21
+ say(` ${c.gray(`${r.files}개 파일 · ${(r.bytes / 1024).toFixed(1)}KB`)}`);
22
+ say('');
23
+ say(` ${c.gray('의존성')} ${a.deps.length === 0 ? c.green('0개') : c.red(a.deps.length + '개')}`);
24
+ say(` ${c.gray('설치 스크립트')} ${a.lifecycle.length === 0 ? c.green('없음') : c.red(a.lifecycle.join(', '))}`);
25
+ say(` ${c.gray('외부 import')} ${a.외부모듈.length === 0 ? c.green('0건') : c.red(a.외부모듈.length + '건')}`);
26
+ say(` ${c.gray('네트워크 호출')} ${a.calls.net.length}곳 ${c.gray('(설정한 주소로만)')}`);
27
+ say(` ${c.gray('포트 열기')} ${a.calls.listen.length === 0 ? c.green('없음') : c.red(a.calls.listen.length + '곳')}`);
28
+ say('');
29
+ say(` ${c.gray('안에 반입심사서.txt 가 같이 들어 있습니다 — 그대로 제출하시면 됩니다.')}`);
30
+ say(` ${c.gray('내용만 먼저 보시려면')} ${c.cyan('deel audit')}`);
31
+ say('');
32
+ return 0;
33
+ }
34
+
35
+ // 묶지 않고 심사 내용만 보기.
36
+ function runAudit() {
37
+ say('');
38
+ say(reviewSheet(audit(), new Date().toISOString().replace('T', ' ').slice(0, 19)));
39
+ return 0;
40
+ }
41
+
9
42
  function parse(argv) {
10
43
  const flags = {};
11
44
  const args = [];
@@ -28,15 +61,35 @@ function help() {
28
61
  say(` ${c.bold('사용법')}`);
29
62
  say('');
30
63
  say(` ${c.cyan('deel')} 대화 시작 (이 폴더에서)`);
64
+ say(` ${c.cyan('deel scan')} 이 PC 에 떠 있는 로컬 서버 전부 찾기`);
65
+ say(` ${c.cyan('deel sessions')} 이 폴더에 남아 있는 대화 목록`);
31
66
  say(` ${c.cyan('deel setup')} 연결 설정 (주소·키·모델)`);
32
67
  say(` ${c.cyan('deel status')} 연결 상태 보기`);
33
68
  say(` ${c.cyan('deel diagnose')} 저장된 연결로 진단 다시 돌리기`);
34
69
  say('');
70
+ say(` ${c.bold('여러 로컬을 같이 쓸 때')}`);
71
+ say('');
72
+ say(` ${c.cyan('deel scan --save')} 찾은 서버·모델을 전부 등록`);
73
+ say(` ${c.gray('--ports 9000,9100')} 기본 자리 말고 더 볼 포트`);
74
+ say(` ${c.gray('--host <주소>')} 기본은 127.0.0.1`);
75
+ say(` ${c.gray('--key <키>')} 키가 필요한 로컬 서버일 때`);
76
+ say(` ${c.gray('대화 중')} ${c.cyan('/model')} ${c.gray('로 서버·모델을 골라 바꿉니다.')}`);
77
+ say('');
78
+ say(` ${c.bold('사내 반입')}`);
79
+ say('');
80
+ say(` ${c.cyan('deel audit')} 의존성·네트워크 호출 자리 심사서 보기`);
81
+ say(` ${c.cyan('deel pack')} 심사서 + 소스를 zip 하나로 묶기`);
82
+ say(` ${c.gray('--out <파일>')} 묶음 파일 이름. 기본은 deel-반입.zip`);
83
+ say('');
35
84
  say(` ${c.bold('대화 시작 옵션')}`);
36
85
  say('');
37
86
  say(` ${c.gray('--root <폴더>')} 작업 범위. 기본은 지금 폴더`);
38
87
  say(` ${c.gray('--mode <모드>')} auto(기본) / confirm / strict`);
39
88
  say(` ${c.gray('--think <수준>')} off / low / medium(기본) / high / max`);
89
+ say(` ${c.gray('--effort <배분>')} even(균일) / save(절약, 기본) / deep(깊게)`);
90
+ say(` ${c.gray('--offline')} 이 컴퓨터 밖으로는 아무것도 안 보냄 (자물쇠)`);
91
+ say(` ${c.gray('--continue')} 이 폴더에서 가장 최근 대화 이어하기`);
92
+ say(` ${c.gray('--resume <id>')} 골라서 이어하기 (deel sessions 로 id 확인)`);
40
93
  say('');
41
94
  say(` ${c.bold('진단 직접 지정')} ${c.gray('— 설정을 남기지 않고 확인만 할 때')}`);
42
95
  say('');
@@ -67,6 +120,10 @@ async function main() {
67
120
  root: flags.root ? String(flags.root) : undefined,
68
121
  mode: flags.mode ? String(flags.mode) : undefined,
69
122
  think: flags.think ? String(flags.think) : undefined,
123
+ effort: flags.effort ? String(flags.effort) : undefined,
124
+ offline: flags.offline === true || flags.offline === 'true',
125
+ continue: flags.continue === true || flags.c === true,
126
+ sessionId: typeof flags.resume === 'string' ? flags.resume : (flags.resume === true ? null : undefined),
70
127
  });
71
128
  case 'status':
72
129
  return showStatus();
@@ -75,6 +132,15 @@ async function main() {
75
132
  case 'diagnose':
76
133
  case 'doctor':
77
134
  return runDiagnose(flags);
135
+ case 'pack':
136
+ return runPack(flags);
137
+ case 'audit':
138
+ return runAudit();
139
+ case 'scan':
140
+ return runScan(flags);
141
+ case 'sessions':
142
+ case 'ls':
143
+ return runSessions(flags);
78
144
  default:
79
145
  say('');
80
146
  say(` ${c.red('모르는 명령')} ${c.bold(cmd)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "description": "로컬 모델·사내 게이트웨이 전용 코딩 에이전트 CLI — 외부 의존성 0개 / Zero-dependency coding agent CLI for local LLMs and private gateways",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,10 +42,11 @@
42
42
  "setup": "node bin/deel.js setup",
43
43
  "diagnose": "node bin/deel.js diagnose",
44
44
  "chat": "node bin/deel.js",
45
- "test": "node test/smoke.js && node test/loop.test.js && node test/edit-bench.js",
45
+ "test": "node test/run.mjs",
46
+ "verify": "node test/no-bundle.test.js && node test/network.test.js && node test/web.test.js",
46
47
  "bench": "node test/edit-bench.js",
47
48
  "demo": "node test/demo.js",
48
- "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/backend/adapter.js && node --check src/tools/index.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/skills/discover.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/ui/ansi.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && echo OK"
49
+ "check": "node --check bin/deel.js && node --check src/repl.js && node --check src/commands.js && node --check src/agent/loop.js && node --check src/agent/session.js && node --check src/agent/effort.js && node --check src/agent/modes.js && node --check src/tools/encoding.js && node --check src/tools/excel-com.js && node --check src/tools/excel.js && node --check src/tools/xlsx.js && node --check src/agent/compact.js && node --check src/agent/store.js && node --check src/agent/sessionui.js && node --check src/backend/adapter.js && node --check src/backend/probe.js && node --check src/backend/detect.js && node --check src/backend/http.js && node --check src/backend/scan.js && node --check src/backend/scanui.js && node --check src/tools/index.js && node --check src/tools/fsutil.js && node --check src/tools/edit-match.js && node --check src/tools/webfetch.js && node --check src/tools/todo.js && node --check src/skills/discover.js && node --check src/plugins/manage.js && node --check src/pack/zip.js && node --check src/pack/tar.js && node --check src/pack/selfpack.js && node --check src/safety/guard.js && node --check src/safety/undo.js && node --check src/safety/audit.js && node --check src/safety/network.js && node --check src/setup.js && node --check src/report.js && node --check src/config.js && node --check src/ui/ansi.js && node --check src/ui/status.js && node --check src/ui/level.js && node --check src/ui/prompt.js && node --check src/ui/spinner.js && echo OK"
49
50
  },
50
51
  "dependencies": {},
51
52
  "devDependencies": {}
@@ -0,0 +1,138 @@
1
+ // 컨텍스트가 차면 오래된 대화를 '요약해서' 이어 간다.
2
+ //
3
+ // 그냥 잘라내면 방금까지 뭘 하고 있었는지 모델이 잊는다. 파일을 다시 읽고,
4
+ // 이미 고친 곳을 또 고치고, 정해 둔 방침을 어긴다. 그래서 자르는 대신 접는다.
5
+ //
6
+ // 접을 때 지켜야 하는 것 두 가지:
7
+ // 1) 도구 호출과 그 결과는 한 몸이다. 사이를 끊으면 규격 위반이라 서버가 400 을 낸다.
8
+ // 2) 요약도 모델이 만든다. 그 요청이 실패하면 프로그램이 멈추면 안 된다 —
9
+ // 실패하면 옛 방식(그냥 자르기)으로 물러선다.
10
+ import { chat } from '../backend/adapter.js';
11
+ import { estimateTokens } from './session.js';
12
+
13
+ // 몇 %에서 접기 시작할지. 접고 나서 다시 금방 차면 아무 소용이 없으니 넉넉히 비운다.
14
+ export const COMPACT_AT = 0.8;
15
+ export const KEEP_TAIL_RATIO = 0.3; // 최근 대화 중 남길 비율
16
+ export const KEEP_HEAD = 2; // 맨 처음 요청은 언제나 남긴다 (목표가 거기 있다)
17
+
18
+ const 요약지시 = `지금까지의 대화를 다음 형식으로 요약하세요. 이어서 일할 사람이 이것만 보고도 계속할 수 있어야 합니다.
19
+ 추측하지 말고 실제로 오간 내용만 쓰세요. 한국어로, 각 항목 3줄 이내로 쓰세요.
20
+
21
+ ## 목표
22
+ 사용자가 무엇을 시켰는가.
23
+
24
+ ## 한 일
25
+ 어떤 파일을 어떻게 고쳤는가. 파일 경로를 그대로 적으세요.
26
+
27
+ ## 알아낸 것
28
+ 코드·환경에 대해 확인된 사실. 다시 조사하지 않아도 되도록.
29
+
30
+ ## 정한 것
31
+ 어떤 방침·이름·구조로 하기로 했는가. 사용자가 거절한 것도 적으세요.
32
+
33
+ ## 남은 일
34
+ 아직 안 한 것.`;
35
+
36
+ /**
37
+ * 도구 호출과 결과가 갈라지지 않는 자리를 찾는다.
38
+ * i 번째부터 남긴다고 할 때, 안전한 i 로 옮겨 준다.
39
+ */
40
+ export function safeCut(messages, i) {
41
+ let k = Math.max(0, Math.min(i, messages.length));
42
+ // tool 결과로 시작하면 그 앞의 assistant(tool_calls) 가 없어 규격이 깨진다. 앞으로 당긴다.
43
+ while (k > 0 && messages[k]?.role === 'tool') k--;
44
+ return k;
45
+ }
46
+
47
+ /**
48
+ * 머리 쪽 자르는 자리.
49
+ * 머리가 '결과를 기다리는 도구 호출' 로 끝나면 그 결과가 접혀 없어져 짝이 깨진다.
50
+ * 그런 assistant 는 머리에서 뺀다 — 접히는 쪽에 같이 넘긴다.
51
+ */
52
+ export function safeHead(messages, k) {
53
+ let h = Math.max(0, Math.min(k, messages.length));
54
+ while (h > 0 && messages[h - 1]?.tool_calls?.length) h--;
55
+ return h;
56
+ }
57
+
58
+ /** 접을 구간과 남길 구간을 나눈다. */
59
+ export function split(messages, { keepHead = KEEP_HEAD, tailRatio = KEEP_TAIL_RATIO } = {}) {
60
+ const n = messages.length;
61
+ const wantTail = Math.max(4, Math.floor(n * tailRatio));
62
+ const cut = safeCut(messages, n - wantTail);
63
+ const head = safeHead(messages, Math.min(keepHead, cut));
64
+ if (cut <= head) return null; // 접을 것이 없다
65
+ const fold = messages.slice(head, cut);
66
+ if (fold.length < 4) return null;
67
+ return { head: messages.slice(0, head), fold, tail: messages.slice(cut) };
68
+ }
69
+
70
+ // 요약에 넣을 글. 도구 결과는 길기만 하고 요약에 도움이 안 되니 앞부분만 준다.
71
+ function transcript(msgs) {
72
+ const out = [];
73
+ for (const m of msgs) {
74
+ const who = m.role === 'user' ? '사용자' : m.role === 'assistant' ? '나' : '도구결과';
75
+ let body = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '');
76
+ if (m.role === 'tool') body = body.slice(0, 400);
77
+ const calls = (m.tool_calls ?? []).map((t) => {
78
+ const fn = t.function ?? t;
79
+ const args = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {});
80
+ return `${fn.name}(${args.slice(0, 160)})`;
81
+ });
82
+ if (calls.length) out.push(`${who}: [도구] ${calls.join(', ')}`);
83
+ if (body.trim()) out.push(`${who}: ${body.trim()}`);
84
+ }
85
+ return out.join('\n');
86
+ }
87
+
88
+ /**
89
+ * 실제로 접는다.
90
+ * @returns {{ok:boolean, folded:number, before:number, after:number, summary?:string, why?:string}}
91
+ */
92
+ export async function compact(session, { auto = false } = {}) {
93
+ const before = session.breakdown().used;
94
+ const parts = split(session.messages);
95
+ if (!parts) return { ok: false, why: '접을 만큼 쌓이지 않았습니다', folded: 0, before, after: before };
96
+
97
+ const text = transcript(parts.fold);
98
+ let summary = null;
99
+ try {
100
+ const r = await chat(session.conn, {
101
+ messages: [
102
+ { role: 'system', content: '너는 대화를 요약한다. 요약 외에 다른 말을 하지 않는다.' },
103
+ { role: 'user', content: `${요약지시}\n\n----- 대화 -----\n${text}` },
104
+ ],
105
+ maxTokens: 1200,
106
+ think: session.conn.kind === 'ollama' ? false : 'low', // 요약에 깊은 사고는 필요 없다
107
+ });
108
+ summary = (r.content ?? '').trim();
109
+ } catch (err) {
110
+ summary = null;
111
+ }
112
+
113
+ // 요약을 못 받았으면 옛 방식으로 물러선다. 멈추지는 않는다.
114
+ if (!summary) {
115
+ const folded = session.trim();
116
+ const after = session.breakdown().used;
117
+ return { ok: folded > 0, folded, before, after, why: '요약을 받지 못해 그냥 줄였습니다', fallback: true };
118
+ }
119
+
120
+ session.messages = [
121
+ ...parts.head,
122
+ {
123
+ role: 'user',
124
+ content: `[앞선 대화 ${parts.fold.length}개를 요약해 접었습니다. 아래가 그 요약입니다.]\n\n${summary}\n\n[요약 끝. 이어서 진행하세요. 파일 내용이 필요하면 다시 읽으세요.]`,
125
+ },
126
+ ...parts.tail,
127
+ ];
128
+ session.filesRead.clear(); // 접힌 뒤에는 읽어 둔 파일도 기억에서 지운다
129
+
130
+ const after = session.breakdown().used;
131
+ return { ok: true, folded: parts.fold.length, before, after, summary, auto };
132
+ }
133
+
134
+ /** 지금 접어야 하는가. */
135
+ export function shouldCompact(session, at = COMPACT_AT) {
136
+ const b = session.breakdown();
137
+ return b.total > 0 && b.used / b.total >= at;
138
+ }
@@ -0,0 +1,139 @@
1
+ // 추론 강도를 '한 값' 이 아니라 '단계별' 로 나눈다.
2
+ //
3
+ // 왜 나누나:
4
+ // 에이전트 한 번의 대답은 모델을 여러 번 부른다. 그런데 부를 때마다 필요한 생각의 양이 다르다.
5
+ // - 처음: 무엇을 할지 정한다. 여기서 틀리면 뒤가 전부 헛돈다. → 세게
6
+ // - 중간: 파일을 읽고 "그래서 다음 한 수" 만 둔다. → 얕아도 된다
7
+ // - 막힘: 도구가 오류를 냈다. 얕게 생각하면 같은 실수를 또 한다. → 세게
8
+ // 전부 똑같이 세게 두면 느리고, 전부 얕게 두면 엉뚱한 길로 간다.
9
+
10
+ export const LEVELS = ['off', 'low', 'medium', 'high', 'max'];
11
+
12
+ export const STAGES = {
13
+ plan: { label: '첫 판단', why: '무엇을 할지 정하는 자리' },
14
+ work: { label: '이어가기', why: '도구 결과를 읽고 다음 한 수' },
15
+ fix: { label: '막혔을 때', why: '직전 도구가 오류를 냄' },
16
+ };
17
+
18
+ // shift = 기준 강도에서 몇 칸 올리고 내릴지.
19
+ // share = 남은 컨텍스트 중 이 단계의 출력에 내줄 비율.
20
+ //
21
+ // 출력 상한을 숫자로 못 박으면 안 된다. 컨텍스트 4k 짜리 모델에 4096 을 주면
22
+ // 입력 자리가 하나도 안 남고, 200k 짜리 모델에는 턱없이 모자란다.
23
+ // 그래서 '남은 자리의 몇 %' 로 정하고, 아래위로만 울타리를 친다.
24
+ export const PROFILES = {
25
+ even: {
26
+ name: '균일',
27
+ desc: '모든 단계 같은 강도 — 예측 가능한 대신 느립니다',
28
+ shift: { plan: 0, work: 0, fix: 0 },
29
+ share: { plan: 0.30, work: 0.30, fix: 0.30 },
30
+ },
31
+ save: {
32
+ name: '절약',
33
+ desc: '첫 판단만 세게, 이어가기는 얕게 — 대개 이게 낫습니다',
34
+ shift: { plan: 0, work: -1, fix: +1 },
35
+ share: { plan: 0.30, work: 0.15, fix: 0.30 },
36
+ },
37
+ deep: {
38
+ name: '깊게',
39
+ desc: '전 단계 한 칸씩 위로 — 어려운 일에만',
40
+ shift: { plan: +1, work: 0, fix: +1 },
41
+ share: { plan: 0.45, work: 0.30, fix: 0.45 },
42
+ },
43
+ };
44
+
45
+ // 울타리.
46
+ // 아래: 이보다 작으면 도구 호출 한 개도 못 뱉는다. 생각을 많이 하는 모델은 더 필요하다.
47
+ // 위: 이 이상은 어떤 모델도 한 번에 잘 안 낸다. 실수로 큰 값을 보내 튕기는 것도 막는다.
48
+ export const MIN_CAP = 512;
49
+ // 컨텍스트가 128k 라도 '한 번에 낼 수 있는 출력' 은 대개 훨씬 작다.
50
+ // 모델 한계보다 큰 max_tokens 를 보내면 그냥 거부하는 게이트웨이가 있어서
51
+ // 안전한 선에서 멈춘다. 더 필요하면 프로필에 maxTokens 를 적어 올릴 수 있다.
52
+ export const MAX_CAP = 16384;
53
+
54
+ const ALIAS = { 균일: 'even', 절약: 'save', 깊게: 'deep', uniform: 'even', thrifty: 'save' };
55
+
56
+ export function normalizeProfile(v) {
57
+ const k = String(v ?? '').trim().toLowerCase();
58
+ return PROFILES[k] ? k : (ALIAS[String(v ?? '').trim()] ?? null);
59
+ }
60
+
61
+ // 기준 강도에서 몇 칸 옮긴다. 끝을 넘지 않는다.
62
+ export function shiftLevel(level, by) {
63
+ const i = LEVELS.indexOf(level);
64
+ if (i < 0) return level;
65
+ return LEVELS[Math.min(LEVELS.length - 1, Math.max(0, i + by))];
66
+ }
67
+
68
+ /**
69
+ * 이번 호출에 쓸 강도.
70
+ * off 는 사람이 "생각 끄기" 를 고른 것이므로 어떤 단계에서도 켜지 않는다.
71
+ */
72
+ export function effortFor(base, profileKey, stage) {
73
+ if (base === 'off') return 'off';
74
+ const p = PROFILES[normalizeProfile(profileKey) ?? 'save'];
75
+ return shiftLevel(base, p.shift[stage] ?? 0);
76
+ }
77
+
78
+ /**
79
+ * 이번 호출에 내줄 출력 토큰 상한.
80
+ *
81
+ * 모델마다 다르다. 컨텍스트가 얼마인지, 지금 얼마나 차 있는지에 따라 남는 자리가 다르고
82
+ * 그 남는 자리 안에서만 답을 받을 수 있다. 고정 숫자를 쓰면 작은 모델에서는 입력이 밀리고
83
+ * 큰 모델에서는 답이 잘린다.
84
+ *
85
+ * @param {object} o
86
+ * @param {number} o.ctx 모델 컨텍스트 (conn.ctx)
87
+ * @param {number} o.used 지금 쓰고 있는 양 (session.breakdown().used)
88
+ * @param {number} o.max 사용자가 프로필에 직접 적어 둔 상한이 있으면 그것을 넘지 않는다
89
+ */
90
+ export function tokensFor(profileKey, stage, { ctx = 0, used = 0, max = null } = {}) {
91
+ const p = PROFILES[normalizeProfile(profileKey) ?? 'save'];
92
+ const share = p.share[stage] ?? 0.3;
93
+
94
+ // 컨텍스트를 모르면(진단 전) 보수적으로 잡는다.
95
+ const room = ctx > 0 ? Math.max(0, ctx - used) : 4096;
96
+
97
+ let cap = Math.floor(room * share);
98
+ // 남은 자리의 절반을 넘겨 주지 않는다. 답이 길어져도 다음 턴이 들어갈 자리는 남겨야 한다.
99
+ cap = Math.min(cap, Math.floor(room / 2));
100
+ cap = Math.min(cap, max ?? MAX_CAP, MAX_CAP);
101
+ cap = Math.max(cap, MIN_CAP);
102
+ // 남은 자리 자체가 바닥이면 울타리 아래라도 남은 만큼만 준다.
103
+ return room > 0 ? Math.min(cap, Math.max(MIN_CAP, room)) : MIN_CAP;
104
+ }
105
+
106
+ /** 잘렸을 때 풀어 줄 최대 상한 — 남은 자리를 거의 다 내준다. */
107
+ export function fullCap({ ctx = 0, used = 0, max = null } = {}) {
108
+ const room = ctx > 0 ? Math.max(0, ctx - used) : 4096;
109
+ return Math.max(MIN_CAP, Math.min(Math.floor(room * 0.8), max ?? MAX_CAP, MAX_CAP));
110
+ }
111
+
112
+ // 잘린 응답인가. 잘린 채로 넘어가면 도구 호출이 반토막 나서 조용히 실패한다.
113
+ export function wasCut(msg) {
114
+ const s = String(msg?.stopped ?? '');
115
+ return s === 'length' || s === 'max_tokens' || s === 'MAX_TOKENS';
116
+ }
117
+
118
+ // 화면에 보여줄 표. 상한은 지금 붙어 있는 모델 기준으로 계산해서 보여준다 —
119
+ // 모델을 바꾸면 이 숫자도 같이 바뀐다.
120
+ export function table(base, profileKey, room = {}) {
121
+ const key = normalizeProfile(profileKey) ?? 'save';
122
+ const p = PROFILES[key];
123
+ return {
124
+ key,
125
+ name: p.name,
126
+ desc: p.desc,
127
+ ctx: room.ctx ?? 0,
128
+ used: room.used ?? 0,
129
+ rows: Object.entries(STAGES).map(([stage, s]) => ({
130
+ stage,
131
+ label: s.label,
132
+ why: s.why,
133
+ level: effortFor(base, key, stage),
134
+ cap: tokensFor(key, stage, room),
135
+ share: p.share[stage],
136
+ moved: p.shift[stage],
137
+ })),
138
+ };
139
+ }