deel-local-cli 0.8.0 → 1.0.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.en.md +686 -28
- package/README.md +571 -31
- package/bin/deel.js +234 -160
- package/package.json +3 -2
- package/src/agent/compact.js +131 -138
- package/src/agent/effort.js +44 -11
- package/src/agent/loop.js +574 -251
- package/src/agent/memory.js +152 -0
- package/src/agent/mention.js +164 -0
- package/src/agent/modes.js +127 -20
- package/src/agent/recall.js +209 -0
- package/src/agent/route.js +156 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +155 -11
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +246 -0
- package/src/backend/detect.js +10 -1
- package/src/backend/http.js +23 -0
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +97 -3
- package/src/commands.js +825 -50
- package/src/oneshot.js +327 -0
- package/src/repl.js +715 -414
- package/src/report.js +20 -6
- package/src/safety/guard.js +123 -7
- package/src/safety/undo.js +96 -11
- package/src/tools/encoding.js +24 -2
- package/src/tools/fsutil.js +70 -0
- package/src/tools/index.js +471 -27
- package/src/tools/webfetch.js +37 -1
- package/src/ui/ansi.js +32 -2
- package/src/ui/diff.js +255 -0
- package/src/ui/level.js +6 -2
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +169 -0
- package/src/ui/status.js +98 -24
- package/src/ui/tui.js +419 -0
- package/src/version.js +28 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 지난 대화 찾기.
|
|
3
|
+
*
|
|
4
|
+
* 왜 필요한가:
|
|
5
|
+
* deel 은 대화를 .deel/sessions/*.jsonl 로 꼬박꼬박 남긴다. 그런데 지금까지
|
|
6
|
+
* 할 수 있는 일은 목록을 보는 것(/sessions)과 통째로 이어받는 것(--continue)
|
|
7
|
+
* 뿐이었다. "저번에 그 인코딩 문제 어떻게 풀었더라" 를 찾을 방법이 없었다.
|
|
8
|
+
* 기록이 있는데 못 찾으면 없는 것과 같다.
|
|
9
|
+
*
|
|
10
|
+
* 왜 색인을 안 만드나:
|
|
11
|
+
* FTS 색인을 두면 빠르지만, 색인은 반드시 낡는다 — 대화 파일은 매 턴 늘어나고,
|
|
12
|
+
* 딴 창에서도 늘어나고, 사람이 지우기도 한다. 낡은 색인은 **없는 것보다 나쁘다**:
|
|
13
|
+
* "못 찾았습니다" 가 "없습니다" 로 읽히기 때문이다.
|
|
14
|
+
* 그래서 그때그때 읽는다. 대신 **얼마나 읽었고 무엇을 못 읽었는지 반드시 말한다.**
|
|
15
|
+
*
|
|
16
|
+
* 의존성 0 이라 점수도 직접 매긴다. 대단한 순위가 필요한 게 아니다 —
|
|
17
|
+
* 낱말이 많이 맞고, 다 맞고, 최근인 것이 위로 오면 된다.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { sessionsDir } from './store.js';
|
|
22
|
+
|
|
23
|
+
// 한 번 찾을 때 읽을 최대 바이트. 넘으면 거기서 멈추고 **멈췄다고 말한다.**
|
|
24
|
+
export const 읽기예산 = 64 * 1024 * 1024;
|
|
25
|
+
// 한 메시지에서 볼 최대 길이. 파일을 통째로 붙인 도구 결과가 수 MB 씩 있다.
|
|
26
|
+
const 메시지최대 = 200 * 1024;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 물음을 낱말로 쪼갠다.
|
|
30
|
+
*
|
|
31
|
+
* 한국어는 띄어쓰기가 낱말 경계와 안 맞는다("인코딩을" ≠ "인코딩"). 그래서
|
|
32
|
+
* 조사처럼 보이는 꼬리를 떼어 **둘 다** 찾는다. 형태소 분석기를 붙일 수는 없다
|
|
33
|
+
* (의존성 0). 이 정도만 해도 "인코딩을 어떻게" 로 "인코딩" 을 찾아낸다.
|
|
34
|
+
*/
|
|
35
|
+
export function 낱말쪼개기(물음) {
|
|
36
|
+
const 조사 = /(을|를|이|가|은|는|에|에서|으로|로|와|과|의|도|만|까지|부터|에게|한테|보다|처럼|랑|이랑)$/;
|
|
37
|
+
const 본 = String(물음 ?? '').toLowerCase().split(/[\s,.;:!?()[\]{}"'`]+/).filter(Boolean);
|
|
38
|
+
const 낱말 = new Set();
|
|
39
|
+
for (const w of 본) {
|
|
40
|
+
if (w.length < 2) continue; // 한 글자는 아무 데나 걸린다
|
|
41
|
+
낱말.add(w);
|
|
42
|
+
const 짧은 = w.replace(조사, '');
|
|
43
|
+
if (짧은.length >= 2 && 짧은 !== w) 낱말.add(짧은);
|
|
44
|
+
}
|
|
45
|
+
return [...낱말];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 맞은 자리 앞뒤를 잘라 보여 줄 토막을 만든다. */
|
|
49
|
+
export function 토막내기(글, 낱말들, 폭 = 150) {
|
|
50
|
+
const s = String(글 ?? '').replace(/\s+/g, ' ').trim();
|
|
51
|
+
if (s.length <= 폭) return s;
|
|
52
|
+
const 낮은 = s.toLowerCase();
|
|
53
|
+
let 자리 = -1;
|
|
54
|
+
for (const w of 낱말들) {
|
|
55
|
+
const i = 낮은.indexOf(w);
|
|
56
|
+
if (i >= 0 && (자리 < 0 || i < 자리)) 자리 = i;
|
|
57
|
+
}
|
|
58
|
+
if (자리 < 0) return s.slice(0, 폭) + '…';
|
|
59
|
+
const 앞 = Math.max(0, 자리 - Math.floor(폭 / 3));
|
|
60
|
+
return (앞 > 0 ? '…' : '') + s.slice(앞, 앞 + 폭) + (앞 + 폭 < s.length ? '…' : '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 메시지 하나를 사람이 읽을 글로. 도구 호출은 이름과 인자만 남긴다. */
|
|
64
|
+
function 글로(m) {
|
|
65
|
+
if (typeof m?.content === 'string' && m.content) return m.content;
|
|
66
|
+
// 도구 호출은 content 가 비어 있고 tool_calls 에 들어 있다.
|
|
67
|
+
if (Array.isArray(m?.tool_calls) && m.tool_calls.length) {
|
|
68
|
+
return m.tool_calls.map((t) => `${t.function?.name ?? '도구'}(${t.function?.arguments ?? ''})`).join(' ');
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(m?.content)) {
|
|
71
|
+
return m.content.map((p) => (typeof p === 'string' ? p : p?.text ?? '')).join(' ');
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const 누구 = { user: '나', assistant: '모델', tool: '도구결과', system: '규칙' };
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 대화가 스스로 적어 둔 시각. 없거나 말이 안 되면 null 을 준다.
|
|
80
|
+
*
|
|
81
|
+
* 옛 기록에는 meta 줄이 아예 없고, 손으로 고치다 깨진 파일도 있다.
|
|
82
|
+
* 여기서 터지면 /recall 이 통째로 죽는다 — 못 읽은 날짜 하나 때문에.
|
|
83
|
+
*/
|
|
84
|
+
function 때(meta) {
|
|
85
|
+
const v = meta?.at;
|
|
86
|
+
if (!v) return null;
|
|
87
|
+
const d = new Date(v);
|
|
88
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 지난 대화에서 찾는다.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} root 작업 폴더
|
|
95
|
+
* @param {string} 물음
|
|
96
|
+
* @param {{limit?: number, 예산?: number, 지금세션?: string, 도구결과까지?: boolean}} o
|
|
97
|
+
* @returns {{맞은것: object[], 본파일: number, 전체파일: number, 읽은바이트: number, 예산초과: boolean, 낱말: string[]}}
|
|
98
|
+
*/
|
|
99
|
+
export function 찾기(root, 물음, o = {}) {
|
|
100
|
+
const limit = o.limit ?? 8;
|
|
101
|
+
const 예산 = o.예산 ?? 읽기예산;
|
|
102
|
+
const 도구결과까지 = o.도구결과까지 ?? false;
|
|
103
|
+
const 낱말 = 낱말쪼개기(물음);
|
|
104
|
+
const dir = sessionsDir(root);
|
|
105
|
+
|
|
106
|
+
const 빈결과 = { 맞은것: [], 본파일: 0, 전체파일: 0, 읽은바이트: 0, 예산초과: false, 낱말 };
|
|
107
|
+
if (!낱말.length || !existsSync(dir)) return 빈결과;
|
|
108
|
+
|
|
109
|
+
// 최근 것부터 본다. 예산이 모자라 멈추더라도 **쓸모 있는 쪽**에서 멈춘다.
|
|
110
|
+
const 파일들 = [];
|
|
111
|
+
for (const name of readdirSync(dir)) {
|
|
112
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
113
|
+
try {
|
|
114
|
+
const file = join(dir, name);
|
|
115
|
+
const st = statSync(file);
|
|
116
|
+
파일들.push({ id: name.slice(0, -6), file, at: st.mtime, bytes: st.size });
|
|
117
|
+
} catch { /* 지워지는 중이거나 권한이 없다 */ }
|
|
118
|
+
}
|
|
119
|
+
파일들.sort((a, b) => b.at - a.at);
|
|
120
|
+
|
|
121
|
+
const 맞은것 = [];
|
|
122
|
+
let 읽은바이트 = 0;
|
|
123
|
+
let 본파일 = 0;
|
|
124
|
+
let 예산초과 = false;
|
|
125
|
+
const 가장최근 = 파일들[0]?.at?.getTime?.() ?? Date.now();
|
|
126
|
+
|
|
127
|
+
for (const f of 파일들) {
|
|
128
|
+
if (읽은바이트 + f.bytes > 예산) { 예산초과 = true; break; }
|
|
129
|
+
let 원문;
|
|
130
|
+
try { 원문 = readFileSync(f.file, 'utf8'); } catch { continue; }
|
|
131
|
+
읽은바이트 += f.bytes;
|
|
132
|
+
본파일 += 1;
|
|
133
|
+
|
|
134
|
+
// 파일 전체에 낱말이 하나도 없으면 줄을 쪼갤 것도 없다. 큰 파일에서 크게 빠르다.
|
|
135
|
+
const 낮은전체 = 원문.toLowerCase();
|
|
136
|
+
if (!낱말.some((w) => 낮은전체.includes(w))) continue;
|
|
137
|
+
|
|
138
|
+
let 차례 = 0;
|
|
139
|
+
let meta = null;
|
|
140
|
+
for (const line of 원문.split('\n')) {
|
|
141
|
+
if (!line) continue;
|
|
142
|
+
let j;
|
|
143
|
+
try { j = JSON.parse(line); } catch { continue; }
|
|
144
|
+
if (j.t === 'meta') { meta = j; continue; }
|
|
145
|
+
차례 += 1;
|
|
146
|
+
if (j.role === 'system') continue;
|
|
147
|
+
if (!도구결과까지 && j.role === 'tool') continue;
|
|
148
|
+
|
|
149
|
+
const 글 = 글로(j).slice(0, 메시지최대);
|
|
150
|
+
if (!글) continue;
|
|
151
|
+
const 낮은 = 글.toLowerCase();
|
|
152
|
+
|
|
153
|
+
let 맞은낱말 = 0;
|
|
154
|
+
let 횟수 = 0;
|
|
155
|
+
for (const w of 낱말) {
|
|
156
|
+
let n = 0;
|
|
157
|
+
let i = 낮은.indexOf(w);
|
|
158
|
+
while (i >= 0 && n < 50) { n += 1; i = 낮은.indexOf(w, i + w.length); }
|
|
159
|
+
if (n) { 맞은낱말 += 1; 횟수 += n; }
|
|
160
|
+
}
|
|
161
|
+
if (!맞은낱말) continue;
|
|
162
|
+
|
|
163
|
+
/*
|
|
164
|
+
* 점수.
|
|
165
|
+
* 낱말이 **몇 개나** 맞았나가 제일 세다 — 두 낱말이 다 나오는 글이
|
|
166
|
+
* 한 낱말이 열 번 나오는 글보다 거의 언제나 원하는 것이다.
|
|
167
|
+
* 최근일수록 조금 올린다. 같은 것을 두 번 물어본 경우 최근 답이 낫다.
|
|
168
|
+
*/
|
|
169
|
+
const 다맞음 = 맞은낱말 === 낱말.length ? 12 : 0;
|
|
170
|
+
const 오래됨 = Math.max(0, (가장최근 - f.at.getTime()) / (1000 * 60 * 60 * 24));
|
|
171
|
+
const 최근점 = Math.max(0, 6 - 오래됨 / 7);
|
|
172
|
+
맞은것.push({
|
|
173
|
+
세션: f.id,
|
|
174
|
+
// 화면에 적는 날짜는 **대화가 적어 둔 날**이지 파일이 만져진 날이 아니다.
|
|
175
|
+
//
|
|
176
|
+
// 이 도구는 오프라인 PC 로 폴더째 옮겨 다니는 것을 전제로 만들었다.
|
|
177
|
+
// 그렇게 옮기면 mtime 은 옮긴 날로 전부 바뀐다 — 반 년 치 대화가
|
|
178
|
+
// 하나같이 "오늘" 이 되고, 그러면 이 화면은 아무 말도 안 하는 셈이다.
|
|
179
|
+
//
|
|
180
|
+
// 점수(최근점)는 그대로 mtime 을 쓴다. 그건 파일을 읽기 전에 정해야
|
|
181
|
+
// 하는 값이고(예산 순서), 순서가 조금 흔들리는 것은 날짜가 통째로
|
|
182
|
+
// 거짓말하는 것과 무게가 다르다.
|
|
183
|
+
언제: 때(meta) ?? f.at,
|
|
184
|
+
model: meta?.model ?? null,
|
|
185
|
+
role: j.role,
|
|
186
|
+
누구: 누구[j.role] ?? j.role,
|
|
187
|
+
차례,
|
|
188
|
+
글,
|
|
189
|
+
토막: 토막내기(글, 낱말),
|
|
190
|
+
점수: 맞은낱말 * 10 + 다맞음 + Math.min(6, 횟수) + 최근점,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
맞은것.sort((a, b) => b.점수 - a.점수 || b.언제 - a.언제);
|
|
196
|
+
|
|
197
|
+
// 한 대화에서 열 줄이 몰려 나오면 다른 대화를 못 본다. 세션당 셋까지만.
|
|
198
|
+
const 세션별 = new Map();
|
|
199
|
+
const 추린것 = [];
|
|
200
|
+
for (const h of 맞은것) {
|
|
201
|
+
const n = 세션별.get(h.세션) ?? 0;
|
|
202
|
+
if (n >= 3) continue;
|
|
203
|
+
세션별.set(h.세션, n + 1);
|
|
204
|
+
추린것.push(h);
|
|
205
|
+
if (추린것.length >= limit) break;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { 맞은것: 추린것, 본파일, 전체파일: 파일들.length, 읽은바이트, 예산초과, 낱말, 전체맞음: 맞은것.length };
|
|
209
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 사용자가 무엇을 시켰는지 보고 알맞은 작업 모드를 고른다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 규칙으로 하나:
|
|
4
|
+
// 모델에게 "이건 무슨 일이냐" 고 한 번 더 물을 수도 있다. 그런데 그러면
|
|
5
|
+
// 한마디 할 때마다 왕복이 하나 늘고, 작은 로컬 모델은 이런 분류를 잘 못한다.
|
|
6
|
+
// 느려지고 틀리기까지 하면 자동으로 하는 의미가 없다.
|
|
7
|
+
//
|
|
8
|
+
// 그래서 낱말로 고른다. 대신 두 가지를 지킨다.
|
|
9
|
+
// 1) 애매하면 안 바꾼다. 종합 모드에 그대로 둔다.
|
|
10
|
+
// 잘못 바꾸는 것보다 안 바꾸는 편이 낫다 — 특히 읽기 전용 모드로
|
|
11
|
+
// 잘못 보내면 사용자는 "왜 파일을 안 고쳐?" 하고 막힌다.
|
|
12
|
+
// 2) 왜 그렇게 봤는지 남긴다. 화면에 그 이유가 같이 뜬다.
|
|
13
|
+
//
|
|
14
|
+
// 사용자가 직접 모드를 고르면 이건 아예 안 돈다. 사람이 고른 것을 뒤집지 않는다.
|
|
15
|
+
|
|
16
|
+
// 낱말과 점수. 큰 점수일수록 그 모드라는 증거가 강하다는 뜻이다.
|
|
17
|
+
//
|
|
18
|
+
// 한국어를 먼저 두되 영어도 같이 본다 — 오류 메시지를 그대로 붙여넣는 일이 흔하다.
|
|
19
|
+
const 표 = {
|
|
20
|
+
debug: [
|
|
21
|
+
// 고장은 신호가 뚜렷하다. 그리고 디버그는 파일을 고칠 수 있으므로
|
|
22
|
+
// 잘못 골라도 막히지 않는다. 그래서 세게 준다.
|
|
23
|
+
[/왜\s*안\s*(되|돼|됨)/, 5], [/안\s*(되는|돼는|됩니다|되네|돼네|돼요|되요)/, 4],
|
|
24
|
+
[/작동\s*안/, 4], [/동작\s*안/, 4],
|
|
25
|
+
[/에러|오류|예외|익셉션/, 4], [/버그/, 4],
|
|
26
|
+
[/실패/, 3], [/터(져|졌|진다)/, 3], [/죽(어|었|는다|네)/, 3],
|
|
27
|
+
[/멈(춰|췄|춘다|추)/, 3], [/먹통/, 3], [/이상(해|하다|한데)/, 3],
|
|
28
|
+
[/재현/, 3], [/스택\s*트레이스|stack\s*trace|traceback/i, 4],
|
|
29
|
+
// 'TypeError' 처럼 앞에 글자가 붙어 오는 일이 훨씬 흔하다.
|
|
30
|
+
// \berror\b 로 잡으면 정작 붙여넣은 진짜 오류 메시지를 놓친다.
|
|
31
|
+
[/error/i, 3], [/\bexception\b|\bcrash|\bfail(s|ed|ing)?\b|\bbroken\b/i, 3],
|
|
32
|
+
[/고장/, 3], [/왜\s*이(래|러|렇게\s*나)/, 2],
|
|
33
|
+
[/왜\s*(못|안)/, 3], [/뭐\s*때문(이|에)/, 3],
|
|
34
|
+
],
|
|
35
|
+
|
|
36
|
+
plan: [
|
|
37
|
+
// '먼저' 와 붙어야 계획이다. 그냥 '어떻게' 는 설명일 수도 있다.
|
|
38
|
+
[/계획/, 5], [/플랜|\bplan\b/i, 5],
|
|
39
|
+
[/먼저\s*(정리|생각|살펴|파악|검토|짜|세)/, 4],
|
|
40
|
+
[/어떤\s*순서/, 4], [/순서(를|대로)?\s*(정|짜|잡)/, 3],
|
|
41
|
+
[/단계(를|로)?\s*(나눠|쪼개|정리)/, 3],
|
|
42
|
+
[/로드맵|\broadmap\b/i, 5],
|
|
43
|
+
[/방향(을|만)?\s*(잡|정)/, 3],
|
|
44
|
+
[/하기\s*전에\s*(정리|살펴|검토)/, 3],
|
|
45
|
+
[/무엇부터|뭐부터/, 3],
|
|
46
|
+
],
|
|
47
|
+
|
|
48
|
+
architect: [
|
|
49
|
+
[/설계/, 5], [/아키텍(처|쳐)|architecture/i, 5],
|
|
50
|
+
[/구조(를|가|는)?\s*(어떻게|바꾸|잡|짜|개선)/, 5],
|
|
51
|
+
[/어떻게\s*(나누|쪼개|짜)/, 5],
|
|
52
|
+
[/모듈(로|을)?\s*(나누|분리)/, 3],
|
|
53
|
+
[/의존(성|\s*방향)/, 3], [/레이어|계층/, 3],
|
|
54
|
+
[/어느\s*(쪽|것)이\s*(나을|좋을)/, 3], [/선택지|대안/, 3],
|
|
55
|
+
[/패턴(을|이)?\s*(쓰|적용|고르)/, 2],
|
|
56
|
+
[/\bdesign\b|\bstructure\b|\brefactor(ing)?\s*(방향|plan)/i, 3],
|
|
57
|
+
],
|
|
58
|
+
|
|
59
|
+
ask: [
|
|
60
|
+
// 설명은 아무것도 안 바꾸는 모드라 잘못 고르면 답답해진다. 신호를 엄격히 본다.
|
|
61
|
+
[/뭐(야|예요|입니까|지)\s*\??$/m, 5], [/무엇(인가|입니까|이야)/, 5],
|
|
62
|
+
[/설명(해|좀|을)/, 5], [/알려\s*(줘|주세요|줄래)/, 3],
|
|
63
|
+
[/어떻게\s*(동작|작동|돌아가)/, 5],
|
|
64
|
+
[/무슨\s*(뜻|의미)|의미(가|는)\s*뭐/, 5],
|
|
65
|
+
[/차이(가|는|점)/, 3],
|
|
66
|
+
[/궁금/, 3], [/왜\s*이렇게\s*(만들|짰|되어)/, 3],
|
|
67
|
+
[/\bwhat\s+is\b|\bexplain\b|\bhow\s+does\b/i, 4],
|
|
68
|
+
[/읽어(만)?\s*보고\s*(설명|알려)/, 4],
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
orchestrator: [
|
|
72
|
+
// 총괄의 신호는 낱말 하나가 아니라 '넓이' 다. 그래서 하나하나는 약하게 두고
|
|
73
|
+
// 여럿이 겹칠 때 문턱을 넘게 한다 — 실제로 큰 일에는 이런 말이 같이 나온다.
|
|
74
|
+
// "전체 로그 형식을 다 통일해줘" 는 '전체' 와 '통일' 이 겹쳐야 총괄이 된다.
|
|
75
|
+
[/전부|모두|싹\s*다|통째로/, 3],
|
|
76
|
+
[/전체/, 3],
|
|
77
|
+
[/하나씩/, 3],
|
|
78
|
+
[/끝까지/, 3], [/마무리까지/, 3],
|
|
79
|
+
[/여러\s*(개|군데|파일|단계|곳)/, 3],
|
|
80
|
+
[/순서대로/, 3],
|
|
81
|
+
[/통일(해|시켜|좀)|일괄/, 2],
|
|
82
|
+
[/흩어진|여기저기/, 2],
|
|
83
|
+
[/\bend[- ]?to[- ]?end\b/i, 3],
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
code: [
|
|
87
|
+
[/고쳐|고쳐줘|수정(해|좀)/, 4],
|
|
88
|
+
[/만들어|추가(해|좀)|넣어(줘|주세요)/, 4],
|
|
89
|
+
[/구현(해|좀)/, 5],
|
|
90
|
+
[/바꿔(줘|주세요)?|변경(해|좀)/, 3],
|
|
91
|
+
[/지워(줘)?|삭제(해|좀)/, 3],
|
|
92
|
+
[/이름\s*바꿔|리네임|rename/i, 3],
|
|
93
|
+
[/옮겨(줘)?/, 2],
|
|
94
|
+
[/리팩터(링)?(해|좀)/, 3],
|
|
95
|
+
[/\bimplement\b|\badd\b|\bfix\b|\bwrite\b/i, 2],
|
|
96
|
+
[/써(줘|주세요)/, 3],
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// 이만큼은 되어야 바꾼다. 못 미치면 종합 모드 그대로 둔다.
|
|
101
|
+
//
|
|
102
|
+
// 읽기 전용 모드는 문턱을 높인다. 잘못 보내면 파일을 못 고쳐서 사용자가 막힌다.
|
|
103
|
+
// 파일을 고칠 수 있는 모드는 잘못 골라도 일이 막히지는 않으므로 낮게 둔다.
|
|
104
|
+
const 문턱 = {
|
|
105
|
+
debug: 3,
|
|
106
|
+
code: 3,
|
|
107
|
+
orchestrator: 4,
|
|
108
|
+
plan: 5,
|
|
109
|
+
architect: 5,
|
|
110
|
+
ask: 5,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 이 한마디가 무슨 일인지 고른다.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} text 사용자가 친 말
|
|
117
|
+
* @returns {{ mode: string|null, score: number, why: string, 점수들: object }}
|
|
118
|
+
* mode 가 null 이면 '모르겠다' 는 뜻이다 — 종합 모드에 그대로 둔다.
|
|
119
|
+
*/
|
|
120
|
+
export function route(text) {
|
|
121
|
+
const s = String(text ?? '');
|
|
122
|
+
const 점수들 = {};
|
|
123
|
+
const 근거 = {};
|
|
124
|
+
|
|
125
|
+
for (const [mode, 규칙들] of Object.entries(표)) {
|
|
126
|
+
let 합 = 0;
|
|
127
|
+
const 걸린것 = [];
|
|
128
|
+
for (const [re, 점] of 규칙들) {
|
|
129
|
+
const m = s.match(re);
|
|
130
|
+
if (!m) continue;
|
|
131
|
+
합 += 점;
|
|
132
|
+
걸린것.push(m[0].trim());
|
|
133
|
+
}
|
|
134
|
+
점수들[mode] = 합;
|
|
135
|
+
근거[mode] = 걸린것;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const 순위 = Object.entries(점수들).sort((a, b) => b[1] - a[1]);
|
|
139
|
+
const [으뜸, 점수] = 순위[0] ?? [null, 0];
|
|
140
|
+
if (!으뜸 || 점수 < (문턱[으뜸] ?? 4)) {
|
|
141
|
+
return { mode: null, score: 점수, why: '무슨 일인지 뚜렷하지 않음', 점수들 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 1·2등이 붙으면 고르지 않는다. 반반인 것을 억지로 정하면 절반은 틀린다.
|
|
145
|
+
const 둘째 = 순위[1]?.[1] ?? 0;
|
|
146
|
+
if (점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
|
|
147
|
+
return { mode: null, score: 점수, why: `${으뜸} 과 ${순위[1][0]} 가 비슷함`, 점수들 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
mode: 으뜸,
|
|
152
|
+
score: 점수,
|
|
153
|
+
why: 근거[으뜸].slice(0, 2).map((x) => `'${x}'`).join(', '),
|
|
154
|
+
점수들,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// 잘려 온 도구 호출에서 **건질 수 있는 만큼 건진다.**
|
|
2
|
+
//
|
|
3
|
+
// 무슨 일이 있었나:
|
|
4
|
+
// 대시보드 HTML 한 장을 만들라고 시켰다. 모델이 Write 를 부르는데 인자 JSON 이
|
|
5
|
+
// 출력 상한에서 잘렸다. 잘린 JSON 은 읽을 수 없으니 통째로 버렸다. 모델은
|
|
6
|
+
// 똑같이 다시 시도했고, 또 같은 자리에서 잘렸다. 13번 만에 컨텍스트가 다 찼다.
|
|
7
|
+
// 71초를 쓰고 **파일은 한 글자도 안 생겼다.**
|
|
8
|
+
//
|
|
9
|
+
// 그런데 잘린 그 JSON 안에는 이미 받아 놓은 250줄이 들어 있었다. 경로도 온전했다.
|
|
10
|
+
// 버릴 이유가 없다. 250줄을 쓰고 "여기까지 썼으니 이어 붙여라" 라고 하면
|
|
11
|
+
// 그 다음 호출은 나머지만 보내면 된다 — 잘릴 일이 없다.
|
|
12
|
+
//
|
|
13
|
+
// 왜 JSON.parse 로는 안 되나:
|
|
14
|
+
// JSON.parse 는 전부 맞거나 전부 틀리거나 둘 중 하나다. 마지막 따옴표가 없다는
|
|
15
|
+
// 이유로 앞의 250줄까지 같이 버린다. 그래서 앞에서부터 읽어 나가다가 더 못
|
|
16
|
+
// 읽는 자리에서 멈추는 방식으로 따로 만든다.
|
|
17
|
+
//
|
|
18
|
+
// 조심할 것:
|
|
19
|
+
// 여기서 건진 값은 **완전하지 않다.** 마지막 값은 잘려 있다. 그래서 이 결과를
|
|
20
|
+
// 쓰는 쪽은 어느 값이 잘렸는지(truncatedKey) 반드시 보고 판단해야 한다.
|
|
21
|
+
// Edit 의 old_string 을 반쪽만 건져 쓰면 엉뚱한 자리를 고친다. loop.js 가
|
|
22
|
+
// Write·Append 만 살리는 이유다.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 잘린 JSON 에서 읽히는 데까지 읽는다.
|
|
26
|
+
*
|
|
27
|
+
* @returns {{args: object, keys: string[], truncatedKey: string|null, complete: boolean}}
|
|
28
|
+
* args 온전히 읽은 값들 (+ 잘린 값 하나는 받은 데까지)
|
|
29
|
+
* truncatedKey 잘린 값의 이름. null 이면 잘린 값 없이 그냥 끝난 것
|
|
30
|
+
* complete 닫는 괄호까지 제대로 나왔나
|
|
31
|
+
*/
|
|
32
|
+
export function partialParse(raw) {
|
|
33
|
+
const s = String(raw ?? '');
|
|
34
|
+
const 빈것 = { args: {}, keys: [], truncatedKey: null, complete: false };
|
|
35
|
+
let i = 0;
|
|
36
|
+
|
|
37
|
+
const 공백건너뛰기 = () => { while (i < s.length && /\s/.test(s[i])) i++; };
|
|
38
|
+
|
|
39
|
+
공백건너뛰기();
|
|
40
|
+
if (s[i] !== '{') return 빈것;
|
|
41
|
+
i++;
|
|
42
|
+
|
|
43
|
+
const args = {};
|
|
44
|
+
const keys = [];
|
|
45
|
+
|
|
46
|
+
for (;;) {
|
|
47
|
+
공백건너뛰기();
|
|
48
|
+
if (i >= s.length) return { args, keys, truncatedKey: null, complete: false };
|
|
49
|
+
if (s[i] === '}') return { args, keys, truncatedKey: null, complete: true };
|
|
50
|
+
if (s[i] === ',') { i++; continue; }
|
|
51
|
+
if (s[i] !== '"') return { args, keys, truncatedKey: null, complete: false };
|
|
52
|
+
|
|
53
|
+
const k = 글읽기(s, i);
|
|
54
|
+
if (!k.complete) return { args, keys, truncatedKey: null, complete: false }; // 이름이 잘렸으면 값도 없다
|
|
55
|
+
i = k.end;
|
|
56
|
+
공백건너뛰기();
|
|
57
|
+
if (s[i] !== ':') return { args, keys, truncatedKey: null, complete: false };
|
|
58
|
+
i++;
|
|
59
|
+
공백건너뛰기();
|
|
60
|
+
if (i >= s.length) return { args, keys, truncatedKey: null, complete: false };
|
|
61
|
+
|
|
62
|
+
if (s[i] === '"') {
|
|
63
|
+
const v = 글읽기(s, i);
|
|
64
|
+
args[k.text] = v.text;
|
|
65
|
+
keys.push(k.text);
|
|
66
|
+
if (!v.complete) return { args, keys, truncatedKey: k.text, complete: false };
|
|
67
|
+
i = v.end;
|
|
68
|
+
} else if (s[i] === '{' || s[i] === '[') {
|
|
69
|
+
const end = 짝찾기(s, i);
|
|
70
|
+
// 안 닫힌 덩어리는 통째로 버린다. 반쪽 객체는 건져도 쓸 데가 없고,
|
|
71
|
+
// 잘못 건지면 있지도 않은 값이 있는 것처럼 보인다.
|
|
72
|
+
if (end < 0) return { args, keys, truncatedKey: null, complete: false };
|
|
73
|
+
try { args[k.text] = JSON.parse(s.slice(i, end + 1)); keys.push(k.text); } catch { /* 못 읽으면 버린다 */ }
|
|
74
|
+
i = end + 1;
|
|
75
|
+
} else {
|
|
76
|
+
// 숫자·true·false·null
|
|
77
|
+
let j = i;
|
|
78
|
+
while (j < s.length && !',}'.includes(s[j])) j++;
|
|
79
|
+
const 조각 = s.slice(i, j).trim();
|
|
80
|
+
if (j >= s.length) return { args, keys, truncatedKey: null, complete: false }; // 값이 잘렸다
|
|
81
|
+
try { args[k.text] = JSON.parse(조각); keys.push(k.text); } catch { /* 버린다 */ }
|
|
82
|
+
i = j;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* JSON 문자열 하나를 읽는다. 끝까지 못 읽어도 읽은 데까지 돌려준다.
|
|
89
|
+
*
|
|
90
|
+
* 이 함수가 이 파일의 핵심이다. \n · \" · \\ 는 물론 \uXXXX 도 풀어야 한다 —
|
|
91
|
+
* 한글이 그 모양으로 오는 게이트웨이가 있다. 실제로 사용자 게이트웨이가 그랬다.
|
|
92
|
+
* 마지막에 escape 가 반토막 나 있으면(\ 하나만, 또는 \u12) 그건 버린다.
|
|
93
|
+
*/
|
|
94
|
+
function 글읽기(s, start) {
|
|
95
|
+
let i = start + 1; // 여는 따옴표 다음
|
|
96
|
+
let out = '';
|
|
97
|
+
while (i < s.length) {
|
|
98
|
+
const ch = s[i];
|
|
99
|
+
if (ch === '"') return { text: out, end: i + 1, complete: true };
|
|
100
|
+
if (ch !== '\\') { out += ch; i++; continue; }
|
|
101
|
+
|
|
102
|
+
const n = s[i + 1];
|
|
103
|
+
if (n === undefined) break; // \ 하나만 남고 끝났다 — 버린다
|
|
104
|
+
if (n === 'u') {
|
|
105
|
+
const hex = s.slice(i + 2, i + 6);
|
|
106
|
+
if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break; // \u12 에서 끊겼다
|
|
107
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
108
|
+
i += 6;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const 표 = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
|
|
112
|
+
if (n in 표) { out += 표[n]; i += 2; continue; }
|
|
113
|
+
out += n; i += 2; // 모르는 escape 는 글자 그대로
|
|
114
|
+
}
|
|
115
|
+
return { text: out, end: s.length, complete: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 여는 괄호에 맞는 닫는 괄호 자리. 없으면 -1. 따옴표 안은 안 센다. */
|
|
119
|
+
function 짝찾기(s, start) {
|
|
120
|
+
const 열림 = s[start];
|
|
121
|
+
const 닫힘 = 열림 === '{' ? '}' : ']';
|
|
122
|
+
let depth = 0;
|
|
123
|
+
let 글속 = false;
|
|
124
|
+
for (let i = start; i < s.length; i++) {
|
|
125
|
+
const ch = s[i];
|
|
126
|
+
if (글속) {
|
|
127
|
+
if (ch === '\\') { i++; continue; }
|
|
128
|
+
if (ch === '"') 글속 = false;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === '"') { 글속 = true; continue; }
|
|
132
|
+
if (ch === 열림) depth++;
|
|
133
|
+
else if (ch === 닫힘) { depth--; if (depth === 0) return i; }
|
|
134
|
+
}
|
|
135
|
+
return -1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 잘린 Write 호출을 '지금 바로 쓸 수 있는 것' 으로 바꾼다.
|
|
140
|
+
*
|
|
141
|
+
* 마지막 줄은 버린다. 반쪽 줄(`<div class="ca`)로 끝나 있으면 이어 붙이는 쪽이
|
|
142
|
+
* 그 반쪽에 정확히 맞춰 이어야 하는데, 약한 모델은 그걸 못 한다. 온전한 줄까지만
|
|
143
|
+
* 남기고 "그 다음 줄부터 쓰라" 고 하면 훨씬 쉬운 문제가 된다.
|
|
144
|
+
*
|
|
145
|
+
* @returns {{path: string, content: string, lines: number, lastLine: string}|null}
|
|
146
|
+
*/
|
|
147
|
+
export function 살린쓰기(raw) {
|
|
148
|
+
const r = partialParse(raw);
|
|
149
|
+
const path = r.args.file_path ?? r.args.path ?? r.args.filePath;
|
|
150
|
+
const body = r.args.content;
|
|
151
|
+
if (typeof path !== 'string' || !path.trim()) return null;
|
|
152
|
+
if (typeof body !== 'string' || !body) return null;
|
|
153
|
+
// 경로 자체가 잘렸으면 엉뚱한 자리에 쓴다. 그건 안 건진다.
|
|
154
|
+
if (r.truncatedKey === 'file_path' || r.truncatedKey === 'path') return null;
|
|
155
|
+
|
|
156
|
+
// 내용이 온전히 다 왔는데도 JSON 이 깨진 것이라면(뒤쪽 다른 인자에서 잘림)
|
|
157
|
+
// 마지막 줄을 버릴 이유가 없다.
|
|
158
|
+
const 내용잘림 = r.truncatedKey === 'content';
|
|
159
|
+
let content = body;
|
|
160
|
+
if (내용잘림) {
|
|
161
|
+
const 끝 = body.lastIndexOf('\n');
|
|
162
|
+
// 줄바꿈이 한 번도 없으면 **아무것도 안 건진다.**
|
|
163
|
+
//
|
|
164
|
+
// 이어 붙이라고 하려면 '어디까지 썼는지' 를 줄 단위로 말해 줄 수 있어야 한다.
|
|
165
|
+
// 온전한 줄이 하나도 없으면 반쪽 줄에 정확히 이어 붙이라고 부탁하는 셈인데,
|
|
166
|
+
// 약한 모델은 그걸 못 한다 — 앞을 다시 쓰거나 이음매가 어긋난다.
|
|
167
|
+
// 그럴 바에는 안 쓰고 '나눠 보내라' 고 하는 편이 낫다.
|
|
168
|
+
if (끝 < 0) return null;
|
|
169
|
+
content = body.slice(0, 끝 + 1);
|
|
170
|
+
}
|
|
171
|
+
if (!content) return null;
|
|
172
|
+
|
|
173
|
+
const lines = content.split('\n');
|
|
174
|
+
const 마지막 = [...lines].reverse().find((l) => l.trim()) ?? '';
|
|
175
|
+
return {
|
|
176
|
+
path: path.trim(),
|
|
177
|
+
content,
|
|
178
|
+
lines: content.endsWith('\n') ? lines.length - 1 : lines.length,
|
|
179
|
+
lastLine: 마지막.trim().slice(0, 60),
|
|
180
|
+
cut: 내용잘림,
|
|
181
|
+
};
|
|
182
|
+
}
|