Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f000ac6c8 | ||
|
|
677bfcd2a9 | ||
|
|
e49be6d04e | ||
|
|
1efabe03b1 | ||
|
|
09cd4c5e31 | ||
|
|
00ce813845 | ||
|
|
c56ce1eb30 | ||
|
|
597207dd33 | ||
|
|
98a1825d01 | ||
|
|
da27c5a306 |
39
.env.example
39
.env.example
@@ -74,6 +74,12 @@ WHISPER_MODEL=small
|
||||
# occasional trailing CJK fragment small models leak on free-form chat).
|
||||
OUTPUT_LANGUAGE=
|
||||
|
||||
# Operator instruction folder: every *.md in this dir is appended to the main
|
||||
# reply LLM's system prompt (filename order), re-read each turn so edits apply
|
||||
# without a rebuild/restart. ./agents is bind-mounted here read-only; only
|
||||
# change this to relocate the folder inside the container. See README "운영자 지시문".
|
||||
AGENTS_DIR=/app/agents
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker desktop (VNC) — used only by the container image
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -98,12 +104,28 @@ CHROME_START_URL=about:blank
|
||||
# on-screen browser for real-time info (search / play / read screen).
|
||||
# false = no screen share; voice only, real-time info via the Gemini API.
|
||||
STREAM_BROWSER=true
|
||||
# Optional: profile dir for browser-based Google search in plain text turns
|
||||
# (no active broadcast). When set, the search helper opens Chrome against this
|
||||
# profile instead of a fresh anonymous one. Sign that profile into Google once
|
||||
# (run a real Chrome with --user-data-dir=<this path> and log in) so Google
|
||||
# treats later searches as a returning user and does not serve the bot-detection
|
||||
# page. Leave blank to use an ephemeral headless session (works only where
|
||||
# Google does not challenge it). Use a DEDICATED dir, not your everyday Chrome
|
||||
# profile, to avoid the "profile in use" lock while Chrome is open.
|
||||
CHROME_USER_DATA_DIR=
|
||||
# Gemini auth for real-time info when STREAM_BROWSER=false.
|
||||
# oauth = use the Gemini CLI with a Google-account login (no API key).
|
||||
# Install once: npm i -g @google/gemini-cli ; then run `gemini` and
|
||||
# "Sign in with Google". Uses the CLI's built-in web-search grounding.
|
||||
# apikey = legacy REST path; needs GEMINI_API_KEY below
|
||||
# (get one at https://aistudio.google.com/app/apikey).
|
||||
# NOTE (2026-06): Google is blocking personal Google accounts on this
|
||||
# path ("This client is no longer supported for Gemini Code Assist for
|
||||
# individuals"). Workspace/org accounts may still work; personal
|
||||
# accounts should use apikey below instead.
|
||||
# apikey = REST path; needs GEMINI_API_KEY below
|
||||
# (get one at https://aistudio.google.com/app/apikey). Recommended for
|
||||
# personal Google accounts now that individual OAuth login is blocked.
|
||||
# Either way, real-time search fail-opens to DDG/Brave/Wikipedia if Gemini is
|
||||
# unavailable, so this is optional, not required.
|
||||
GEMINI_AUTH=oauth
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
@@ -174,11 +196,18 @@ VOICE_SILENCE_MS=800
|
||||
JARVIS_ROLE=full
|
||||
|
||||
# --- GPU per OS: pick the matching compose override via COMPOSE_FILE ---
|
||||
# Ubuntu (nvidia-container-toolkit / CDI):
|
||||
# IMPORTANT: the file separator is OS-specific. Linux/macOS use ":" (colon);
|
||||
# Windows uses ";" (semicolon), because ":" is taken by the drive letter (C:).
|
||||
# Using the wrong one makes Docker treat the whole string as a single missing
|
||||
# filename ("...gpu-windows.yml: The system cannot find the file specified").
|
||||
# Ubuntu / macOS (nvidia-container-toolkit / CDI):
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml
|
||||
# Windows 11 (Docker Desktop + WSL2 + NVIDIA):
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-windows.yml
|
||||
# Windows 11 (Docker Desktop + WSL2 + NVIDIA) — note the ";" separator:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml
|
||||
# Browser-only host (no GPU needed): leave COMPOSE_FILE unset (base only).
|
||||
# Default below is the Linux form; Windows users must change ":" to ";" AND
|
||||
# swap gpu-linux for gpu-windows. If unsure, comment this out and pass the
|
||||
# files explicitly: docker compose -f docker-compose.yml -f <gpu-override> ...
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml
|
||||
|
||||
# --- Browser HOST (JARVIS_ROLE=browser) — e.g. this LAN machine ---
|
||||
|
||||
6
.gitattributes
vendored
6
.gitattributes
vendored
@@ -7,3 +7,9 @@
|
||||
|
||||
# PowerShell is more forgiving but the same logic applies.
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Shell scripts run inside the Linux container; they MUST stay LF even when
|
||||
# checked out on Windows. autocrlf=true would otherwise inject CR and break
|
||||
# `set -o pipefail`, shebangs, and heredocs (e.g. docker/setup-melo.sh failing
|
||||
# the image build with "set: pipefail: invalid option name").
|
||||
*.sh text eol=lf
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -10,8 +10,14 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
DISPLAY=:1 \
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
|
||||
PATH=/opt/venv/bin:/root/.bun/bin:/usr/local/bin:/usr/bin:/bin \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
# `video` is REQUIRED for NVENC/NVDEC: it tells the NVIDIA Container Toolkit to
|
||||
# inject libnvidia-encode.so.1 / libnvidia-decode.so.1 into the container. With
|
||||
# only `compute,utility` you get CUDA (ollama/whisper/melo) + nvidia-smi, but the
|
||||
# Go-Live broadcast's h264_nvenc fails with "Cannot load libnvidia-encode.so.1".
|
||||
# Applies on both Linux (CDI) and Windows Docker Desktop (WSL2).
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,video
|
||||
|
||||
# --- System packages: desktop, VNC, Chrome deps, ffmpeg, python, ocr ---
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -63,7 +69,11 @@ RUN ls -d /opt/venv/lib/python*/site-packages/nvidia/cublas/lib \
|
||||
# Heavy layer (torch CPU + transformers + MeCab); placed before the app
|
||||
# COPY so it stays cached across source-only changes. ---
|
||||
COPY docker/setup-melo.sh /app/docker/setup-melo.sh
|
||||
RUN bash /app/docker/setup-melo.sh
|
||||
# Strip CR before running: a Windows checkout (autocrlf) yields CRLF, which makes
|
||||
# bash read line 18 as `set -euxo pipefail\r` and abort with
|
||||
# "set: pipefail: invalid option name". .gitattributes pins *.sh to LF, but this
|
||||
# keeps the build working even on a not-yet-renormalised working tree.
|
||||
RUN sed -i 's/\r$//' /app/docker/setup-melo.sh && bash /app/docker/setup-melo.sh
|
||||
|
||||
# --- Human input + window management for the on-screen Chrome control tool.
|
||||
# Placed AFTER the heavy melo layer so it doesn't bust that cache. xdotool
|
||||
@@ -81,6 +91,11 @@ RUN cd /app/bot && bun install --frozen-lockfile || bun install
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
# Normalise all container shell scripts to LF. On a Windows checkout (autocrlf)
|
||||
# these arrive as CRLF, which would break their shebangs at runtime (entrypoint,
|
||||
# run-*.sh) the same way it broke setup-melo.sh at build time.
|
||||
RUN find /app/docker /app/scripts -name '*.sh' -exec sed -i 's/\r$//' {} +
|
||||
|
||||
# --- Default Piper voice (best-effort at build; entrypoint retries if absent) ---
|
||||
RUN bash docker/download-piper.sh || true
|
||||
|
||||
|
||||
20
README.md
20
README.md
@@ -76,12 +76,14 @@ docker compose up -d --build
|
||||
매번 `-f`를 치기 싫으면 `.env`에 한 줄 넣어두면 그냥 `docker compose up -d`로 됩니다(override가 자동 적용):
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
# Linux / macOS (구분자 = 콜론 ":")
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml
|
||||
# Windows 11
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-windows.yml
|
||||
# Windows 11 (구분자 = 세미콜론 ";" — 콜론은 드라이브 문자 C: 와 충돌)
|
||||
COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml
|
||||
```
|
||||
|
||||
> ⚠️ `COMPOSE_FILE`의 파일 구분자는 OS마다 다릅니다: Linux/macOS는 `:`, Windows는 `;`. Windows에서 `:`를 쓰면 Docker가 전체를 파일 하나 이름으로 읽어 `... The system cannot find the file specified` 에러가 납니다. 헷갈리면 `COMPOSE_FILE`을 비워두고 실행 시 직접 지정하세요: `docker compose -f docker-compose.yml -f docker-compose.gpu-windows.yml up -d --build`.
|
||||
|
||||
> Linux와 Windows는 GPU를 컨테이너에 넣는 방식이 달라서 override 파일이 갈립니다. Linux는 CDI(`devices: nvidia.com/gpu=all`), Windows(Docker Desktop)는 Compose의 `deploy.resources.reservations.devices`(`driver: nvidia`)를 씁니다. 호스트 사전 준비는 아래 "GPU 가속" 절 참고.
|
||||
|
||||
`docker compose up` 한 번이면 자동으로:
|
||||
@@ -245,6 +247,18 @@ cd bot; bun run register; bun run start # 창 2: (일반 봇이면) 슬래시
|
||||
- `OLLAMA_CHAT_MODEL` — 두뇌 LLM (기본 `qwen2.5:3b`)
|
||||
- `COMPOSE_FILE` — OS별 GPU override를 매번 `-f`로 안 치고 자동 적용 (위 "실행 — Docker" 참고)
|
||||
- `output_language` — 출력 언어 고정(비우면 사용자 언어). 설정 웹 UI(`/settings`)에서 바꾸면 env 기본값보다 우선하며 컨테이너 재생성 후에도 유지됩니다.
|
||||
- `AGENTS_DIR` — 운영자 지시문 폴더(기본 `/app/agents`, `./agents`가 read-only로 마운트됨). 아래 "운영자 지시문" 참고.
|
||||
|
||||
---
|
||||
|
||||
## 운영자 지시문 (`agents/*.md`)
|
||||
|
||||
`agents/` 폴더에 마크다운 파일을 넣으면 그 내용이 어시스턴트의 메인 답변 시스템 프롬프트 뒤에 그대로 추가됩니다. 페르소나(집사 성격)는 그대로 두고 규칙·말투·금칙어 등을 덧붙일 때 쓰세요.
|
||||
|
||||
- `agents/` 안의 모든 `*.md`를 **파일명 순서**로 이어 붙입니다. 순서를 정하려면 `00-tone.md`, `10-rules.md`처럼 숫자 접두사를 쓰세요.
|
||||
- **매 답변마다 다시 읽습니다.** 파일을 저장하면 다음 발화부터 바로 반영되며, 재빌드/재시작이 필요 없습니다(폴더가 read-only로 마운트됨).
|
||||
- 폴더가 없거나 비어 있으면 아무 일도 일어나지 않습니다(fail-open).
|
||||
- `agents/example.md.sample`을 `rules.md` 등 `*.md`로 복사해서 시작하세요. `.sample` 파일은 로드되지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
15
agents/example.md.sample
Normal file
15
agents/example.md.sample
Normal file
@@ -0,0 +1,15 @@
|
||||
# Operator instruction file (example)
|
||||
#
|
||||
# HOW TO USE: copy or rename this file to anything ending in `.md`
|
||||
# (e.g. `rules.md`). Every `*.md` in this folder is appended to the assistant's
|
||||
# main reply system prompt, in filename order — use number prefixes like
|
||||
# `00-tone.md`, `10-rules.md` to control ordering. Edits take effect on the
|
||||
# NEXT reply; no rebuild or restart is needed (the folder is read per turn).
|
||||
#
|
||||
# Files ending in `.sample` (like this one) are ignored, so this template never
|
||||
# affects replies until you rename it to `*.md`.
|
||||
#
|
||||
# Everything below a heading is treated as plain instruction text for the LLM.
|
||||
|
||||
Always keep replies under two sentences.
|
||||
When the user asks about deployment, mention the relevant docker compose command.
|
||||
@@ -1,33 +1,112 @@
|
||||
// True-mode browser action core. Drives the on-screen Chrome (CDP at CDP_PORT,
|
||||
// default 9222) so the action is visible on the Go-Live broadcast, and prints a
|
||||
// JSON result on stdout for the Python `browseAndSearch` tool to wrap.
|
||||
// Browser action core. Prefers the on-screen Chrome (CDP at CDP_PORT, default
|
||||
// 9222) so the action is visible on the Go-Live broadcast, and prints a JSON
|
||||
// result on stdout for the Python `browseAndSearch` tool to wrap.
|
||||
//
|
||||
// node browse-search.mjs "<query>" [search|youtube]
|
||||
//
|
||||
// - search : Google-search the query, return the top organic results.
|
||||
// - youtube : search YouTube and play the first result.
|
||||
//
|
||||
// Backend selection for `search`:
|
||||
// 1. The broadcast Chrome over CDP (visible on the Go-Live stream).
|
||||
// 2. Else, if CHROME_USER_DATA_DIR is set, a persistent Chrome using that
|
||||
// profile dir. Logging that dedicated profile into Google once lets Google
|
||||
// treat later searches as a returning signed-in user, which avoids the
|
||||
// bot-detection interstitial that blocks a fresh anonymous session.
|
||||
// 3. Else a fresh ephemeral headless Chrome (works only where Google does not
|
||||
// challenge the session, e.g. a non-flagged residential IP).
|
||||
// `youtube` only makes sense on the visible broadcast Chrome, so it never uses
|
||||
// the headless/persistent fallback.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const CDP = process.env.CDP_PORT || '9222';
|
||||
// Use 127.0.0.1, not "localhost": in containers localhost can resolve to IPv6
|
||||
// (::1) first while Chrome's CDP listens on IPv4, giving ECONNREFUSED ::1.
|
||||
const CDP_HOST = process.env.CDP_HOST || '127.0.0.1';
|
||||
const USER_DATA_DIR = process.env.CHROME_USER_DATA_DIR || '';
|
||||
const UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||
const query = process.argv[2] || '';
|
||||
const mode = (process.argv[3] || 'search').toLowerCase();
|
||||
const out = (o) => { process.stdout.write(JSON.stringify(o)); };
|
||||
|
||||
if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); }
|
||||
|
||||
let b;
|
||||
let connected; // CDP Browser (the broadcast Chrome — never kill it)
|
||||
let launchedBrowser; // ephemeral headless Browser we launched
|
||||
let persistent; // persistent BrowserContext we launched
|
||||
let launched = false;
|
||||
let page;
|
||||
|
||||
// Try system Chrome (channel:'chrome') first so no extra Playwright browser
|
||||
// download is needed; fall back to Playwright's bundled chromium.
|
||||
async function tryLaunch(launchFn) {
|
||||
let err;
|
||||
for (const opts of [{ headless: true, channel: 'chrome' }, { headless: true }]) {
|
||||
try {
|
||||
return await launchFn(opts);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function acquirePage() {
|
||||
// 1. Broadcast Chrome over CDP.
|
||||
try {
|
||||
connected = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
|
||||
const ctx = connected.contexts()[0];
|
||||
page = ctx.pages()[0] || (await ctx.newPage());
|
||||
return;
|
||||
} catch (e) {
|
||||
if (mode === 'youtube') throw e; // youtube needs the visible broadcast Chrome
|
||||
}
|
||||
|
||||
// 2. Persistent profile (signed-in) when configured.
|
||||
if (USER_DATA_DIR) {
|
||||
persistent = await tryLaunch((opts) =>
|
||||
chromium.launchPersistentContext(USER_DATA_DIR, { ...opts, locale: 'ko-KR', userAgent: UA }),
|
||||
);
|
||||
launched = true;
|
||||
page = persistent.pages()[0] || (await persistent.newPage());
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Ephemeral headless.
|
||||
launchedBrowser = await tryLaunch((opts) => chromium.launch(opts));
|
||||
launched = true;
|
||||
const ctx = await launchedBrowser.newContext({ locale: 'ko-KR', userAgent: UA });
|
||||
page = await ctx.newPage();
|
||||
}
|
||||
|
||||
async function closeAll() {
|
||||
try { await persistent?.close(); } catch { /* ignore */ }
|
||||
try { await launchedBrowser?.close(); } catch { /* ignore */ }
|
||||
try { await connected?.close(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Human-like search: land on the site's home page, type the query into its
|
||||
// search box one key at a time, and press Enter — the way a person would,
|
||||
// rather than jumping straight to a results URL.
|
||||
async function typeSearch(homeUrl, boxSelector, query) {
|
||||
await page.goto(homeUrl, { waitUntil: 'domcontentloaded' });
|
||||
const box = page.locator(boxSelector).first();
|
||||
await box.waitFor({ timeout: 15000 });
|
||||
await box.click();
|
||||
await box.pressSequentially(query, { delay: 45 });
|
||||
await box.press('Enter');
|
||||
}
|
||||
|
||||
try {
|
||||
b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
|
||||
const ctx = b.contexts()[0];
|
||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
||||
await acquirePage();
|
||||
page.setDefaultTimeout(20000);
|
||||
await page.bringToFront().catch(() => {});
|
||||
|
||||
if (mode === 'youtube') {
|
||||
await page.goto(`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`, { waitUntil: 'domcontentloaded' });
|
||||
// Type into YouTube's search box like a person, then play the first result.
|
||||
await typeSearch('https://www.youtube.com/?hl=ko', 'input#search, input[name="search_query"]', query);
|
||||
await page.waitForSelector('ytd-video-renderer a#video-title, a#video-title', { timeout: 20000 });
|
||||
const first = page.locator('ytd-video-renderer a#video-title, a#video-title').first();
|
||||
const title = (await first.getAttribute('title').catch(() => '')) || (await first.innerText().catch(() => ''));
|
||||
@@ -36,8 +115,19 @@ try {
|
||||
await page.evaluate(() => { const v = document.querySelector('video'); if (v && v.paused) v.play(); });
|
||||
out({ ok: true, mode, title: (title || '').trim(), url: page.url() });
|
||||
} else {
|
||||
await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}&hl=ko`, { waitUntil: 'domcontentloaded' });
|
||||
// Type into Google's search box like a person, then read the results.
|
||||
await typeSearch('https://www.google.com/?hl=ko', 'textarea[name="q"], input[name="q"]', query);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1500);
|
||||
// Google serves its bot-detection interstitial (/sorry/index) to sessions it
|
||||
// suspects are automated. Detect it structurally (by URL, locale-independent)
|
||||
// and fail fast so the Python caller fail-opens to the DDG cascade instead of
|
||||
// treating an empty challenge page as "no results".
|
||||
if (page.url().includes('/sorry/')) {
|
||||
await closeAll();
|
||||
out({ ok: false, error: 'google-bot-challenge', headless: launched });
|
||||
process.exit(1);
|
||||
}
|
||||
const results = await page.evaluate(() => {
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
@@ -55,11 +145,11 @@ try {
|
||||
}
|
||||
return items;
|
||||
});
|
||||
out({ ok: true, mode, query, count: results.length, results });
|
||||
out({ ok: true, mode, query, count: results.length, results, headless: launched });
|
||||
}
|
||||
await b.close();
|
||||
await closeAll();
|
||||
} catch (e) {
|
||||
try { await b?.close(); } catch { /* ignore */ }
|
||||
await closeAll();
|
||||
out({ ok: false, error: String(e?.message || e) });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -150,12 +150,17 @@ def _ensure_brain():
|
||||
compute = os.environ.get("WHISPER_COMPUTE_TYPE", "auto")
|
||||
try:
|
||||
whisper = WhisperModel(cfg.whisper_model, device=device, compute_type=compute)
|
||||
# Log the device actually resolved by CTranslate2 (device="auto"
|
||||
# picks cuda when available) so a silent CPU load is visible.
|
||||
resolved = str(getattr(getattr(whisper, "model", None), "device", device)).lower()
|
||||
print(f"[bridge] whisper loaded on {resolved} (compute={compute})", flush=True)
|
||||
except Exception as ge:
|
||||
# GPU not available / unsupported -> fall back to CPU so the
|
||||
# bridge still works without a GPU passed to the container.
|
||||
if device != "cpu":
|
||||
print(f"[bridge] whisper device='{device}' failed ({ge}); falling back to CPU", flush=True)
|
||||
whisper = WhisperModel(cfg.whisper_model, device="cpu", compute_type="int8")
|
||||
print("[bridge] whisper loaded on cpu (compute=int8)", flush=True)
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu-windows.yml up -d
|
||||
#
|
||||
# Or set COMPOSE_FILE in .env (recommended):
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-windows.yml
|
||||
# Or set COMPOSE_FILE in .env (note the ";" separator on Windows — ":" collides
|
||||
# with the C: drive letter and breaks file resolution):
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml
|
||||
services:
|
||||
ollama:
|
||||
deploy:
|
||||
|
||||
@@ -97,6 +97,10 @@ services:
|
||||
BROWSER_CONTROL_BIND: ${BROWSER_CONTROL_BIND:-0.0.0.0}
|
||||
BROWSER_CONTROL_PORT: ${BROWSER_CONTROL_PORT:-8777}
|
||||
BROWSER_CONTROL_URL: ${BROWSER_CONTROL_URL:-}
|
||||
# Folder of operator *.md instruction files appended to the main reply
|
||||
# LLM's system prompt. Bind-mounted from ./agents below; override only to
|
||||
# relocate it inside the container.
|
||||
AGENTS_DIR: ${AGENTS_DIR:-/app/agents}
|
||||
# No hard depends_on ollama: a browser-host (`docker compose up -d javis`)
|
||||
# must NOT pull in Ollama. Full/bot layouts start it with a plain
|
||||
# `docker compose up -d` (all services); the bridge tolerates Ollama warming
|
||||
@@ -149,6 +153,11 @@ services:
|
||||
# If unseeded, the path fail-opens to the DDG/Brave cascade and the
|
||||
# entrypoint logs a warning. Only consumed when GEMINI_AUTH=oauth.
|
||||
- ${GEMINI_OAUTH_DIR:-./docker/gemini-oauth}:/root/.gemini
|
||||
# Operator instruction files. Every *.md here is appended to the main
|
||||
# reply LLM's system prompt (filename order), read per turn so edits apply
|
||||
# on the next reply without a rebuild/restart. Read-only; a project-
|
||||
# relative path resolves identically on Linux and Windows Docker Desktop.
|
||||
- ./agents:/app/agents:ro
|
||||
|
||||
volumes:
|
||||
ollama_models:
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
One image, three roles (`JARVIS_ROLE`), selected in `.env`. GPU is added per OS
|
||||
via a compose override picked with `COMPOSE_FILE`.
|
||||
|
||||
> `COMPOSE_FILE`'s file separator is OS-specific: Linux/macOS use `:`, Windows
|
||||
> uses `;` (a colon collides with the `C:` drive letter). Using `:` on Windows
|
||||
> yields `... The system cannot find the file specified`. If in doubt, leave
|
||||
> `COMPOSE_FILE` unset and pass the files explicitly:
|
||||
> `docker compose -f docker-compose.yml -f docker-compose.gpu-windows.yml up -d`.
|
||||
|
||||
## A. All-in-one (single machine)
|
||||
|
||||
Everything (desktop + Chrome + bridge + bot + TTS) in one container.
|
||||
@@ -10,8 +16,8 @@ Everything (desktop + Chrome + bridge + bot + TTS) in one container.
|
||||
```
|
||||
# .env
|
||||
JARVIS_ROLE=full
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml # Ubuntu
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-windows.yml # Windows 11
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml # Ubuntu/macOS (":" )
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml # Windows 11 (";" )
|
||||
DISCORD_SELFBOT_TOKEN=...
|
||||
DISCORD_GUILD_ID=...
|
||||
|
||||
@@ -45,8 +51,8 @@ Watch it on this machine’s VNC (`localhost:5901`) / noVNC (`localhost:6080`).
|
||||
# .env
|
||||
JARVIS_ROLE=bot
|
||||
BROWSER_CONTROL_URL=http://192.168.10.9:8777 # the browser host's LAN IP
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml # Ubuntu
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-windows.yml # Windows 11
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu-linux.yml # Ubuntu/macOS (":" )
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.gpu-windows.yml # Windows 11 (";" )
|
||||
DISCORD_SELFBOT_TOKEN=...
|
||||
DISCORD_GUILD_ID=...
|
||||
|
||||
@@ -68,6 +74,11 @@ human-style input (visible on its VNC).
|
||||
compose runs from PowerShell/cmd). Seed it once from a machine with a browser and
|
||||
the logged-in Gemini CLI (`npm i -g @google/gemini-cli`, then `gemini` ->
|
||||
"Sign in with Google"), copying the login state:
|
||||
(Note: as of 2026-06 Google blocks personal Google accounts on this CLI login
|
||||
with "This client is no longer supported for Gemini Code Assist for
|
||||
individuals". Workspace/org accounts may still work; personal accounts should
|
||||
use `GEMINI_AUTH=apikey` with a key from https://aistudio.google.com/app/apikey
|
||||
instead. Real-time search fail-opens to DDG/Brave/Wikipedia either way.)
|
||||
`cp -r ~/.gemini/. docker/gemini-oauth/`. The essential file is `oauth_creds.json`
|
||||
(it holds the refresh token; `GOOGLE_GENAI_USE_GCA=true` forces OAuth, so that is
|
||||
the file the startup readiness check looks for) - copying the whole dir simply also
|
||||
|
||||
@@ -13,6 +13,7 @@ Every distinct LLM call in Jarvis, what feeds it, what consumes it, and how it i
|
||||
- Redacted user query
|
||||
- Recent dialogue (last 5 minutes), including in-loop tool-call + tool-role messages from prior replies within the active conversation (tool carryover, `DialogueMemory.record_tool_turn` / `get_recent_turns_with_tools` in [src/jarvis/memory/conversation.py](src/jarvis/memory/conversation.py); per-prompt cap via `cfg.tool_carryover_max_turns` / `tool_carryover_per_entry_chars`; storage cap `_tool_turns_max_storage = 16`; cleared on `stop` signal AND on new-conversation entry; UNTRUSTED WEB EXTRACT fence markers preserved on truncation; both `content` and `tool_calls[*].function.arguments` scrubbed on write)
|
||||
- Unified system prompt from [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) + ASR note + tool-protocol guidance. Reply language is resolved by `reply_language_directive(lang, cfg.tts_engine)` where `lang = _resolve_output_language()` — the single source of truth that prefers the settings-web UI value (config JSON `output_language`) over the compose `OUTPUT_LANGUAGE` env, so changing the language in the settings page takes effect. An explicit lock wins (forces "reply only in `<language>`", also forbidding other scripts so small models stop leaking trailing CJK/Hanja); else a Piper/Chatterbox TTS forces English (English-only voices); else (multilingual TTS, no lock) the assistant replies in the user's own language. The directive is inserted near the FRONT of the guidance list so a small model gives it primacy, and the SAME resolved `lang` feeds `build_system_prompt()`, which rewrites the persona's "in the user's language" clause to the locked language so the persona cannot contradict the directive (previously the persona read the raw env while the directive read the config value, so a settings-UI change was honoured by one and ignored by the other). Gated in `_build_initial_system_message()` at [engine.py](src/jarvis/reply/engine.py).
|
||||
- **Operator instructions** (two sources, both framed "Additional instructions from the operator:" and appended near the end of the guidance list): the settings-UI `llm_instructions` config field, and every `*.md` file in `AGENTS_DIR` (default `/app/agents`, bind-mounted from `./agents`). The file-based set is read once per turn by `load_agent_instructions()` in [src/jarvis/system_prompt.py](src/jarvis/system_prompt.py) and concatenated in filename order, so dropping/editing a `.md` applies on the next reply with no rebuild/restart; fail-open to `""` when the folder is absent/empty/unreadable.
|
||||
- **Warm profile block** (query-agnostic User + Directives excerpt from the knowledge graph, composed by `build_warm_profile()` / `format_warm_profile_block()` in [src/jarvis/memory/graph_ops.py](src/jarvis/memory/graph_ops.py) at Step 3.5 of `reply()`; no LLM call, pure SQLite read; injected unconditionally so personalisation is the default; result cached in `DialogueMemory._hot_cache` under `DialogueMemory.WARM_PROFILE_CACHE_KEY` for the lifetime of the active conversation. Invalidated on `stop`, on new-conversation entry, AND on User/Directives graph mutations via the listener registered in [src/jarvis/daemon.py](src/jarvis/daemon.py) against `register_graph_mutation_listener` in [src/jarvis/memory/graph.py](src/jarvis/memory/graph.py); World-branch writes are ignored)
|
||||
- Digested memory enrichment (optional, see #4)
|
||||
- Time + location context (re-injected each turn)
|
||||
|
||||
@@ -9,7 +9,11 @@ import os
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from ..utils.redact import redact
|
||||
from ..system_prompt import build_system_prompt, reply_language_directive
|
||||
from ..system_prompt import (
|
||||
build_system_prompt,
|
||||
load_agent_instructions,
|
||||
reply_language_directive,
|
||||
)
|
||||
from ..tools.registry import run_tool_with_retries, generate_tools_description, generate_tools_json_schema, BUILTIN_TOOLS
|
||||
from ..tools.builtin.stop import STOP_SIGNAL
|
||||
from ..debug import debug_log
|
||||
@@ -1702,6 +1706,10 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
|
||||
# the directive used the config value made the two contradict each other.
|
||||
_output_language = _resolve_output_language()
|
||||
_persona_prompt = build_system_prompt(_assistant_name, _output_language)
|
||||
# File-based operator instructions: every *.md in AGENTS_DIR (default
|
||||
# /app/agents, bind-mounted from ./agents). Read once per turn so edits in
|
||||
# the folder apply on the next reply without a restart; fail-open to "".
|
||||
_agent_instructions = load_agent_instructions()
|
||||
|
||||
def _build_initial_system_message() -> str:
|
||||
guidance = [_persona_prompt.strip()]
|
||||
@@ -1810,6 +1818,12 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any],
|
||||
if _user_instructions:
|
||||
guidance.append("Additional instructions from the operator:\n" + _user_instructions)
|
||||
|
||||
# File-based operator instructions: the concatenated agents/*.md content
|
||||
# resolved once above. Same framing/placement as the settings-UI field
|
||||
# so both are treated as authoritative operator guidance.
|
||||
if _agent_instructions:
|
||||
guidance.append("Additional instructions from the operator:\n" + _agent_instructions)
|
||||
|
||||
# Recency reinforcement: repeat the language lock at the very END too.
|
||||
# In a ~5k-token prompt the front-placed rule gets "lost in the middle";
|
||||
# bigger models (qwen2.5:7b) otherwise leak Chinese/Cyrillic mid-reply.
|
||||
|
||||
@@ -6,8 +6,51 @@ who renames the wake word (e.g. "Friday") gets a butler with the matching
|
||||
name rather than a persona hardcoded to "Jarvis".
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Default location of the operator's file-based instruction folder. In the
|
||||
# Docker deployment ./agents is bind-mounted here (see docker-compose.yml), so a
|
||||
# user can drop *.md files in without rebuilding. Overridable via AGENTS_DIR.
|
||||
_DEFAULT_AGENTS_DIR = "/app/agents"
|
||||
|
||||
|
||||
def load_agent_instructions(agents_dir: Optional[str] = None) -> str:
|
||||
"""Concatenate every ``*.md`` in the agents dir into one instruction block.
|
||||
|
||||
Files are read in filename order (so ``00-tone.md`` precedes ``10-rules.md``)
|
||||
and joined with blank lines. This lets the operator extend the main reply
|
||||
LLM's system prompt by dropping Markdown files into a folder, no code change
|
||||
or restart required — the caller reads this once per turn.
|
||||
|
||||
Resolution order for the directory: explicit ``agents_dir`` arg, then the
|
||||
``AGENTS_DIR`` env var, then ``/app/agents``.
|
||||
|
||||
Fail-open by design: a missing or empty directory, an unreadable file, or
|
||||
any unexpected error yields ``""`` so a misconfigured folder can never break
|
||||
a reply. Only regular ``*.md`` files are read; other files are ignored.
|
||||
"""
|
||||
directory = agents_dir or os.environ.get("AGENTS_DIR") or _DEFAULT_AGENTS_DIR
|
||||
try:
|
||||
base = Path(directory)
|
||||
if not base.is_dir():
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for path in sorted(base.glob("*.md"), key=lambda p: p.name):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
continue
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
_SYSTEM_PROMPT_TEMPLATE: str = (
|
||||
"Persona: you are a British butler named {name} — polite, composed, quietly amused, and "
|
||||
"quietly enjoying yourself. Default voice is dry, witty, and lightly sarcastic: you notice "
|
||||
|
||||
@@ -22,7 +22,29 @@ path (evals, text entry) and falls back to the master flag:
|
||||
|
||||
- **on-screen Chrome**: `browser_search()` drives Chrome (Node CDP helper
|
||||
`bot/scripts/stream-test/browse-search.mjs`) to Google-search the query, so
|
||||
the action is visible on the Go-Live broadcast.
|
||||
the action is visible on the Go-Live broadcast. The helper searches the
|
||||
human way — it loads the site home page, types the query into the search box
|
||||
one key at a time, and presses Enter (both Google `search` and `youtube`),
|
||||
rather than jumping to a results URL. When no broadcast Chrome is
|
||||
reachable on CDP (e.g. a plain text turn with no active broadcast), the helper
|
||||
falls back, for `search` only, to launching its own Chrome so browser-based
|
||||
Google search still works with no API cost. Fallback order:
|
||||
- **CDP** (the broadcast Chrome) — preferred, visible on the stream.
|
||||
- **Persistent profile** when `CHROME_USER_DATA_DIR` is set — Chrome opened
|
||||
against that profile dir (system `channel: 'chrome'`, else bundled chromium).
|
||||
Logging that dedicated profile into Google once lets Google treat later
|
||||
searches as a returning signed-in user, which is what avoids the
|
||||
bot-detection interstitial. This is the reliable way to get browser Google
|
||||
search in plain text turns.
|
||||
- **Ephemeral headless** otherwise — a fresh anonymous session; works only
|
||||
where Google does not challenge it (e.g. a non-flagged residential IP).
|
||||
|
||||
The `youtube` action never uses the fallback (it only makes sense on the
|
||||
visible broadcast Chrome). Caveat: an anonymous (not-signed-in) session can be
|
||||
served Google's bot-detection interstitial (`/sorry/index`); the helper
|
||||
detects this structurally by URL and fails fast, so the caller fail-opens to
|
||||
the DDG / Brave / Wikipedia cascade rather than treating the challenge page as
|
||||
"no results".
|
||||
- **Gemini**: answers, with the sub-mode chosen by `cfg.gemini_auth`
|
||||
(env `GEMINI_AUTH`, default `oauth`):
|
||||
- `oauth` (default): `gemini_cli_search()` shells out to the Gemini CLI
|
||||
|
||||
@@ -7,6 +7,7 @@ hardcoded to Jarvis.
|
||||
|
||||
from jarvis.system_prompt import (
|
||||
build_system_prompt,
|
||||
load_agent_instructions,
|
||||
output_language_directive,
|
||||
reply_language_directive,
|
||||
ENGLISH_ONLY_DIRECTIVE,
|
||||
@@ -108,3 +109,57 @@ class TestReplyLanguageDirective:
|
||||
def test_lock_wins_even_with_multilingual_tts(self):
|
||||
directive = reply_language_directive("Korean", "melo")
|
||||
assert directive is not None and "Korean" in directive
|
||||
|
||||
|
||||
class TestLoadAgentInstructions:
|
||||
"""Operator can extend the reply LLM's system prompt by dropping *.md files
|
||||
into an agents/ folder. The loader concatenates them in filename order and
|
||||
fails open so a missing/empty/broken folder never breaks a reply."""
|
||||
|
||||
def test_missing_dir_returns_empty(self, tmp_path):
|
||||
assert load_agent_instructions(str(tmp_path / "does-not-exist")) == ""
|
||||
|
||||
def test_empty_dir_returns_empty(self, tmp_path):
|
||||
assert load_agent_instructions(str(tmp_path)) == ""
|
||||
|
||||
def test_reads_and_concatenates_single_file(self, tmp_path):
|
||||
(tmp_path / "rules.md").write_text("Always be brief.", encoding="utf-8")
|
||||
assert load_agent_instructions(str(tmp_path)) == "Always be brief."
|
||||
|
||||
def test_files_are_ordered_by_filename(self, tmp_path):
|
||||
# Filename prefixes let the operator control ordering.
|
||||
(tmp_path / "10-second.md").write_text("SECOND", encoding="utf-8")
|
||||
(tmp_path / "00-first.md").write_text("FIRST", encoding="utf-8")
|
||||
result = load_agent_instructions(str(tmp_path))
|
||||
assert result.index("FIRST") < result.index("SECOND")
|
||||
|
||||
def test_only_md_files_are_read(self, tmp_path):
|
||||
(tmp_path / "note.txt").write_text("IGNORE ME", encoding="utf-8")
|
||||
(tmp_path / "use.md").write_text("USE ME", encoding="utf-8")
|
||||
result = load_agent_instructions(str(tmp_path))
|
||||
assert "USE ME" in result
|
||||
assert "IGNORE ME" not in result
|
||||
|
||||
def test_blank_files_are_skipped(self, tmp_path):
|
||||
(tmp_path / "blank.md").write_text(" \n ", encoding="utf-8")
|
||||
(tmp_path / "real.md").write_text("Real instruction.", encoding="utf-8")
|
||||
assert load_agent_instructions(str(tmp_path)) == "Real instruction."
|
||||
|
||||
def test_env_var_is_used_when_no_arg(self, tmp_path, monkeypatch):
|
||||
(tmp_path / "a.md").write_text("FROM ENV", encoding="utf-8")
|
||||
monkeypatch.setenv("AGENTS_DIR", str(tmp_path))
|
||||
assert load_agent_instructions() == "FROM ENV"
|
||||
|
||||
def test_explicit_arg_overrides_env(self, tmp_path, monkeypatch):
|
||||
(tmp_path / "env.md").write_text("ENV", encoding="utf-8")
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
(other / "arg.md").write_text("ARG", encoding="utf-8")
|
||||
monkeypatch.setenv("AGENTS_DIR", str(tmp_path))
|
||||
assert load_agent_instructions(str(other)) == "ARG"
|
||||
|
||||
def test_a_file_path_instead_of_dir_returns_empty(self, tmp_path):
|
||||
f = tmp_path / "file.md"
|
||||
f.write_text("x", encoding="utf-8")
|
||||
# Pointed at a file, not a directory → fail-open.
|
||||
assert load_agent_instructions(str(f)) == ""
|
||||
|
||||
Reference in New Issue
Block a user