@sejuneoh/aitop 0.0.0-stage → 0.21.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/LICENSE +21 -0
- package/README.md +235 -2
- package/bin/aitop.mjs +360 -0
- package/package.json +36 -4
- package/src/attention.mjs +70 -0
- package/src/brand.mjs +30 -0
- package/src/budget.mjs +29 -0
- package/src/cache.mjs +78 -0
- package/src/collect.mjs +51 -0
- package/src/config.mjs +169 -0
- package/src/doctor.mjs +218 -0
- package/src/frames.mjs +24 -0
- package/src/help.mjs +111 -0
- package/src/json.mjs +57 -0
- package/src/keys.mjs +154 -0
- package/src/log.mjs +130 -0
- package/src/notify.mjs +251 -0
- package/src/order.mjs +69 -0
- package/src/pricing.mjs +130 -0
- package/src/procs.mjs +205 -0
- package/src/sources/agy-ledger.mjs +153 -0
- package/src/sources/antigravity.mjs +763 -0
- package/src/sources/claude-ledger.mjs +181 -0
- package/src/sources/claude-tracker.mjs +183 -0
- package/src/sources/claude.mjs +352 -0
- package/src/sources/codex-ledger.mjs +141 -0
- package/src/sources/codex.mjs +374 -0
- package/src/sources/common.mjs +228 -0
- package/src/sources/files.mjs +116 -0
- package/src/sources/gemini.mjs +26 -0
- package/src/sources/patch.mjs +78 -0
- package/src/sqlite.mjs +50 -0
- package/src/tail.mjs +109 -0
- package/src/text.mjs +256 -0
- package/src/turns.mjs +79 -0
- package/src/ui/chrome.mjs +163 -0
- package/src/ui/detail.mjs +360 -0
- package/src/ui/draw.mjs +152 -0
- package/src/ui/glyphs.mjs +58 -0
- package/src/ui/table.mjs +425 -0
- package/src/ui/theme.mjs +137 -0
- package/src/ui.mjs +99 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
// Antigravity CLI (agy): ~/.gemini/antigravity-cli 의 SQLite 기록을 읽기 전용으로 읽는다.
|
|
2
|
+
// - conversation_summaries.db: 대화마다 제목, 작업 폴더, 실행 상태, 부모 대화(서브에이전트)
|
|
3
|
+
// - conversations/<id>.db 의 gen_metadata: 모델 호출 한 번에 한 줄. protobuf 안에 모델 이름과 토큰 사용량이 있다.
|
|
4
|
+
// 실행 중인 agy 와 대화는 /proc/<pid>/fd 에 열린 presence/<id>.lock 으로 짝짓는다. /proc 이 없으면 작업 폴더로 짝짓는다.
|
|
5
|
+
// 세션은 Gemini 탭에 보인다 (agy 는 Gemini CLI 의 후속 도구다).
|
|
6
|
+
import { statSync, openSync, readSync, closeSync, existsSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { join, basename, resolve } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { openReadOnly, loadSqlite } from '../sqlite.mjs';
|
|
11
|
+
import { findProcesses, isAgy, openFiles } from '../procs.mjs';
|
|
12
|
+
import { addActivity, emptyTokens, mergeActivity, pushEvent, describeTool, toolDetail, isNoisePrompt, MEMBER_FADE_MS } from './common.mjs';
|
|
13
|
+
import { contextWindow, geminiCost } from '../pricing.mjs';
|
|
14
|
+
import { debug, debugIo } from '../log.mjs';
|
|
15
|
+
|
|
16
|
+
// /proc/<pid>/fd 의 링크는 늘 심볼릭 링크를 푼 절대 경로라서, 기록 폴더도 같은 모양으로 바꿔 둔다
|
|
17
|
+
// (끝의 /, 상대 경로, 심볼릭 링크로 걸린 ~/.gemini). 폴더가 없으면 절대 경로까지만 바꾼다.
|
|
18
|
+
export function agyRoot(dir) {
|
|
19
|
+
const abs = resolve(dir);
|
|
20
|
+
try {
|
|
21
|
+
return realpathSync(abs);
|
|
22
|
+
} catch {
|
|
23
|
+
return abs;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const AGY_ROOT = agyRoot(process.env.AITOP_AGY_HOME ?? join(homedir(), '.gemini', 'antigravity-cli'));
|
|
28
|
+
|
|
29
|
+
const RECENT_MS = 24 * 3600 * 1000;
|
|
30
|
+
const BATCH = 32;
|
|
31
|
+
const RUNNING = 'CASCADE_RUN_STATUS_RUNNING';
|
|
32
|
+
|
|
33
|
+
// ---- protobuf: 필요한 필드만 꺼내는 최소 해석기 ----
|
|
34
|
+
|
|
35
|
+
function varint(b, i) {
|
|
36
|
+
let x = 0;
|
|
37
|
+
let s = 1;
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (i >= b.length) throw new Error('truncated');
|
|
40
|
+
const c = b[i++];
|
|
41
|
+
x += (c & 127) * s;
|
|
42
|
+
s *= 128;
|
|
43
|
+
if (c < 128) return [x, i];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 메시지의 맨 위 필드를 [번호, 값] 목록으로. 정수는 number, 길이 있는 값은 Buffer 조각이다.
|
|
48
|
+
export function pbFields(buf) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < buf.length) {
|
|
52
|
+
let key;
|
|
53
|
+
[key, i] = varint(buf, i);
|
|
54
|
+
const f = Math.floor(key / 8);
|
|
55
|
+
const w = key % 8;
|
|
56
|
+
if (w === 0) {
|
|
57
|
+
let v;
|
|
58
|
+
[v, i] = varint(buf, i);
|
|
59
|
+
out.push([f, v]);
|
|
60
|
+
} else if (w === 2) {
|
|
61
|
+
let n;
|
|
62
|
+
[n, i] = varint(buf, i);
|
|
63
|
+
if (i + n > buf.length) throw new Error('truncated');
|
|
64
|
+
out.push([f, buf.subarray(i, i + n)]);
|
|
65
|
+
i += n;
|
|
66
|
+
} else if (w === 1) i += 8;
|
|
67
|
+
else if (w === 5) i += 4;
|
|
68
|
+
else throw new Error(`wire type ${w}`);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const msg = (fields, n) => fields.find(([f, v]) => f === n && Buffer.isBuffer(v))?.[1] ?? null;
|
|
74
|
+
const int = (fields, n) => fields.find(([f, v]) => f === n && typeof v === 'number')?.[1] ?? 0;
|
|
75
|
+
|
|
76
|
+
// gen_metadata 한 줄 → { model, usage, step }. 필드 번호는 기록에서 확인한 값이다:
|
|
77
|
+
// 1 → 19 모델 이름, 1 → 17 → 2 사용량 (2 입력, 3 출력, 4 캐시 쓰기, 5 캐시 읽기). 출력(3) 은 생각(9) + 답변(10) 이다.
|
|
78
|
+
// 입력(2) 에는 캐시 읽기가 들어 있지 않다. 1 → 20 은 {1 이름, 2 값} 목록이고, 그 가운데 last_step_index 가
|
|
79
|
+
// 이 호출 직전 단계(steps.idx)다. 호출 기록에는 시각이 없어 그 단계의 만든 시각을 호출 시각으로 쓴다.
|
|
80
|
+
export function readGeneration(data) {
|
|
81
|
+
try {
|
|
82
|
+
const gen = msg(pbFields(Buffer.from(data)), 1);
|
|
83
|
+
if (!gen) return null;
|
|
84
|
+
const f = pbFields(gen);
|
|
85
|
+
const model = msg(f, 19)?.toString('utf8') || null;
|
|
86
|
+
let step = null;
|
|
87
|
+
for (const [n, v] of f) {
|
|
88
|
+
if (n !== 20 || !Buffer.isBuffer(v)) continue;
|
|
89
|
+
let kv;
|
|
90
|
+
try {
|
|
91
|
+
kv = pbFields(v);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
debug('agy', 'gen_metadata field decode failed', e);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (msg(kv, 1)?.toString('utf8') !== 'last_step_index') continue;
|
|
97
|
+
const x = Number(msg(kv, 2)?.toString('utf8'));
|
|
98
|
+
if (Number.isInteger(x) && x >= 0) step = x;
|
|
99
|
+
}
|
|
100
|
+
const stats = msg(f, 17);
|
|
101
|
+
const usage = stats && msg(pbFields(stats), 2);
|
|
102
|
+
if (!usage) return { model, usage: null, step };
|
|
103
|
+
const u = pbFields(usage);
|
|
104
|
+
return { model, usage: { input: int(u, 2), output: int(u, 3), cacheWrite: int(u, 4), cacheRead: int(u, 5) }, step };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
debug('agy', 'gen_metadata decode failed', e);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// step_payload 의 앞부분만으로 만든 시각(5 → 1)을 읽는다. 뒤가 잘려 있어도 그 필드까지만 온전하면 된다.
|
|
112
|
+
export function stepTime(data) {
|
|
113
|
+
const b = Buffer.from(data ?? EMPTY);
|
|
114
|
+
let i = 0;
|
|
115
|
+
try {
|
|
116
|
+
while (i < b.length) {
|
|
117
|
+
let key;
|
|
118
|
+
[key, i] = varint(b, i);
|
|
119
|
+
const w = key % 8;
|
|
120
|
+
if (w === 0) [, i] = varint(b, i);
|
|
121
|
+
else if (w === 2) {
|
|
122
|
+
let n;
|
|
123
|
+
[n, i] = varint(b, i);
|
|
124
|
+
if (Math.floor(key / 8) === 5) {
|
|
125
|
+
const meta = b.subarray(i, Math.min(b.length, i + n));
|
|
126
|
+
let j = 0;
|
|
127
|
+
while (j < meta.length) {
|
|
128
|
+
let k, len;
|
|
129
|
+
[k, j] = varint(meta, j);
|
|
130
|
+
if (k % 8 === 0) [, j] = varint(meta, j);
|
|
131
|
+
else if (k % 8 !== 2) return null;
|
|
132
|
+
else {
|
|
133
|
+
[len, j] = varint(meta, j);
|
|
134
|
+
if (k === 10) {
|
|
135
|
+
if (j + len > meta.length) return null;
|
|
136
|
+
const t = pbFields(meta.subarray(j, j + len));
|
|
137
|
+
return int(t, 1) ? int(t, 1) * 1000 + Math.floor(int(t, 2) / 1e6) : null;
|
|
138
|
+
}
|
|
139
|
+
j += len;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
i += n;
|
|
145
|
+
} else if (w === 1) i += 8;
|
|
146
|
+
else if (w === 5) i += 4;
|
|
147
|
+
else return null;
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {
|
|
150
|
+
debug('agy', 'step time decode failed', e);
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- 진행 기록: steps 한 줄이 단계 하나다 ----
|
|
156
|
+
|
|
157
|
+
// step_payload 의 필드 번호는 기록에서 확인한 값이다: 1 단계 종류, 5 메타 (1 만든 시각 {1 초, 2 나노초},
|
|
158
|
+
// 4 도구 호출 {1 호출 id, 2 이름, 3 인자 JSON}), 19 → 2 사용자 입력, 20 → 1 답변 글, 20 → 7 답변이 낸 도구 호출 (5 → 4 와 같은 모양).
|
|
159
|
+
export const STEP = { PROMPT: 14, REPLY: 15, TOOL: 132 };
|
|
160
|
+
// steps.status: 1 대기, 2 실행 중, 8 답변 생성 중, 9 사용자 허락 대기, 3 끝. 3 이 아닌 넷은 아직 바뀌는 중이다.
|
|
161
|
+
// 9 는 도구 호출이 사용자의 허락을 기다리는 동안이다 (MCP 호출, run_command 에서 보았다). 허락하면 2 로 바뀐다.
|
|
162
|
+
const OPEN = new Set([1, 2, 8, 9]);
|
|
163
|
+
const APPROVAL = 9;
|
|
164
|
+
const MAX_TURNS = 50;
|
|
165
|
+
const EMPTY = Buffer.alloc(0);
|
|
166
|
+
|
|
167
|
+
// 도구 호출을 한 줄로. agy 도구 인자는 PascalCase 다 (read_file {Path}, run_command {CommandLine}, call_mcp_tool {ServerName, ToolName}).
|
|
168
|
+
export function describeAgyTool(name, args = {}) {
|
|
169
|
+
if (name === 'call_mcp_tool') return describeTool(`mcp ${args.ServerName ?? ''}/${args.ToolName ?? ''}`, {});
|
|
170
|
+
if (name === 'run_command') return describeTool('Bash', { command: args.CommandLine ?? '' });
|
|
171
|
+
const path = args.Path ?? args.AbsolutePath ?? args.TargetFile;
|
|
172
|
+
if (typeof path === 'string') return describeTool(`${name} ${basename(path)}`, {});
|
|
173
|
+
return describeTool(name, args);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toolOf(call) {
|
|
177
|
+
if (!call) return null;
|
|
178
|
+
const f = pbFields(call);
|
|
179
|
+
const name = msg(f, 2)?.toString('utf8');
|
|
180
|
+
if (!name) return null;
|
|
181
|
+
let args = {};
|
|
182
|
+
try {
|
|
183
|
+
args = JSON.parse(msg(f, 3)?.toString('utf8') ?? '{}');
|
|
184
|
+
} catch (e) {
|
|
185
|
+
debug('agy', 'tool arguments parse failed', e);
|
|
186
|
+
}
|
|
187
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
188
|
+
return { name, what: describeAgyTool(name, a), detail: agyToolDetail(name, a) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 허락을 기다리는 도구 호출의 전체 내용: 명령은 $ 없이 그대로, 파일은 전체 경로, MCP 는 서버/도구 이름과 인자.
|
|
192
|
+
export function agyToolDetail(name, args = {}) {
|
|
193
|
+
if (name === 'call_mcp_tool') {
|
|
194
|
+
const { ServerName, ToolName, ...rest } = args;
|
|
195
|
+
const head = `${ServerName ?? ''}/${ToolName ?? ''}`;
|
|
196
|
+
const body = Object.keys(rest).length ? toolDetail('mcp', rest) : null;
|
|
197
|
+
return toolDetail('mcp', `${head}${body ? ' ' + body : ''}`);
|
|
198
|
+
}
|
|
199
|
+
if (name === 'run_command') return toolDetail('Bash', { command: args.CommandLine ?? '' });
|
|
200
|
+
const path = args.Path ?? args.AbsolutePath ?? args.TargetFile;
|
|
201
|
+
if (typeof path === 'string') return toolDetail('Read', { file_path: path });
|
|
202
|
+
return toolDetail(name, args);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// step_payload 한 줄 → { type, at, text, tool }. 해석하지 못하면 null.
|
|
206
|
+
export function readStep(data) {
|
|
207
|
+
try {
|
|
208
|
+
const f = pbFields(Buffer.from(data ?? EMPTY));
|
|
209
|
+
const type = int(f, 1);
|
|
210
|
+
if (!type) return null;
|
|
211
|
+
const meta = pbFields(msg(f, 5) ?? EMPTY);
|
|
212
|
+
const t = msg(meta, 1) && pbFields(msg(meta, 1));
|
|
213
|
+
const step = { type, at: t ? int(t, 1) * 1000 + Math.floor(int(t, 2) / 1e6) : null, text: null, tool: null };
|
|
214
|
+
if (type === STEP.PROMPT) step.text = msg(pbFields(msg(f, 19) ?? EMPTY), 2)?.toString('utf8') || null;
|
|
215
|
+
else if (type === STEP.REPLY) {
|
|
216
|
+
const b = pbFields(msg(f, 20) ?? EMPTY);
|
|
217
|
+
step.text = msg(b, 1)?.toString('utf8') || null;
|
|
218
|
+
step.tool = toolOf(msg(b, 7));
|
|
219
|
+
} else if (type === STEP.TOOL) step.tool = toolOf(msg(meta, 4));
|
|
220
|
+
return step;
|
|
221
|
+
} catch (e) {
|
|
222
|
+
debug('agy', 'step decode failed', e);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- 대화별 기록 (gen_metadata 와 steps 를 새 줄만 이어 읽는다) ----
|
|
228
|
+
|
|
229
|
+
const convs = new Map();
|
|
230
|
+
|
|
231
|
+
const fresh = () => ({
|
|
232
|
+
sig: null,
|
|
233
|
+
idx: -1,
|
|
234
|
+
tokens: emptyTokens(),
|
|
235
|
+
model: null,
|
|
236
|
+
prompt: 0,
|
|
237
|
+
cost: 0,
|
|
238
|
+
costNoCache: 0,
|
|
239
|
+
costUnknown: false,
|
|
240
|
+
// 시간별 토큰 (common.mjs addActivity). 호출 시각은 호출 직전 단계의 만든 시각이다.
|
|
241
|
+
activity: {},
|
|
242
|
+
// steps 는 step 번까지 다 넣었고, 그 뒤로는 seen 에 적힌 줄만 넣었다.
|
|
243
|
+
step: -1,
|
|
244
|
+
seen: [],
|
|
245
|
+
events: [],
|
|
246
|
+
turns: [],
|
|
247
|
+
turnCount: 0,
|
|
248
|
+
lastTool: null,
|
|
249
|
+
last: null,
|
|
250
|
+
// 아직 바뀌는 중인 단계 가운데 가장 뒤의 것 (지금 무엇을 하는지 보이는 데 쓴다).
|
|
251
|
+
open: null,
|
|
252
|
+
// 허락을 기다리는 단계 가운데 가장 뒤의 것. main·wal 은 그 상태를 본 파일에서 읽었을 때의 본 파일·-wal 서명이다.
|
|
253
|
+
wait: null,
|
|
254
|
+
path: null,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// SQLite 파일 머리 24번째 바이트의 변경 카운터. 쓰기마다 1 씩 늘어서, 수정 시각·크기가 그대로인 쓰기도 알아챈다.
|
|
258
|
+
function changeCounter(path) {
|
|
259
|
+
let fd;
|
|
260
|
+
try {
|
|
261
|
+
fd = openSync(path, 'r');
|
|
262
|
+
const b = Buffer.alloc(4);
|
|
263
|
+
return readSync(fd, b, 0, 4, 24) === 4 ? b.readUInt32BE(0) : '';
|
|
264
|
+
} catch {
|
|
265
|
+
return '';
|
|
266
|
+
} finally {
|
|
267
|
+
if (fd != null) closeSync(fd);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 파일 서명. 커널의 수정 시각은 몇 ms 단위로 거칠고 WAL 모드에서는 변경 카운터가 늘지 않아,
|
|
272
|
+
// 다시 읽을지는 -wal 의 상태까지 함께 본다 (agy 가 쓰면 -wal 이 생기거나 바뀌고, 합치면 지워지거나 비워진다).
|
|
273
|
+
// 읽은 결과를 버릴지는 본 파일만 본다 (immutable 은 본 파일만 읽는다).
|
|
274
|
+
export function sigOf(path, withWal = true) {
|
|
275
|
+
let main;
|
|
276
|
+
try {
|
|
277
|
+
const st = statSync(path);
|
|
278
|
+
main = `${changeCounter(path)};${st.mtimeMs}:${st.size}`;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (!withWal) return main;
|
|
283
|
+
try {
|
|
284
|
+
const w = statSync(`${path}-wal`);
|
|
285
|
+
return `${main};${w.mtimeMs}:${w.size}`;
|
|
286
|
+
} catch {
|
|
287
|
+
return `${main};-`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const outOf = (budget) => budget.left <= 0 || (budget.read > 0 && Date.now() >= (budget.deadline ?? Infinity));
|
|
292
|
+
|
|
293
|
+
const STEP_HEAD = 16384;
|
|
294
|
+
|
|
295
|
+
// 호출 직전 단계들의 만든 시각. 단계 내용은 클 수 있어 앞부분만 읽고, 거기서 못 찾은 것만 통째로 읽는다.
|
|
296
|
+
function stepTimes(db, idxs, budget) {
|
|
297
|
+
const out = new Map();
|
|
298
|
+
if (!idxs.length) return out;
|
|
299
|
+
const head = db.prepare('select substr(step_payload, 1, ?) d from steps where idx = ?');
|
|
300
|
+
const full = db.prepare('select step_payload d from steps where idx = ?');
|
|
301
|
+
for (const i of new Set(idxs)) {
|
|
302
|
+
const r = head.get(STEP_HEAD, i);
|
|
303
|
+
spend(budget, r?.d);
|
|
304
|
+
let at = r?.d ? stepTime(r.d) : null;
|
|
305
|
+
if (at == null && r?.d?.length === STEP_HEAD) {
|
|
306
|
+
const f = full.get(i);
|
|
307
|
+
spend(budget, f?.d);
|
|
308
|
+
at = stepTime(f?.d);
|
|
309
|
+
}
|
|
310
|
+
if (at != null) out.set(i, at);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// gen_metadata 를 s.idx 다음부터 이어 읽어 사용량이 있는 호출마다 onCall({ model, usage, at, cost }) 을 부른다.
|
|
316
|
+
// at 은 호출 직전 단계의 만든 시각이고, 단계를 찾지 못하면 fallbackAt(대화 기록 파일의 수정 시각)이다.
|
|
317
|
+
// cost 는 at 시각의 가격으로 환산한 값이고, 가격표에 없는 모델이면 null.
|
|
318
|
+
// 돌려주는 값: 'done' 끝까지, 'torn' 마지막 줄이 쓰는 중, 'exhausted' 읽기 한도.
|
|
319
|
+
export function readCalls(db, s, last, budget, fallbackAt, onCall) {
|
|
320
|
+
const q = db.prepare('select idx, data from gen_metadata where idx > ? order by idx limit ?');
|
|
321
|
+
const hasSteps = Boolean(db.prepare("select 1 from sqlite_master where type = 'table' and name = 'steps'").get());
|
|
322
|
+
for (;;) {
|
|
323
|
+
const rows = q.all(s.idx, BATCH);
|
|
324
|
+
const calls = [];
|
|
325
|
+
let torn = false;
|
|
326
|
+
for (const r of rows) {
|
|
327
|
+
spend(budget, r.data);
|
|
328
|
+
const x = readGeneration(r.data);
|
|
329
|
+
// 마지막 줄을 해석하지 못하면 아직 쓰는 중일 수 있으니 그 앞에서 멈추고 다음 갱신에 다시 읽는다.
|
|
330
|
+
// 뒤에 줄이 더 있는데도 못 읽는 줄은 형식이 다른 것이라 건너뛴다.
|
|
331
|
+
if (!x && r.idx === last) {
|
|
332
|
+
torn = true;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
calls.push({ idx: r.idx, x });
|
|
336
|
+
}
|
|
337
|
+
const times = hasSteps ? stepTimes(db, calls.map((c) => c.x?.usage && c.x.step).filter((i) => i != null), budget) : new Map();
|
|
338
|
+
for (const { idx, x } of calls) {
|
|
339
|
+
s.idx = idx;
|
|
340
|
+
if (x?.model) s.model = x.model;
|
|
341
|
+
if (!x?.usage) continue;
|
|
342
|
+
const at = times.get(x.step) ?? fallbackAt;
|
|
343
|
+
onCall({ model: s.model, usage: x.usage, at, cost: geminiCost(s.model, x.usage, at) });
|
|
344
|
+
}
|
|
345
|
+
if (torn) return 'torn';
|
|
346
|
+
if (rows.length < BATCH) return 'done';
|
|
347
|
+
if (outOf(budget)) return 'exhausted';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const callTokens = (u) => u.input + u.output + u.cacheRead + u.cacheWrite;
|
|
352
|
+
|
|
353
|
+
// gen_metadata 를 이어 읽어 n 의 토큰과 비용에 더한다. 돌려주는 값은 readCalls 와 같다.
|
|
354
|
+
function readGenerations(db, n, last, budget, fallbackAt) {
|
|
355
|
+
return readCalls(db, n, last, budget, fallbackAt, ({ usage: u, cost, at }) => {
|
|
356
|
+
const t = n.tokens;
|
|
357
|
+
t.input += u.input;
|
|
358
|
+
t.output += u.output;
|
|
359
|
+
t.cacheRead += u.cacheRead;
|
|
360
|
+
t.cacheWrite += u.cacheWrite;
|
|
361
|
+
t.total += callTokens(u);
|
|
362
|
+
n.prompt = u.input + u.cacheRead + u.cacheWrite;
|
|
363
|
+
addActivity(n.activity, at, callTokens(u));
|
|
364
|
+
n.cost += cost?.total ?? 0;
|
|
365
|
+
n.costNoCache += cost?.noCache ?? 0;
|
|
366
|
+
if (!cost && callTokens(u) > 0) n.costUnknown = true;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// steps 를 이어 읽어 n 의 진행 기록에 넣는다. 돌려주는 값은 readGenerations 와 같다.
|
|
371
|
+
// agy 는 단계 한 줄을 만든 뒤 상태와 내용을 그 자리에서 고쳐 쓰므로, 아직 바뀌는 중인 단계는 넣지 않고 다음에 다시 읽는다.
|
|
372
|
+
// 그 뒤의 단계를 먼저 넣었으면 n.seen 에 적어 두어 두 번 넣지 않는다. 바뀌는 중인 단계는 매번 다시 훑으므로 open·wait 도 매번 새로 정한다.
|
|
373
|
+
function readSteps(db, n, last, budget) {
|
|
374
|
+
const q = db.prepare('select idx, status, step_payload data from steps where idx > ? order by idx limit ?');
|
|
375
|
+
const seen = new Set(n.seen);
|
|
376
|
+
let cur = n.step;
|
|
377
|
+
let hold = null;
|
|
378
|
+
let result = 'done';
|
|
379
|
+
n.open = null;
|
|
380
|
+
n.wait = null;
|
|
381
|
+
scan: for (;;) {
|
|
382
|
+
const rows = q.all(cur, BATCH);
|
|
383
|
+
for (const r of rows) {
|
|
384
|
+
spend(budget, r.data);
|
|
385
|
+
if (seen.has(r.idx)) {
|
|
386
|
+
cur = r.idx;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const s = readStep(r.data);
|
|
390
|
+
// 바뀌는 중인 단계는 내용이 아직 비어 있어도 나중에 다시 읽는다.
|
|
391
|
+
if (OPEN.has(r.status)) {
|
|
392
|
+
cur = r.idx;
|
|
393
|
+
hold ??= r.idx;
|
|
394
|
+
if (s) n.open = { ...s, idx: r.idx, status: r.status };
|
|
395
|
+
if (r.status === APPROVAL) n.wait = { idx: r.idx, what: s?.tool?.what || '도구 허락', detail: s?.tool?.detail ?? null };
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!s && r.idx === last) {
|
|
399
|
+
hold ??= r.idx;
|
|
400
|
+
result = 'torn';
|
|
401
|
+
break scan;
|
|
402
|
+
}
|
|
403
|
+
cur = r.idx;
|
|
404
|
+
applyStep(n, s, r.idx);
|
|
405
|
+
seen.add(r.idx);
|
|
406
|
+
}
|
|
407
|
+
if (rows.length < BATCH) break;
|
|
408
|
+
if (outOf(budget)) {
|
|
409
|
+
result = 'exhausted';
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
n.step = hold === null ? cur : hold - 1;
|
|
414
|
+
n.seen = [...seen].filter((i) => i > n.step);
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 늦게 끝난 단계는 뒤 단계보다 나중에 들어오므로 시각 순서 자리로 옮긴다.
|
|
419
|
+
function addEvent(n, at, kind, text) {
|
|
420
|
+
const e = pushEvent(n, at, kind, text);
|
|
421
|
+
if (!e) return null;
|
|
422
|
+
const ev = n.events;
|
|
423
|
+
for (let i = ev.length - 1; i > 0 && ev[i - 1].at > e.at; i--) [ev[i - 1], ev[i]] = [ev[i], ev[i - 1]];
|
|
424
|
+
return e;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function applyStep(n, s, idx) {
|
|
428
|
+
if (!s) return;
|
|
429
|
+
const at = s.at ? new Date(s.at).toISOString() : null;
|
|
430
|
+
if (s.type === STEP.PROMPT) {
|
|
431
|
+
if (s.text && !isNoisePrompt(s.text)) {
|
|
432
|
+
const e = addEvent(n, at, 'prompt', s.text);
|
|
433
|
+
if (e) {
|
|
434
|
+
n.turns.push({ n: ++n.turnCount, at: e.at, text: e.text.slice(0, 120) });
|
|
435
|
+
if (n.turns.length > MAX_TURNS) n.turns.shift();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} else if (s.type === STEP.REPLY) {
|
|
439
|
+
if (s.text) addEvent(n, at, 'reply', s.text);
|
|
440
|
+
} else if (s.type === STEP.TOOL) {
|
|
441
|
+
if (s.tool) {
|
|
442
|
+
addEvent(n, at, 'tool', s.tool.what);
|
|
443
|
+
n.lastTool = s.tool.what;
|
|
444
|
+
}
|
|
445
|
+
} else return;
|
|
446
|
+
if (idx > (n.last?.idx ?? -1)) n.last = { type: s.type, tool: s.tool, idx };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const spend = (budget, data) => {
|
|
450
|
+
const size = data?.length ?? 0;
|
|
451
|
+
budget.left -= size;
|
|
452
|
+
budget.read = (budget.read ?? 0) + size;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const mtimeOf = (path) => {
|
|
456
|
+
try {
|
|
457
|
+
return statSync(path).mtimeMs;
|
|
458
|
+
} catch {
|
|
459
|
+
return Date.now();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
export const maxIdx = (db, table) => db.prepare(`select max(idx) m from ${table}`).get()?.m ?? -1;
|
|
464
|
+
|
|
465
|
+
// 대화 기록 하나를 한 번 열어 토큰(gen_metadata)과 진행 기록(steps)을 함께 읽는다.
|
|
466
|
+
export function readConversation(id, budget) {
|
|
467
|
+
let g = convs.get(id);
|
|
468
|
+
if (!g) convs.set(id, (g = fresh()));
|
|
469
|
+
const path = join(AGY_ROOT, 'conversations', `${id}.db`);
|
|
470
|
+
const sig = sigOf(path);
|
|
471
|
+
if (sig === null || sig === g.sig) return g;
|
|
472
|
+
const mainSig = sigOf(path, false);
|
|
473
|
+
if (outOf(budget)) {
|
|
474
|
+
budget.exhausted = true;
|
|
475
|
+
return g;
|
|
476
|
+
}
|
|
477
|
+
const mtime = mtimeOf(path);
|
|
478
|
+
// 복사본에 더하고, 끝까지 탈 없이 읽었을 때만 반영한다.
|
|
479
|
+
let n = { ...g, tokens: { ...g.tokens }, activity: { ...g.activity }, events: [...g.events], turns: [...g.turns], seen: [...g.seen] };
|
|
480
|
+
let db;
|
|
481
|
+
let ok = false;
|
|
482
|
+
try {
|
|
483
|
+
db = openReadOnly(path);
|
|
484
|
+
if (!db) return g;
|
|
485
|
+
const tables = new Set(db.prepare("select name from sqlite_master where type = 'table'").all().map((r) => r.name));
|
|
486
|
+
const lastGen = tables.has('gen_metadata') ? maxIdx(db, 'gen_metadata') : -1;
|
|
487
|
+
const lastStep = tables.has('steps') ? maxIdx(db, 'steps') : -1;
|
|
488
|
+
// 마지막 줄 번호가 줄었으면 기록이 새로 쓰인 것이니 처음부터 다시 읽는다.
|
|
489
|
+
if (lastGen < n.idx || lastStep < n.step) n = fresh();
|
|
490
|
+
const gen = tables.has('gen_metadata') ? readGenerations(db, n, lastGen, budget, mtime) : 'done';
|
|
491
|
+
const steps = gen !== 'exhausted' && tables.has('steps') ? readSteps(db, n, lastStep, budget) : 'done';
|
|
492
|
+
if (gen === 'done' && steps === 'done') n.sig = sig;
|
|
493
|
+
if (gen === 'exhausted' || steps === 'exhausted') budget.exhausted = true;
|
|
494
|
+
n.path = path;
|
|
495
|
+
// 허락 대기를 본 파일에서 처음 읽은 때의 -wal 서명을 남긴다. 본 파일이 그대로인 채 -wal 만 바뀌면 같은 대기로 보고 남긴 값을 둔다.
|
|
496
|
+
if (n.wait) {
|
|
497
|
+
const prev = g.wait;
|
|
498
|
+
const same = prev?.idx === n.wait.idx && prev.main === mainSig;
|
|
499
|
+
n.wait = { ...n.wait, main: mainSig, wal: same ? prev.wal : walSig(path) };
|
|
500
|
+
}
|
|
501
|
+
// 읽는 동안 agy 가 본 파일에 썼으면 읽은 내용을 믿을 수 없으니 버리고 다음 갱신에 다시 읽는다.
|
|
502
|
+
ok = sigOf(path, false) === mainSig;
|
|
503
|
+
} catch (e) {
|
|
504
|
+
debugIo('agy', 'conversation read failed', e, path);
|
|
505
|
+
// agy 가 쓰는 중이라 잠겨 있으면 다음 갱신에 다시 읽는다.
|
|
506
|
+
} finally {
|
|
507
|
+
db?.close();
|
|
508
|
+
}
|
|
509
|
+
if (ok) Object.assign(g, n);
|
|
510
|
+
return g;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---- 대화 목록 ----
|
|
514
|
+
|
|
515
|
+
// '2026-11-06 02:19:18.516555503+00:00' 형식. 값이 없으면 0001-01-01 이 들어 있는데, Date.parse 는 이것을 2001 년으로 읽으므로 연도를 먼저 본다.
|
|
516
|
+
export function parseTime(s) {
|
|
517
|
+
if (!(Number(String(s ?? '').slice(0, 4)) >= 2000)) return null;
|
|
518
|
+
return Date.parse(s) || null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function cwdOf(workspaceUris) {
|
|
522
|
+
try {
|
|
523
|
+
const first = JSON.parse(workspaceUris || '[]')[0];
|
|
524
|
+
if (!first) return null;
|
|
525
|
+
return first.startsWith('file:') ? fileURLToPath(first) : first;
|
|
526
|
+
} catch (e) {
|
|
527
|
+
debug('agy', 'workspace_uris parse failed', e);
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 읽는 동안 agy 가 쓰면 결과를 버리고 지난번 목록을 쓴다 (한 번 갱신 동안 세션이 사라져 보이지 않게).
|
|
533
|
+
let summaries = { sig: null, rows: [] };
|
|
534
|
+
|
|
535
|
+
function readSummaries() {
|
|
536
|
+
const path = join(AGY_ROOT, 'conversation_summaries.db');
|
|
537
|
+
const sig = sigOf(path);
|
|
538
|
+
if (sig === null) return [];
|
|
539
|
+
if (sig === summaries.sig) return summaries.rows;
|
|
540
|
+
const mainSig = sigOf(path, false);
|
|
541
|
+
let db;
|
|
542
|
+
try {
|
|
543
|
+
db = openReadOnly(path);
|
|
544
|
+
if (!db) return [];
|
|
545
|
+
const rows = db
|
|
546
|
+
.prepare(
|
|
547
|
+
`select conversation_id id, title, step_count steps, last_modified_time modified, workspace_uris ws, status,
|
|
548
|
+
agent_name agent, parent_conversation_id parent, not_fully_idle busy, killed
|
|
549
|
+
from conversation_summaries order by last_modified_time desc limit 500`,
|
|
550
|
+
)
|
|
551
|
+
.all()
|
|
552
|
+
.map((r) => ({ ...r, cwd: cwdOf(r.ws), updatedAt: parseTime(r.modified), running: r.status === RUNNING || Boolean(r.busy) }));
|
|
553
|
+
if (sigOf(path, false) === mainSig) summaries = { sig, rows };
|
|
554
|
+
} catch (e) {
|
|
555
|
+
debugIo('agy', 'conversation_summaries read failed', e, path);
|
|
556
|
+
// 다음 갱신에 다시 읽는다.
|
|
557
|
+
} finally {
|
|
558
|
+
db?.close();
|
|
559
|
+
}
|
|
560
|
+
return summaries.rows;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// 열린 파일 경로에서 대화 id 를 꺼낸다 (presence/<id>.lock, conversations/<id>.db[-wal|-shm]).
|
|
564
|
+
// root 는 agyRoot() 로 바꾼 경로여야 한다.
|
|
565
|
+
export function idFromPath(p, root = AGY_ROOT) {
|
|
566
|
+
if (!p.startsWith(root + '/')) return null;
|
|
567
|
+
const m = /^(?:presence\/([\w-]+)\.lock|conversations\/([\w-]+)\.db(?:-wal|-shm)?)(?: \(deleted\))?$/.exec(p.slice(root.length + 1));
|
|
568
|
+
return m ? m[1] ?? m[2] : null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const rootOf = (id, byId) => {
|
|
572
|
+
let cur = byId.get(id);
|
|
573
|
+
for (let i = 0; cur?.parent && byId.has(cur.parent) && i < 10; i++) cur = byId.get(cur.parent);
|
|
574
|
+
return cur?.id ?? id;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// 실행 중인 agy 프로세스와 최상위 대화를 짝짓는다. 짝을 못 찾은 프로세스는 따로 돌려준다.
|
|
578
|
+
export function matchLive(procs, rows, filesOf = openFiles, dir = AGY_ROOT) {
|
|
579
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
580
|
+
const live = new Map();
|
|
581
|
+
const loose = [];
|
|
582
|
+
for (const p of procs) {
|
|
583
|
+
const files = filesOf(p.pid);
|
|
584
|
+
const ids = (files ?? []).map((f) => idFromPath(f, dir)).filter((id) => id && byId.has(id));
|
|
585
|
+
if (ids.length) {
|
|
586
|
+
for (const id of ids) {
|
|
587
|
+
const root = rootOf(id, byId);
|
|
588
|
+
if (!live.has(root)) live.set(root, p.pid);
|
|
589
|
+
}
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
// /proc 이 없으면 같은 작업 폴더에서 가장 최근에 바뀐 대화를 고른다.
|
|
593
|
+
const guess = files === null ? rows.find((r) => !r.parent && r.cwd === p.cwd && !live.has(r.id)) : null;
|
|
594
|
+
if (guess) live.set(guess.id, p.pid);
|
|
595
|
+
else loose.push(p);
|
|
596
|
+
}
|
|
597
|
+
return { live, loose };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function member(r, g) {
|
|
601
|
+
return {
|
|
602
|
+
id: r.id,
|
|
603
|
+
name: r.agent || 'agent',
|
|
604
|
+
model: g.model,
|
|
605
|
+
task: r.title ?? '',
|
|
606
|
+
status: r.running ? 'busy' : 'done',
|
|
607
|
+
started: g.tokens.total > 0,
|
|
608
|
+
phase: r.running ? { kind: 'think', text: `작업 중 · ${r.steps}단계` } : { kind: 'done', text: '완료' },
|
|
609
|
+
now: '',
|
|
610
|
+
tokens: g.tokens.total,
|
|
611
|
+
cost: g.cost,
|
|
612
|
+
costNoCache: g.costNoCache,
|
|
613
|
+
costUnknown: g.costUnknown,
|
|
614
|
+
// 기록에 시작 시각이 없어 마지막으로 바뀐 시각을 둔다 (정렬용).
|
|
615
|
+
updatedAt: r.updatedAt,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// -wal 의 크기·수정 시각. 열지 않고 stat 만 한다.
|
|
620
|
+
function walSig(path) {
|
|
621
|
+
try {
|
|
622
|
+
const w = statSync(`${path}-wal`);
|
|
623
|
+
return `${w.mtimeMs}:${w.size}`;
|
|
624
|
+
} catch {
|
|
625
|
+
return '-';
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 허락을 기다리는 도구 호출이 있으면 그 도구를 한 줄로 돌려준다.
|
|
630
|
+
// immutable 로는 -wal 을 읽지 못해 본 파일에 합쳐진 뒤에야 대기가 보이고, 허락한 뒤에도 다시 합쳐질 때까지 대기로 남는다.
|
|
631
|
+
// 그래서 대기를 본 뒤 -wal 이 바뀌었으면 agy 가 다시 쓰고 있는 것(사용자가 이미 답한 것)으로 보고 대기로 치지 않는다.
|
|
632
|
+
export function waitingFor(g) {
|
|
633
|
+
const w = g.wait;
|
|
634
|
+
if (!w) return null;
|
|
635
|
+
if (g.path && w.wal !== undefined && walSig(g.path) !== w.wal) return null;
|
|
636
|
+
return w.what;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 실행 중일 때는 아직 바뀌는 중인 단계, 없으면 마지막으로 끝난 단계로 지금 할 일을 짐작한다.
|
|
640
|
+
// 끝난 답변이 도구 호출을 냈으면 그 도구를 실행할 차례이고, 끝난 도구 호출 뒤에는 모델이 결과를 읽고 생각한다.
|
|
641
|
+
export function phaseOf(live, r, g, runningMembers = 0) {
|
|
642
|
+
if (!live) return { kind: 'ended', text: '종료' };
|
|
643
|
+
const extra = runningMembers ? ` · 에이전트 ${runningMembers}개 실행 중` : '';
|
|
644
|
+
const wait = waitingFor(g);
|
|
645
|
+
if (wait) return { kind: 'waiting', text: `응답 대기 · ${wait}${extra}` };
|
|
646
|
+
if (!r.running) return { kind: 'idle', text: '다음 지시 대기' };
|
|
647
|
+
// 바뀌는 중인 단계와 끝난 단계 가운데 더 뒤의 것이 지금 하는 일이다 (앞의 명령이 오래 도는 동안 뒤 단계가 이어질 수 있다).
|
|
648
|
+
const open = g.open && (!g.last || (g.open.idx ?? Infinity) > (g.last.idx ?? -1));
|
|
649
|
+
const s = open ? g.open : (g.last ?? g.open);
|
|
650
|
+
let p;
|
|
651
|
+
if (!s) p = { kind: 'think', text: `작업 중 · ${r.steps}단계` };
|
|
652
|
+
else if (s.type === STEP.TOOL && open) p = { kind: 'tool', text: `도구 실행 · ${s.tool?.what || '도구'}` };
|
|
653
|
+
else if (s.type === STEP.REPLY && !open && s.tool) p = { kind: 'tool', text: `도구 실행 · ${s.tool.what}` };
|
|
654
|
+
else if (s.type === STEP.REPLY) p = { kind: 'write', text: '답변 작성 중' };
|
|
655
|
+
else p = { kind: 'think', text: '생각 중' };
|
|
656
|
+
return { ...p, text: p.text + extra };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function placeholder(p, text) {
|
|
660
|
+
return {
|
|
661
|
+
tool: 'gemini',
|
|
662
|
+
id: `agy-${p.pid}`,
|
|
663
|
+
pid: p.pid,
|
|
664
|
+
kind: 'interactive',
|
|
665
|
+
name: basename(p.cwd ?? '') || 'agy',
|
|
666
|
+
cwd: p.cwd,
|
|
667
|
+
project: basename(p.cwd ?? ''),
|
|
668
|
+
model: null,
|
|
669
|
+
live: true,
|
|
670
|
+
status: 'idle',
|
|
671
|
+
phase: { kind: 'idle', text },
|
|
672
|
+
cost: null,
|
|
673
|
+
now: '',
|
|
674
|
+
updatedAt: Date.now(),
|
|
675
|
+
tokens: emptyTokens(),
|
|
676
|
+
context: { used: 0, window: 0 },
|
|
677
|
+
events: [],
|
|
678
|
+
members: [],
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function collectAgy({ showEnded, budget }) {
|
|
683
|
+
const procs = findProcesses(isAgy);
|
|
684
|
+
// 기록 폴더가 없으면 agy 를 실행한 적이 없는 것이다 (테스트에서 AITOP_AGY_HOME 으로 실제 기록과 떼어 놓을 때도 쓴다).
|
|
685
|
+
if (!existsSync(AGY_ROOT) || (!procs.length && !showEnded)) return [];
|
|
686
|
+
if (!loadSqlite()) return procs.map((p) => placeholder(p, '실행 중 (세부 정보는 Node 22.15 이상)'));
|
|
687
|
+
|
|
688
|
+
const rows = readSummaries();
|
|
689
|
+
const { live, loose } = matchLive(procs, rows);
|
|
690
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
691
|
+
const children = new Map();
|
|
692
|
+
for (const r of rows) if (r.parent) {
|
|
693
|
+
const root = rootOf(r.id, byId);
|
|
694
|
+
children.set(root, [...(children.get(root) ?? []), r]);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const out = [];
|
|
698
|
+
// 살아 있는 대화부터 읽어 읽기 한도가 오래된 기록에 먼저 쓰이지 않게 한다.
|
|
699
|
+
// 부모가 목록에 없는 서브에이전트(목록 상한 밖)는 혼자 최상위로 본다.
|
|
700
|
+
const tops = rows.filter((r) => rootOf(r.id, byId) === r.id && (live.has(r.id) || (showEnded && Date.now() - (r.updatedAt ?? 0) < RECENT_MS)));
|
|
701
|
+
tops.sort((a, b) => Number(live.has(b.id)) - Number(live.has(a.id)));
|
|
702
|
+
for (const r of tops) {
|
|
703
|
+
const isLive = live.has(r.id);
|
|
704
|
+
const g = readConversation(r.id, budget);
|
|
705
|
+
const kids = children.get(r.id) ?? [];
|
|
706
|
+
// 서브에이전트 기록은 한 번만 읽어 행과 합계가 같은 값을 쓰게 한다.
|
|
707
|
+
const kidUsage = kids.map((c) => readConversation(c.id, budget));
|
|
708
|
+
// 끝난 서브에이전트는 Claude 팀원처럼 잠시 "완료"로 보였다가 행에서 빠진다. 합계에는 그대로 남는다.
|
|
709
|
+
const members = kids
|
|
710
|
+
.map((c, i) => member(c, kidUsage[i]))
|
|
711
|
+
.filter((m) => m.status === 'busy' || (m.updatedAt != null && Date.now() - m.updatedAt <= MEMBER_FADE_MS))
|
|
712
|
+
.sort((a, b) => (a.updatedAt ?? 0) - (b.updatedAt ?? 0));
|
|
713
|
+
// 메인과 서브에이전트를 항목별로 더한다 (Claude 세션과 같은 방식).
|
|
714
|
+
const tokens = { ...g.tokens, main: g.tokens.total, sub: 0, subCount: kids.length };
|
|
715
|
+
let cost = g.cost;
|
|
716
|
+
let costNoCache = g.costNoCache;
|
|
717
|
+
let costUnknown = g.costUnknown;
|
|
718
|
+
for (const u of kidUsage) {
|
|
719
|
+
const t = u.tokens;
|
|
720
|
+
for (const k of Object.keys(emptyTokens())) tokens[k] += t[k];
|
|
721
|
+
tokens.sub += t.total;
|
|
722
|
+
cost += u.cost;
|
|
723
|
+
costNoCache += u.costNoCache;
|
|
724
|
+
costUnknown ||= u.costUnknown;
|
|
725
|
+
}
|
|
726
|
+
const running = members.filter((m) => m.status === 'busy').length;
|
|
727
|
+
const wait = isLive ? waitingFor(g) : null;
|
|
728
|
+
out.push({
|
|
729
|
+
tool: 'gemini',
|
|
730
|
+
id: r.id,
|
|
731
|
+
pid: live.get(r.id) ?? null,
|
|
732
|
+
kind: 'interactive',
|
|
733
|
+
name: r.title || basename(r.cwd ?? '') || 'agy',
|
|
734
|
+
cwd: r.cwd,
|
|
735
|
+
project: basename(r.cwd ?? ''),
|
|
736
|
+
branch: null,
|
|
737
|
+
model: g.model,
|
|
738
|
+
live: isLive,
|
|
739
|
+
status: !isLive ? 'ended' : wait ? 'waiting' : r.running ? 'busy' : 'idle',
|
|
740
|
+
phase: phaseOf(isLive, r, g, running),
|
|
741
|
+
cost,
|
|
742
|
+
costNoCache,
|
|
743
|
+
costUnknown,
|
|
744
|
+
waitingFor: wait,
|
|
745
|
+
waitDetail: wait ? (g.wait.detail ?? wait) : null,
|
|
746
|
+
now: g.lastTool ?? g.events.at(-1)?.text ?? '',
|
|
747
|
+
startedAt: null,
|
|
748
|
+
updatedAt: r.updatedAt ?? Date.now(),
|
|
749
|
+
tokens,
|
|
750
|
+
context: { used: g.prompt, window: contextWindow(g.model, g.prompt) },
|
|
751
|
+
events: g.events,
|
|
752
|
+
files: [],
|
|
753
|
+
turns: g.turns,
|
|
754
|
+
activity: mergeActivity([g.activity, ...kidUsage.map((u) => u.activity)]),
|
|
755
|
+
members: isLive ? members : [],
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
for (const p of loose) out.push(placeholder(p, '첫 입력 대기'));
|
|
759
|
+
|
|
760
|
+
const keep = new Set(rows.map((r) => r.id));
|
|
761
|
+
for (const id of convs.keys()) if (!keep.has(id)) convs.delete(id);
|
|
762
|
+
return out;
|
|
763
|
+
}
|