@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,116 @@
|
|
|
1
|
+
// 세션이 Edit/Write 도구로 고친 파일을 모은다. Claude 기록의 toolUseResult.structuredPatch 를 그대로 쓴다.
|
|
2
|
+
// Bash(sed, git checkout 등)로 바꾼 파일은 기록에 변경 내용이 없어 잡히지 않는다.
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { mask, cleanLine } from '../text.mjs';
|
|
5
|
+
import { parseTs } from './common.mjs';
|
|
6
|
+
|
|
7
|
+
// log: 편집 한 번마다 { at, add, del, created }. 지시 탭에서 그 지시의 숫자만 다시 셀 때 쓴다 (turns.mjs).
|
|
8
|
+
export const FILE_LIMITS = { files: 60, hunksPerFile: 8, lineChars: 200, hunkLines: 300, lines: 1500, log: 200 };
|
|
9
|
+
|
|
10
|
+
// 비밀값이 들어 있을 만한 파일은 목록에는 보이되 변경 내용은 저장하지 않는다.
|
|
11
|
+
const SECRET_FILE = /^\.env(\.|$)|\.(pem|key|p12|pfx|jks|keystore)$|^id_(rsa|dsa|ecdsa|ed25519)|^\.(npmrc|netrc|pgpass)$|secret|credential|api[._-]?key|private[._-]?key/i;
|
|
12
|
+
|
|
13
|
+
export function isSecretFile(path) {
|
|
14
|
+
return SECRET_FILE.test(basename(path));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const clean = (s) => mask(cleanLine(s)).slice(0, FILE_LIMITS.lineChars);
|
|
18
|
+
|
|
19
|
+
function toHunk(h, at) {
|
|
20
|
+
const lines = Array.isArray(h?.lines) ? h.lines : [];
|
|
21
|
+
return {
|
|
22
|
+
at,
|
|
23
|
+
oldStart: h.oldStart ?? 0,
|
|
24
|
+
oldLines: h.oldLines ?? 0,
|
|
25
|
+
newStart: h.newStart ?? 0,
|
|
26
|
+
newLines: h.newLines ?? 0,
|
|
27
|
+
// Codex apply_patch 는 줄 번호 없이 @@ 문맥만 있다.
|
|
28
|
+
...(h.unnumbered ? { unnumbered: true, context: clean(h.context ?? '') } : {}),
|
|
29
|
+
lines: lines.slice(0, FILE_LIMITS.hunkLines).map(clean),
|
|
30
|
+
cut: Math.max(0, lines.length - FILE_LIMITS.hunkLines),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const count = (lines, mark) => lines.reduce((a, l) => a + (typeof l === 'string' && l[0] === mark ? 1 : 0), 0);
|
|
35
|
+
|
|
36
|
+
// toolUseResult 한 건을 { path, created, add, del, hunks } 로 바꾼다. 편집 결과가 아니면 null.
|
|
37
|
+
export function readChange(r, at) {
|
|
38
|
+
if (!r || typeof r !== 'object' || typeof r.filePath !== 'string') return null;
|
|
39
|
+
if (r.type === 'delete') return { path: r.filePath, created: false, deleted: true, add: 0, del: 0, hunks: [] };
|
|
40
|
+
const patch = Array.isArray(r.structuredPatch) ? r.structuredPatch.filter((h) => h && Array.isArray(h.lines)) : null;
|
|
41
|
+
if (r.type === 'create' && typeof r.content === 'string') {
|
|
42
|
+
// 새 파일은 diff 조각 없이 본문만 남으므로 본문을 + 줄로 보인다.
|
|
43
|
+
const body = r.content.replace(/\n$/, '').split('\n');
|
|
44
|
+
const hunk = toHunk({ oldStart: 0, oldLines: 0, newStart: 1, newLines: body.length, lines: body.map((l) => '+' + l) }, at);
|
|
45
|
+
return { path: r.filePath, created: true, add: body.length, del: 0, hunks: [hunk] };
|
|
46
|
+
}
|
|
47
|
+
if (!patch) return null;
|
|
48
|
+
const all = patch.flatMap((h) => h.lines);
|
|
49
|
+
return { path: r.filePath, created: false, add: count(all, '+'), del: count(all, '-'), hunks: patch.map((h) => toHunk(h, at)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// files: 기록 하나의 파일 목록 (배열, 캐시에 그대로 저장된다).
|
|
53
|
+
export function addChange(files, r, timestamp) {
|
|
54
|
+
const at = parseTs(timestamp);
|
|
55
|
+
const c = readChange(r, at);
|
|
56
|
+
if (!c) return;
|
|
57
|
+
let f = files.find((x) => x.path === c.path);
|
|
58
|
+
if (!f) files.push((f = { path: c.path, created: c.created, edits: 0, add: 0, del: 0, at, hidden: isSecretFile(c.path), dropped: 0, hunks: [], log: [] }));
|
|
59
|
+
f.edits++;
|
|
60
|
+
f.deleted = Boolean(c.deleted);
|
|
61
|
+
f.add += c.add;
|
|
62
|
+
f.del += c.del;
|
|
63
|
+
f.at = Math.max(f.at, at);
|
|
64
|
+
f.log.push({ at, add: c.add, del: c.del, created: c.created });
|
|
65
|
+
if (f.log.length > FILE_LIMITS.log) f.log.shift();
|
|
66
|
+
if (f.hidden) return;
|
|
67
|
+
f.hunks.push(...c.hunks);
|
|
68
|
+
while (f.hunks.length > FILE_LIMITS.hunksPerFile) dropOldest(f);
|
|
69
|
+
enforceLimits(files);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dropOldest(f) {
|
|
73
|
+
f.hunks.shift();
|
|
74
|
+
f.dropped++;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 파일 수와 저장하는 diff 줄 수에 상한을 둔다. 오래된 것부터 버리고, 줄 수 통계는 남긴다.
|
|
78
|
+
function enforceLimits(files) {
|
|
79
|
+
files.sort((a, b) => b.at - a.at);
|
|
80
|
+
if (files.length > FILE_LIMITS.files) files.length = FILE_LIMITS.files;
|
|
81
|
+
let total = files.reduce((a, f) => a + f.hunks.reduce((b, h) => b + h.lines.length, 0), 0);
|
|
82
|
+
while (total > FILE_LIMITS.lines) {
|
|
83
|
+
let oldest = null;
|
|
84
|
+
for (const f of files) if (f.hunks.length && (!oldest || f.hunks[0].at < oldest.hunks[0].at)) oldest = f;
|
|
85
|
+
if (!oldest) break;
|
|
86
|
+
total -= oldest.hunks[0].lines.length;
|
|
87
|
+
dropOldest(oldest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 메인과 서브에이전트의 파일 목록을 경로별로 합친다. 변경 조각마다 누가 고쳤는지 붙이고, 최근에 고친 파일이 앞에 온다.
|
|
92
|
+
// startedAt: 서브에이전트가 시작한 시각. 그 편집은 편집 시각이 아니라 이 시각으로 지시에 나눈다
|
|
93
|
+
// (백그라운드 서브에이전트가 다음 지시 뒤까지 일해도 자기를 부른 지시에 들어가게).
|
|
94
|
+
export function mergeFiles(sources) {
|
|
95
|
+
const byPath = new Map();
|
|
96
|
+
for (const { agent, files, startedAt } of sources) {
|
|
97
|
+
const turnAt = (at) => startedAt ?? at;
|
|
98
|
+
for (const f of files ?? []) {
|
|
99
|
+
let m = byPath.get(f.path);
|
|
100
|
+
if (!m) byPath.set(f.path, (m = { path: f.path, created: f.created, edits: 0, add: 0, del: 0, at: 0, hidden: f.hidden, dropped: 0, hunks: [], log: [], agents: [] }));
|
|
101
|
+
m.created ||= f.created;
|
|
102
|
+
if (f.at >= m.at) m.deleted = Boolean(f.deleted);
|
|
103
|
+
m.edits += f.edits;
|
|
104
|
+
m.add += f.add;
|
|
105
|
+
m.del += f.del;
|
|
106
|
+
m.at = Math.max(m.at, f.at);
|
|
107
|
+
m.dropped += f.dropped;
|
|
108
|
+
for (const e of f.log ?? []) m.log.push({ ...e, turnAt: turnAt(e.at) });
|
|
109
|
+
if (!m.agents.includes(agent)) m.agents.push(agent);
|
|
110
|
+
for (const h of f.hunks) m.hunks.push({ ...h, agent, turnAt: turnAt(h.at) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const out = [...byPath.values()];
|
|
114
|
+
for (const f of out) f.hunks.sort((a, b) => b.at - a.at);
|
|
115
|
+
return out.sort((a, b) => b.at - a.at);
|
|
116
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Gemini CLI: 아직 세션 기록 형식을 확인하지 못해 실행 중인 프로세스만 보여 준다.
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { findProcesses, isGemini } from '../procs.mjs';
|
|
4
|
+
import { emptyTokens } from './common.mjs';
|
|
5
|
+
|
|
6
|
+
export function collectGemini() {
|
|
7
|
+
return findProcesses(isGemini).map((p) => ({
|
|
8
|
+
tool: 'gemini',
|
|
9
|
+
id: `pid-${p.pid}`,
|
|
10
|
+
pid: p.pid,
|
|
11
|
+
kind: 'interactive',
|
|
12
|
+
name: basename(p.cwd ?? '') || 'gemini',
|
|
13
|
+
cwd: p.cwd,
|
|
14
|
+
project: basename(p.cwd ?? ''),
|
|
15
|
+
live: true,
|
|
16
|
+
status: 'live',
|
|
17
|
+
phase: { kind: 'idle', text: '실행 중 (세부 정보 미지원)' },
|
|
18
|
+
cost: null,
|
|
19
|
+
now: '',
|
|
20
|
+
updatedAt: Date.now(),
|
|
21
|
+
tokens: emptyTokens(),
|
|
22
|
+
context: { used: 0, window: 0 },
|
|
23
|
+
events: [],
|
|
24
|
+
members: [],
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Codex apply_patch 본문을 파일별 변경으로 바꾼다. 결과는 Claude 의 toolUseResult 와 같은 모양이라 files.mjs addChange 에 그대로 넘긴다.
|
|
2
|
+
// *** Begin Patch
|
|
3
|
+
// *** Update File: src/a.mjs (*** Move to: src/b.mjs 가 뒤따를 수 있다)
|
|
4
|
+
// @@ function foo (문맥. 줄 번호는 없다)
|
|
5
|
+
// 그대로인 줄 / -지운 줄 / +쓴 줄
|
|
6
|
+
// *** Add File: src/new.mjs (+ 줄이 본문)
|
|
7
|
+
// *** Delete File: src/old.mjs
|
|
8
|
+
// *** End Patch
|
|
9
|
+
import { isAbsolute, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// 글 안에서 패치 부분만 꺼낸다 (예전 shell 호출은 명령 인자 안에 들어 있다).
|
|
12
|
+
// 시작·끝 표시는 줄 전체가 그 표시일 때만 본다. 본문 글에 '*** End Patch' 가 들어 있어도 끊기지 않게 한다.
|
|
13
|
+
export function extractPatch(text) {
|
|
14
|
+
const s = String(text ?? '').replace(/\r\n/g, '\n');
|
|
15
|
+
const begin = /^\*\*\* Begin Patch[ \t]*$/m.exec(s);
|
|
16
|
+
if (!begin) return null;
|
|
17
|
+
const rest = s.slice(begin.index);
|
|
18
|
+
const end = /^\*\*\* End Patch[ \t]*$/m.exec(rest);
|
|
19
|
+
return end ? rest.slice(0, end.index + end[0].length) : rest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parsePatch(text, cwd) {
|
|
23
|
+
const patch = extractPatch(text);
|
|
24
|
+
if (!patch) return [];
|
|
25
|
+
const abs = (p) => (isAbsolute(p) || !cwd ? p : join(cwd, p));
|
|
26
|
+
const out = [];
|
|
27
|
+
let cur = null;
|
|
28
|
+
let hunk = null;
|
|
29
|
+
const startHunk = (context) => {
|
|
30
|
+
hunk = { unnumbered: true, context, oldStart: 0, oldLines: 0, newStart: 0, newLines: 0, lines: [] };
|
|
31
|
+
cur.structuredPatch.push(hunk);
|
|
32
|
+
};
|
|
33
|
+
for (let line of patch.split('\n')) {
|
|
34
|
+
let m;
|
|
35
|
+
if ((m = /^\*\*\* (Update|Add|Delete) File: (.+)$/.exec(line))) {
|
|
36
|
+
const path = abs(m[2].trim());
|
|
37
|
+
cur = m[1] === 'Add' ? { type: 'create', filePath: path, body: [] } : m[1] === 'Delete' ? { type: 'delete', filePath: path } : { filePath: path, structuredPatch: [] };
|
|
38
|
+
hunk = null;
|
|
39
|
+
out.push(cur);
|
|
40
|
+
} else if ((m = /^\*\*\* Move to: (.+)$/.exec(line)) && cur) {
|
|
41
|
+
cur.filePath = abs(m[1].trim());
|
|
42
|
+
} else if (line.startsWith('***')) {
|
|
43
|
+
continue;
|
|
44
|
+
} else if (cur?.type === 'create') {
|
|
45
|
+
if (line.startsWith('+')) cur.body.push(line.slice(1));
|
|
46
|
+
} else if (cur?.structuredPatch) {
|
|
47
|
+
if (line.startsWith('@@')) startHunk(line.slice(2).trim());
|
|
48
|
+
// 앞 공백 없이 비어 있는 줄은 빈 문맥 줄로 본다.
|
|
49
|
+
else if (/^[ +-]/.test(line) || (line === '' && hunk)) {
|
|
50
|
+
if (!hunk) startHunk('');
|
|
51
|
+
if (line === '') line = ' ';
|
|
52
|
+
hunk.lines.push(line);
|
|
53
|
+
if (line[0] !== '+') hunk.oldLines++;
|
|
54
|
+
if (line[0] !== '-') hunk.newLines++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const c of out) {
|
|
59
|
+
if (c.type === 'create') {
|
|
60
|
+
c.content = c.body.join('\n');
|
|
61
|
+
delete c.body;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// apply_patch 결과가 성공인지. JSON 결과는 exit_code, 글 결과는 실패 문구로 본다. 모르면 성공으로 본다.
|
|
68
|
+
export function patchSucceeded(output) {
|
|
69
|
+
const s = String(output ?? '');
|
|
70
|
+
try {
|
|
71
|
+
const o = JSON.parse(s);
|
|
72
|
+
const code = o?.metadata?.exit_code;
|
|
73
|
+
if (typeof code === 'number') return code === 0;
|
|
74
|
+
return patchSucceeded(o?.output ?? '');
|
|
75
|
+
} catch {
|
|
76
|
+
return !/^\s*(error|failed|apply_patch verification failed|invalid patch)/i.test(s);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/sqlite.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Node 에 들어 있는 node:sqlite 를 쓸 수 있으면 돌려준다. 외부 라이브러리 없이 SQLite 기록을 읽기 위해서다.
|
|
2
|
+
// node:sqlite 가 없거나(Node 22.13 미만) file: 주소 열기를 못 하면(22.13·22.14) null. immutable 로 열려면 file: 주소가 필요하다.
|
|
3
|
+
// 처음 불러올 때 나오는 ExperimentalWarning 은 화면을 깨뜨리므로 이 경고만 삼킨다.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
6
|
+
import { basename } from 'node:path';
|
|
7
|
+
|
|
8
|
+
let sqlite;
|
|
9
|
+
|
|
10
|
+
export function loadSqlite() {
|
|
11
|
+
if (sqlite !== undefined) return sqlite;
|
|
12
|
+
sqlite = null;
|
|
13
|
+
const get = process.getBuiltinModule;
|
|
14
|
+
if (typeof get !== 'function') return sqlite;
|
|
15
|
+
const emit = process.emitWarning;
|
|
16
|
+
process.emitWarning = function (warning, ...rest) {
|
|
17
|
+
if (/sqlite/i.test(String(warning?.message ?? warning))) return;
|
|
18
|
+
return emit.call(this, warning, ...rest);
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
const mod = get('node:sqlite');
|
|
22
|
+
sqlite = mod?.DatabaseSync && opensUri(mod) ? mod : null;
|
|
23
|
+
} catch {
|
|
24
|
+
sqlite = null;
|
|
25
|
+
} finally {
|
|
26
|
+
process.emitWarning = emit;
|
|
27
|
+
}
|
|
28
|
+
return sqlite;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 이 파일 자신을 immutable 주소로 열어 본다. file: 주소를 모르는 버전은 그런 이름의 파일이 없어 여기서 실패한다.
|
|
32
|
+
// SQLite 는 첫 읽기 전까지 내용을 보지 않으므로 DB 가 아닌 파일도 열리고, 아무 파일도 만들지 않는다.
|
|
33
|
+
function opensUri(mod) {
|
|
34
|
+
try {
|
|
35
|
+
new mod.DatabaseSync(`${pathToFileURL(fileURLToPath(import.meta.url)).href}?immutable=1`, { readOnly: true }).close();
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 늘 immutable 로 연다. 보통 읽기 전용 연결은 WAL 모드 DB 옆에 -wal·-shm 을 만들거나 고치고, 쓰는 쪽보다 늦게 닫히면
|
|
43
|
+
// 그 파일들이 지워지지 않고 남는다. immutable 은 파일을 하나도 만들거나 고치지 않는 대신 -wal 에 있는 최신 내용은 보지 않는다
|
|
44
|
+
// (쓰는 쪽이 본 파일에 합친 뒤에 보인다). 읽는 동안 다른 쪽이 쓰면 내용이 어긋날 수 있어, 부르는 쪽이 읽기 전후를 비교한다.
|
|
45
|
+
export function openReadOnly(path) {
|
|
46
|
+
const s = loadSqlite();
|
|
47
|
+
if (!s) return null;
|
|
48
|
+
if (!existsSync(path)) throw Object.assign(new Error(`없음: ${basename(path)}`), { code: 'ENOENT' });
|
|
49
|
+
return new s.DatabaseSync(`${pathToFileURL(path).href}?immutable=1`, { readOnly: true });
|
|
50
|
+
}
|
package/src/tail.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// JSONL 파일을 청크 단위로 따라 읽는다. 한 번에 읽는 양을 budget 으로 제한해서, 큰 파일을 처음 읽을 때도
|
|
2
|
+
// 화면 갱신과 키 입력이 멈추지 않게 한다. 원본은 읽기 전용으로만 연다.
|
|
3
|
+
import { openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
4
|
+
import { debug, debugIo } from './log.mjs';
|
|
5
|
+
|
|
6
|
+
const CHUNK = 1 << 20;
|
|
7
|
+
const NL = 0x0a;
|
|
8
|
+
|
|
9
|
+
// 한 번의 갱신(틱) 동안 모든 파일이 나눠 쓰는 읽기 한도. 바이트와 시간(ms) 중 먼저 닿는 쪽에서 멈춘다.
|
|
10
|
+
// 한도 때문에 덜 읽은 파일이 있으면 exhausted 가 true 가 되어, 부르는 쪽이 곧바로 이어 읽을 수 있다.
|
|
11
|
+
// 시간 한도는 이번 틱에 한 청크라도 읽은 뒤부터 본다. 파일 목록·세션 정보 읽기 같은 고정 작업이 한도를 넘어도 매 틱 조금씩은 앞으로 간다.
|
|
12
|
+
export function createBudget(bytes = Infinity, ms = Infinity) {
|
|
13
|
+
return { left: bytes, deadline: Number.isFinite(ms) ? Date.now() + ms : Infinity, exhausted: false, read: 0 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const outOf = (budget) => budget.left <= 0 || (budget.read > 0 && Date.now() >= (budget.deadline ?? Infinity));
|
|
17
|
+
|
|
18
|
+
export class JsonlTail {
|
|
19
|
+
constructor(path) {
|
|
20
|
+
this.path = path;
|
|
21
|
+
this.offset = 0;
|
|
22
|
+
// 아직 줄바꿈이 오지 않은 뒷부분 조각들. 청크 경계에서 한글 같은 여러 바이트 문자가 잘려도 깨지지 않게 바이트로 두고,
|
|
23
|
+
// 아주 긴 줄도 조각을 모아 두었다가 줄바꿈이 올 때 한 번만 합친다.
|
|
24
|
+
this.rest = [];
|
|
25
|
+
this.restLength = 0;
|
|
26
|
+
this.mtimeMs = 0;
|
|
27
|
+
this.size = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 완성된 줄까지 읽은 위치. 캐시에 저장해 두고 다음 실행에서 여기서부터 읽는다.
|
|
31
|
+
get lineOffset() {
|
|
32
|
+
return this.offset - this.restLength;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get done() {
|
|
36
|
+
return this.offset >= this.size;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get progress() {
|
|
40
|
+
return this.size ? Math.min(1, this.offset / this.size) : 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 새로 붙은 줄을 onLine 으로 하나씩 넘긴다. 읽은 줄 수를 돌려준다.
|
|
44
|
+
read(onLine, budget = createBudget()) {
|
|
45
|
+
let st;
|
|
46
|
+
try {
|
|
47
|
+
st = statSync(this.path);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
debugIo('jsonl', 'stat failed', e, this.path);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
this.mtimeMs = st.mtimeMs;
|
|
53
|
+
this.size = st.size;
|
|
54
|
+
// 파일이 줄었으면(새로 쓰임) 처음부터 다시 읽는다. 누적값을 들고 있는 쪽은 onReset 에서 비워야 두 번 더하지 않는다.
|
|
55
|
+
if (st.size < this.offset) {
|
|
56
|
+
this.offset = 0;
|
|
57
|
+
this.rest = [];
|
|
58
|
+
this.restLength = 0;
|
|
59
|
+
this.onReset?.();
|
|
60
|
+
}
|
|
61
|
+
if (st.size === this.offset) return 0;
|
|
62
|
+
if (outOf(budget)) {
|
|
63
|
+
budget.exhausted = true;
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let count = 0;
|
|
68
|
+
const fd = openSync(this.path, 'r');
|
|
69
|
+
const buf = Buffer.alloc(CHUNK);
|
|
70
|
+
try {
|
|
71
|
+
while (this.offset < st.size) {
|
|
72
|
+
if (outOf(budget)) {
|
|
73
|
+
budget.exhausted = true;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
const n = readSync(fd, buf, 0, Math.min(CHUNK, st.size - this.offset), this.offset);
|
|
77
|
+
if (n <= 0) break;
|
|
78
|
+
this.offset += n;
|
|
79
|
+
budget.left -= n;
|
|
80
|
+
budget.read = (budget.read ?? 0) + n;
|
|
81
|
+
const chunk = buf.subarray(0, n);
|
|
82
|
+
const end = chunk.lastIndexOf(NL);
|
|
83
|
+
if (end < 0) {
|
|
84
|
+
this.rest.push(Buffer.from(chunk));
|
|
85
|
+
this.restLength += n;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const data = this.restLength ? Buffer.concat([...this.rest, chunk.subarray(0, end)]) : chunk.subarray(0, end);
|
|
89
|
+
this.rest = end + 1 < n ? [Buffer.from(chunk.subarray(end + 1))] : [];
|
|
90
|
+
this.restLength = n - end - 1;
|
|
91
|
+
for (const line of data.toString('utf8').split('\n')) {
|
|
92
|
+
if (!line) continue;
|
|
93
|
+
let x;
|
|
94
|
+
try {
|
|
95
|
+
x = JSON.parse(line);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
debug('jsonl', 'line parse failed', e, this.path);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
onLine(x);
|
|
101
|
+
count++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
closeSync(fd);
|
|
106
|
+
}
|
|
107
|
+
return count;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/text.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// 터미널 표시 폭 계산과 자르기. 한글·CJK·이모지는 2칸을 차지한다.
|
|
2
|
+
|
|
3
|
+
const WIDE = [
|
|
4
|
+
[0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
|
|
5
|
+
[0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
|
|
6
|
+
[0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
|
|
7
|
+
[0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
// 동아시아 폭이 '모호(A)' 인 글자 (Unicode 15 EastAsianWidth.txt, 사용자 영역 제외). [시작, 끝] 쌍을 한 줄로 이어 둔다.
|
|
11
|
+
// 한중일 글꼴·설정을 쓰는 터미널은 이 글자들(·, …, →, ─, ●, ▃ 등)을 2칸으로 그린다.
|
|
12
|
+
const AMBIGUOUS = [
|
|
13
|
+
0xa1, 0xa1, 0xa4, 0xa4, 0xa7, 0xa8, 0xaa, 0xaa, 0xae, 0xae, 0xb0, 0xb4,
|
|
14
|
+
0xb6, 0xba, 0xbc, 0xbf, 0xc6, 0xc6, 0xd0, 0xd0, 0xd7, 0xd8, 0xde, 0xe1,
|
|
15
|
+
0xe6, 0xe6, 0xe8, 0xea, 0xec, 0xed, 0xf0, 0xf0, 0xf2, 0xf3, 0xf7, 0xfa,
|
|
16
|
+
0xfc, 0xfc, 0xfe, 0xfe, 0x101, 0x101, 0x111, 0x111, 0x113, 0x113, 0x11b, 0x11b,
|
|
17
|
+
0x126, 0x127, 0x12b, 0x12b, 0x131, 0x133, 0x138, 0x138, 0x13f, 0x142, 0x144, 0x144,
|
|
18
|
+
0x148, 0x14b, 0x14d, 0x14d, 0x152, 0x153, 0x166, 0x167, 0x16b, 0x16b, 0x1ce, 0x1ce,
|
|
19
|
+
0x1d0, 0x1d0, 0x1d2, 0x1d2, 0x1d4, 0x1d4, 0x1d6, 0x1d6, 0x1d8, 0x1d8, 0x1da, 0x1da,
|
|
20
|
+
0x1dc, 0x1dc, 0x251, 0x251, 0x261, 0x261, 0x2c4, 0x2c4, 0x2c7, 0x2c7, 0x2c9, 0x2cb,
|
|
21
|
+
0x2cd, 0x2cd, 0x2d0, 0x2d0, 0x2d8, 0x2db, 0x2dd, 0x2dd, 0x2df, 0x2df, 0x391, 0x3a1,
|
|
22
|
+
0x3a3, 0x3a9, 0x3b1, 0x3c1, 0x3c3, 0x3c9, 0x401, 0x401, 0x410, 0x44f, 0x451, 0x451,
|
|
23
|
+
0x2010, 0x2010, 0x2013, 0x2016, 0x2018, 0x2019, 0x201c, 0x201d, 0x2020, 0x2022, 0x2024, 0x2027,
|
|
24
|
+
0x2030, 0x2030, 0x2032, 0x2033, 0x2035, 0x2035, 0x203b, 0x203b, 0x203e, 0x203e, 0x2074, 0x2074,
|
|
25
|
+
0x207f, 0x207f, 0x2081, 0x2084, 0x20ac, 0x20ac, 0x2103, 0x2103, 0x2105, 0x2105, 0x2109, 0x2109,
|
|
26
|
+
0x2113, 0x2113, 0x2116, 0x2116, 0x2121, 0x2122, 0x2126, 0x2126, 0x212b, 0x212b, 0x2153, 0x2154,
|
|
27
|
+
0x215b, 0x215e, 0x2160, 0x216b, 0x2170, 0x2179, 0x2189, 0x2189, 0x2190, 0x2199, 0x21b8, 0x21b9,
|
|
28
|
+
0x21d2, 0x21d2, 0x21d4, 0x21d4, 0x21e7, 0x21e7, 0x2200, 0x2200, 0x2202, 0x2203, 0x2207, 0x2208,
|
|
29
|
+
0x220b, 0x220b, 0x220f, 0x220f, 0x2211, 0x2211, 0x2215, 0x2215, 0x221a, 0x221a, 0x221d, 0x2220,
|
|
30
|
+
0x2223, 0x2223, 0x2225, 0x2225, 0x2227, 0x222c, 0x222e, 0x222e, 0x2234, 0x2237, 0x223c, 0x223d,
|
|
31
|
+
0x2248, 0x2248, 0x224c, 0x224c, 0x2252, 0x2252, 0x2260, 0x2261, 0x2264, 0x2267, 0x226a, 0x226b,
|
|
32
|
+
0x226e, 0x226f, 0x2282, 0x2283, 0x2286, 0x2287, 0x2295, 0x2295, 0x2299, 0x2299, 0x22a5, 0x22a5,
|
|
33
|
+
0x22bf, 0x22bf, 0x2312, 0x2312, 0x2460, 0x24e9, 0x24eb, 0x254b, 0x2550, 0x2573, 0x2580, 0x258f,
|
|
34
|
+
0x2592, 0x2595, 0x25a0, 0x25a1, 0x25a3, 0x25a9, 0x25b2, 0x25b3, 0x25b6, 0x25b7, 0x25bc, 0x25bd,
|
|
35
|
+
0x25c0, 0x25c1, 0x25c6, 0x25c8, 0x25cb, 0x25cb, 0x25ce, 0x25d1, 0x25e2, 0x25e5, 0x25ef, 0x25ef,
|
|
36
|
+
0x2605, 0x2606, 0x2609, 0x2609, 0x260e, 0x260f, 0x261c, 0x261c, 0x261e, 0x261e, 0x2640, 0x2640,
|
|
37
|
+
0x2642, 0x2642, 0x2660, 0x2661, 0x2663, 0x2665, 0x2667, 0x266a, 0x266c, 0x266d, 0x266f, 0x266f,
|
|
38
|
+
0x269e, 0x269f, 0x26bf, 0x26bf, 0x26c6, 0x26cd, 0x26cf, 0x26d3, 0x26d5, 0x26e1, 0x26e3, 0x26e3,
|
|
39
|
+
0x26e8, 0x26e9, 0x26eb, 0x26f1, 0x26f4, 0x26f4, 0x26f6, 0x26f9, 0x26fb, 0x26fc, 0x26fe, 0x26ff,
|
|
40
|
+
0x273d, 0x273d, 0x2776, 0x277f, 0x2b56, 0x2b59, 0x3248, 0x324f, 0xfffd, 0xfffd, 0x1f100, 0x1f10a,
|
|
41
|
+
0x1f110, 0x1f12d, 0x1f130, 0x1f169, 0x1f170, 0x1f18d, 0x1f18f, 0x1f190, 0x1f19b, 0x1f1ac,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// 폭이 모호한 글자를 2칸으로 셀지. AITOP_AMBIGUOUS=wide (설정 파일 ambiguous) 이면 켠다.
|
|
45
|
+
// bin 이 설정을 정한 뒤 setAmbiguous 로 다시 넘긴다 (이 모듈은 설정보다 먼저 불린다).
|
|
46
|
+
let ambiguousIsWide = process.env.AITOP_AMBIGUOUS === 'wide';
|
|
47
|
+
export const ambiguousWide = () => ambiguousIsWide;
|
|
48
|
+
export function setAmbiguous(mode) {
|
|
49
|
+
ambiguousIsWide = mode === 'wide';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isAmbiguous(cp) {
|
|
53
|
+
if (cp < 0xa1) return false;
|
|
54
|
+
let lo = 0;
|
|
55
|
+
let hi = AMBIGUOUS.length / 2 - 1;
|
|
56
|
+
while (lo <= hi) {
|
|
57
|
+
const mid = (lo + hi) >> 1;
|
|
58
|
+
if (cp < AMBIGUOUS[mid * 2]) hi = mid - 1;
|
|
59
|
+
else if (cp > AMBIGUOUS[mid * 2 + 1]) lo = mid + 1;
|
|
60
|
+
else return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function charWidth(cp) {
|
|
66
|
+
if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
|
|
67
|
+
if (cp >= 0x300 && cp <= 0x36f) return 0;
|
|
68
|
+
for (const [a, b] of WIDE) if (cp >= a && cp <= b) return 2;
|
|
69
|
+
return ambiguousIsWide && isAmbiguous(cp) ? 2 : 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function width(s) {
|
|
73
|
+
let w = 0;
|
|
74
|
+
for (const ch of s) w += charWidth(ch.codePointAt(0));
|
|
75
|
+
return w;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function truncate(s, max) {
|
|
79
|
+
if (max <= 0) return '';
|
|
80
|
+
if (width(s) <= max) return s;
|
|
81
|
+
const mark = ellipsis(max);
|
|
82
|
+
const room = max - width(mark);
|
|
83
|
+
let out = '';
|
|
84
|
+
let w = 0;
|
|
85
|
+
for (const ch of s) {
|
|
86
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
87
|
+
if (w + cw > room) break;
|
|
88
|
+
out += ch;
|
|
89
|
+
w += cw;
|
|
90
|
+
}
|
|
91
|
+
// 공백만 있는 조각은 … 없이 그냥 자른다 (조각 사이 간격이 … 로 바뀌지 않게).
|
|
92
|
+
if (!s.trim()) return ' '.repeat(max);
|
|
93
|
+
// 원래 글이 … 로 끝나는 곳에서 잘리면 … 가 두 번 붙지 않게 한다.
|
|
94
|
+
return out.replace(/[…\s]+$/u, '') + mark;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 자른 자리 표시. 폭이 모호한 글자를 2칸으로 세는데 1칸만 남으면 … 대신 . 을 쓴다.
|
|
98
|
+
export const ellipsis = (room = 2) => (width('…') <= room ? '…' : '.');
|
|
99
|
+
|
|
100
|
+
export function pad(s, n, align = 'left') {
|
|
101
|
+
const t = truncate(s, n);
|
|
102
|
+
const gap = ' '.repeat(Math.max(0, n - width(t)));
|
|
103
|
+
return align === 'right' ? gap + t : t + gap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 기록에서 읽은 문자열이 터미널 제어 순서(ESC 등)나 방향 바꾸기 문자를 담고 있으면 화면이 깨지거나 터미널 동작이 바뀐다.
|
|
107
|
+
// 폭이 0인 글자(U+200B~200D, U+2060, U+FEFF)도 지운다. 명령 사이에 숨으면 보이는 글자와 실제 명령이 달라진다.
|
|
108
|
+
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f---]/g;
|
|
109
|
+
|
|
110
|
+
export function stripControl(s) {
|
|
111
|
+
return String(s ?? '').replace(CONTROL, '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// diff 한 줄: 탭은 두 칸으로 펼치고 줄바꿈과 제어문자는 지운다.
|
|
115
|
+
export function cleanLine(s) {
|
|
116
|
+
return stripControl(String(s ?? '').replace(/\t/g, ' ').replace(/[\r\n]/g, ''));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 마크다운 답변을 활동 한 줄용 평문으로 바꾼다. 구분선·표 구분 줄·코드 펜스와
|
|
120
|
+
// `**질문:**` 처럼 굵은 이름표만 있는 줄은 건너뛰고, 굵게·인용·머리·백틱 기호를 지운다.
|
|
121
|
+
// 밑줄 하나(a_b)나 별표 하나(*.mjs)처럼 이름에 흔한 글자는 건드리지 않는다.
|
|
122
|
+
export function plainText(s) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const raw of String(s ?? '').split('\n')) {
|
|
125
|
+
// 인용 표시는 '> ' 처럼 뒤에 공백이 올 때만 지운다 ('>= 3' 은 그대로).
|
|
126
|
+
let l = raw.trim().replace(/^(>(\s|$))+/, '').replace(/^#{1,6}\s+/, '');
|
|
127
|
+
if (!l || /^```/.test(l) || /^([-*_]\s*){3,}$/.test(l)) continue;
|
|
128
|
+
// 표 구분 줄: | : - 와 공백만 있는 줄.
|
|
129
|
+
if (/^[|:\-\s]+$/.test(l) && l.includes('|') && l.includes('--')) continue;
|
|
130
|
+
if (/^(\*\*|__)[^*_]+(\*\*|__):?$/.test(l) && /:(\*\*|__)?$/.test(l)) continue;
|
|
131
|
+
// 코드 조각은 떼어 두었다가 기호 정리가 끝난 뒤 백틱만 빼고 되돌린다.
|
|
132
|
+
// __dirname, __main__ 처럼 밑줄 두 개로 시작하는 이름도 같은 방법으로 지킨다.
|
|
133
|
+
const code = [];
|
|
134
|
+
l = l.replace(/`([^`]*)`/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`).replace(/(?<![\w/])__\w+(?:__)?(?![\w/])/g, (c) => `\u0000${code.push(c) - 1}\u0000`);
|
|
135
|
+
if (l.startsWith('|')) l = l.replace(/^\||\|$/g, '').split('|').map((c) => c.trim()).filter(Boolean).join(' · ');
|
|
136
|
+
// 강조 기호는 앞뒤가 글자·/·* 에 붙지 않았을 때만 지운다 (__init__, src/**/x, 2**10 은 그대로).
|
|
137
|
+
// 밑줄 두 개로 감싼 한 단어(__main__, __dirname)는 이름으로 보고 그대로 둔다.
|
|
138
|
+
l = l
|
|
139
|
+
.replace(/(^|[^\w/*])\*\*(?=\S)(.+?)(?<=\S)\*\*(?![\w/*])/g, '$1$2')
|
|
140
|
+
.replace(/(^|[^\w/])__(?=\S)(.+?)(?<=\S)__(?![\w/])/g, (m, pre, inner) => (/^\w+$/.test(inner) ? m : pre + inner));
|
|
141
|
+
l = l.replace(/\u0000(\d+)\u0000/g, (_, i) => code[i]).trim();
|
|
142
|
+
if (l) out.push(l);
|
|
143
|
+
}
|
|
144
|
+
return out.join(' ');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function oneLine(s) {
|
|
148
|
+
return stripControl(String(s ?? '').replace(/\s+/g, ' ')).trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 앞을 잘라 끝(파일 이름)을 남긴다.
|
|
152
|
+
export function truncateLeft(s, max) {
|
|
153
|
+
if (max <= 0) return '';
|
|
154
|
+
if (width(s) <= max) return s;
|
|
155
|
+
const chars = [...s];
|
|
156
|
+
const mark = ellipsis(max);
|
|
157
|
+
let out = '';
|
|
158
|
+
let w = 0;
|
|
159
|
+
for (let i = chars.length - 1; i >= 0; i--) {
|
|
160
|
+
const cw = charWidth(chars[i].codePointAt(0));
|
|
161
|
+
if (w + cw > max - width(mark)) break;
|
|
162
|
+
out = chars[i] + out;
|
|
163
|
+
w += cw;
|
|
164
|
+
}
|
|
165
|
+
return mark + out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 화면 공유 때 새지 않도록 비밀값처럼 보이는 문자열을 가린다.
|
|
169
|
+
const SECRET_PATTERNS = [
|
|
170
|
+
/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g,
|
|
171
|
+
/\bsk-ant-[A-Za-z0-9_-]{16,}/g,
|
|
172
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
173
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
174
|
+
/\bxox[abpr]-[A-Za-z0-9-]{10,}/g,
|
|
175
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
176
|
+
/\b[A-Fa-f0-9]{40,}\b/g,
|
|
177
|
+
];
|
|
178
|
+
const KEY_VALUE = /((?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*"?)[^\s"']{6,}/gi;
|
|
179
|
+
|
|
180
|
+
export function mask(s) {
|
|
181
|
+
let out = String(s ?? '').replace(KEY_VALUE, '$1••••');
|
|
182
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, '••••');
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── 엄격한 가림: 허락을 기다리는 요청 전체(상세 패널 요청 구획, --json waitDetail)에만 쓴다 ──
|
|
187
|
+
// 명령을 그대로 사용자에게 보이는 자리라, 애매하면 가린다. 평범한 낱말 하나가 가려지는 편이 비밀값이 새는 것보다 낫다.
|
|
188
|
+
// 모든 반복에는 상한을 두어 긴 입력에서도 처리 시간이 길이에 비례하게 한다.
|
|
189
|
+
const M = '••••';
|
|
190
|
+
const STRICT_WORD = '(?:token|secret|passw(?:or)?d|pass(?![a-z])|pwd|(?<![a-z])pin(?![a-z])|key|passphrase|credentials?|creds?(?![a-z])|authorization|auth(?!or)|cookie|session[_-]?id|signature|(?<![a-z])sig(?![a-z]))';
|
|
191
|
+
const NAME = `[A-Za-z0-9_.-]{0,64}${STRICT_WORD}[A-Za-z0-9_.-]{0,64}`;
|
|
192
|
+
// 이 이름이면 값 전체가 비밀이다. key 하나뿐인 이름도 비밀로 친다.
|
|
193
|
+
export const STRICT_NAME = new RegExp(`^(?:${NAME}|key)$`, 'i');
|
|
194
|
+
// 값: "따옴표 안 전체", '따옴표 안 전체', `백틱 안 전체`, 아니면 공백·구분자 앞까지.
|
|
195
|
+
const VALUE = `(?:\$\{[^}\n]{0,256}\}|"[^"\\n]{0,4096}"|'[^'\\n]{0,4096}'|\`[^\`\\n]{0,4096}\`|[^\\s"'\`,;&}]{1,4096})`;
|
|
196
|
+
const PRIVATE_BEGIN = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY( BLOCK)?-----/;
|
|
197
|
+
const STRICT_RULES = [
|
|
198
|
+
// Authorization: Bearer xxx → 방식 이름을 남기고 값만. Cookie / Set-Cookie 는 줄 끝(또는 따옴표)까지 전부.
|
|
199
|
+
[/\b((?:proxy-)?authorization["']?\s{0,16}[:=]\s{0,16}["'`]?)(?:(bearer|basic|token|digest|negotiate)\s{1,16})?[^\s"'`,]{1,4096}/gi, (_, h, k) => `${h}${k ? k + ' ' : ''}${M}`],
|
|
200
|
+
[/\b((?:set-)?cookie["']?\s{0,16}[:=]\s{0,16})[^"'`\n]{1,4096}/gi, `$1${M}`],
|
|
201
|
+
[/\b(bearer)\s{1,16}(?!••••)[A-Za-z0-9._~+/=-]{8,4096}/gi, `$1 ${M}`],
|
|
202
|
+
[/\b(basic)\s{1,16}(?=[A-Za-z0-9+/]{0,4096}[0-9=])[A-Za-z0-9+/]{8,4096}={0,2}/gi, `$1 ${M}`],
|
|
203
|
+
// scheme://user:pass@host — 비밀번호에 띄어쓰기가 있어도 @ 까지 가린다.
|
|
204
|
+
[/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]{0,30}:\/\/[^\s:@/]{1,256}:)[^@/\n"']{1,256}@/gi, `$1${M}@`],
|
|
205
|
+
// URL 질의의 key=, token=, sig= 등.
|
|
206
|
+
[new RegExp(`([?&;](?:${NAME}|key|code)=)[^&#\\s"'\`]{1,4096}`, 'gi'), `$1${M}`],
|
|
207
|
+
// curl -u user:pass, --user user:pass
|
|
208
|
+
[/((?:^|\s)(?:-u|-U|--user|--proxy-user)(?:\s{1,16}|=)["']?)([^\s:"']{1,256}):[^\s"']{1,4096}/g, `$1$2:${M}`],
|
|
209
|
+
// --password hunter2, --token=abc, --api-key "a b"
|
|
210
|
+
[new RegExp(`((?:^|\\s)--?${NAME})(=|\\s{1,16})(?![<>|&;-])(?!["'\`]?••••)${VALUE}`, 'gi'), `$1$2${M}`],
|
|
211
|
+
// 명령마다 정해진 짧은 비밀번호 옵션: mysql -pX, sshpass -p X, redis-cli -a X
|
|
212
|
+
[/\b((?:mysql|mysqldump|mysqladmin|mysqlimport|mysqlshow|mysqlcheck|mariadb|mariadb-dump|mariadb-admin)\b[^\n|;&]{0,200}?\s-p)(?!••••)[^\s-]\S{0,4096}/g, `$1${M}`],
|
|
213
|
+
[/\b(sshpass\b[^\n|;&]{0,200}?\s-p)(\s{0,16})(?!••••)\S{1,4096}/g, `$1$2${M}`],
|
|
214
|
+
[/\b(redis-cli\b[^\n|;&]{0,200}?\s(?:-a|--pass))(\s{1,16})(?!••••)\S{1,4096}/g, `$1$2${M}`],
|
|
215
|
+
[/\b(aws\s{1,16}configure\s{1,16}set\s{1,16}\S{0,64}(?:secret|key|token)\S{0,64}\s{1,16})\S{1,4096}/gi, `$1${M}`],
|
|
216
|
+
// 이름=값, 이름: 값, "이름": "값", 이름 = "a b c". 따옴표 값은 통째로 가린다.
|
|
217
|
+
// 셸 따옴표 안의 JSON(\"password\":\"x\")처럼 따옴표 앞에 \ 가 붙어도 같게 본다.
|
|
218
|
+
[new RegExp(`(?<![A-Za-z0-9_.-])(${NAME}(?:\\\\?["'])?\\s{0,16}[=:]\\s{0,16}\\\\?)(?!["'\`]?(?:(?:bearer|basic|token|digest|negotiate)\\s{1,16})?••••)${VALUE}`, 'gi'), `$1${M}`],
|
|
219
|
+
[/\bAIza[0-9A-Za-z_-]{20,4096}/g, M],
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// -----BEGIN … PRIVATE KEY----- 부터 -----END … PRIVATE KEY----- 까지(여러 줄)를 •••• 하나로. 끝 표시가 없으면 끝까지 가린다.
|
|
223
|
+
function maskPrivateKeys(s) {
|
|
224
|
+
let out = '';
|
|
225
|
+
let rest = s;
|
|
226
|
+
for (let m = PRIVATE_BEGIN.exec(rest); m; m = PRIVATE_BEGIN.exec(rest)) {
|
|
227
|
+
out += rest.slice(0, m.index) + M;
|
|
228
|
+
const end = rest.indexOf('-----END ', m.index + m[0].length);
|
|
229
|
+
const close = end < 0 ? -1 : rest.indexOf('-----', end + 9);
|
|
230
|
+
rest = close < 0 ? '' : rest.slice(close + 5);
|
|
231
|
+
}
|
|
232
|
+
return out + rest;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function maskStrict(s) {
|
|
236
|
+
let out = maskPrivateKeys(String(s ?? ''));
|
|
237
|
+
for (const [re, to] of STRICT_RULES) out = out.replace(re, to);
|
|
238
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, M);
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function fmtTokens(n) {
|
|
243
|
+
if (!n) return '0';
|
|
244
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
245
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e8 ? 0 : 1) + 'M';
|
|
246
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + 'k';
|
|
247
|
+
return String(n);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function fmtAgo(ms) {
|
|
251
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
252
|
+
if (s < 60) return s + 's';
|
|
253
|
+
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
254
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
|
255
|
+
return Math.floor(s / 86400) + 'd';
|
|
256
|
+
}
|