shellbase 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,8 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
87
87
  | 세션 갈아타기 | 터미널 화면 위쪽의 **기기 이름(▾)** 탭 → 원하는 세션 선택 |
88
88
  | 세션 끄기 | 목록의 **전원 아이콘**, 또는 터미널 화면의 기기 이름(▾) → **이 세션 끄기** |
89
89
  | 화면만 닫기 | `← 목록` 으로 나가기 — 컴퓨터의 세션은 계속 살아있어요 |
90
+ | 파일 열어보기·고치기 | **`📁 폴더 찾기`** → 목록에서 **파일**을 탭하면 에디터가 열려요 (수정 후 `저장`) |
91
+ | 세션 이름 바꾸기 | 기기 이름(▾) → 세션 옆 연필 버튼 |
90
92
 
91
93
  - **끄기는 진짜로 꺼요.** 컴퓨터에서 돌아가던 그 터미널이 종료되고 목록에서도 자동으로 사라져요
92
94
  (`Ctrl+C` 를 누른 것과 같아요). 프로세스가 죽었는데 목록에만 남아있는 항목은 휴지통 아이콘으로
@@ -97,6 +99,16 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
97
99
  종료돼요(그 대신 다음 실행 때 자동 복구됩니다). 세션을 하나만 끄고 싶으면 폰에서 그 세션의 `✕` 를
98
100
  쓰세요 — 나머지 세션은 그대로 유지돼요.
99
101
 
102
+ ### 파일 보기·편집
103
+
104
+ `📁 폴더 찾기` 목록에는 폴더와 함께 **파일**도 나와요. 파일을 탭하면 문법 강조가 되는 에디터가 열리고,
105
+ 고친 뒤 `저장` 을 누르면 그 컴퓨터의 파일이 실제로 바뀝니다.
106
+
107
+ - 512KB 까지, **글자 파일만** 열려요 (사진·실행파일 같은 이진 파일은 자동으로 걸러집니다)
108
+ - 저장은 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해서, 저장 도중 끊겨도 원본이 깨지지 않아요
109
+ - 파일 내용은 터미널 화면과 똑같이 세션 키로 암호화해서 주고받아요
110
+ - 에디터는 **파일을 처음 열 때만 따로 내려받아요** — 터미널만 쓰면 그만큼 안 받습니다
111
+
100
112
  > ⚠️ **도커로 돌릴 때, 마지막 세션을 폰에서 끄면 도커가 다시 띄워요.** 세션이 0개가 되면 프로세스가
101
113
  > 종료되는데, `--restart unless-stopped` 는 정상 종료(exit 0)에도 컨테이너를 자동 재시작하기 때문이에요
102
114
  > (실제로 확인한 동작). 즉 마지막 세션 끄기는 **사실상 재시작**이 됩니다. 세션이 여러 개일 때 하나만
package/dist/agent.js CHANGED
@@ -12,6 +12,7 @@ import { promptApproval } from './prompt.js';
12
12
  import { touchRecentDir } from './recent-dirs.js';
13
13
  import { listDirs } from './browse.js';
14
14
  import { readTextFile, writeTextFile, chunkContent } from './files.js';
15
+ import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
15
16
  import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
16
17
  const HEARTBEAT_MS = 20_000;
17
18
  const APPROVAL_TIMEOUT_MS = 30_000;
