triflux 2.4.6 → 2.4.7
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/bin/triflux.mjs +20 -0
- package/hud/hud-qos-status.mjs +16 -8
- package/package.json +1 -1
- package/scripts/notion-read.mjs +498 -0
package/bin/triflux.mjs
CHANGED
|
@@ -169,6 +169,12 @@ function cmdSetup() {
|
|
|
169
169
|
"hud-qos-status.mjs"
|
|
170
170
|
);
|
|
171
171
|
|
|
172
|
+
syncFile(
|
|
173
|
+
join(PKG_ROOT, "scripts", "notion-read.mjs"),
|
|
174
|
+
join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
|
|
175
|
+
"notion-read.mjs"
|
|
176
|
+
);
|
|
177
|
+
|
|
172
178
|
// 스킬 동기화 (~/.claude/skills/{name}/SKILL.md)
|
|
173
179
|
const skillsSrc = join(PKG_ROOT, "skills");
|
|
174
180
|
const skillsDst = join(CLAUDE_DIR, "skills");
|
|
@@ -326,6 +332,11 @@ function cmdDoctor(options = {}) {
|
|
|
326
332
|
join(CLAUDE_DIR, "hud", "hud-qos-status.mjs"),
|
|
327
333
|
"hud-qos-status.mjs"
|
|
328
334
|
);
|
|
335
|
+
syncFile(
|
|
336
|
+
join(PKG_ROOT, "scripts", "notion-read.mjs"),
|
|
337
|
+
join(CLAUDE_DIR, "scripts", "notion-read.mjs"),
|
|
338
|
+
"notion-read.mjs"
|
|
339
|
+
);
|
|
329
340
|
// 스킬 동기화
|
|
330
341
|
const fSkillsSrc = join(PKG_ROOT, "skills");
|
|
331
342
|
const fSkillsDst = join(CLAUDE_DIR, "skills");
|
|
@@ -790,6 +801,7 @@ ${updateNotice}
|
|
|
790
801
|
${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
|
|
791
802
|
${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 버전으로 업데이트${RESET}
|
|
792
803
|
${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
|
|
804
|
+
${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
|
|
793
805
|
${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
|
|
794
806
|
|
|
795
807
|
${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
|
|
@@ -819,6 +831,14 @@ switch (cmd) {
|
|
|
819
831
|
}
|
|
820
832
|
case "update": cmdUpdate(); break;
|
|
821
833
|
case "list": case "ls": cmdList(); break;
|
|
834
|
+
case "notion-read": case "nr": {
|
|
835
|
+
const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
|
|
836
|
+
const nrArgs = process.argv.slice(3).map(a => `"${a}"`).join(" ");
|
|
837
|
+
try {
|
|
838
|
+
execSync(`"${process.execPath}" "${scriptPath}" ${nrArgs}`, { stdio: "inherit", timeout: 660000 });
|
|
839
|
+
} catch (e) { process.exit(e.status || 1); }
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
822
842
|
case "version": case "--version": case "-v": cmdVersion(); break;
|
|
823
843
|
case "help": case "--help": case "-h": cmdHelp(); break;
|
|
824
844
|
default:
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -437,9 +437,13 @@ function renderAlignedRows(rows) {
|
|
|
437
437
|
function getMicroLine(stdin, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
|
|
438
438
|
const ctx = getContextPercent(stdin);
|
|
439
439
|
|
|
440
|
-
// Claude 5h/1w
|
|
441
|
-
const cF = claudeUsage
|
|
442
|
-
|
|
440
|
+
// Claude 5h/1w (reset 과거면 0으로 표시)
|
|
441
|
+
const cF = claudeUsage
|
|
442
|
+
? (isResetPast(claudeUsage.fiveHourResetsAt) ? 0 : clampPercent(claudeUsage.fiveHourPercent ?? 0))
|
|
443
|
+
: null;
|
|
444
|
+
const cW = claudeUsage
|
|
445
|
+
? (isResetPast(claudeUsage.weeklyResetsAt) ? 0 : clampPercent(claudeUsage.weeklyPercent ?? 0))
|
|
446
|
+
: null;
|
|
443
447
|
const cVal = cF != null
|
|
444
448
|
? `${colorByProvider(cF, `${cF}`, claudeOrange)}${dim("/")}${colorByProvider(cW, `${cW}`, claudeOrange)}`
|
|
445
449
|
: dim("--/--");
|
|
@@ -838,7 +842,7 @@ function formatResetRemaining(isoOrUnix) {
|
|
|
838
842
|
const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
|
|
839
843
|
if (isNaN(d.getTime())) return "";
|
|
840
844
|
const diffMs = d.getTime() - Date.now();
|
|
841
|
-
if (diffMs <= 0) return "
|
|
845
|
+
if (diffMs <= 0) return "";
|
|
842
846
|
const totalMinutes = Math.floor(diffMs / 60000);
|
|
843
847
|
const totalHours = Math.floor(totalMinutes / 60);
|
|
844
848
|
const minutes = totalMinutes % 60;
|
|
@@ -856,7 +860,7 @@ function formatResetRemainingDayHour(isoOrUnix) {
|
|
|
856
860
|
const d = typeof isoOrUnix === "string" ? new Date(isoOrUnix) : new Date(isoOrUnix * 1000);
|
|
857
861
|
if (isNaN(d.getTime())) return "";
|
|
858
862
|
const diffMs = d.getTime() - Date.now();
|
|
859
|
-
if (diffMs <= 0) return "
|
|
863
|
+
if (diffMs <= 0) return "";
|
|
860
864
|
const totalMinutes = Math.floor(diffMs / 60000);
|
|
861
865
|
const days = Math.floor(totalMinutes / (60 * 24));
|
|
862
866
|
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
@@ -941,6 +945,7 @@ function getGeminiEmail() {
|
|
|
941
945
|
// ============================================================================
|
|
942
946
|
function getCodexRateLimits() {
|
|
943
947
|
const now = new Date();
|
|
948
|
+
let todayHasFiles = false;
|
|
944
949
|
for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
|
|
945
950
|
const d = new Date(now.getTime() - dayOffset * 86_400_000);
|
|
946
951
|
const sessDir = join(
|
|
@@ -953,6 +958,7 @@ function getCodexRateLimits() {
|
|
|
953
958
|
let files;
|
|
954
959
|
try { files = readdirSync(sessDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); }
|
|
955
960
|
catch { continue; }
|
|
961
|
+
if (dayOffset === 0 && files.length > 0) todayHasFiles = true;
|
|
956
962
|
for (const file of files) {
|
|
957
963
|
try {
|
|
958
964
|
const content = readFileSync(join(sessDir, file), "utf-8");
|
|
@@ -978,6 +984,8 @@ function getCodexRateLimits() {
|
|
|
978
984
|
if (Object.keys(buckets).length > 0) return buckets;
|
|
979
985
|
} catch { /* 파일 읽기 실패 무시 */ }
|
|
980
986
|
}
|
|
987
|
+
// 오늘 세션 파일이 존재하지만 rate_limits가 없으면 어제 stale 데이터로 폴백하지 않음
|
|
988
|
+
if (todayHasFiles && dayOffset === 0) return null;
|
|
981
989
|
}
|
|
982
990
|
return null;
|
|
983
991
|
}
|
|
@@ -1134,7 +1142,7 @@ function readCodexRateLimitSnapshot() {
|
|
|
1134
1142
|
|
|
1135
1143
|
function refreshCodexRateLimitsCache() {
|
|
1136
1144
|
const buckets = getCodexRateLimits();
|
|
1137
|
-
|
|
1145
|
+
// buckets가 null이어도 캐시 갱신 (stale 데이터 제거)
|
|
1138
1146
|
writeJsonSafe(CODEX_QUOTA_CACHE_PATH, { timestamp: Date.now(), buckets });
|
|
1139
1147
|
return buckets;
|
|
1140
1148
|
}
|
|
@@ -1257,8 +1265,8 @@ function getClaudeRows(stdin, claudeUsage, combinedSvPct) {
|
|
|
1257
1265
|
const svSuffix = `${dim("sv:")}${svStr}`;
|
|
1258
1266
|
|
|
1259
1267
|
// API 실측 데이터 사용 (없으면 플레이스홀더)
|
|
1260
|
-
const fiveHourPercent = claudeUsage?.fiveHourPercent ?? 0;
|
|
1261
|
-
const weeklyPercent = claudeUsage?.weeklyPercent ?? 0;
|
|
1268
|
+
const fiveHourPercent = isResetPast(claudeUsage?.fiveHourResetsAt) ? 0 : (claudeUsage?.fiveHourPercent ?? 0);
|
|
1269
|
+
const weeklyPercent = isResetPast(claudeUsage?.weeklyResetsAt) ? 0 : (claudeUsage?.weeklyPercent ?? 0);
|
|
1262
1270
|
const fiveHourReset = claudeUsage?.fiveHourResetsAt
|
|
1263
1271
|
? formatResetRemaining(claudeUsage.fiveHourResetsAt)
|
|
1264
1272
|
: "n/a";
|
package/package.json
CHANGED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// notion-read.mjs v1.1 — Notion 대형 페이지 리더 (Codex/Gemini/Claude MCP 위임)
|
|
3
|
+
//
|
|
4
|
+
// Codex/Gemini/Claude CLI에 설치된 Notion MCP를 활용하여 대형 페이지를 마크다운으로 추출.
|
|
5
|
+
// 폴백 체인: Codex(무료) → Gemini(무료) → Claude(최후) → 에러
|
|
6
|
+
//
|
|
7
|
+
// 사용법:
|
|
8
|
+
// node notion-read.mjs <notion-url-or-page-id> [옵션]
|
|
9
|
+
// tfx notion-read <notion-url-or-page-id> [옵션]
|
|
10
|
+
//
|
|
11
|
+
// 옵션:
|
|
12
|
+
// --output, -o <file> 결과 파일 저장 (기본: stdout)
|
|
13
|
+
// --timeout, -t <sec> CLI 타임아웃 (기본: 600)
|
|
14
|
+
// --cli, -c <codex|gemini> CLI 강제 지정 (기본: 자동 + 폴백)
|
|
15
|
+
// --depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
|
|
16
|
+
// --guest notion-guest 통합 사용 (기본: notion)
|
|
17
|
+
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync } from "fs";
|
|
20
|
+
import { join, dirname } from "path";
|
|
21
|
+
import { homedir, tmpdir } from "os";
|
|
22
|
+
|
|
23
|
+
const VERSION = "1.1";
|
|
24
|
+
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
25
|
+
const MCP_CACHE = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
|
|
26
|
+
const LOG_FILE = join(CLAUDE_DIR, "logs", "cli-route-stats.jsonl");
|
|
27
|
+
const ACC_FILE = join(CLAUDE_DIR, "cache", "sv-accumulator.json");
|
|
28
|
+
|
|
29
|
+
// ── ANSI 색상 ──
|
|
30
|
+
const AMBER = "\x1b[38;5;214m";
|
|
31
|
+
const GREEN = "\x1b[38;5;82m";
|
|
32
|
+
const RED = "\x1b[38;5;196m";
|
|
33
|
+
const YELLOW = "\x1b[33m";
|
|
34
|
+
const DIM = "\x1b[2m";
|
|
35
|
+
const BOLD = "\x1b[1m";
|
|
36
|
+
const RESET = "\x1b[0m";
|
|
37
|
+
const GRAY = "\x1b[38;5;245m";
|
|
38
|
+
|
|
39
|
+
// ── URL 파싱 ──
|
|
40
|
+
function parseNotionUrl(input) {
|
|
41
|
+
// 32자리 hex (하이픈 없는 page_id)
|
|
42
|
+
if (/^[a-f0-9]{32}$/i.test(input)) {
|
|
43
|
+
return { pageId: input, blockId: null };
|
|
44
|
+
}
|
|
45
|
+
// UUID 형식
|
|
46
|
+
if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input)) {
|
|
47
|
+
return { pageId: input.replace(/-/g, ""), blockId: null };
|
|
48
|
+
}
|
|
49
|
+
// URL에서 page_id + optional #block_id 추출
|
|
50
|
+
const urlMatch = input.match(/([a-f0-9]{32})(?:#([a-f0-9]{32}))?/i);
|
|
51
|
+
if (urlMatch) {
|
|
52
|
+
return { pageId: urlMatch[1], blockId: urlMatch[2] || null };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── MCP 가용성 확인 ──
|
|
58
|
+
function getNotionMcpClis(useGuest) {
|
|
59
|
+
const serverName = useGuest ? "notion-guest" : "notion";
|
|
60
|
+
const result = { codex: false, gemini: false };
|
|
61
|
+
|
|
62
|
+
if (!existsSync(MCP_CACHE)) return result;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const inv = JSON.parse(readFileSync(MCP_CACHE, "utf8"));
|
|
66
|
+
|
|
67
|
+
if (inv.codex?.servers) {
|
|
68
|
+
result.codex = inv.codex.servers.some(
|
|
69
|
+
(s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (inv.gemini?.servers) {
|
|
73
|
+
result.gemini = inv.gemini.servers.some(
|
|
74
|
+
(s) => s.name === serverName && (s.status === "enabled" || s.status === "configured"),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── CLI 존재 확인 ──
|
|
83
|
+
function cliExists(name) {
|
|
84
|
+
try {
|
|
85
|
+
const cmd = process.platform === "win32" ? `where ${name} 2>nul` : `which ${name} 2>/dev/null`;
|
|
86
|
+
const result = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
|
|
87
|
+
return !!result.trim();
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 프롬프트 생성 ──
|
|
94
|
+
function buildPrompt(pageId, blockId, depth, useGuest, includeComments) {
|
|
95
|
+
const mcpServer = useGuest ? "notion-guest" : "notion";
|
|
96
|
+
const targetBlock = blockId || pageId;
|
|
97
|
+
const blockNote = blockId
|
|
98
|
+
? `\n시작 블록: ${blockId} — 이 블록과 그 하위 블록만 읽어라.`
|
|
99
|
+
: "";
|
|
100
|
+
|
|
101
|
+
return `Notion 페이지를 마크다운으로 추출하라.
|
|
102
|
+
|
|
103
|
+
페이지 ID: ${pageId}${blockNote}
|
|
104
|
+
|
|
105
|
+
## 실행 지침
|
|
106
|
+
|
|
107
|
+
${mcpServer} MCP 서버의 도구를 사용하라.
|
|
108
|
+
|
|
109
|
+
### 1단계: 페이지 메타데이터
|
|
110
|
+
페이지 조회 도구를 호출하라 (page_id: "${pageId}").
|
|
111
|
+
제목과 주요 속성을 기록하라.
|
|
112
|
+
404 에러가 나면 ${useGuest ? "notion" : "notion-guest"} 서버로 재시도하라.
|
|
113
|
+
|
|
114
|
+
### 2단계: 블록 읽기 (페이지네이션 필수)
|
|
115
|
+
블록 자식 조회 도구를 호출하라 (block_id: "${targetBlock}", page_size: 100).
|
|
116
|
+
|
|
117
|
+
**페이지네이션 — 반드시 수행:**
|
|
118
|
+
- 응답의 has_more가 true이면, next_cursor를 start_cursor로 전달하여 반복 호출.
|
|
119
|
+
- has_more가 false가 될 때까지 계속 반복. 대형 페이지는 5-10회 이상 필요.
|
|
120
|
+
- 절대 첫 페이지만 읽고 멈추지 마라.
|
|
121
|
+
|
|
122
|
+
### 3단계: 중첩 블록 재귀
|
|
123
|
+
각 블록의 has_children이 true이면, 해당 block_id로 블록 자식 조회를 재귀 호출.
|
|
124
|
+
최대 깊이: ${depth}단계. 깊이 초과 시 "[깊이 초과]" 표시.
|
|
125
|
+
|
|
126
|
+
### 4단계: 댓글 수집${includeComments ? `
|
|
127
|
+
페이지 및 블록 댓글을 수집하라.
|
|
128
|
+
- 댓글 조회 도구를 호출하라 (block_id: "${pageId}")로 페이지 전체 댓글을 가져와라.
|
|
129
|
+
- 응답의 has_more가 true이면 next_cursor로 반복.
|
|
130
|
+
- 각 댓글의 parent.type이 "block_id"이면 해당 블록의 인라인 댓글이다.
|
|
131
|
+
- parent.type이 "page_id"이면 페이지 레벨 토론 댓글이다.
|
|
132
|
+
- 404 에러 발생 시 댓글 권한이 없는 것이므로 건너뛰어라.` : `
|
|
133
|
+
댓글 수집을 건너뛴다 (--comments 플래그 미지정).`}
|
|
134
|
+
|
|
135
|
+
### 5단계: 마크다운 변환
|
|
136
|
+
- heading_1/2/3 → #/##/###
|
|
137
|
+
- paragraph → rich_text의 plain_text 연결
|
|
138
|
+
- bulleted_list_item → - 항목
|
|
139
|
+
- numbered_list_item → 1. 항목
|
|
140
|
+
- to_do → - [ ] 또는 - [x]
|
|
141
|
+
- toggle → **제목** + 하위 내용 들여쓰기
|
|
142
|
+
- code → \`\`\`언어 + 코드 + \`\`\`
|
|
143
|
+
- quote → > 인용
|
|
144
|
+
- callout → > 콜아웃
|
|
145
|
+
- table + table_row → 마크다운 테이블 (| 헤더 | ... |)
|
|
146
|
+
- image → 
|
|
147
|
+
- bookmark → [북마크](url)
|
|
148
|
+
- divider → ---
|
|
149
|
+
- column_list/column → 순서대로 출력
|
|
150
|
+
- child_page → [하위 페이지: 제목]
|
|
151
|
+
- child_database → [하위 DB: 제목]
|
|
152
|
+
- synced_block → 원본 내용 출력
|
|
153
|
+
- 기타 → [블록타입: 지원안됨]
|
|
154
|
+
|
|
155
|
+
### 출력 규칙
|
|
156
|
+
- 페이지 제목을 # 헤더로 시작
|
|
157
|
+
- 모든 블록을 빠짐없이 순서대로 출력
|
|
158
|
+
- 읽기 실패 블록은 <!-- 읽기 실패: block_id --> 주석 남기기
|
|
159
|
+
- rich_text의 annotations (bold, italic, code, strikethrough) 반영
|
|
160
|
+
- 링크는 [텍스트](url) 형식${includeComments ? `
|
|
161
|
+
- 블록 인라인 댓글: 해당 블록 바로 아래에 > **[댓글]** @작성자: 내용 형식으로 삽입
|
|
162
|
+
- 페이지 토론 댓글: 문서 맨 끝에 ## 토론 섹션으로 모아서 출력
|
|
163
|
+
- 댓글의 rich_text도 마크다운으로 변환` : ""}
|
|
164
|
+
- 최종 결과만 출력 — 중간 과정 설명 불필요`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── CLI 실행 (임시 파일 + execSync — Windows .cmd 호환) ──
|
|
168
|
+
function runWithCli(cliType, prompt, timeout) {
|
|
169
|
+
const cliName = cliType === "claude" ? "claude" : cliType === "codex" ? "codex" : "gemini";
|
|
170
|
+
if (!cliExists(cliName)) {
|
|
171
|
+
return { success: false, output: "", error: `${cliType} CLI 미설치`, cli: cliType };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 프롬프트를 임시 파일에 저장 (shell escaping 회피)
|
|
175
|
+
const promptFile = join(tmpdir(), `notion-prompt-${Date.now()}.md`);
|
|
176
|
+
writeFileSync(promptFile, prompt, "utf8");
|
|
177
|
+
const promptPath = promptFile.replace(/\\/g, "/");
|
|
178
|
+
|
|
179
|
+
// CLI에 전달할 짧은 메타 프롬프트
|
|
180
|
+
const metaPrompt = `Read the file at ${promptPath} and execute all instructions in it exactly as described. Output only the final markdown result.`;
|
|
181
|
+
|
|
182
|
+
let cmd;
|
|
183
|
+
if (cliType === "codex") {
|
|
184
|
+
cmd = `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "${metaPrompt}"`;
|
|
185
|
+
} else if (cliType === "gemini") {
|
|
186
|
+
cmd = `gemini -m gemini-3-flash-preview -y --allowed-mcp-server-names notion,notion-guest --prompt "${metaPrompt}"`;
|
|
187
|
+
} else {
|
|
188
|
+
// Claude CLI — print 모드 (MCP 도구 자동 접근)
|
|
189
|
+
cmd = `claude -p "${metaPrompt}"`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.error(`${AMBER}▸${RESET} ${cliType}로 실행 중... (timeout: ${timeout}s)`);
|
|
193
|
+
const startTime = Date.now();
|
|
194
|
+
|
|
195
|
+
let stdout = "";
|
|
196
|
+
let stderr = "";
|
|
197
|
+
let exitCode = 0;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
stdout = execSync(cmd, {
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
timeout: (timeout + 30) * 1000,
|
|
203
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
204
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
205
|
+
cwd: process.cwd(),
|
|
206
|
+
});
|
|
207
|
+
} catch (e) {
|
|
208
|
+
exitCode = e.status || (e.killed ? 124 : 1);
|
|
209
|
+
stdout = e.stdout || "";
|
|
210
|
+
stderr = e.stderr || "";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
214
|
+
|
|
215
|
+
// 임시 파일 정리
|
|
216
|
+
try { unlinkSync(promptFile); } catch {}
|
|
217
|
+
|
|
218
|
+
// 실행 로그 기록
|
|
219
|
+
logExecution(cliType, exitCode, elapsed, timeout, stderr);
|
|
220
|
+
|
|
221
|
+
if (exitCode === 0 && stdout) {
|
|
222
|
+
return { success: true, output: stdout, cli: cliType, elapsed };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const isTimeout = exitCode === 124;
|
|
226
|
+
return {
|
|
227
|
+
success: false,
|
|
228
|
+
output: stdout,
|
|
229
|
+
error: isTimeout ? `timeout (${timeout}s)` : `exit ${exitCode}`,
|
|
230
|
+
stderr: stderr.slice(-500),
|
|
231
|
+
cli: cliType,
|
|
232
|
+
elapsed,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Codex JSON-line 출력 정리 ──
|
|
237
|
+
function cleanCodexOutput(raw) {
|
|
238
|
+
const lines = raw.split(/\r?\n/);
|
|
239
|
+
const texts = [];
|
|
240
|
+
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
const trimmed = line.trim();
|
|
243
|
+
if (!trimmed) continue;
|
|
244
|
+
|
|
245
|
+
// JSON-line 형식이면 파싱
|
|
246
|
+
if (trimmed.startsWith("{")) {
|
|
247
|
+
try {
|
|
248
|
+
const obj = JSON.parse(trimmed);
|
|
249
|
+
if (["message", "completed", "output_text"].includes(obj.type)) {
|
|
250
|
+
const text = obj.text || obj.content || obj.output || "";
|
|
251
|
+
if (text) texts.push(text);
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
} catch {
|
|
255
|
+
// JSON 파싱 실패 → 일반 텍스트로 처리
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
texts.push(line);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return texts.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── 실행 로그 (cli-route.sh 호환) ──
|
|
266
|
+
function logExecution(cliType, exitCode, elapsed, timeout, stderr) {
|
|
267
|
+
try {
|
|
268
|
+
const logDir = dirname(LOG_FILE);
|
|
269
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
270
|
+
|
|
271
|
+
const ts = new Date().toISOString();
|
|
272
|
+
const status = exitCode === 0 ? "success" : exitCode === 124 ? "timeout" : "failed";
|
|
273
|
+
const entry = JSON.stringify({
|
|
274
|
+
ts,
|
|
275
|
+
agent: "notion-read",
|
|
276
|
+
cli: cliType,
|
|
277
|
+
effort: cliType === "codex" ? "high" : cliType === "claude" ? "sonnet" : "flash",
|
|
278
|
+
run_mode: "fg",
|
|
279
|
+
opus_oversight: "false",
|
|
280
|
+
status,
|
|
281
|
+
exit_code: exitCode,
|
|
282
|
+
elapsed_sec: elapsed,
|
|
283
|
+
timeout_sec: timeout,
|
|
284
|
+
mcp_profile: "notion",
|
|
285
|
+
input_tokens: 0,
|
|
286
|
+
output_tokens: 0,
|
|
287
|
+
total_tokens: 0,
|
|
288
|
+
});
|
|
289
|
+
appendFileSync(LOG_FILE, entry + "\n");
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── 메인 ──
|
|
294
|
+
function main() {
|
|
295
|
+
const args = process.argv.slice(2);
|
|
296
|
+
|
|
297
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
298
|
+
console.log(`
|
|
299
|
+
${AMBER}${BOLD}notion-read${RESET} ${DIM}v${VERSION}${RESET}
|
|
300
|
+
${GRAY}Notion 대형 페이지 리더 — Codex/Gemini MCP 위임${RESET}
|
|
301
|
+
|
|
302
|
+
${BOLD}사용법${RESET}
|
|
303
|
+
tfx notion-read <notion-url-or-page-id> [옵션]
|
|
304
|
+
|
|
305
|
+
${BOLD}옵션${RESET}
|
|
306
|
+
--output, -o <file> 결과 파일 저장 (기본: stdout)
|
|
307
|
+
--timeout, -t <sec> CLI 타임아웃 (기본: 600)
|
|
308
|
+
--cli, -c <codex|gemini|claude> CLI 강제 지정 (기본: 자동 + 폴백)
|
|
309
|
+
--depth, -d <n> 중첩 블록 최대 깊이 (기본: 3)
|
|
310
|
+
--comments 블록/페이지 댓글 포함
|
|
311
|
+
--guest notion-guest 통합 사용
|
|
312
|
+
|
|
313
|
+
${BOLD}폴백 체인${RESET}
|
|
314
|
+
Codex(무료) → Gemini(무료) → Claude(최후) → 에러
|
|
315
|
+
|
|
316
|
+
${BOLD}예시${RESET}
|
|
317
|
+
tfx notion-read https://notion.so/Page-abc123def456...
|
|
318
|
+
tfx notion-read abc123def456... --output page.md --comments
|
|
319
|
+
tfx notion-read abc123def456... --cli gemini --timeout 900
|
|
320
|
+
tfx notion-read abc123def456... --guest --comments
|
|
321
|
+
`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 인자 파싱
|
|
326
|
+
const input = args[0];
|
|
327
|
+
let outputFile = null;
|
|
328
|
+
let timeout = 600;
|
|
329
|
+
let forceCli = null;
|
|
330
|
+
let depth = 3;
|
|
331
|
+
let useGuest = false;
|
|
332
|
+
let includeComments = false;
|
|
333
|
+
|
|
334
|
+
for (let i = 1; i < args.length; i++) {
|
|
335
|
+
switch (args[i]) {
|
|
336
|
+
case "--output":
|
|
337
|
+
case "-o":
|
|
338
|
+
outputFile = args[++i];
|
|
339
|
+
break;
|
|
340
|
+
case "--timeout":
|
|
341
|
+
case "-t":
|
|
342
|
+
timeout = parseInt(args[++i]) || 600;
|
|
343
|
+
break;
|
|
344
|
+
case "--cli":
|
|
345
|
+
case "-c":
|
|
346
|
+
forceCli = args[++i];
|
|
347
|
+
break;
|
|
348
|
+
case "--depth":
|
|
349
|
+
case "-d":
|
|
350
|
+
depth = parseInt(args[++i]) || 3;
|
|
351
|
+
break;
|
|
352
|
+
case "--guest":
|
|
353
|
+
useGuest = true;
|
|
354
|
+
break;
|
|
355
|
+
case "--comments":
|
|
356
|
+
includeComments = true;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// URL 파싱
|
|
362
|
+
const parsed = parseNotionUrl(input);
|
|
363
|
+
if (!parsed) {
|
|
364
|
+
console.error(`${RED}✗${RESET} 유효하지 않은 Notion URL/ID: ${input}`);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
console.error(
|
|
369
|
+
`${AMBER}▸${RESET} 페이지: ${parsed.pageId}${parsed.blockId ? ` (블록: ${parsed.blockId})` : ""}`,
|
|
370
|
+
);
|
|
371
|
+
console.error(`${GRAY} 통합: ${useGuest ? "notion-guest" : "notion"} | 깊이: ${depth} | 댓글: ${includeComments ? "O" : "X"} | 타임아웃: ${timeout}s${RESET}`);
|
|
372
|
+
|
|
373
|
+
// MCP 가용성 확인
|
|
374
|
+
const mcpAvail = getNotionMcpClis(useGuest);
|
|
375
|
+
console.error(
|
|
376
|
+
`${GRAY} MCP: codex=${mcpAvail.codex ? "O" : "X"} gemini=${mcpAvail.gemini ? "O" : "X"}${RESET}`,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// MCP 미설치 안내
|
|
380
|
+
if (!mcpAvail.codex && !mcpAvail.gemini) {
|
|
381
|
+
console.error(`${YELLOW}!${RESET} Codex/Gemini에 Notion MCP 미설치.`);
|
|
382
|
+
console.error(`${GRAY} Codex: codex mcp add notion${RESET}`);
|
|
383
|
+
console.error(`${GRAY} Gemini: ~/.gemini/settings.json에 notion 서버 추가${RESET}`);
|
|
384
|
+
console.error(`${GRAY} 설치 후 tfx doctor --reset으로 캐시 갱신${RESET}`);
|
|
385
|
+
} else if (!mcpAvail.codex) {
|
|
386
|
+
console.error(`${GRAY} Codex에 Notion MCP 미설치: codex mcp add notion${RESET}`);
|
|
387
|
+
} else if (!mcpAvail.gemini) {
|
|
388
|
+
console.error(`${GRAY} Gemini에 Notion MCP 미설치: ~/.gemini/settings.json 확인${RESET}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// CLI 실행 순서 결정 (Codex → Gemini → Claude)
|
|
392
|
+
let cliOrder;
|
|
393
|
+
if (forceCli) {
|
|
394
|
+
cliOrder = [forceCli];
|
|
395
|
+
} else {
|
|
396
|
+
cliOrder = [];
|
|
397
|
+
if (mcpAvail.codex) cliOrder.push("codex");
|
|
398
|
+
if (mcpAvail.gemini) cliOrder.push("gemini");
|
|
399
|
+
// Claude는 항상 최종 폴백 (자체 Notion MCP — notion-guest 포함)
|
|
400
|
+
cliOrder.push("claude");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 프롬프트 생성
|
|
404
|
+
const prompt = buildPrompt(parsed.pageId, parsed.blockId, depth, useGuest, includeComments);
|
|
405
|
+
// Claude 폴백용: notion/notion-guest 양쪽 시도 프롬프트
|
|
406
|
+
const claudePrompt = buildPrompt(parsed.pageId, parsed.blockId, depth, false, includeComments)
|
|
407
|
+
.replace(
|
|
408
|
+
"404 에러가 나면 notion-guest 서버로 재시도하라.",
|
|
409
|
+
"404 에러가 나면 반드시 notion-guest 서버로 재시도하라. notion, notion-guest, claude_ai_Notion 등 사용 가능한 모든 Notion MCP 서버를 시도하라.",
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// 실행 + 폴백
|
|
413
|
+
let lastResult = null;
|
|
414
|
+
let notionAccessFailed = false;
|
|
415
|
+
|
|
416
|
+
for (const cli of cliOrder) {
|
|
417
|
+
const currentPrompt = cli === "claude" ? claudePrompt : prompt;
|
|
418
|
+
const result = runWithCli(cli, currentPrompt, timeout);
|
|
419
|
+
lastResult = result;
|
|
420
|
+
|
|
421
|
+
if (result.success) {
|
|
422
|
+
// Codex JSON-line 출력 정리
|
|
423
|
+
let output = cli === "codex" ? cleanCodexOutput(result.output) : result.output;
|
|
424
|
+
output = output.trim();
|
|
425
|
+
|
|
426
|
+
// 실패 마커 감지 (404, 접근 실패 등)
|
|
427
|
+
const isFailureOutput =
|
|
428
|
+
(output.includes("조회 실패") || output.includes("읽기 실패") || output.includes("not_found")) &&
|
|
429
|
+
output.length < 500;
|
|
430
|
+
|
|
431
|
+
if (output.length > 100 && !isFailureOutput) {
|
|
432
|
+
console.error(`${GREEN}✓${RESET} ${cli}로 성공 (${output.length} chars, ${result.elapsed}s)`);
|
|
433
|
+
|
|
434
|
+
if (outputFile) {
|
|
435
|
+
const outDir = dirname(outputFile);
|
|
436
|
+
if (outDir && outDir !== "." && !existsSync(outDir)) {
|
|
437
|
+
mkdirSync(outDir, { recursive: true });
|
|
438
|
+
}
|
|
439
|
+
writeFileSync(outputFile, output, "utf8");
|
|
440
|
+
console.error(`${GREEN}✓${RESET} 저장: ${outputFile}`);
|
|
441
|
+
} else {
|
|
442
|
+
console.log(output);
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 404 접근 실패 감지
|
|
448
|
+
if (isFailureOutput) {
|
|
449
|
+
notionAccessFailed = true;
|
|
450
|
+
console.error(`${YELLOW}!${RESET} ${cli}: Notion 접근 실패 (404) — 폴백`);
|
|
451
|
+
if (!useGuest && cli !== "claude") {
|
|
452
|
+
console.error(`${GRAY} --guest 플래그로 notion-guest 통합 시도 가능${RESET}`);
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
console.error(`${YELLOW}!${RESET} ${cli}: 출력 부족 (${output.length} chars) — 폴백`);
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
console.error(`${YELLOW}!${RESET} ${cli} 실패: ${result.error}`);
|
|
459
|
+
if (result.stderr) {
|
|
460
|
+
console.error(`${GRAY} stderr: ${result.stderr.slice(0, 200)}${RESET}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 다음 CLI로 폴백
|
|
465
|
+
const idx = cliOrder.indexOf(cli);
|
|
466
|
+
if (idx < cliOrder.length - 1) {
|
|
467
|
+
const next = cliOrder[idx + 1];
|
|
468
|
+
console.error(`${AMBER}▸${RESET} ${next}로 폴백 시도...`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 전체 실패
|
|
473
|
+
console.error(`${RED}✗${RESET} 모든 CLI 실패`);
|
|
474
|
+
if (notionAccessFailed) {
|
|
475
|
+
console.error(`${YELLOW}!${RESET} Notion 페이지 접근 권한 문제.`);
|
|
476
|
+
console.error(`${GRAY} 1. Notion에서 페이지 → ... → 연결(Connections)에서 통합 추가${RESET}`);
|
|
477
|
+
console.error(`${GRAY} 2. --guest 플래그로 notion-guest 통합 시도${RESET}`);
|
|
478
|
+
console.error(`${GRAY} 3. --cli claude로 Claude 직접 사용 (Claude에 접근 권한 있는 경우)${RESET}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 부분 결과라도 출력
|
|
482
|
+
if (lastResult?.output) {
|
|
483
|
+
const partial = lastResult.output.trim();
|
|
484
|
+
if (partial.length > 10) {
|
|
485
|
+
const cleaned = lastResult.cli === "codex" ? cleanCodexOutput(partial) : partial;
|
|
486
|
+
if (outputFile) {
|
|
487
|
+
writeFileSync(outputFile, cleaned, "utf8");
|
|
488
|
+
console.error(`${YELLOW}!${RESET} 부분 결과 저장: ${outputFile}`);
|
|
489
|
+
} else {
|
|
490
|
+
console.log(cleaned);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
main();
|