deel-local-cli 1.8.0 → 1.10.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.
Files changed (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
@@ -1,5 +1,5 @@
1
1
  // 대화 상태와 컨텍스트 셈. /context 가 보여주는 숫자가 여기서 나온다.
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { 그림장수, 글만, 그림한장토큰 } from '../backend/vision.js';
5
5
  import { get as workMode, 말 as 모드말, DEFAULT as WORK_DEFAULT } from './modes.js';
@@ -9,6 +9,7 @@ import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
9
9
  import { 지문 } from './project.js';
10
10
  import { 프롬프트토막 as 기억토막 } from './memory.js';
11
11
  import { 못박기 } from './pins.js';
12
+ import { 파일기억 } from './filemem.js';
12
13
  import { 언어, 지시말, 말 as 옮긴말 } from '../i18n/index.js';
13
14
  import { 셸안내 } from '../tools/shell.js';
14
15
 
@@ -231,6 +232,27 @@ export class Session {
231
232
  */
232
233
  this.못박은것 = new 못박기();
233
234
  this.filesRead = new Map(); // 경로 → 추정 토큰
235
+ /*
236
+ * 읽은 파일을 들고 있다가 **바뀐 만큼만** 다시 싣는다 (agent/filemem.js).
237
+ *
238
+ * filesRead 와 둘로 나눠 둔 이유: 저쪽은 `/context` 가 「몇 개 읽었나」 를
239
+ * 세는 자리라 값이 토큰 수뿐이다. 여기는 글 자체를 들고 있어야 한다.
240
+ * 한 맵에 두 가지를 담으면 화면 셈이 글자까지 들고 다니게 된다.
241
+ */
242
+ this.파일기억 = new 파일기억();
243
+ /*
244
+ * 접거나 줄여도 잃으면 안 되는 두 가지 (아래 못박을것).
245
+ *
246
+ * 이번요청 — 이번 턴에 사람이 시킨 말 원문. loop.js 가 턴마다 채운다.
247
+ * 할일 — 마지막 TodoWrite 목록. loop.js 가 그 결과에서 채운다.
248
+ *
249
+ * 요약은 요약이라 「네 가지를 고쳐 달라고 했다」로 뭉개지고, 할 일 목록은
250
+ * 도구 결과 자리에만 살아서 접히면 통째로 사라진다. 둘 다 접는 자리에서
251
+ * 다시 박으려면 세션이 들고 있어야 한다 — ctx 에만 두면 우리 코드만 보고
252
+ * 모델은 못 본다.
253
+ */
254
+ this.이번요청 = '';
255
+ this.할일 = [];
234
256
  this.changes = new Map(); // 경로 → {added, removed, times}. /diff 가 본다
235
257
  /*
236
258
  * 상태줄이 보는 두 숫자.
@@ -269,6 +291,8 @@ export class Session {
269
291
  this.본것 = new 지켜본것();
270
292
  this.급정한것 = null;
271
293
  this.startedAt = Date.now();
294
+ /** 규칙 파일이 있는데 못 읽었나. `{이름, 까닭}` — /status 가 이걸 말한다. */
295
+ this.규칙못읽음 = null;
272
296
  this.rules = this.#loadRules();
273
297
  /*
274
298
  * 이 폴더가 무슨 프로젝트인가 (agent/project.js).
@@ -289,11 +313,27 @@ export class Session {
289
313
  this.memory = 기억토막(this.root);
290
314
  }
291
315
 
316
+ /**
317
+ * 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md 중 먼저 읽히는 하나.
318
+ *
319
+ * 없는 것은 그냥 없는 것이라 아무 말도 안 한다. 그런데 **있는데 못 읽는**
320
+ * 것까지 같이 삼키면 안 된다 — 권한이 막혔거나 같은 이름의 폴더가 있으면
321
+ * 그렇게 된다. 사람은 규칙을 적어 뒀으니 걸려 있다고 믿는데 실제로는 하나도
322
+ * 안 걸린 채로 일이 돈다. 「운영 DB 는 건드리지 마라」 를 적어 놓고 그게
323
+ * 안 걸린 것이 여기서 나올 수 있는 제일 나쁜 모양이다.
324
+ * 못 읽은 것은 적어 두고 /status 가 '없음' 대신 그 까닭을 말한다.
325
+ */
292
326
  #loadRules() {
293
327
  for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
294
- const p = join(this.root, name);
295
- if (existsSync(p)) {
296
- try { return { name, text: readFileSync(p, 'utf8').slice(0, 20000) }; } catch {}
328
+ try { return { name, text: readFileSync(join(this.root, name), 'utf8').slice(0, 20000) }; }
329
+ catch (err) {
330
+ // 없으면 그냥 없는 것이다 말할 일이 아니다. existsSync 먼저 보지
331
+ // 않는 이유도 여기 있다: 보고 나서 읽는 사이에 지워지면 그 ENOENT 를
332
+ // 「있는데 못 읽었다」 로 적게 된다.
333
+ if (err?.code === 'ENOENT') continue;
334
+ // 첫 번째 것만 적어 둔다. 뒤엣것이 읽히면 그게 규칙이 되지만, 사람이
335
+ // 적어 둔 자리를 못 읽었다는 사실은 그래도 남아야 한다.
336
+ if (!this.규칙못읽음) this.규칙못읽음 = { 이름: name, 까닭: err?.code ?? err?.message ?? String(err) };
297
337
  }
298
338
  }
299
339
  return null;
@@ -505,6 +545,19 @@ export class Session {
505
545
  this.messages = 고침.messages;
506
546
  this.#턴표 = this.#턴표.filter((x) => this.messages.includes(x.표));
507
547
  this.#다음턴 = null;
548
+ /*
549
+ * 들고 있던 파일 내용도 같이 버린다. clear() 가 하는 것과 같은 까닭이다.
550
+ *
551
+ * 되감기는 그 턴의 Read 결과까지 대화에서 **진짜로 지운다.** 그런데 파일
552
+ * 기억은 그대로 남아서, 같은 파일을 다시 읽으면 「앞에서 읽은 그대로입니다」
553
+ * 나 「나머지는 앞에 실린 그대로입니다」 를 내민다 — 그 「앞엣것」 은 방금
554
+ * 이 줄이 지웠다.
555
+ *
556
+ * /undo 는 특히 그렇다. 파일을 고치고(기억에 담김) 되돌리면 디스크는 옛
557
+ * 모습으로 돌아가는데 기억은 고친 모습이라, 다시 읽을 때 있지도 않은 차이를
558
+ * 적어 보내게 된다. 여기서 한 줄 지우면 그냥 통째로 다시 읽는다.
559
+ */
560
+ this.파일기억?.잊기();
508
561
  return { 걷은것: 전 - this.messages.length, 고친것: 고침.고친것, 사람말, 턴: 표들.map((x) => x.턴) };
509
562
  }
510
563
 
@@ -517,6 +570,17 @@ export class Session {
517
570
  clear() {
518
571
  this.messages = [];
519
572
  this.filesRead.clear();
573
+ this.파일기억.잊기();
574
+ /*
575
+ * 할 일 목록과 시킨 말도 같이 버린다.
576
+ *
577
+ * 이 둘은 접거나 줄일 때 다시 박히는 것들이다(못박을것). 대화를 지운 뒤에도
578
+ * 들고 있으면, 새로 시작한 일이 처음 접히는 순간 **지운 대화의 할 일**이
579
+ * 되살아나 붙는다. 모델은 그걸 지금 시킨 것으로 알고 하러 간다 —
580
+ * 안 지운 것만 못하다.
581
+ */
582
+ this.할일 = [];
583
+ this.이번요청 = '';
520
584
  this.#턴표 = [];
521
585
  this.#다음턴 = null;
522
586
  return this;
@@ -725,13 +789,65 @@ export class Session {
725
789
  if (dropped <= 0) return 0;
726
790
  this.messages = [
727
791
  ...this.messages.slice(0, keepHead),
728
- { role: 'user', content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)` },
792
+ {
793
+ role: 'user',
794
+ /*
795
+ * 줄일 때도 시킨 말과 남은 할 일을 다시 박는다.
796
+ *
797
+ * 이 길은 **요약을 못 받아 물러선** 길이다. 서버가 흔들릴 때 지나가는
798
+ * 길이라 제일 자주 밟히는데, 여태 여기에는 못 박는 것이 없었다.
799
+ * 머리 2개만 남기므로, 사용자가 세 번째 턴에서 네 가지를 시켰다면
800
+ * 그 네 가지가 그냥 사라진다. 접기(compact) 쪽에만 못 박아 두면
801
+ * 정작 제일 자주 지나가는 길에서만 조용히 어긋난다.
802
+ */
803
+ content: `(앞선 대화 ${dropped}개를 줄였습니다. 필요하면 파일을 다시 읽으세요.)\n\n` + 못박을것(this),
804
+ },
729
805
  ...this.messages.slice(keepTail),
730
806
  ];
731
807
  return dropped;
732
808
  }
733
809
  }
734
810
 
811
+ /*
812
+ * ── 접거나 줄여도 이것만은 글자 그대로 남긴다 ──────────────────────────
813
+ *
814
+ * 요약은 요약이다. 네 가지를 적어 준 요청이 「네 가지를 고쳐 달라고 했다」
815
+ * 한 줄로 뭉개지고, **그 네 가지가 무엇이었는지는 사라진다.** 접힌 뒤로는
816
+ * 원문이 어디에도 없으니 남은 것을 이어 하려 해도 무엇이 남았는지 모른다.
817
+ *
818
+ * 할 일 목록도 같다. 목록이 사는 자리가 도구 결과 하나뿐이라, 55%에서 한 줄로
819
+ * 접히고 80%에서 요약에 뭉개진다. 접힘 문구는 「필요하면 다시 읽으세요」인데
820
+ * 할 일 목록은 **다시 읽을 파일이 없다.**
821
+ *
822
+ * 그래서 둘 다 접는 자리·줄이는 자리에서 다시 박는다. 끝난 항목은 안 싣는다 —
823
+ * 자리를 먹기만 하고, 이 쪽지가 답할 질문은 「무엇이 남았나」 하나다.
824
+ *
825
+ * compact.js 가 이 두 함수를 그대로 내보낸다. safeCut·safeHead 와 같은 까닭이다 —
826
+ * 접기와 줄이기가 **같은 것**을 박아야 하고, 한쪽만 고치면 물러서는 순간
827
+ * 대화가 어긋난다.
828
+ */
829
+ const 못박을길이 = 1200;
830
+
831
+ export function 못박은요청(session) {
832
+ const 원문 = String(session?.이번요청 ?? '').trim();
833
+ if (!원문) return '';
834
+ const 실을것 = 원문.length > 못박을길이
835
+ ? `${원문.slice(0, 못박을길이)}\n…(뒷부분 줄임)`
836
+ : 원문;
837
+ return `[이번에 시킨 말 — 요약이 아니라 원문 그대로입니다. 여기 적힌 것을 빠짐없이 하세요.]\n${실을것}\n\n`;
838
+ }
839
+
840
+ export function 못박은할일(session) {
841
+ const 남은 = (session?.할일 ?? []).filter((x) => x?.state !== 'done');
842
+ if (!남은.length) return '';
843
+ const 줄 = 남은.map((x) => `${x.state === 'doing' ? '▶ (하는 중)' : '☐'} ${String(x.text ?? '').trim()}`);
844
+ return `[아직 안 끝난 할 일 — 접히기 전 목록 그대로입니다. 이걸 이어서 하세요.]\n${줄.join('\n')}\n\n`;
845
+ }
846
+
847
+ export function 못박을것(session) {
848
+ return 못박은요청(session) + 못박은할일(session);
849
+ }
850
+
735
851
  /**
736
852
  * 도구 호출과 결과가 갈라지지 않는 자리를 찾는다.
737
853
  * i 번째부터 남긴다고 할 때, 안전한 i 로 옮겨 준다.
@@ -14,7 +14,7 @@
14
14
  // 마지막 줄까지는 성하다. 통째로 다시 쓰는 방식이면 그 순간 파일이 깨진다.
15
15
  import {
16
16
  existsSync, mkdirSync, readdirSync, readFileSync,
17
- appendFileSync, writeFileSync, statSync, rmSync,
17
+ appendFileSync, writeFileSync, statSync, rmSync, chmodSync,
18
18
  } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
 
@@ -51,19 +51,93 @@ export class Store {
51
51
  this.auto = id == null; // 우리가 지은 이름인가 (그러면 겹칠 때 바꿔도 된다)
52
52
  this.#use(id ?? freeId(this.dir));
53
53
  this.opened = false;
54
+ /** 적으려다 못 적은 줄 수와 첫 까닭. 0 이 아니면 이 대화는 반만 남는다. */
55
+ this.못쓴수 = 0;
56
+ this.못쓴까닭 = null;
57
+ this.말한적있나 = false;
58
+ // 이 파일과 묶인 대화, 그리고 그 대화에서 **마지막으로 적어 둔** 할 일·시킨 말.
59
+ // 안 바뀐 것을 다시 적지 않으려고 들고 있는다 (살림적기).
60
+ this.따라갈세션 = null;
61
+ this.적은할일 = null;
62
+ this.적은요청 = null;
54
63
  }
55
64
 
56
65
  #use(id) {
57
66
  this.id = id;
58
67
  this.file = join(this.dir, `${id}.jsonl`);
68
+ this.#잠갔나 = false; // 다른 파일로 옮겨 갔으면 그 파일은 아직 안 잠갔다
69
+ }
70
+
71
+ /*
72
+ * 만든 뒤 본인만 읽게 잠근다 (config.js 가 설정 파일에 하는 것과 같다).
73
+ *
74
+ * 여기 남는 것은 대화 전체다 — 사람이 붙여 넣은 글, 읽은 파일 내용, 모델이
75
+ * 쓴 답. 홈이 공유 폴더에 있거나 같은 PC 를 여럿이 쓰면 그게 그대로 읽힌다.
76
+ * 한 번만 건다. 윈도우(NTFS)에서는 chmod 가 아무 일도 안 한다 — 거기서는
77
+ * 못 잠근 것이지 잠근 척하지 않는다.
78
+ *
79
+ * 건 결과를 `잠금` 에 남긴다. "본인만 읽게 잠근다" 는 말은 지켜지는지
80
+ * **밖에서 볼 수 있어야** 뜻이 있다 (keystore.js 의 마지막명령줄 과 같은
81
+ * 까닭). 윈도우에서 값이 남는다고 실제로 잠겼다는 뜻은 아니다.
82
+ */
83
+ #잠갔나 = false;
84
+ /** 파일 권한을 건 결과. `{모드}` 면 걸었고, `{못함}` 이면 못 걸었다. */
85
+ 잠금 = null;
86
+ #잠그기() {
87
+ if (this.#잠갔나) return;
88
+ this.#잠갔나 = true;
89
+ try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
90
+ catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
59
91
  }
60
92
 
61
93
  #open() {
62
94
  if (this.opened) return;
63
- mkdirSync(this.dir, { recursive: true });
95
+ // 폴더를 만들어도 여기서 던지지 않는다. 던지면 대화 자체가 그 자리에서
96
+ // 끊긴다 — 기록을 남기려다 작업을 죽이면 본말이 뒤집힌다.
97
+ // 조용히 넘기는 것도 아니다: 폴더가 없으면 바로 아래 쓰기가 깨지고,
98
+ // 그 자리에서 세어 화면에 오른다.
99
+ try { mkdirSync(this.dir, { recursive: true }); } catch { /* 아래 쓰기가 센다 */ }
64
100
  this.opened = true;
65
101
  }
66
102
 
103
+ /*
104
+ * 못 적은 것을 세어 둔다.
105
+ *
106
+ * 이 파일 첫 줄이 파는 문장이 「껐다 켜도 이어서 하게 한다」 이고, /sessions
107
+ * 화면은 대놓고 「지금 대화는 나가지 않아도 계속 저장되고 있습니다」 라고
108
+ * 적어 준다. 디스크가 차거나 홈이 읽기 전용이거나 파일을 누가 잡고 있으면
109
+ * 그 두 문장이 거짓이 되는데, 여태 화면은 아무 말이 없었다.
110
+ *
111
+ * 그 침묵이 비싼 까닭: 사람이 알아차리는 자리가 **다음 날 --resume** 이다.
112
+ * 그때는 이미 대화가 없고, 되돌릴 방법도 없다. 안 적히고 있다는 것만 그때
113
+ * 알았어도 창을 안 닫거나 중요한 것을 따로 적어 뒀을 것이다.
114
+ * 감사기록(safety/audit.js)에서 한 것과 같은 방식으로 세어 둔다.
115
+ */
116
+ #못썼다(err) {
117
+ this.못쓴수 += 1;
118
+ // 까닭은 코드가 먼저다 — EACCES·ENOSPC 한 낱말이 긴 문장보다 알아보기 쉽고,
119
+ // 파일 경로가 섞여 들어가지 않아 화면 한 줄에 들어간다.
120
+ if (!this.못쓴까닭) this.못쓴까닭 = err?.code ?? err?.message ?? String(err);
121
+ }
122
+
123
+ /** 저장이 새고 있나. 새고 있으면 `{수, 까닭}`, 멀쩡하면 null. */
124
+ 못쓴것() {
125
+ return this.못쓴수 ? { 수: this.못쓴수, 까닭: this.못쓴까닭 } : null;
126
+ }
127
+
128
+ /**
129
+ * 화면이 **한 번만** 말하게 하려고 쓴다. 처음 물어볼 때만 알려 주고 그 뒤로는 null.
130
+ *
131
+ * 한 줄 못 적을 때마다 경고를 찍으면 사람은 이틀 만에 그 줄을 안 읽게 되고,
132
+ * 그러면 정작 처음 한 번도 못 읽힌다. 세어 둔 값(못쓴것)은 안 지운다 —
133
+ * /sessions 화면은 언제 열어도 지금 몇 건인지 말해야 한다.
134
+ */
135
+ 처음못쓴것() {
136
+ if (!this.못쓴수 || this.말한적있나) return null;
137
+ this.말한적있나 = true;
138
+ return this.못쓴것();
139
+ }
140
+
67
141
  /**
68
142
  * 첫 줄은 머리글 — 어떤 모델·어떤 폴더에서 한 대화인지.
69
143
  *
@@ -77,9 +151,12 @@ export class Store {
77
151
  for (let tries = 0; tries < 50; tries++) {
78
152
  try {
79
153
  writeFileSync(this.file, head, { encoding: 'utf8', flag: 'wx' });
154
+ this.#잠그기();
80
155
  return this;
81
156
  } catch (err) {
82
- if (err?.code !== 'EEXIST') return this; // 못 적어도 대화는 계속돼야 한다
157
+ // 못 적어도 대화는 계속돼야 한다. 다만 조용히 넘기지는 않는다 —
158
+ // 머리글을 못 적었으면 그 뒤의 줄도 십중팔구 못 적는다.
159
+ if (err?.code !== 'EEXIST') { this.#못썼다(err); return this; }
83
160
  if (!this.auto) return this; // 이어쓰기 — 이미 있는 게 맞다
84
161
  this.#use(freeId(this.dir)); // 누가 채 갔다. 옆자리로.
85
162
  }
@@ -88,14 +165,78 @@ export class Store {
88
165
  }
89
166
 
90
167
  #write(obj) {
91
- try { appendFileSync(this.file, JSON.stringify(obj) + '\n', 'utf8'); }
92
- catch { /* 기록에 실패해도 대화는 계속돼야 한다 */ }
168
+ try {
169
+ appendFileSync(this.file, JSON.stringify(obj) + '\n', 'utf8');
170
+ this.#잠그기();
171
+ } catch (err) { this.#못썼다(err); } // 계속은 하되, 몇 줄을 잃었는지는 센다
93
172
  }
94
173
 
95
174
  // 메시지 하나를 덧붙인다. 대화가 진행되는 대로 즉시 남긴다.
96
175
  append(msg) {
97
176
  this.#open();
98
177
  this.#write({ t: 'msg', m: msg });
178
+ // 남은 할 일과 시킨 말도 이 자리에서 같이 본다 (아래 살림적기).
179
+ // 따로 부르는 자리를 만들면 언젠가 한 길에서만 부르게 되고, 그 길로 들어온
180
+ // 사람만 이어하기가 반쪽이 된다.
181
+ this.살림적기();
182
+ }
183
+
184
+ /*
185
+ * ── 남은 할 일과 시킨 말 원문 ──────────────────────────────────────────
186
+ *
187
+ * 1.9.2 는 **한 대화 안에서** 이 둘이 접히거나 줄어들며 사라지던 것을 막았다
188
+ * (session.js 의 못박을것). 그런데 둘 다 세션 객체, 곧 메모리에만 있어서
189
+ * 창을 닫는 한 번에 그 보호가 통째로 없어졌다 — 이어받으면 오간 말은 돌아오고
190
+ * 못 박은 것도 돌아오는데, 남은 할 일과 시킨 말 원문만 안 돌아온다.
191
+ * 「요청사항이 많으면 몇 개 까먹는다」 가 껐다 켜는 자리에서 되풀이된다.
192
+ *
193
+ * 메시지와 따로 한 줄로 두는 이유는 pins 와 같다 — 접기·요약은 메시지를
194
+ * 손보는데 이 둘은 그 손질에 닿으면 안 된다. 마지막 것이 이긴다.
195
+ */
196
+
197
+ /**
198
+ * 이 대화의 할 일·시킨 말을 이 저장 파일과 묶는다.
199
+ *
200
+ * 처음 묶을 때 파일에 적힌 것을 세션으로 되살린다 — 이어하기의 빠져 있던
201
+ * 절반이 여기다. 그 뒤로는 append 가 돌 때마다 **바뀐 만큼만** 적는다.
202
+ */
203
+ 살림따라가기(session) {
204
+ if (!session) return this;
205
+ const 처음 = this.따라갈세션 !== session;
206
+ this.따라갈세션 = session;
207
+ if (처음) this.#살림되살리기(session);
208
+ return this;
209
+ }
210
+
211
+ #살림되살리기(session) {
212
+ const { 할일, 이번요청 } = this.load();
213
+ // 적힌 것이 없으면 세션이 들고 있던 것을 그대로 둔다. 빈 값으로 덮으면
214
+ // 새 갈래를 열자마자 방금 시킨 말이 사라진다 — 갈래마다 파일이 따로다.
215
+ if (할일) session.할일 = 할일;
216
+ if (이번요청) session.이번요청 = 이번요청;
217
+ this.적은할일 = JSON.stringify(session.할일 ?? []);
218
+ this.적은요청 = String(session.이번요청 ?? '');
219
+ }
220
+
221
+ /**
222
+ * 바뀐 것만 적는다. 안 바뀌었으면 한 줄도 안 늘린다.
223
+ *
224
+ * 메시지마다 목록을 통째로 다시 적으면 긴 대화에서 파일이 몇 배가 되고,
225
+ * 그 값은 매번 사람이 기다리는 시간이다. pins 를 사람이 고칠 때만 적는
226
+ * 것과 같은 뜻이다.
227
+ */
228
+ 살림적기(session = this.따라갈세션) {
229
+ if (!session) return;
230
+ const 할일 = JSON.stringify(session.할일 ?? []);
231
+ if (할일 !== this.적은할일) {
232
+ this.적은할일 = 할일;
233
+ this.#write({ t: 'todo', at: new Date().toISOString(), 목록: session.할일 ?? [] });
234
+ }
235
+ const 요청 = String(session.이번요청 ?? '');
236
+ if (요청 !== this.적은요청) {
237
+ this.적은요청 = 요청;
238
+ this.#write({ t: 'request', at: new Date().toISOString(), 글: 요청 });
239
+ }
99
240
  }
100
241
 
101
242
  /**
@@ -132,11 +273,31 @@ export class Store {
132
273
  // 여기가 놓치기 쉬운 자리다. 파일을 새로 쓰면서 못 박은 것을 안 옮기면,
133
274
  // '요약해도 안 지워진다' 는 말이 바로 그 요약에서 거짓이 된다.
134
275
  const 못박은것 = this.못박은것읽기();
276
+ // 남은 할 일과 시킨 말 원문도 같은 이유로 옮겨 싣는다. 접힌 자리에서
277
+ // 다시 박으라고 들고 있는 것들인데, 정작 접는 자리에서 파일에서 빠지면
278
+ // 이어받을 때 그 둘만 없는 대화가 된다.
279
+ //
280
+ // 옮겨 싣기 **전에** 아직 안 적은 것을 먼저 적는다. 여기서 파일만 보고
281
+ // 옮기면, 메시지가 한 번도 안 붙은 사이에 할 일이 바뀐 경우 그 바뀐 것이
282
+ // 통째로 사라진다 — 접는 자리는 원래 제일 크게 잃는 자리다.
283
+ this.살림적기();
284
+ const { 할일, 이번요청 } = this.load();
135
285
  const lines = [JSON.stringify({ t: 'meta', at: new Date().toISOString(), ...meta })];
136
286
  lines.push(JSON.stringify({ t: 'note', at: new Date().toISOString(), note }));
137
287
  if (못박은것.length) lines.push(JSON.stringify({ t: 'pins', at: new Date().toISOString(), 목록: 못박은것 }));
288
+ if (할일) lines.push(JSON.stringify({ t: 'todo', at: new Date().toISOString(), 목록: 할일 }));
289
+ if (이번요청) lines.push(JSON.stringify({ t: 'request', at: new Date().toISOString(), 글: 이번요청 }));
138
290
  for (const m of messages) lines.push(JSON.stringify({ t: 'msg', m }));
139
- try { writeFileSync(this.file, lines.join('\n') + '\n', 'utf8'); } catch {}
291
+ /*
292
+ * 통째로 다시 쓰면 파일이 새로 만들어진다 — 그때 잠금도 다시 걸어야 한다.
293
+ * 그리고 여기서 깨지면 앞의 append 들이 남긴 것까지 통째로 못 고친 셈이
294
+ * 된다. 제일 크게 잃는 자리라 더더욱 조용히 넘길 수 없다.
295
+ */
296
+ try {
297
+ writeFileSync(this.file, lines.join('\n') + '\n', 'utf8');
298
+ this.#잠갔나 = false;
299
+ this.#잠그기();
300
+ } catch (err) { this.#못썼다(err); }
140
301
  }
141
302
 
142
303
  readMeta() {
@@ -148,19 +309,35 @@ export class Store {
148
309
  } catch { return null; }
149
310
  }
150
311
 
151
- // 저장된 대화를 읽어 온다. 깨진 줄은 건너뛴다 — 도중에 죽었을 수 있다.
312
+ /**
313
+ * 저장된 대화를 읽어 온다. 깨진 줄은 건너뛴다 — 도중에 죽었을 수 있다.
314
+ *
315
+ * 파일을 아예 못 여는 경우(권한, 다른 프로그램이 잡고 있음)에도 던지지 않는다.
316
+ * 이 함수는 list() 가 폴더의 대화를 하나씩 훑으면서 부른다. 한 파일에서
317
+ * 던지면 **목록 화면 전체가 안 뜨고**, 멀쩡히 이어할 수 있는 나머지 대화까지
318
+ * 사람 눈에서 사라진다. 못 읽은 것은 못읽음 에 까닭을 담아 알린다.
319
+ */
152
320
  load() {
153
321
  if (!existsSync(this.file)) return { meta: null, messages: [] };
322
+ let 글;
323
+ try { 글 = readFileSync(this.file, 'utf8'); }
324
+ catch (err) { return { meta: null, messages: [], 못읽음: err?.code ?? err?.message ?? String(err) }; }
154
325
  let meta = null;
155
326
  const messages = [];
156
- for (const line of readFileSync(this.file, 'utf8').split('\n')) {
327
+ // 마지막 것이 이긴다. 없으면 null 이고, null 은 '적힌 적이 없다' 는 뜻이라
328
+ // 빈 목록·빈 글과 구별된다 — 되살릴 때 그 차이로 덮을지 말지를 정한다.
329
+ let 할일 = null;
330
+ let 이번요청 = null;
331
+ for (const line of 글.split('\n')) {
157
332
  if (!line.trim()) continue;
158
333
  let j;
159
334
  try { j = JSON.parse(line); } catch { continue; }
160
335
  if (j.t === 'meta') meta = j;
161
336
  else if (j.t === 'msg' && j.m) messages.push(j.m);
337
+ else if (j.t === 'todo' && Array.isArray(j.목록)) 할일 = j.목록;
338
+ else if (j.t === 'request' && typeof j.글 === 'string') 이번요청 = j.글;
162
339
  }
163
- return { meta, messages };
340
+ return { meta, messages, 할일, 이번요청 };
164
341
  }
165
342
  }
166
343
 
@@ -40,7 +40,19 @@ export class Threads {
40
40
  this.session = session;
41
41
  this.ctx = ctx;
42
42
  this.새store = 새store;
43
- this.갈래들 = [{ 이름: '본줄기', messages: session.messages, usage: session.usage, todos: ctx?.todos ?? null, store: 첫store }];
43
+ /*
44
+ * 남은 할 일과 시킨 말 원문을 저장 파일과 묶는다 (agent/store.js 의 살림따라가기).
45
+ *
46
+ * **첫 한마디가 오기 전**인 여기여야 한다. 턴이 시작되면 loop.js 가
47
+ * 이번요청을 새 말로 덮으므로, 그 뒤에 되살리면 방금 시킨 말이 어제 시킨
48
+ * 말로 되돌아간다. 여태 이 둘만 메모리에 있어서, 이어받아도 오간 말은
49
+ * 돌아오는데 남은 할 일은 안 돌아왔다.
50
+ */
51
+ 첫store?.살림따라가기?.(session);
52
+ this.갈래들 = [{
53
+ 이름: '본줄기', messages: session.messages, usage: session.usage, todos: ctx?.todos ?? null,
54
+ 할일: session.할일, 이번요청: session.이번요청, store: 첫store,
55
+ }];
44
56
  this.자리 = 0;
45
57
  this.센것 = 1;
46
58
  }
@@ -55,6 +67,11 @@ export class Threads {
55
67
  g.messages = this.session.messages;
56
68
  g.usage = this.session.usage;
57
69
  g.todos = this.ctx?.todos ?? null;
70
+ // 남은 할 일·시킨 말도 갈래 것이다. 저장 파일이 갈래마다 따로라, 이걸 안
71
+ // 담아 두면 곁가지에서 적은 할 일을 들고 본줄기로 돌아와 본줄기 파일에
72
+ // 적어 버린다 — 이어받을 때 하지도 않은 일이 본줄기에 남는다.
73
+ g.할일 = this.session.할일;
74
+ g.이번요청 = this.session.이번요청;
58
75
  }
59
76
 
60
77
  /** 갈래 하나를 화면으로 꺼낸다. */
@@ -64,6 +81,10 @@ export class Threads {
64
81
  this.session.messages = g.messages;
65
82
  this.session.usage = g.usage;
66
83
  if (this.ctx) this.ctx.todos = g.todos;
84
+ this.session.할일 = g.할일 ?? [];
85
+ this.session.이번요청 = g.이번요청 ?? '';
86
+ // 적는 자리도 지금 갈래의 파일로 옮긴다 (agent/store.js 의 살림따라가기).
87
+ g.store?.살림따라가기?.(this.session);
67
88
  // 상태줄이 지금 어느 갈래인지 보여 줄 수 있게 남긴다.
68
89
  // 갈래가 하나뿐이면 안 보인다 — 안 쓰는 사람 화면은 그대로여야 한다.
69
90
  this.session.갈래표 = this.갈래들.length > 1 ? g.이름 : null;
@@ -84,6 +105,10 @@ export class Threads {
84
105
  messages: 물려줄것 ? [...물려줄것] : [],
85
106
  usage: { in: 0, out: 0, calls: 0, ms: 0, retries: 0 },
86
107
  todos: null,
108
+ // 할 일도 todos 와 같이 비워서 시작한다. 곁가지는 다른 일을 하러 나가는
109
+ // 것이고, 물려받으면 본줄기의 남은 일을 곁가지 파일에도 적게 된다.
110
+ 할일: [],
111
+ 이번요청: '',
87
112
  store,
88
113
  };
89
114
  this.갈래들.push(g);