@@ -77,6 +78,10 @@ export async function runAgent(options) {
77
78
  const sessions = new Map();
78
79
  // 폰에서 저장 중인 파일 조각 모음 (경로별)
79
80
  const writeBuffers = new Map();
81
+ // 폰에서 온 녹음 조각 모음 (파일 저장과 같은 방식)
82
+ const audioBuffers = new Map();
83
+ // 이 컴퓨터에서 찾은 음성인식 서버 주소 — 시작할 때 한 번 찾고, 없으면 요청이 올 때 다시 찾는다
84
+ let sttUrl = null;
80
85
  function defaultName(cwd) {
81
86
  return cwd === os.homedir() ? hostLabel : `${hostLabel} · ${path.basename(cwd)}`;
82
87
  }
@@ -409,6 +414,66 @@ export async function runAgent(options) {
409
414
  console.log(`📱 폰에서 파일을 저장했어요: ${filePath}`);
410
415
  return;
411
416
  }
417
+ // 폰에서 마이크로 말한 녹음 — 조각이 다 모이면 이 컴퓨터의 음성인식 서버로 넘겨 글자로 돌려준다.
418
+ // 녹음은 인터넷의 다른 음성인식 서비스로 나가지 않고 이 컴퓨터 안에서만 처리된다.
419
+ if (frame.kind === 'stt_audio') {
420
+ const control = readControl(frame.data, session.frameKey);
421
+ if (!control ||
422
+ typeof control.id !== 'string' ||
423
+ typeof control.chunk !== 'string' ||
424
+ typeof control.index !== 'number' ||
425
+ typeof control.total !== 'number') {
426
+ return;
427
+ }
428
+ const recordId = control.id;
429
+ const key = `${session.deviceId}:${recordId}`;
430
+ const now = Date.now();
431
+ for (const [bufferKey, buffer] of audioBuffers) {
432
+ if (now - buffer.at > AUDIO_CHUNK_TTL_MS)
433
+ audioBuffers.delete(bufferKey);
434
+ }
435
+ const buffer = control.index === 0
436
+ ? { parts: [], total: control.total, at: now }
437
+ : (audioBuffers.get(key) ?? { parts: [], total: control.total, at: now });
438
+ buffer.parts[control.index] = control.chunk;
439
+ buffer.at = now;
440
+ audioBuffers.set(key, buffer);
441
+ if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
442
+ return;
443
+ audioBuffers.delete(key);
444
+ const audioBase64 = buffer.parts.join('');
445
+ const fail = (reason) => send({
446
+ kind: 'stt_error',
447
+ to: session.deviceId,
448
+ data: encryptFrame(JSON.stringify({ id: recordId, reason }), session.frameKey),
449
+ });
450
+ if (audioBase64.length > MAX_AUDIO_BASE64) {
451
+ void fail('녹음이 너무 길어요. 조금 짧게 나눠서 말해주세요.');
452
+ return;
453
+ }
454
+ void (async () => {
455
+ if (!sttUrl)
456
+ sttUrl = await findSttServer();
457
+ if (!sttUrl) {
458
+ await fail(STT_SETUP_HINT);
459
+ return;
460
+ }
461
+ const result = await transcribe(sttUrl, audioBase64);
462
+ if (!result.ok) {
463
+ // 서버가 꺼졌을 수도 있으니 다음 요청 때 다시 찾도록 주소를 비운다
464
+ sttUrl = null;
465
+ await fail(result.reason);
466
+ return;
467
+ }
468
+ console.log(`📱 폰에서 말한 내용을 받아썼어요: "${result.text}"`);
469
+ await send({
470
+ kind: 'stt_text',
471
+ to: session.deviceId,
472
+ data: encryptFrame(JSON.stringify({ id: recordId, text: result.text }), session.frameKey),
473
+ });
474
+ })();
475
+ return;
476
+ }
412
477
  if (frame.kind === 'list_dirs') {
413
478
  const control = readControl(frame.data, session.frameKey);
414
479
  if (!control)
@@ -465,6 +530,13 @@ export async function runAgent(options) {
465
530
  heartbeatDevice(cb, session.rowId).catch((err) => console.error(`하트비트 실패(${session.name}):`, err.message));
466
531
  }
467
532
  }, HEARTBEAT_MS);
533
+ // 음성인식 서버가 이 컴퓨터에 떠 있으면 폰에서 마이크 버튼을 쓸 수 있다 (없어도 나머지 기능은 그대로)
534
+ void findSttServer().then((url) => {
535
+ sttUrl = url;
536
+ console.log(url
537
+ ? `🎤 음성인식 서버를 찾았어요: ${url} — 폰에서 마이크로 말할 수 있어요.`
538
+ : '🎤 음성인식 서버가 없어요 — 폰 마이크 기능은 꺼진 상태예요 (SHELLBASE_STT_URL 로 주소를 지정할 수 있어요).');
539
+ });
468
540
  startSleepGuard();
