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/src/agent/loop.js CHANGED
@@ -3,6 +3,10 @@
3
3
  import { chat, chatStream, assistantMessage, toolMessage } from '../backend/adapter.js';
4
4
  import { toolSchemas, runTool, TOOLS } from '../tools/index.js';
5
5
  import { isMutating } from '../safety/guard.js';
6
+ import { effortFor, tokensFor, fullCap, wasCut } from './effort.js';
7
+ import { compact, shouldCompact } from './compact.js';
8
+ import { isOffline } from '../safety/network.js';
9
+ import { get as workMode } from './modes.js';
6
10
 
7
11
  // think 값을 규격에 맞게. 'off' 는 사고를 끈다.
8
12
  function thinkFor(conn, level) {
@@ -10,39 +14,121 @@ function thinkFor(conn, level) {
10
14
  return level;
11
15
  }
12
16
 
13
- export async function* run(session, ctx, userText) {
17
+ // 아무것도 바꾸는 도구들. 이것들만 동시에 돌린다.
18
+ //
19
+ // 왜 이것만인가:
20
+ // Read 세 개를 동시에 하는 것은 안전하다 — 서로 안 건드린다.
21
+ // Write·Edit 을 동시에 돌리면 같은 파일을 두 갈래로 고칠 수 있고,
22
+ // 되돌리기 스냅샷 순서도 뒤엉킨다. Bash 는 무슨 짓을 할지 알 수 없다.
23
+ // 그래서 '읽기만 하는 것' 이라고 확실한 도구만 묶는다.
24
+ const 읽기전용 = new Set(['Read', 'Glob', 'Grep', 'Skill', 'WebFetch']);
25
+
26
+ /**
27
+ * 호출 목록을 '같이 돌려도 되는 덩어리' 로 자른다.
28
+ * 읽기 전용이 이어지면 한 덩어리, 그 밖의 것은 하나씩 따로.
29
+ */
30
+ export function 묶기(calls) {
31
+ const out = [];
32
+ for (const call of calls) {
33
+ const 안전 = 읽기전용.has(call.name);
34
+ const 끝 = out.at(-1);
35
+ if (안전 && 끝?.parallel) 끝.calls.push(call);
36
+ else out.push({ parallel: 안전, calls: [call] });
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export async function* run(session, ctx, userText, { signal = null } = {}) {
14
42
  session.push({ role: 'user', content: userText });
15
43
  ctx.audit.turn(userText);
16
44
  ctx.history.nextTurn();
17
45
 
18
46
  const conn = session.conn;
19
- const tools = toolSchemas(null, { hasSkills: (session.skills?.length ?? 0) > 0 });
47
+
48
+ /**
49
+ * 중단하고 나갈 때 대화를 성한 상태로 남긴다.
50
+ *
51
+ * 모델이 도구를 부르겠다고 해 놓고 결과가 안 들어간 채로 끝나면, 다음에 이어할 때
52
+ * 그 배열을 그대로 보낼 수 없다 — 서버가 400 을 낸다. 그래서 부른 만큼
53
+ * '중단됨' 결과를 채워 짝을 맞춘다.
54
+ */
55
+ const 짝맞추기 = () => {
56
+ const last = session.messages.at(-1);
57
+ const calls = last?.tool_calls ?? [];
58
+ if (!calls.length) return;
59
+ const 이미 = session.messages.filter((m) => m.role === 'tool').length;
60
+ for (const t of calls) {
61
+ session.push(toolMessage(conn.kind, {
62
+ callId: t.id ?? `call_${이미 + 1}`,
63
+ name: t.function?.name ?? t.name ?? '?',
64
+ content: '사용자가 중단했습니다. 실행하지 않았습니다.',
65
+ }));
66
+ }
67
+ };
68
+ const tools = toolSchemas(null, {
69
+ hasSkills: (session.skills?.length ?? 0) > 0,
70
+ web: session.web !== false && !isOffline(), // 오프라인이면 웹 도구는 아예 안 보여 준다
71
+ work: session.work, // 작업 모드가 쓰는 것만 (modes.js)
72
+ });
73
+ // 모드마다 생각의 배분과 걸음 수가 다르다. 사용자가 따로 정했으면 그걸 존중한다.
74
+ const 모드 = workMode(session.work);
75
+ const effort = session.effortSet ? session.effort : (모드.effort ?? session.effort);
76
+ const think = session.thinkSet ? session.think : (모드.think ?? session.think);
77
+ const maxSteps = session.stepsSet ? session.maxSteps : (모드.steps ?? session.maxSteps);
20
78
  const attempted = new Set(); // 같은 변경성 명령을 두 번 실행하지 않기 위한 기록
21
79
  let steps = 0;
80
+ let lastToolFailed = false; // 직전 단계에서 도구가 오류를 냈나 → 다음 판단은 세게
22
81
 
23
- while (steps < session.maxSteps) {
82
+ while (steps < maxSteps) {
24
83
  steps++;
25
- const opts = {
84
+ // 단계마다 필요한 생각의 양이 다르다. effort.js 가 그 배분을 갖고 있다.
85
+ const stage = steps === 1 ? 'plan' : lastToolFailed ? 'fix' : 'work';
86
+ const level = effortFor(think, effort, stage);
87
+ // 상한은 모델 컨텍스트와 지금 찬 양에서 계산한다. 고정 숫자가 아니다.
88
+ const room = { ctx: conn.ctx ?? 0, used: session.breakdown().used, max: conn.maxTokens ?? null };
89
+ const cap = tokensFor(effort, stage, room);
90
+ yield { type: 'stage', stage, level, cap, step: steps };
91
+
92
+ const ask = (maxTokens, think) => ({
26
93
  messages: session.wire(),
27
94
  tools,
28
- think: thinkFor(conn, session.think),
29
- maxTokens: 4096,
30
- };
95
+ think: thinkFor(conn, think),
96
+ maxTokens,
97
+ signal,
98
+ });
31
99
 
32
100
  let msg;
33
- try {
101
+ const askModel = async function* (maxTokens, think) {
34
102
  if (conn.streaming) {
35
- for await (const ev of chatStream(conn, opts)) {
103
+ for await (const ev of chatStream(conn, ask(maxTokens, think))) {
36
104
  if (ev.type === 'done') msg = ev.message;
37
105
  else yield ev;
38
106
  }
39
107
  } else {
40
108
  yield { type: 'waiting' };
41
- msg = await chat(conn, opts);
109
+ msg = await chat(conn, ask(maxTokens, think));
42
110
  if (msg.thinking) yield { type: 'thinking', text: msg.thinking };
43
111
  if (msg.content) yield { type: 'content', text: msg.content };
44
112
  }
113
+ };
114
+
115
+ try {
116
+ yield* askModel(cap, level);
117
+
118
+ // 아낀 상한 때문에 대답이 잘렸다면, 그 단계만 상한을 풀어 한 번 다시 부른다.
119
+ // 잘린 채로 넘어가면 도구 호출이 반토막 나서 조용히 실패한다 —
120
+ // 생각을 많이 하는 모델에서 특히 잘 생긴다.
121
+ const full = fullCap(room);
122
+ if (wasCut(msg) && cap < full) {
123
+ yield { type: 'retry', why: '대답이 상한에서 잘렸습니다', from: cap, to: full };
124
+ yield* askModel(full, level);
125
+ }
45
126
  } catch (err) {
127
+ if (err?.name === 'Aborted' || signal?.aborted) {
128
+ 짝맞추기();
129
+ yield { type: 'aborted', steps };
130
+ return;
131
+ }
46
132
  yield { type: 'error', text: err.message };
47
133
  return;
48
134
  }
@@ -58,66 +144,106 @@ export async function* run(session, ctx, userText) {
58
144
  return;
59
145
  }
60
146
 
61
- // 도구를 순서대로 실행한다.
62
- for (const call of msg.toolCalls) {
63
- if (!TOOLS[call.name]) {
64
- session.push(toolMessage(conn.kind, {
65
- callId: call.id, name: call.name,
66
- content: `모르는 도구입니다. 쓸 수 있는 것: ${Object.keys(TOOLS).join(', ')}`,
67
- }));
68
- yield { type: 'tool', name: call.name, args: call.args, result: { error: '모르는 도구' } };
69
- continue;
70
- }
147
+ // 도구를 돌린다. 읽기만 하는 것들이 이어지면 한꺼번에, 나머지는 하나씩.
148
+ lastToolFailed = false;
149
+ const 거절 = (call, note) => {
150
+ lastToolFailed = true;
151
+ session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
152
+ };
71
153
 
72
- // 변경성 명령은 실패해도 다시 실행하지 않는다 — 두 번 돌면 사고다.
73
- if (call.name === 'Bash' && isMutating(call.args?.command)) {
74
- const key = String(call.args.command).trim();
75
- if (attempted.has(key)) {
76
- const note = '같은 변경성 명령을 다시 실행하지 않습니다. 두 번 실행되면 사고가 납니다.';
77
- session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
78
- yield { type: 'tool', name: call.name, args: call.args, result: { error: note } };
79
- continue;
154
+ for (const 덩어리 of 묶기(msg.toolCalls)) {
155
+ // 돌리는 중에 끊었다면, 남은 것은 실행하지 않고 결과 자리만 채운다.
156
+ // 자리를 비우면 짝이 깨져 다음에 이어할 수 없다.
157
+ if (signal?.aborted) {
158
+ for (const call of 덩어리.calls) {
159
+ session.push(toolMessage(conn.kind, {
160
+ callId: call.id, name: call.name,
161
+ content: '사용자가 중단했습니다. 실행하지 않았습니다.',
162
+ }));
80
163
  }
81
- attempted.add(key);
164
+ continue;
82
165
  }
83
166
 
84
- // 모드에 따라 물어본다. 기본(auto)은 묻고 되돌리기로 대응한다.
85
- const needsOk = session.mode === 'strict'
86
- ? ['Write', 'Edit', 'Bash'].includes(call.name)
87
- : session.mode === 'confirm'
88
- ? (call.name === 'Bash' && isMutating(call.args?.command))
89
- : false;
90
- if (needsOk && ctx.confirm) {
91
- const ok = await ctx.confirm(call.name, call.args);
92
- if (!ok) {
93
- const note = '사용자가 거부했습니다. 다른 방법을 찾거나 이유를 물어보세요.';
94
- session.push(toolMessage(conn.kind, { callId: call.id, name: call.name, content: note }));
95
- yield { type: 'tool', name: call.name, args: call.args, result: { error: '거부됨' } };
167
+ // 먼저 하나씩 걸러 낸다 물어보는 것도 여기서. 실제 실행은 통과한 것만.
168
+ const 실행할것 = [];
169
+ for (const call of 덩어리.calls) {
170
+ if (!TOOLS[call.name]) {
171
+ 거절(call, `모르는 도구입니다. 수 있는 것: ${Object.keys(TOOLS).join(', ')}`);
172
+ yield { type: 'tool', name: call.name, args: call.args, result: { error: '모르는 도구' } };
96
173
  continue;
97
174
  }
98
- }
99
175
 
100
- yield { type: 'tool_start', name: call.name, args: call.args };
101
- const t0 = Date.now();
102
- const result = await runTool(call.name, call.args, ctx);
103
- const ms = Date.now() - t0;
104
- session.usage.ms += ms;
105
-
106
- if (call.name === 'Read' && result.content) session.noteRead(call.args.file_path, result.content);
176
+ // 변경성 명령은 실패해도 다시 실행하지 않는다 두 번 돌면 사고다.
177
+ if (call.name === 'Bash' && isMutating(call.args?.command)) {
178
+ const key = String(call.args.command).trim();
179
+ if (attempted.has(key)) {
180
+ const note = '같은 변경성 명령을 다시 실행하지 않습니다. 두 번 실행되면 사고가 납니다.';
181
+ 거절(call, note);
182
+ yield { type: 'tool', name: call.name, args: call.args, result: { error: note } };
183
+ continue;
184
+ }
185
+ attempted.add(key);
186
+ }
107
187
 
108
- session.push(toolMessage(conn.kind, {
109
- callId: call.id,
110
- name: call.name,
111
- content: result.error ? `오류: ${result.error}` : result.content ?? '',
112
- }));
113
- yield { type: 'tool', name: call.name, args: call.args, result, ms };
188
+ // 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
189
+ const needsOk = session.mode === 'strict'
190
+ ? ['Write', 'Edit', 'Bash'].includes(call.name)
191
+ : session.mode === 'confirm'
192
+ ? (call.name === 'Bash' && isMutating(call.args?.command))
193
+ : false;
194
+ if (needsOk && ctx.confirm) {
195
+ const ok = await ctx.confirm(call.name, call.args);
196
+ if (!ok) {
197
+ 거절(call, '사용자가 거부했습니다. 다른 방법을 찾거나 이유를 물어보세요.');
198
+ yield { type: 'tool', name: call.name, args: call.args, result: { error: '거부됨' } };
199
+ continue;
200
+ }
201
+ }
202
+ 실행할것.push(call);
203
+ }
204
+ if (!실행할것.length) continue;
205
+
206
+ // 여럿을 같이 돌릴 때는 '시작' 을 따로 알리지 않는다.
207
+ // 화면에서 이름 셋이 먼저 뜨고 결과 셋이 뒤에 몰려 붙으면, 어느 결과가
208
+ // 어느 파일 것인지 읽을 수 없다. 그럴 때는 끝난 것부터 이름과 결과를 함께 그린다.
209
+ const 함께 = 실행할것.length > 1;
210
+ if (!함께) yield { type: 'tool_start', name: 실행할것[0].name, args: 실행할것[0].args };
211
+ else yield { type: 'tools_start', names: 실행할것.map((x) => x.name), count: 실행할것.length };
212
+
213
+ const 한개 = async (call) => {
214
+ const t0 = Date.now();
215
+ let result;
216
+ try { result = await runTool(call.name, call.args, ctx); }
217
+ catch (err) { result = { error: String(err?.message ?? err) }; }
218
+ return { call, result, ms: Date.now() - t0 };
219
+ };
220
+
221
+ // 여럿이면 동시에. 읽기만 하는 것들이라 서로 방해하지 않는다.
222
+ const 결과들 = 함께
223
+ ? await Promise.all(실행할것.map(한개))
224
+ : [await 한개(실행할것[0])];
225
+
226
+ for (const { call, result, ms } of 결과들) {
227
+ session.usage.ms += ms;
228
+ if (result.error) lastToolFailed = true;
229
+ if (call.name === 'Read' && result.content) session.noteRead(call.args.file_path, result.content);
230
+ session.push(toolMessage(conn.kind, {
231
+ callId: call.id,
232
+ name: call.name,
233
+ content: result.error ? `오류: ${result.error}` : result.content ?? '',
234
+ }));
235
+ yield { type: 'tool', name: call.name, args: call.args, result, ms, parallel: 함께 };
236
+ }
114
237
  }
115
238
 
116
- // 컨텍스트가 차오르면 오래된 대화를 줄인다.
117
- const b = session.breakdown();
118
- if (b.used > b.total * 0.8) {
119
- const dropped = session.trim();
120
- if (dropped) yield { type: 'trimmed', dropped };
239
+ if (signal?.aborted) { yield { type: 'aborted', steps }; return; }
240
+
241
+ // 컨텍스트가 차오르면 오래된 대화를 '요약해서' 접는다. 그냥 자르면 하던 일을 잊는다.
242
+ if (shouldCompact(session)) {
243
+ yield { type: 'compacting' };
244
+ const r = await compact(session, { auto: true });
245
+ if (r.ok) yield { type: 'compacted', ...r };
246
+ else yield { type: 'compact_failed', why: r.why };
121
247
  }
122
248
  }
123
249
 
@@ -0,0 +1,153 @@
1
+ // 작업 모드. 지금 무슨 일을 하는 중인지에 따라 도구·추론·말투를 한꺼번에 바꾼다.
2
+ //
3
+ // 승인 정책(auto/confirm/strict)과는 다른 축이다. 헷갈리면 안 된다.
4
+ // 승인 정책 = 얼마나 물어보나
5
+ // 작업 모드 = 무슨 일을 하는 중인가
6
+ // 둘은 곱해진다. '설계 모드 + strict' 도, '코드 모드 + auto' 도 말이 된다.
7
+ //
8
+ // 왜 도구를 아예 빼는가:
9
+ // 설계 모드에서 파일을 고치면 안 된다고 프롬프트로 부탁할 수도 있다.
10
+ // 그런데 모델은 부탁을 잊는다. 아예 목록에서 빼면 잊을 것이 없다.
11
+ // 오프라인일 때 웹 도구를 숨기는 것과 같은 방식이다.
12
+
13
+ // 읽기만 하는 도구. 무엇을 바꾸지 않는다.
14
+ const 읽기 = ['Read', 'Glob', 'Grep', 'WebFetch', 'Skill'];
15
+ // 계획을 적는 도구. 파일을 안 건드리므로 읽기 전용 모드에서도 준다.
16
+ const 계획 = ['TodoWrite'];
17
+ // 바꾸는 도구.
18
+ const 쓰기 = ['Write', 'Edit', 'Bash'];
19
+
20
+ export const MODES = {
21
+ code: {
22
+ id: 'code',
23
+ name: '코드',
24
+ en: 'Code',
25
+ glyph: '◆',
26
+ hint: '고치고 만든다',
27
+ tools: [...읽기, ...계획, ...쓰기],
28
+ effort: 'save', // 첫 판단만 세게, 이어가기는 얕게
29
+ think: null, // 사용자가 정한 값을 그대로 쓴다
30
+ steps: 24,
31
+ say: '코드를 읽고 고칩니다. 고치기 전에 반드시 먼저 읽으세요.',
32
+ },
33
+
34
+ architect: {
35
+ id: 'architect',
36
+ name: '설계',
37
+ en: 'Architect',
38
+ glyph: '◈',
39
+ hint: '구조를 짠다 · 파일은 안 건드림',
40
+ tools: [...읽기, ...계획],
41
+ effort: 'deep',
42
+ think: 'high',
43
+ steps: 20,
44
+ say: '구조와 설계를 다룹니다. 파일을 바꾸는 도구는 주어지지 않았습니다. '
45
+ + '지금 코드를 충분히 읽고, 어디를 어떻게 바꿀지 근거와 함께 제안하세요. '
46
+ + '고르는 이유와 버리는 이유를 같이 적으세요.',
47
+ },
48
+
49
+ ask: {
50
+ id: 'ask',
51
+ name: '묻기',
52
+ en: 'Ask',
53
+ glyph: '◇',
54
+ hint: '설명만 · 아무것도 안 바꿈',
55
+ tools: [...읽기],
56
+ effort: 'even',
57
+ think: 'low',
58
+ steps: 8,
59
+ say: '질문에 답하고 설명합니다. 시키지 않은 일을 벌이지 마세요. '
60
+ + '코드를 읽어 근거를 대되, 고치라는 말이 없으면 고칠 것을 제안하지 마세요.',
61
+ },
62
+
63
+ debug: {
64
+ id: 'debug',
65
+ name: '디버그',
66
+ en: 'Debug',
67
+ glyph: '◉',
68
+ hint: '원인을 찾는다',
69
+ tools: [...읽기, ...계획, ...쓰기],
70
+ effort: 'deep',
71
+ think: 'high',
72
+ steps: 32, // 원인 찾기는 왔다 갔다 하므로 여유를 준다
73
+ say: '무엇이 잘못됐는지 찾습니다. 고치기 전에 원인을 먼저 밝히세요. '
74
+ + '짐작으로 고치지 말고, 확인할 수 있는 것을 확인하세요 — 로그를 보고, '
75
+ + '작은 것을 실제로 돌려 보고, 무엇이 사실인지 말한 다음 고치세요.',
76
+ },
77
+
78
+ plan: {
79
+ id: 'plan',
80
+ name: '계획',
81
+ en: 'Plan',
82
+ glyph: '☰',
83
+ hint: '먼저 계획 · 승인 뒤 실행',
84
+ tools: [...읽기, ...계획],
85
+ effort: 'deep',
86
+ think: 'high',
87
+ steps: 16,
88
+ say: '먼저 계획만 세웁니다. 파일을 바꾸는 도구는 주어지지 않았습니다. '
89
+ + '무엇을 어떤 순서로 할지, 위험이 무엇인지 적고 멈추세요. '
90
+ + '사용자가 승인하면 그때 코드 모드로 바뀝니다.',
91
+ },
92
+
93
+ orchestrator: {
94
+ id: 'orchestrator',
95
+ name: '총괄',
96
+ en: 'Orchestrator',
97
+ glyph: '❋',
98
+ hint: '큰 일을 쪼개서 끝까지',
99
+ tools: [...읽기, ...계획, ...쓰기],
100
+ effort: 'save',
101
+ think: null,
102
+ steps: 40, // 여러 갈래를 끝까지 끌고 가야 한다
103
+ say: '여러 단계가 걸리는 일을 끝까지 끌고 갑니다. '
104
+ + '먼저 TodoWrite 로 할 일을 쪼개 적고, 하나씩 끝낼 때마다 갱신하세요. '
105
+ + '한 번에 하나만 진행 중으로 두세요. 다 끝나면 무엇을 했는지 요약하세요.',
106
+ },
107
+ };
108
+
109
+ export const ORDER = ['code', 'plan', 'architect', 'debug', 'ask', 'orchestrator'];
110
+ export const DEFAULT = 'code';
111
+
112
+ /** 이름을 관대하게 받는다. 한글·영문·줄임말 다 통한다. */
113
+ export function normalize(v) {
114
+ const s = String(v ?? '').trim().toLowerCase();
115
+ if (!s) return null;
116
+ if (MODES[s]) return s;
117
+ const 별명 = {
118
+ '코드': 'code', 'c': 'code',
119
+ '계획': 'plan', '플랜': 'plan', 'p': 'plan',
120
+ '설계': 'architect', '아키': 'architect', 'arch': 'architect', 'a': 'architect',
121
+ '디버그': 'debug', '버그': 'debug', 'd': 'debug',
122
+ '묻기': 'ask', '질문': 'ask', '일상': 'ask', 'q': 'ask',
123
+ '총괄': 'orchestrator', '오케': 'orchestrator', 'orch': 'orchestrator', 'o': 'orchestrator',
124
+ };
125
+ return 별명[s] ?? null;
126
+ }
127
+
128
+ export function get(id) {
129
+ return MODES[normalize(id) ?? DEFAULT];
130
+ }
131
+
132
+ /** Shift+Tab 으로 돌릴 때 다음 모드. */
133
+ export function next(id) {
134
+ const i = ORDER.indexOf(normalize(id) ?? DEFAULT);
135
+ return ORDER[(i + 1) % ORDER.length];
136
+ }
137
+
138
+ /** 이 모드가 파일을 바꿀 수 있나. 화면에 자물쇠를 그릴지 정하는 데 쓴다. */
139
+ export function canWrite(id) {
140
+ return get(id).tools.some((t) => 쓰기.includes(t));
141
+ }
142
+
143
+ /**
144
+ * 이 모드에서 모델에게 보여 줄 도구 이름들.
145
+ *
146
+ * 있는 것 중에서 고르는 것이지, 없는 것을 만들어 주지 않는다.
147
+ * 스킬이 없으면 Skill 은 애초에 없고, 오프라인이면 WebFetch 가 없다.
148
+ * 그 판단은 부르는 쪽이 이미 했다.
149
+ */
150
+ export function allow(id, 있는것) {
151
+ const 허용 = new Set(get(id).tools);
152
+ return 있는것.filter((name) => 허용.has(name));
153
+ }
@@ -1,6 +1,8 @@
1
1
  // 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { get as workMode, DEFAULT as WORK_DEFAULT } from './modes.js';
5
+ import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
4
6
 
5
7
  // 토큰 추정 — 정확한 토크나이저 없이 대략만 센다.
6
8
  // 한글은 글자당 약 1토큰, 영문·코드는 약 4글자당 1토큰으로 본다.
@@ -25,11 +27,16 @@ const BASE_RULES = `너는 deel 다. 사용자의 작업 폴더 안에서 코드
25
27
  - 사용자에게 답할 때는 한국어로, 짧게. 코드를 통째로 붙여넣지 말고 무엇이 달라졌는지 말한다.`;
26
28
 
27
29
  export class Session {
28
- constructor(conn, { root, mode = 'auto', think = 'medium', maxSteps = 24 } = {}) {
30
+ constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = 24 } = {}) {
29
31
  this.conn = conn;
30
32
  this.root = root;
31
- this.mode = mode;
32
- this.think = think;
33
+ this.mode = mode; // 승인 정책 — 얼마나 물어보나 (auto/confirm/strict)
34
+ this.work = work ?? WORK_DEFAULT; // 작업 모드 — 무슨 일을 하는 중인가 (modes.js)
35
+ // 사용자 수준 — 화면에 무엇을 내놓을지만 정한다. 안전 장치는 안 바꾼다 (ui/level.js)
36
+ this.level = normLevel(level) ?? LEVEL_DEFAULT;
37
+ this.think = think; // 기준 강도
38
+ this.effort = effort; // 그 강도를 단계별로 어떻게 나눌지 (effort.js)
39
+ this.web = web; // 웹 읽기 도구를 줄지 (오프라인이면 무조건 안 준다)
33
40
  this.maxSteps = maxSteps;
34
41
  this.messages = [];
35
42
  this.filesRead = new Map(); // 경로 → 추정 토큰
@@ -56,6 +63,10 @@ export class Session {
56
63
  systemPrompt() {
57
64
  const parts = [BASE_RULES];
58
65
  parts.push(`\n작업 폴더: ${this.root}\n이 폴더 밖의 파일은 읽지도 쓰지도 못한다.`);
66
+
67
+ // 지금 무슨 일을 하는 중인지. 도구 목록도 이 모드에 맞춰 이미 걸러져 있다.
68
+ const w = workMode(this.work);
69
+ parts.push(`\n--- 지금 모드: ${w.name} (${w.en}) ---\n${w.say}`);
59
70
  if (this.rules) parts.push(`\n--- ${this.rules.name} (사용자 규칙, 위 원칙보다 우선) ---\n${this.rules.text}`);
60
71
  const listed = this.listedSkills();
61
72
  if (listed.length) {
@@ -0,0 +1,59 @@
1
+ // deel sessions — 이 폴더에 남아 있는 대화 목록.
2
+ import { c, say, rule, pad, mark, clip, width } from '../ui/ansi.js';
3
+ import { list, remove, sessionsDir } from './store.js';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ // "3분 전", "어제" 처럼. 목록에서는 절대 시각보다 이쪽이 눈에 잘 들어온다.
7
+ function 언제(d) {
8
+ const s = Math.floor((Date.now() - d.getTime()) / 1000);
9
+ if (s < 60) return '방금';
10
+ if (s < 3600) return `${Math.floor(s / 60)}분 전`;
11
+ if (s < 86400) return `${Math.floor(s / 3600)}시간 전`;
12
+ if (s < 172800) return '어제';
13
+ if (s < 604800) return `${Math.floor(s / 86400)}일 전`;
14
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
15
+ }
16
+
17
+ export function runSessions(flags = {}) {
18
+ const root = flags.root ? String(flags.root) : process.cwd();
19
+
20
+ if (flags.rm || flags.delete) {
21
+ const id = String(flags.rm ?? flags.delete);
22
+ const r = remove(root, id);
23
+ say('');
24
+ say(r.error ? ` ${mark.no} ${r.error}` : ` ${mark.ok} 지웠습니다: ${c.bold(r.removed)}`);
25
+ say('');
26
+ return r.error ? 1 : 0;
27
+ }
28
+
29
+ const rows = list(root, { limit: flags.all ? 1000 : 20 });
30
+ say('');
31
+ rule('이 폴더의 대화', 78);
32
+ say(` ${c.gray(root)}`);
33
+ say('');
34
+
35
+ if (!rows.length) {
36
+ say(` ${c.gray('남아 있는 대화가 없습니다.')}`);
37
+ if (!existsSync(sessionsDir(root))) {
38
+ say(` ${c.gray('이 폴더에서 deel 을 한 번 쓰면 여기에 쌓입니다.')}`);
39
+ }
40
+ say('');
41
+ return 0;
42
+ }
43
+
44
+ const wId = Math.max(...rows.map((r) => width(r.id)));
45
+ for (const [i, r] of rows.entries()) {
46
+ const 최근 = i === 0 ? c.hgreen('●') : c.gray('·');
47
+ say(` ${최근} ${c.bold(pad(r.id, wId))} ${c.gray(pad(언제(r.at), 9))}`
48
+ + `${c.gray(pad(`${r.turns}턴`, 6, 'right'))} ${c.gray(pad(clip(r.model, 22), 24))}`);
49
+ say(` ${c.gray(' ')}${clip(r.first, 68)}`);
50
+ }
51
+ say('');
52
+ say(` ${c.gray('가장 최근 것 이어하기')} ${c.cyan('deel --continue')}`);
53
+ say(` ${c.gray('골라서 이어하기')} ${c.cyan(`deel --resume ${rows[0].id}`)}`);
54
+ say(` ${c.gray('하나 지우기')} ${c.cyan(`deel sessions --rm ${rows.at(-1).id}`)}`);
55
+ say('');
56
+ say(` ${c.gray(`저장 위치: ${sessionsDir(root)} (.gitignore 에 들어 있어 깃에 안 올라갑니다)`)}`);
57
+ say('');
58
+ return 0;
59
+ }