Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 29 additions & 92 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,137 +2,74 @@
# Copy this file to .env and fill in your values before running install.sh
# cp .env.example .env
#
# ── VaultWarden Integration ──────────────────────────────────────────────────────
# ── Bitwarden / VaultWarden Integration ───────────────────────────────────────
# This project supports <vaultwarden:path> placeholders in .env values.
# The start.sh script automatically resolves them using the `bw` CLI before
# starting the stack.
#
# Placeholder format: <vaultwarden:collection/path>
# The install.sh script can set this up for you.
# Placeholder format: <vaultwarden:organization-id/item-name>
# Examples:
# <vaultwarden:ai-cluster/services/open-webui>
# <vaultwarden:ai-cluster/infra/anthropic-api>
# <vaultwarden:ai-cluster/services/litellm>
#
# Requirements:
# - Bitwarden CLI installed: https://bitwarden.com/download/
# - Vault unlocked, or BW_CLIENT_ID + BW_CLIENT_SECRET + VAULT_MASTER_PASSWORD set
# - Organization ID: f8a8b00f-496a-44d3-b9d6-5ed28ecd95a3
# - Bitwarden CLI installed (npm install -g @bitwarden/cli)
# - Vault unlocked, or BW_CLIENT_ID + BW_CLIENT_SECRET set
#
# For self-hosted VaultWarden, set:
# BW_SERVER_URL=https://vaultwarden.example.com
#
# To manually resolve: ./scripts/resolve-vaultwarden.sh --in-place
# To resolve manually: ./scripts/resolve-vaultwarden.sh

# ─── Host paths ───────────────────────────────────────────────────────────────
# User who will run the stack (must be in docker group)
STACK_USER=yourusername

# Where Ollama models and config are stored on the host
OLLAMA_DATA=/home/${STACK_USER}/.ollama

# ─── Intel GPU device nodes ───────────────────────────────────────────────────
# Run: ls -la /dev/dri/ to find your card node (card0 or card1)
# On Meteor Lake / Arrow Lake this can drift between reboots
# check-arc-gpu.sh will detect and correct this automatically on each boot
GPU_CARD=/dev/dri/card1
GPU_RENDER=/dev/dri/renderD128

# ── Ollama ────────────────────────────────────────────────────────────────────
# Port mappings (change if you have conflicts)
OLLAMA_PORT=11434
OLLAMA_DATA=/home/${STACK_USER}/.ollama
# Optional: SSH key for remote model sync (leave blank if not using)
OLLAMA_SSH_KEY=/home/${STACK_USER}/.ssh/id_ed25519_ollama
OLLAMA_SSH_KEY_PUB=/home/${STACK_USER}/.ssh/id_ed25519_ollama.pub

# ── Cloud API Keys ────────────────────────────────────────────────────────────
ANTHROPIC_API_KEY=<vaultwarden:ai-cluster/infra/anthropic-api>
GEMINI_API_KEY=<vaultwarden:ai-cluster/infra/gemini-api>
OPENAI_API_KEY=<vaultwarden:ai-cluster/infra/openai-api> # optional — leave blank if not using OpenAI
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=your-gemini-key
OPENAI_API_KEY= # optional — leave blank if not using OpenAI

# ── LiteLLM ───────────────────────────────────────────────────────────────────
LITELLM_PORT=4000
# Admin key — used to log into LiteLLM UI and as Open WebUI's OpenAI API key
# Change this before exposing on any network
LITELLM_MASTER_KEY=sk-local-admin-changeme

# ── Olla ──────────────────────────────────────────────────────────────────────
# ── Olla — unified LLM router ─────────────────────────────────────────────────
OLLA_PORT=40114