469
541
  process.on('SIGINT', () => void shutdownProcess());
470
542
  process.on('SIGTERM', () => void shutdownProcess());
package/dist/stt.js ADDED
@@ -0,0 +1,65 @@
1
+ // 폰에서 보낸 녹음을 "이 컴퓨터에 떠 있는 음성인식 서버"로 넘겨 글자로 바꾼다.
2
+ // 녹음이 컴퓨터 밖으로 나가지 않는 게 핵심 — 폰 → (암호화된 실시간 채널) → 이 컴퓨터 → 로컬 whisper.
3
+ // 실시간 프레임은 한 번에 20KB 남짓만 보낼 수 있어서 녹음도 파일과 같은 방식으로 조각내 받는다
4
+ export const AUDIO_CHUNK_TTL_MS = 60_000;
5
+ // 대략 1~2분 분량 (base64 로 부풀린 크기 기준)
6
+ export const MAX_AUDIO_BASE64 = 2 * 1024 * 1024;
7
+ // 서버를 찾는 순서: 직접 지정한 주소 → 이 컴퓨터 → 도커 안에서 본 호스트
8
+ function candidates() {
9
+ const list = [];
10
+ const configured = process.env.SHELLBASE_STT_URL?.trim();
11
+ if (configured)
12
+ list.push(configured.replace(/\/$/, ''));
13
+ list.push('http://127.0.0.1:5005', 'http://172.17.0.1:5005', 'http://host.docker.internal:5005');
14
+ return list;
15
+ }
16
+ async function isAlive(baseUrl) {
17
+ try {
18
+ const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1500) });
19
+ return res.ok;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ // 시작할 때 한 번 찾아두고, 못 찾으면 요청이 올 때 다시 한 번 찾아본다(그 사이에 켰을 수도 있으니)
26
+ export async function findSttServer() {
27
+ for (const url of candidates()) {
28
+ if (await isAlive(url))
29
+ return url;
30
+ }
31
+ return null;
32
+ }
33
+ export async function transcribe(baseUrl, audioBase64) {
34
+ const languages = (process.env.SHELLBASE_STT_LANGS ?? 'ko')
35
+ .split(',')
36
+ .map((lang) => lang.trim())
37
+ .filter(Boolean);
38
+ try {
39
+ const res = await fetch(`${baseUrl}/transcribe_json`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ audio_base64: audioBase64, languages }),
43
+ signal: AbortSignal.timeout(60_000),
44
+ });
45
+ if (!res.ok) {
46
+ return { ok: false, reason: `음성인식 서버가 오류를 돌려줬어요 (HTTP ${res.status}).` };
47
+ }
48
+ const body = (await res.json());
49
+ if (body.ok === false) {
50
+ return { ok: false, reason: body.error ?? '음성을 알아듣지 못했어요.' };
51
+ }
52
+ const text = (body.text ?? '').trim();
53
+ if (!text)
54
+ return { ok: false, reason: '말소리가 들리지 않았어요. 다시 한 번 말해주세요.' };
55
+ return { ok: true, text };
56
+ }
57
+ catch (err) {
58
+ const reason = err.name === 'TimeoutError'
59
+ ? '음성인식이 너무 오래 걸려서 멈췄어요.'
60
+ : `음성인식 서버에 연결하지 못했어요: ${err.message}`;
61
+ return { ok: false, reason };
62
+ }
63
+ }
64
+ export const STT_SETUP_HINT = '이 컴퓨터에서 음성인식 서버를 찾지 못했어요. whisper 같은 음성인식 서버를 켜고, 주소가 다르면 ' +
65
+ 'SHELLBASE_STT_URL=http://127.0.0.1:5005 처럼 지정한 뒤 에이전트를 다시 시작해주세요.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {