@cliphijack/santaclaude 1.0.14 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/santaclaude.js +81 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cliphijack/santaclaude",
3
- "version": "1.0.14",
3
+ "version": "1.0.17",
4
4
  "publishConfig": { "access": "public" },
5
5
  "description": "SantaClaude 커넥터 — 클라우드 예약을 내 로컬 Claude(tmux)에 발사",
6
6
  "bin": { "santaclaude": "./santaclaude.js" },
package/santaclaude.js CHANGED
@@ -80,6 +80,35 @@ function captureWindow(session, name) {
80
80
  try { return execFileSync('tmux', ['capture-pane', '-t', session + ':' + name, '-p', '-e', '-S', '-500'], { encoding: 'utf8' }).replace(/\n+$/, '').slice(-60000); }
81
81
  catch (e) { return ''; }
82
82
  }
83
+ // 🔍 로컬 스킬 발견 — ~/.claude/skills + 각 루돌프 프로젝트 .claude/skills 스캔 (등록 누를 때만 1회)
84
+ function parseFrontmatter(md) {
85
+ const m = String(md).match(/^\s*---\s*\n([\s\S]*?)\n---/); let name = '', desc = '';
86
+ if (m) { const nm = m[1].match(/^name:\s*(.+)$/m); const dm = m[1].match(/^description:\s*(.+)$/m); if (nm) name = nm[1].trim(); if (dm) desc = dm[1].trim(); }
87
+ return { name, desc };
88
+ }
89
+ function scanSkillDir(dir, scope, sessionName, out, seen) {
90
+ let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
91
+ for (const e of entries) {
92
+ if (!e.isDirectory()) continue;
93
+ const sd = path.join(dir, e.name);
94
+ let md = ''; try { md = fs.readFileSync(path.join(sd, 'SKILL.md'), 'utf8'); } catch (err) { continue; } // SKILL.md 없으면 스킬 아님
95
+ const fm = parseFrontmatter(md);
96
+ const name = String(fm.name || e.name).slice(0, 80); const key = name.toLowerCase();
97
+ if (seen.has(key)) continue; seen.add(key);
98
+ out.push({ name, desc: String(fm.desc || '').slice(0, 200), scope, dir: sd, session: sessionName || '' });
99
+ }
100
+ }
101
+ function collectLocalSkills(session) {
102
+ const out = [], seen = new Set();
103
+ scanSkillDir(path.join(os.homedir(), '.claude', 'skills'), '전역 (~/.claude/skills)', '', out, seen);
104
+ try {
105
+ for (const w of listWindows(session)) {
106
+ let cwd = ''; try { cwd = execFileSync('tmux', ['display-message', '-t', session + ':' + w, '-p', '#{pane_current_path}'], { encoding: 'utf8' }).trim(); } catch (e) {}
107
+ if (cwd && cwd !== os.homedir()) scanSkillDir(path.join(cwd, '.claude', 'skills'), '프로젝트: ' + w, w, out, seen);
108
+ }
109
+ } catch (e) {}
110
+ return out.slice(0, 200);
111
+ }
83
112
  function captureAll(session) {
84
113
  const o = {}; for (const w of listWindows(session)) o[w] = captureWindow(session, w); return o;
85
114
  }
@@ -158,6 +187,32 @@ function linesToTurns(lines, withImgs) {
158
187
  function parseTurns(file, maxTurns) {
159
188
  return linesToTurns(readTail(file, 2 * 1024 * 1024), false).slice(-Math.max(10, Math.min(200, maxTurns || 60)));
160
189
  }
190
+ // 🔥 열일 루돌프 — 최근 24h 토큰 합산(로컬 JSONL의 usage). opt-in(리그 등판) 때만 보고. 숫자만, 대화내용 0
191
+ function collectTok24h() {
192
+ const cutoff = Date.now() - 24 * 3600 * 1000;
193
+ let files = [];
194
+ try {
195
+ const base = path.join(os.homedir(), '.claude', 'projects'); if (!fs.existsSync(base)) return 0;
196
+ for (const d of fs.readdirSync(base)) {
197
+ const dp = path.join(base, d); try { if (!fs.statSync(dp).isDirectory()) continue; } catch (e) { continue; }
198
+ for (const f of fs.readdirSync(dp)) { if (!f.endsWith('.jsonl')) continue; const fp = path.join(dp, f); try { if (fs.statSync(fp).mtimeMs > cutoff) files.push({ fp, m: fs.statSync(fp).mtimeMs }); } catch (e) {} }
199
+ }
200
+ } catch (e) { return 0; }
201
+ files.sort((a, b) => b.m - a.m); files = files.slice(0, 25); // 최근 수정 25개만(부담 방지)
202
+ let tok = 0;
203
+ for (const { fp } of files) {
204
+ for (const line of readTail(fp, 6 * 1024 * 1024)) {
205
+ if (!line) continue; let o; try { o = JSON.parse(line); } catch (e) { continue; }
206
+ if (o.type !== 'assistant') continue;
207
+ const ts = o.timestamp ? Date.parse(o.timestamp) : 0; if (ts && ts < cutoff) continue;
208
+ const u = o.message && o.message.usage; if (!u) continue;
209
+ tok += (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
210
+ }
211
+ }
212
+ return tok;
213
+ }
214
+ let LEAGUE_ON = false, _tokCache = 0, _tokAt = 0;
215
+ function tok24h() { const now = Date.now(); if (LEAGUE_ON && now - _tokAt > 5 * 60 * 1000) { _tokCache = collectTok24h(); _tokAt = now; } return LEAGUE_ON ? _tokCache : 0; }
161
216
 
162
217
  async function post(api, p, body) {
163
218
  const r = await fetch(api + p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -298,6 +353,17 @@ async function run(conf) {
298
353
  // 루돌프 = 작업장(session) 안의 윈도우(탭). 별도 세션 아님.
299
354
  async function runControl(cmd) {
300
355
  try {
356
+ if (cmd.action === 'scan-skills') {
357
+ const skills = collectLocalSkills(session);
358
+ try { await fetch(api + '/api/localskills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, skills }) }); } catch (e) {}
359
+ console.log(' 🔍 로컬 스킬 ' + skills.length + '개 발견 → 보고');
360
+ return;
361
+ }
362
+ if (cmd.action === 'league') { // 🔥 열일 루돌프 리그 opt-in 토글 (켜질 때만 24h 토큰 보고)
363
+ LEAGUE_ON = !!cmd.on; _tokAt = 0; // 캐시 무효화 → 다음 hb에 즉시 반영
364
+ console.log(' 🔥 열일 리그 등판: ' + (LEAGUE_ON ? 'ON' : 'OFF'));
365
+ return;
366
+ }
301
367
  if (cmd.action === 'skill') {
302
368
  // 마켓 스킬 이식 — 그 루돌프 프로젝트 폴더 .claude/skills/<name>/ 에 설치 + claude에 적용 주입
303
369
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
@@ -326,27 +392,34 @@ async function run(conf) {
326
392
  return;
327
393
  }
328
394
  if (cmd.action === 'image') {
329
- // 웹이 올린 이미지를 받아 로컬에 저장하고 claude에 경로 주입
395
+ // 웹이 올린 파일을 받아 로컬에 저장하고 claude에 경로 주입 (이미지·txt·mp3·mp4 등 전부)
330
396
  const dir = path.join(os.homedir(), '.santaclaude', 'img');
331
397
  try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
332
- const paths = [];
398
+ const MIME_EXT = {}; for (const k in EXT_MIME) if (!(EXT_MIME[k] in MIME_EXT)) MIME_EXT[EXT_MIME[k]] = k; // mime→ext 역매핑
399
+ const paths = []; let allImg = true;
333
400
  for (const im of (cmd.imgs || [])) {
334
401
  try {
335
402
  const r = await fetch(api + '/api/img?token=' + encodeURIComponent(token) + '&id=' + encodeURIComponent(im.id));
336
403
  if (!r.ok) continue;
337
404
  const j = await r.json();
338
- const ext = String(j.mime || '').includes('png') ? 'png' : String(j.mime || '').includes('webp') ? 'webp' : 'jpg';
339
- const fp = path.join(dir, Date.now() + '_' + paths.length + '.' + ext);
405
+ const mime = String(j.mime || '');
406
+ if (!mime.startsWith('image/')) allImg = false;
407
+ const safe = String(im.name || j.name || '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
408
+ let fname;
409
+ if (safe && /\.[A-Za-z0-9]+$/.test(safe)) fname = Date.now() + '_' + paths.length + '_' + safe; // 원본 파일명·확장자 보존
410
+ else { const ext = MIME_EXT[mime] || (mime.includes('png') ? 'png' : mime.includes('webp') ? 'webp' : mime.startsWith('image/') ? 'jpg' : 'bin'); fname = Date.now() + '_' + paths.length + '.' + ext; }
411
+ const fp = path.join(dir, fname);
340
412
  fs.writeFileSync(fp, Buffer.from(j.data, 'base64'));
341
413
  paths.push(fp);
342
414
  } catch (e) {}
343
415
  }
344
416
  if (paths.length) {
345
417
  const w = (cmd.name && windowExists(session, cmd.name)) ? (session + ':' + cmd.name) : pane;
346
- const msg = (cmd.note ? cmd.note + ' ' : '') + '첨부 이미지 ' + paths.length + '장 봐줘: ' + paths.join(' ');
418
+ const label = allImg ? ('첨부 이미지 ' + paths.length + '') : ('첨부 파일 ' + paths.length + '');
419
+ const msg = (cmd.note ? cmd.note + ' ' : '') + label + ' 봐줘: ' + paths.join(' ');
347
420
  execFileSync('tmux', ['send-keys', '-t', w, '-l', msg]);
348
421
  setTimeout(() => { try { execFileSync('tmux', ['send-keys', '-t', w, 'Enter']); } catch (e) {} }, 300);
349
- console.log(` 🖼️ 이미지 ${paths.length} → ${w}`);
422
+ console.log(` 📎 ${label} → ${w}`);
350
423
  }
351
424
  return;
352
425
  }
@@ -493,7 +566,7 @@ async function run(conf) {
493
566
  console.log(`[발사] ${new Date().toISOString()} → ${tgt}: ${String(j.message).slice(0, 60)}`);
494
567
  try { inject(tgt, '[SantaClaude] ' + j.message); } catch (e) { console.warn(` 주입 실패(${tgt}): ${e.message}`); }
495
568
  }
496
- function sendHb() { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify({ type: 'hb', pane, sessions: listWindows(session) })); } catch (e) {} } }
569
+ function sendHb() { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify({ type: 'hb', pane, sessions: listWindows(session), token: LEAGUE_ON ? token : undefined, tok: LEAGUE_ON ? tok24h() : undefined })); } catch (e) {} } }
497
570
  // 화면 미러 — 변경됐을 때만 전송(claude 멈추면 0). 2.5초 체크라 응답 직후 빠르게 반영
498
571
  let lastScr = '';
499
572
  function sendScreen() { if (!ws || ws.readyState !== 1) return; let cur; try { cur = JSON.stringify(captureAll(session)); } catch (e) { return; } if (cur === lastScr) return; lastScr = cur; try { ws.send(JSON.stringify({ type: 'screen', screens: JSON.parse(cur) })); } catch (e) {} }
@@ -519,7 +592,7 @@ async function run(conf) {
519
592
  // 폴링 폴백 (node<21 = WebSocket 미지원) — 워커가 DO 백엔드라 폴링이어도 KV write 0
520
593
  async function pollTick() {
521
594
  try {
522
- post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session) }).catch(() => {});
595
+ post(api, '/api/heartbeat', { token, pane, sessions: listWindows(session), screens: captureAll(session), tok: LEAGUE_ON ? tok24h() : undefined }).catch(() => {});
523
596
  post(api, '/api/control/claim', { token }).then((c) => { for (const cmd of (c && c.commands) || []) runControl(cmd); }).catch(() => {});
524
597
  const d = await post(api, '/api/jobs/claim', { token });
525
598
  for (const j of (d.jobs || [])) onJob(j);