# Additional Ollama nodes on your LAN.
# Declare each extra node as its own OLLAMA_REMOTE_* variable, since the setup
# scripts read OLLAMA_REMOTE_* entries when generating proxy/olla.yaml and
# registering remote connections.
#
# Format:
# OLLAMA_REMOTE_<NAME>=http://host:port[:priority]
#
# Priority is optional (defaults to 70). The local ollama-arc node (100) and
# litellm-cloud (50) are always included — only add your *extra* nodes here.
#
# Format: OLLAMA_REMOTE_<NAME>=http://host:port[:priority]
# Examples:
# One extra node:
# OLLAMA_REMOTE_WORKSTATION=http://192.168.1.50:11434:75
# Multiple nodes:
# OLLAMA_REMOTE_WORKSTATION=http://192.168.1.50:11434:75
# OLLAMA_REMOTE_NAS_BOX=http://192.168.1.51:11434:60
# No priority (defaults to 70):
# OLLAMA_REMOTE_WORKSTATION=http://192.168.1.50:11434
# OLLAMA_REMOTE_WORKSTATION=http://192.168.1.50:11434:75
# OLLAMA_REMOTE_NAS_BOX=http://192.168.1.51:11434

# Advanced Olla tuning (optional — defaults shown)
# OLLA_ENGINE=sherpa # or "olla" for circuit breakers + connection pooling
# OLLA_LOAD_BALANCER=least-connections # or "round-robin" or "priority"
# OLLA_ENGINE=sherpa
# OLLA_LOAD_BALANCER=least-connections
# OLLA_REQUEST_LOGGING=true

# ── Open WebUI ────────────────────────────────────────────────────────────────
WEBUI_PORT=3000
WEBUI_NAME=AssistantOS
WEBUI_SECRET_KEY=<vaultwarden:ai-cluster/services/open-webui> # Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

# ── Open Terminal ─────────────────────────────────────────────────────────────
TERMINAL_PORT=8000
# Change these before deploying — do not use defaults in production
OPEN_TERMINAL_API_KEY=<vaultwarden:ai-cluster/services/open-terminal>

# ── Pipelines ─────────────────────────────────────────────────────────────────
PIPELINES_PORT=9099
PIPELINES_API_KEY=<vaultwarden:ai-cluster/services/pipelines>
# ── Retriever — Obsidian vault RAG service ────────────────────────────────────
# Lightweight replacement for Khoj + PostgreSQL.
# Uses sqlite-vec (file-based), hybrid search (FTS5 + vector), watchdog live indexing.
# API-only: no web UI. Designed as an OpenCode tool.
RETRIEVER_PORT=42000
RETRIEVER_VAULT_PATH=/home/${STACK_USER}/obsidian
RETRIEVER_EMBED_MODEL=nomic-embed-text
RETRIEVER_CHUNK_SIZE=512
RETRIEVER_CHUNK_OVERLAP=64

# ─── Remote Ollama instances ──────────────────────────────────────────────────
# Format: OLLAMA_REMOTE_<name>=http://<ip>:11434
# The name becomes the instance key in System Diagnostics and Open WebUI connections.
# Add as many as you need — post-install.sh will register all of them automatically.
#
# These are added to Olla's routing and can be discovered by discover-herd.sh.
# Examples:
# OLLAMA_REMOTE_frank=http://10.10.1.1:11434
# OLLAMA_REMOTE_lab1=http://10.10.1.2:11434
# OLLAMA_REMOTE_lab2=http://10.10.1.3:11434
# OLLAMA_REMOTE_lab3=http://10.10.1.4:11434
# OLLAMA_REMOTE_lab4=http://10.10.1.5:11434

# ─── Models to pull after install ─────────────────────────────────────────────
# Space-separated list of models to pull on first run
# Recommended stack for Intel Arc iGPU (14b models run well on 32GB shared RAM)
# MODELS_TO_PULL="deepseek-r1:14b qwen2.5-coder:14b gemma3:12b qwen2.5:14b nomic-embed-text:latest"

# ─── Khoj (AI second brain + Obsidian RAG) ────────────────────────────────────
# Khoj indexes your Obsidian vault for semantic search and RAG over your notes.
# See docs/khoj-setup.md for Obsidian plugin configuration.
KHOJ_PORT=42110
KHOJ_ADMIN_EMAIL=admin@localhost
KHOJ_ADMIN_PASSWORD=<vaultwarden:ai-cluster/services/khoj>
# Generate a random secret key: python3 -c "import secrets; print(secrets.token_hex(32))"
KHOJ_DJANGO_SECRET_KEY=<vaultwarden:ai-cluster/services/khoj (notes)> # Generate: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
KHOJ_DB_PASSWORD=<vaultwarden:ai-cluster/services/khoj-db>
# Tip: generate a strong password with:
# python3 -c "import secrets; print(secrets.token_hex(24))"
# Set to true to allow unauthenticated (anonymous) local access to Khoj.
# Leave false (default) unless you are behind a trusted reverse proxy.
KHOJ_NO_AUTH=false
# Path to your Obsidian vault on the host machine (mounted read-only into Khoj)
OBSIDIAN_VAULT_PATH=/home/yourusername/obsidian-vault

# ─── Khoj Sync (CouchDB → Khoj live indexing) ─────────────────────────────────
COUCHDB_URL=https://sync.yourdomain.com
COUCHDB_DB=your-journal
COUCHDB_USER=your-couchdb-username
COUCHDB_PASSWORD=<vaultwarden:ai-cluster/services/khoj-sync-couchdb>
# Set to true after first successful sync to skip full re-index on restart
KHOJ_SYNC_SKIP_INITIAL=false
KHOJ_SYNC_LOG_LEVEL=INFO
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ── secrets & local config ────────────────────────────────────────────────────
.env
.env.backup*
.env.example.backup*
.env.*.backup*
tmp/

Expand Down Expand Up @@ -31,3 +32,6 @@ Thumbs.db

# Opencode
AGENTS.md

# Obsidian workspace config (local + auto-generated by install.sh)
.obsidian/
11 changes: 11 additions & 0 deletions .opencode/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"vault-search": true,
"vault-search_per_source": true
},
"permission": {
"vault-search": "allow",
"vault-search_per_source": "allow"
}
}
62 changes: 62 additions & 0 deletions .opencode/tools/vault-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { tool } from "@opencode-ai/plugin"

export default tool({
description: "Search your Obsidian vault for notes matching a query. Uses the retriever service (sqlite-vec + FTS5 hybrid search). Returns file paths, content snippets, and relevance scores.",
args: {
query: tool.schema.string().describe("Natural language search query"),
top_k: tool.schema.number().default(5).describe("Number of results to return (default 5)"),
include_content: tool.schema.boolean().default(true).describe("Include full chunk content in results"),
},
async execute(args) {
const resp = await fetch("http://localhost:42000/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: args.query, top_k: args.top_k }),
})
if (!resp.ok) {
return `Retriever error: ${resp.status} ${resp.statusText}`
}
const data = await resp.json()
if (!data.results || data.results.length === 0) {
return `No results found for: "${args.query}"`
}
return data.results.map((r: any) => {
let out = `## ${r.filepath} (score: ${r.score})`
if (r.parent_heading) out += `\nSection: ${r.parent_heading}`
if (args.include_content) out += `\n${r.content.slice(0, 2000)}`
return out
}).join("\n\n---\n\n")
},
})

export const per_source = tool({
description: "Search only a specific file or subdirectory in your Obsidian vault",
args: {
query: tool.schema.string().describe("Natural language search query"),
path_filter: tool.schema.string().describe("Filter results to a specific file or directory (e.g. 'networking/' or 'projects/ideas.md')"),
top_k: tool.schema.number().default(5).describe("Number of results to return (default 5)"),
},
async execute(args) {
const resp = await fetch("http://localhost:42000/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: args.query, top_k: args.top_k * 2 }),
})
if (!resp.ok) {
return `Retriever error: ${resp.status} ${resp.statusText}`
}
const data = await resp.json()
let results = data.results || []
results = results.filter((r: any) => r.filepath.startsWith(args.path_filter))
results = results.slice(0, args.top_k)
if (results.length === 0) {
return `No results in "${args.path_filter}" for: "${args.query}"`
}
return results.map((r: any) => {
let out = `## ${r.filepath} (score: ${r.score})`
if (r.parent_heading) out += `\nSection: ${r.parent_heading}`
out += `\n${r.content.slice(0, 2000)}`
return out
}).join("\n\n---\n\n")
},
})
Loading