deel-local-cli 0.9.0 → 1.0.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 +470 -27
- package/README.md +481 -31
- package/bin/deel.js +234 -186
- 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 +11 -3
- package/src/agent/recall.js +209 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +140 -10
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +133 -61
- package/src/backend/detect.js +10 -1
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/commands.js +493 -53
- package/src/oneshot.js +327 -0
- package/src/repl.js +787 -479
- package/src/report.js +19 -5
- 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 +13 -1
- package/src/ui/diff.js +255 -0
- package/src/ui/inputbox.js +184 -0
- package/src/ui/level.js +3 -1
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +214 -0
- package/src/ui/status.js +81 -17
- package/src/ui/wrap.js +68 -0
- package/src/version.js +28 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 기억 — 대화가 끝나도 남는 것.
|
|
3
|
+
*
|
|
4
|
+
* 왜 필요한가:
|
|
5
|
+
* 대화는 켤 때마다 처음부터다. 그래서 매번 같은 것을 설명하게 된다 —
|
|
6
|
+
* "우리 문서는 CP949 다", "검증 포트로 7080 은 쓰지 마라", "빌드는 npm run b".
|
|
7
|
+
* /compact 로 접히면 그마저 사라진다. 사람은 두 번째부터 짜증이 나고,
|
|
8
|
+
* 세 번째부터는 그냥 안 쓴다.
|
|
9
|
+
*
|
|
10
|
+
* 지난 대화 찾기(recall.js)와는 다른 물건이다. 그쪽은 **찾아야** 나오고,
|
|
11
|
+
* 이쪽은 **처음부터 들어가 있다.** 매번 찾으라고 할 수 없는 것 —
|
|
12
|
+
* 지켜야 하는 규칙, 되풀이하면 안 되는 실수 — 이 여기 온다.
|
|
13
|
+
*
|
|
14
|
+
* 어디에 두나:
|
|
15
|
+
* .deel/memory.md — 사람이 열어 고칠 수 있는 글이다. 데이터베이스가 아니다.
|
|
16
|
+
* 이게 중요하다. 모델이 잘못 적은 것을 사람이 지울 수 있어야 한다.
|
|
17
|
+
* 틀린 기억은 없느니만 못하다 — 매 요청에 실려 계속 틀리게 만든다.
|
|
18
|
+
*
|
|
19
|
+
* 왜 상한을 두나:
|
|
20
|
+
* 기억은 **매 요청마다** 통째로 나간다. 백 줄이면 백 줄이 매번 나간다.
|
|
21
|
+
* 그래서 자리를 정해 두고, 넘으면 넘었다고 말한다.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
// 기억 전체가 차지할 수 있는 최대 글자. 대략 2,000 토큰쯤이다.
|
|
27
|
+
export const 기억최대 = 6000;
|
|
28
|
+
// 한 줄이 이보다 길면 자른다. 모델이 파일을 통째로 기억에 넣으려 드는 일이 있다.
|
|
29
|
+
export const 한줄최대 = 400;
|
|
30
|
+
// 줄 수 상한. 자리가 남아도 이보다 많으면 사람이 못 읽는다.
|
|
31
|
+
export const 줄최대 = 60;
|
|
32
|
+
|
|
33
|
+
export const 자리 = (root) => join(root, '.deel', 'memory.md');
|
|
34
|
+
|
|
35
|
+
const 머리말 = `# 기억
|
|
36
|
+
|
|
37
|
+
이 파일은 deel 이 대화 사이에 들고 다니는 메모입니다.
|
|
38
|
+
매 요청마다 모델에게 통째로 실려 나갑니다 — 그러니 짧게, 오래 갈 것만 적습니다.
|
|
39
|
+
|
|
40
|
+
직접 고치셔도 됩니다. 틀린 줄은 지우세요. 지우면 그걸로 끝입니다.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/** 파일에서 기억 줄을 읽어 온다. 머리말과 빈 줄은 뺀다. */
|
|
44
|
+
export function 읽기(root) {
|
|
45
|
+
const p = 자리(root);
|
|
46
|
+
if (!existsSync(p)) return { 줄들: [], 자리: p, 있음: false };
|
|
47
|
+
let 글;
|
|
48
|
+
try { 글 = readFileSync(p, 'utf8'); } catch { return { 줄들: [], 자리: p, 있음: false }; }
|
|
49
|
+
const 줄들 = [];
|
|
50
|
+
for (const raw of 글.split(/\r?\n/)) {
|
|
51
|
+
const l = raw.trim();
|
|
52
|
+
if (!l) continue;
|
|
53
|
+
if (l.startsWith('#')) continue; // 머리말
|
|
54
|
+
if (l.startsWith('이 파일은') || l.startsWith('매 요청마다')) continue;
|
|
55
|
+
if (l.startsWith('직접 고치셔도') || l.startsWith('직접 고치')) continue;
|
|
56
|
+
줄들.push(l.replace(/^[-*]\s*/, '')); // 목록 표시는 떼고 담는다
|
|
57
|
+
}
|
|
58
|
+
return { 줄들, 자리: p, 있음: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 줄들을 파일로 되돌려 쓴다. */
|
|
62
|
+
export function 쓰기(root, 줄들) {
|
|
63
|
+
const p = 자리(root);
|
|
64
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
65
|
+
const 몸 = 줄들.map((l) => `- ${l}`).join('\n');
|
|
66
|
+
writeFileSync(p, `${머리말}\n${몸}\n`, 'utf8');
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 한 줄을 더한다.
|
|
72
|
+
*
|
|
73
|
+
* 이미 있는 것과 거의 같으면 안 더한다. 안 그러면 같은 말이 조금씩 다른 꼴로
|
|
74
|
+
* 스무 줄 쌓인다 — 모델은 자기가 방금 적은 것을 기억 못 하기 때문이다.
|
|
75
|
+
*
|
|
76
|
+
* @returns {{ok: boolean, why?: string, 줄: string, 줄수: number, 넘침?: boolean}}
|
|
77
|
+
*/
|
|
78
|
+
export function 더하기(root, 글) {
|
|
79
|
+
const 줄 = 다듬기(글);
|
|
80
|
+
if (!줄) return { ok: false, why: '적을 내용이 비었습니다', 줄: '', 줄수: 0 };
|
|
81
|
+
|
|
82
|
+
const { 줄들 } = 읽기(root);
|
|
83
|
+
if (줄들.some((x) => 같은말(x, 줄))) {
|
|
84
|
+
return { ok: false, why: '이미 기억하고 있습니다', 줄, 줄수: 줄들.length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const 새것 = [...줄들, 줄];
|
|
88
|
+
// 자리가 넘치면 **오래된 것부터** 뺀다. 최근에 정한 것이 대개 맞다.
|
|
89
|
+
let 넘침 = false;
|
|
90
|
+
while (새것.length > 줄최대 || 새것.join('\n').length > 기억최대) {
|
|
91
|
+
if (새것.length <= 1) break;
|
|
92
|
+
새것.shift();
|
|
93
|
+
넘침 = true;
|
|
94
|
+
}
|
|
95
|
+
쓰기(root, 새것);
|
|
96
|
+
return { ok: true, 줄, 줄수: 새것.length, 넘침 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 번호로 지운다(1부터). 사람이 /memory 화면을 보고 고르는 자리다. */
|
|
100
|
+
export function 지우기(root, 번호) {
|
|
101
|
+
const { 줄들 } = 읽기(root);
|
|
102
|
+
const i = Number(번호) - 1;
|
|
103
|
+
if (!Number.isInteger(i) || i < 0 || i >= 줄들.length) return { ok: false, why: '그런 번호가 없습니다' };
|
|
104
|
+
const 뺀것 = 줄들[i];
|
|
105
|
+
const 남은것 = 줄들.filter((_, n) => n !== i);
|
|
106
|
+
쓰기(root, 남은것);
|
|
107
|
+
return { ok: true, 뺀것, 줄수: 남은것.length };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 통째로 비운다. */
|
|
111
|
+
export function 비우기(root) {
|
|
112
|
+
쓰기(root, []);
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 한 줄로 다듬는다 — 줄바꿈을 없애고 길이를 자른다. */
|
|
117
|
+
function 다듬기(글) {
|
|
118
|
+
let s = String(글 ?? '').replace(/\s+/g, ' ').trim();
|
|
119
|
+
s = s.replace(/^[-*]\s*/, '');
|
|
120
|
+
if (s.length > 한줄최대) s = s.slice(0, 한줄최대) + '…';
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 두 줄이 사실상 같은 말인가.
|
|
126
|
+
*
|
|
127
|
+
* 글자 그대로 비교하면 "CP949 를 쓴다" 와 "CP949를 쓴다" 가 다른 줄이 된다.
|
|
128
|
+
* 공백·조사·문장부호를 털고 견준다. 완벽할 필요는 없다 —
|
|
129
|
+
* 여기서 하려는 것은 '똑같은 말이 쌓이는 것' 을 막는 것뿐이다.
|
|
130
|
+
*/
|
|
131
|
+
function 같은말(a, b) {
|
|
132
|
+
const 털기 = (s) => String(s).toLowerCase().replace(/[\s.,!?~·'"()[\]]/g, '');
|
|
133
|
+
const x = 털기(a);
|
|
134
|
+
const y = 털기(b);
|
|
135
|
+
if (x === y) return true;
|
|
136
|
+
// 한쪽이 다른 쪽을 통째로 품고 있고 길이 차가 크지 않으면 같은 말로 본다.
|
|
137
|
+
const [짧, 긴] = x.length <= y.length ? [x, y] : [y, x];
|
|
138
|
+
return 짧.length >= 8 && 긴.includes(짧) && 긴.length - 짧.length <= 짧.length * 0.5;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 시스템 프롬프트에 넣을 토막.
|
|
143
|
+
*
|
|
144
|
+
* 없으면 빈 글을 돌려준다 — "기억: (없음)" 같은 줄을 넣으면 그 자체가
|
|
145
|
+
* 매 요청마다 나가는 쓰레기가 된다.
|
|
146
|
+
*/
|
|
147
|
+
export function 프롬프트토막(root) {
|
|
148
|
+
const { 줄들 } = 읽기(root);
|
|
149
|
+
if (!줄들.length) return '';
|
|
150
|
+
return '\n--- 기억 (지난 대화에서 정한 것. 사용자가 다시 말하지 않아도 지킨다) ---\n'
|
|
151
|
+
+ 줄들.map((l) => `- ${l}`).join('\n');
|
|
152
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @파일 지목 — 말 속에 @경로 를 쓰면 그 파일을 바로 붙여 보낸다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가:
|
|
4
|
+
// 지금은 파일 하나 보여주려면 모델이 Read 도구를 스스로 불러야 한다. 왕복이
|
|
5
|
+
// 한 번 더 든다. 로컬 모델은 도구 호출이 약해서 그 한 번이 자주 헛돈다 —
|
|
6
|
+
// 엉뚱한 경로를 부르거나, 아예 안 부르고 지어낸다. 사람은 이미 어느 파일인지
|
|
7
|
+
// 알고 있는데 모델더러 찾아보라고 시키는 셈이다.
|
|
8
|
+
//
|
|
9
|
+
// 여기서 어려운 것은 붙이는 일이 아니라 '아닌 것을 파일로 오해하지 않기' 다.
|
|
10
|
+
// @ 로 시작하는 것은 세상에 널렸다 — 이메일 주소, CSS 의 @media, 파이썬 장식자,
|
|
11
|
+
// npm 의 @scope/pkg, 사람 아이디. 잘못 붙이면 엉뚱한 글이 대화에 섞여 들어가고,
|
|
12
|
+
// 사람은 자기가 안 보낸 것이 왜 거기 있는지 모른다.
|
|
13
|
+
//
|
|
14
|
+
// 그래서 규칙을 하나로 뒀다: **실제로 있는 경로일 때만 붙인다.**
|
|
15
|
+
// 없으면 아무 말 없이 글자 그대로 둔다. 지목이 아니었을 테니 조용한 편이 맞다.
|
|
16
|
+
import { existsSync, statSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { readTextFull } from '../tools/fsutil.js';
|
|
18
|
+
import { estimateTokens } from './session.js';
|
|
19
|
+
|
|
20
|
+
// 붙일 수 있는 최대 토큰. 부르는 쪽에서 컨텍스트에 맞춰 넘겨준다.
|
|
21
|
+
const 기본예산 = 6000;
|
|
22
|
+
const 폴더최대 = 60; // 폴더를 지목했을 때 늘어놓을 항목 수
|
|
23
|
+
const 뒤에붙는것 = /[,.?!;:)\]}"'」』]+$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 글에서 @지목을 찾는다. 붙일지 말지는 여기서 안 정한다 — 자리만 찾는다.
|
|
27
|
+
*
|
|
28
|
+
* @returns {Array<{path:string, start:number, end:number, raw:string}>}
|
|
29
|
+
*/
|
|
30
|
+
export function findMentions(text) {
|
|
31
|
+
const s = String(text ?? '');
|
|
32
|
+
const out = [];
|
|
33
|
+
for (let i = 0; i < s.length; i++) {
|
|
34
|
+
if (s[i] !== '@') continue;
|
|
35
|
+
// 앞이 글자면 지목이 아니다 — hong@example.com 의 @ 가 여기서 걸러진다.
|
|
36
|
+
const 앞 = i > 0 ? s[i - 1] : ' ';
|
|
37
|
+
if (!/[\s(\[{'"]/.test(앞)) continue;
|
|
38
|
+
|
|
39
|
+
// @"띄어쓰기 든 이름.txt" — 따옴표로 묶으면 통째로 하나다.
|
|
40
|
+
if (s[i + 1] === '"' || s[i + 1] === "'") {
|
|
41
|
+
const 따옴 = s[i + 1];
|
|
42
|
+
const 끝 = s.indexOf(따옴, i + 2);
|
|
43
|
+
if (끝 > i + 2) {
|
|
44
|
+
out.push({ path: s.slice(i + 2, 끝), start: i, end: 끝 + 1, raw: s.slice(i, 끝 + 1) });
|
|
45
|
+
i = 끝;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let j = i + 1;
|
|
51
|
+
while (j < s.length && !/[\s"'`]/.test(s[j])) j++;
|
|
52
|
+
if (j === i + 1) continue; // @ 뒤가 비었다
|
|
53
|
+
const raw = s.slice(i, j);
|
|
54
|
+
out.push({ path: s.slice(i + 1, j), start: i, end: j, raw });
|
|
55
|
+
i = j - 1;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 이 지목이 가리키는 실제 자리. 못 찾거나 범위 밖이면 왜인지 같이 준다. */
|
|
61
|
+
function 찾아보기(m, scope) {
|
|
62
|
+
const 후보 = [m.path];
|
|
63
|
+
// 뒤에 붙은 문장부호를 떼고도 한 번 본다 — "@src/a.js, 이것" 처럼 쓴다.
|
|
64
|
+
const 뗀것 = m.path.replace(뒤에붙는것, '');
|
|
65
|
+
if (뗀것 && 뗀것 !== m.path) 후보.push(뗀것);
|
|
66
|
+
|
|
67
|
+
for (const p of 후보) {
|
|
68
|
+
if (!p) continue;
|
|
69
|
+
let abs;
|
|
70
|
+
try { abs = scope.resolve(p); }
|
|
71
|
+
catch (err) { return { why: 'blocked', note: err.message, path: p }; }
|
|
72
|
+
if (existsSync(abs)) return { abs, path: p };
|
|
73
|
+
}
|
|
74
|
+
return { why: 'missing', path: m.path };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function 폴더내용(abs, show) {
|
|
78
|
+
let 목록 = [];
|
|
79
|
+
try { 목록 = readdirSync(abs, { withFileTypes: true }); }
|
|
80
|
+
catch (err) { return `(폴더를 못 읽었습니다: ${err.message})`; }
|
|
81
|
+
const 줄들 = 목록.slice(0, 폴더최대).map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
82
|
+
const 더 = 목록.length > 폴더최대 ? `\n… 전체 ${목록.length}개 중 ${폴더최대}개까지` : '';
|
|
83
|
+
return `${show} 는 폴더입니다. 안에 든 것:\n${줄들.join('\n')}${더}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @지목을 실제 내용으로 바꿔 붙인다.
|
|
88
|
+
*
|
|
89
|
+
* 글 자체는 안 건드린다 — 사람이 쓴 말은 그대로 두고, 뒤에 파일을 덧붙인다.
|
|
90
|
+
* 지목한 자리를 내용으로 갈아치우면 "@src/a.js 이거 왜 느려?" 가 코드 덩어리
|
|
91
|
+
* 한복판에서 끝나는 문장이 되어 버려서, 모델이 무엇을 물었는지 놓친다.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} text
|
|
94
|
+
* @param {{scope?:object, budget?:number, seen?:Set<string>, onRead?:Function}} opts
|
|
95
|
+
* seen 을 주면 통째로 붙은 파일을 '읽은 것' 으로 적어 둔다 — 그래야 Edit 이
|
|
96
|
+
* 'Read 부터 하라' 며 되돌려 보내지 않는다. 잘린 파일은 안 적는다.
|
|
97
|
+
* 안 본 데를 본 것으로 치면 그 자리를 그냥 고쳐 버린다.
|
|
98
|
+
*/
|
|
99
|
+
export function expand(text, { scope = null, budget = 기본예산, seen = null, onRead = null } = {}) {
|
|
100
|
+
const 원문 = String(text ?? '');
|
|
101
|
+
const 빈답 = { text: 원문, attached: [], missing: [], blocked: [] };
|
|
102
|
+
if (!원문.includes('@') || !scope?.resolve) return 빈답;
|
|
103
|
+
|
|
104
|
+
const 지목들 = findMentions(원문);
|
|
105
|
+
if (!지목들.length) return 빈답;
|
|
106
|
+
|
|
107
|
+
const attached = [];
|
|
108
|
+
const missing = [];
|
|
109
|
+
const blocked = [];
|
|
110
|
+
const 붙일것 = [];
|
|
111
|
+
const 본것 = new Set();
|
|
112
|
+
let 남은예산 = Math.max(200, Number(budget) || 기본예산);
|
|
113
|
+
|
|
114
|
+
for (const m of 지목들) {
|
|
115
|
+
const 자리 = 찾아보기(m, scope);
|
|
116
|
+
if (자리.why === 'blocked') { blocked.push({ path: 자리.path, why: 자리.note }); continue; }
|
|
117
|
+
if (자리.why === 'missing') { missing.push(자리.path); continue; }
|
|
118
|
+
if (본것.has(자리.abs)) continue; // 같은 파일을 두 번 적지 않는다
|
|
119
|
+
본것.add(자리.abs);
|
|
120
|
+
|
|
121
|
+
const show = scope.show ? scope.show(자리.abs) : 자리.path;
|
|
122
|
+
let 몸통;
|
|
123
|
+
let 통째로 = true;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
if (statSync(자리.abs).isDirectory()) {
|
|
127
|
+
몸통 = 폴더내용(자리.abs, show);
|
|
128
|
+
} else {
|
|
129
|
+
// 사내 파일은 CP949 가 흔하다. 그냥 읽으면 통째로 깨진다.
|
|
130
|
+
const 읽음 = readTextFull(자리.abs);
|
|
131
|
+
const 줄들 = 읽음.text.split('\n');
|
|
132
|
+
const 붙은글 = 줄들.map((l, i) => `${String(i + 1).padStart(6)}\t${l}`).join('\n');
|
|
133
|
+
if (estimateTokens(붙은글) <= 남은예산) {
|
|
134
|
+
몸통 = 붙은글;
|
|
135
|
+
} else {
|
|
136
|
+
// 예산만큼만 앞에서 자른다. 글자 수로 어림잡되 남는 쪽으로 잡는다.
|
|
137
|
+
통째로 = false;
|
|
138
|
+
const 어림 = Math.max(1, Math.floor(남은예산 * 3));
|
|
139
|
+
const 자른것 = 붙은글.slice(0, 어림);
|
|
140
|
+
const 마지막줄 = 자른것.split('\n').length;
|
|
141
|
+
몸통 = `${자른것}\n… 전체 ${줄들.length}줄 중 ${마지막줄}줄까지만 붙였습니다 (나머지는 Read 로 읽으세요)`;
|
|
142
|
+
}
|
|
143
|
+
if (onRead) onRead(자리.abs, 읽음.text);
|
|
144
|
+
if (통째로 && seen) seen.add(자리.abs);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// 못 읽는 파일(바이너리 등)은 붙이지 않는다. 그렇다고 말은 해 준다.
|
|
148
|
+
몸통 = `(못 읽었습니다: ${err.message})`;
|
|
149
|
+
통째로 = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const 토막 = `\n\n--- ${show} ---\n${몸통}\n--- ${show} 끝 ---`;
|
|
153
|
+
남은예산 -= estimateTokens(토막);
|
|
154
|
+
붙일것.push(토막);
|
|
155
|
+
attached.push({ path: 자리.abs, show, full: 통째로 });
|
|
156
|
+
if (남은예산 <= 0) break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!붙일것.length) return { text: 원문, attached, missing, blocked };
|
|
160
|
+
const 머리 = attached.length === 1
|
|
161
|
+
? '아래는 사용자가 @ 로 지목한 파일입니다. 이미 읽은 것으로 치고 답하세요.'
|
|
162
|
+
: `아래는 사용자가 @ 로 지목한 파일 ${attached.length}개입니다. 이미 읽은 것으로 치고 답하세요.`;
|
|
163
|
+
return { text: `${원문}\n\n${머리}${붙일것.join('')}`, attached, missing, blocked };
|
|
164
|
+
}
|
package/src/agent/modes.js
CHANGED
|
@@ -11,11 +11,19 @@
|
|
|
11
11
|
// 오프라인일 때 웹 도구를 숨기는 것과 같은 방식이다.
|
|
12
12
|
|
|
13
13
|
// 읽기만 하는 도구. 무엇을 바꾸지 않는다.
|
|
14
|
-
|
|
14
|
+
// Recall 은 지난 대화를 찾는다 — 파일은 안 건드리므로 읽기 쪽이다.
|
|
15
|
+
// 묻기 모드에도 준다: "저번에 이거 어떻게 했더라" 가 딱 묻기 모드의 일이다.
|
|
16
|
+
const 읽기 = ['Read', 'Glob', 'Grep', 'WebFetch', 'Skill', 'Recall'];
|
|
15
17
|
// 계획을 적는 도구. 파일을 안 건드리므로 읽기 전용 모드에서도 준다.
|
|
16
|
-
|
|
18
|
+
//
|
|
19
|
+
// Remember 도 여기 있다. 기억은 사용자의 소스를 안 건드리고 .deel/memory.md
|
|
20
|
+
// 한 줄을 더할 뿐이다. 오히려 계획·설계 모드에서 정한 것이야말로 다음에
|
|
21
|
+
// 이어질 때 필요하다 — 거기서 못 적으면 정한 것이 그대로 날아간다.
|
|
22
|
+
const 계획 = ['TodoWrite', 'Remember'];
|
|
17
23
|
// 바꾸는 도구.
|
|
18
|
-
|
|
24
|
+
// Append 는 Write 와 짝이다 — 출력 상한이 작은 모델이 큰 파일을 나눠 쓰는 길이다.
|
|
25
|
+
// Write 를 주는 자리에는 반드시 같이 준다. 하나만 주면 나눠 쓸 방법이 없어진다.
|
|
26
|
+
const 쓰기 = ['Write', 'Append', 'Edit', 'Bash'];
|
|
19
27
|
|
|
20
28
|
export const MODES = {
|
|
21
29
|
// 처음에는 여기서 시작한다.
|
|
@@ -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
|
+
}
|