|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# ob — Open Brain CLI |
| 4 | +# A lightweight command-line interface for Open Brain personal memory. |
| 5 | +# Dependencies: curl, jq |
| 6 | +# License: FSL-1.1-MIT (same as Open Brain) |
| 7 | +# |
| 8 | +set -euo pipefail |
| 9 | + |
| 10 | +OB_VERSION="1.0.0" |
| 11 | + |
| 12 | +# ─── Configuration ──────────────────────────────────────────────────────────── |
| 13 | + |
| 14 | +: "${OB_SUPABASE_URL:=}" |
| 15 | +: "${OB_SUPABASE_KEY:=}" |
| 16 | +: "${OB_OPENROUTER_KEY:=}" |
| 17 | +: "${OB_THRESHOLD:=0.7}" |
| 18 | +: "${OB_COUNT:=10}" |
| 19 | + |
| 20 | +OPENROUTER_BASE="https://openrouter.ai/api/v1" |
| 21 | + |
| 22 | +# ─── Helpers ────────────────────────────────────────────────────────────────── |
| 23 | + |
| 24 | +die() { echo "ob: error: $*" >&2; exit 1; } |
| 25 | + |
| 26 | +check_deps() { |
| 27 | + command -v curl >/dev/null 2>&1 || die "curl is required but not installed" |
| 28 | + command -v jq >/dev/null 2>&1 || die "jq is required but not installed" |
| 29 | +} |
| 30 | + |
| 31 | +check_config() { |
| 32 | + [[ -n "$OB_SUPABASE_URL" ]] || die "OB_SUPABASE_URL is not set" |
| 33 | + [[ -n "$OB_SUPABASE_KEY" ]] || die "OB_SUPABASE_KEY is not set" |
| 34 | + [[ -n "$OB_OPENROUTER_KEY" ]] || die "OB_OPENROUTER_KEY is not set" |
| 35 | +} |
| 36 | + |
| 37 | +supabase_headers() { |
| 38 | + echo -H "apikey: $OB_SUPABASE_KEY" -H "Authorization: Bearer $OB_SUPABASE_KEY" |
| 39 | +} |
| 40 | + |
| 41 | +# ─── API Functions ──────────────────────────────────────────────────────────── |
| 42 | + |
| 43 | +get_embedding() { |
| 44 | + local text="$1" |
| 45 | + curl -sf "$OPENROUTER_BASE/embeddings" \ |
| 46 | + -H "Authorization: Bearer $OB_OPENROUTER_KEY" \ |
| 47 | + -H "Content-Type: application/json" \ |
| 48 | + -d "$(jq -n --arg t "$text" '{model: "openai/text-embedding-3-small", input: $t}')" \ |
| 49 | + | jq -c '.data[0].embedding' |
| 50 | +} |
| 51 | + |
| 52 | +extract_metadata() { |
| 53 | + local text="$1" |
| 54 | + local system_prompt='Extract metadata from the user'\''s captured thought. Return JSON with: "people" (array of people mentioned, empty if none), "action_items" (array of implied to-dos, empty if none), "topics" (array of 1-3 short topic tags, always at least one), "type" (one of "observation", "task", "idea", "reference", "person_note"). Only extract what'\''s explicitly there.' |
| 55 | + |
| 56 | + curl -sf "$OPENROUTER_BASE/chat/completions" \ |
| 57 | + -H "Authorization: Bearer $OB_OPENROUTER_KEY" \ |
| 58 | + -H "Content-Type: application/json" \ |
| 59 | + -d "$(jq -n --arg t "$text" --arg s "$system_prompt" '{ |
| 60 | + model: "openai/gpt-4o-mini", |
| 61 | + response_format: {type: "json_object"}, |
| 62 | + messages: [ |
| 63 | + {role: "system", content: $s}, |
| 64 | + {role: "user", content: $t} |
| 65 | + ] |
| 66 | + }')" \ |
| 67 | + | jq -c '.choices[0].message.content | fromjson' |
| 68 | +} |
| 69 | + |
| 70 | +# ─── Commands ───────────────────────────────────────────────────────────────── |
| 71 | + |
| 72 | +cmd_capture() { |
| 73 | + local content="${1:?Usage: ob capture \"thought text\"}" |
| 74 | + |
| 75 | + echo "Generating embedding..." >&2 |
| 76 | + local embedding |
| 77 | + embedding=$(get_embedding "$content") || die "Failed to generate embedding" |
| 78 | + |
| 79 | + echo "Extracting metadata..." >&2 |
| 80 | + local metadata |
| 81 | + metadata=$(extract_metadata "$content") || metadata='{"topics":["uncategorized"],"type":"observation"}' |
| 82 | + |
| 83 | + echo "Saving thought..." >&2 |
| 84 | + local result |
| 85 | + result=$(curl -sf -X POST "$OB_SUPABASE_URL/rest/v1/thoughts" \ |
| 86 | + -H "apikey: $OB_SUPABASE_KEY" \ |
| 87 | + -H "Authorization: Bearer $OB_SUPABASE_KEY" \ |
| 88 | + -H "Content-Type: application/json" \ |
| 89 | + -H "Prefer: return=representation" \ |
| 90 | + -d "$(jq -n \ |
| 91 | + --arg c "$content" \ |
| 92 | + --argjson e "$embedding" \ |
| 93 | + --argjson m "$metadata" \ |
| 94 | + '{content: $c, embedding: $e, metadata: $m}')" \ |
| 95 | + ) || die "Failed to insert thought" |
| 96 | + |
| 97 | + echo "$result" | jq '{ |
| 98 | + success: true, |
| 99 | + id: .[0].id, |
| 100 | + content: .[0].content, |
| 101 | + metadata: .[0].metadata, |
| 102 | + created_at: .[0].created_at |
| 103 | + }' |
| 104 | +} |
| 105 | + |
| 106 | +cmd_search() { |
| 107 | + local query="${1:?Usage: ob search \"query text\"}" |
| 108 | + local threshold="${OB_THRESHOLD}" |
| 109 | + local count="${OB_COUNT}" |
| 110 | + local json_output=false |
| 111 | + |
| 112 | + shift |
| 113 | + while [[ $# -gt 0 ]]; do |
| 114 | + case "$1" in |
| 115 | + --threshold) threshold="$2"; shift 2 ;; |
| 116 | + --count) count="$2"; shift 2 ;; |
| 117 | + --json) json_output=true; shift ;; |
| 118 | + *) die "Unknown flag: $1" ;; |
| 119 | + esac |
| 120 | + done |
| 121 | + |
| 122 | + local embedding |
| 123 | + embedding=$(get_embedding "$query") || die "Failed to generate query embedding" |
| 124 | + |
| 125 | + local results |
| 126 | + results=$(curl -sf -X POST "$OB_SUPABASE_URL/rest/v1/rpc/match_thoughts" \ |
| 127 | + -H "apikey: $OB_SUPABASE_KEY" \ |
| 128 | + -H "Authorization: Bearer $OB_SUPABASE_KEY" \ |
| 129 | + -H "Content-Type: application/json" \ |
| 130 | + -d "$(jq -n \ |
| 131 | + --argjson e "$embedding" \ |
| 132 | + --argjson t "$threshold" \ |
| 133 | + --argjson c "$count" \ |
| 134 | + '{query_embedding: $e, match_threshold: $t, match_count: $c, filter: {}}')" \ |
| 135 | + ) || die "Failed to search thoughts" |
| 136 | + |
| 137 | + if [[ "$json_output" == "true" ]]; then |
| 138 | + echo "$results" | jq . |
| 139 | + return |
| 140 | + fi |
| 141 | + |
| 142 | + local result_count |
| 143 | + result_count=$(echo "$results" | jq 'length') |
| 144 | + |
| 145 | + if [[ "$result_count" -eq 0 ]]; then |
| 146 | + echo "No results found for: $query" |
| 147 | + return |
| 148 | + fi |
| 149 | + |
| 150 | + echo "Found $result_count results:" |
| 151 | + echo "" |
| 152 | + |
| 153 | + echo "$results" | jq -r '.[] | "[" + (.similarity * 100 | round / 100 | tostring) + "] " + .content + "\n Topics: " + ((.metadata.topics // []) | join(", ")) + (if (.metadata.people // []) | length > 0 then " | People: " + ((.metadata.people // []) | join(", ")) else "" end) + (if .metadata.type then " | Type: " + .metadata.type else "" end) + "\n Captured: " + .created_at + "\n"' |
| 154 | +} |
| 155 | + |
| 156 | +cmd_recent() { |
| 157 | + local count="${1:-$OB_COUNT}" |
| 158 | + |
| 159 | + local results |
| 160 | + results=$(curl -sf \ |
| 161 | + "$OB_SUPABASE_URL/rest/v1/thoughts?order=created_at.desc&limit=$count&select=id,content,metadata,created_at" \ |
| 162 | + -H "apikey: $OB_SUPABASE_KEY" \ |
| 163 | + -H "Authorization: Bearer $OB_SUPABASE_KEY" \ |
| 164 | + ) || die "Failed to list thoughts" |
| 165 | + |
| 166 | + local result_count |
| 167 | + result_count=$(echo "$results" | jq 'length') |
| 168 | + |
| 169 | + echo "$result_count most recent thoughts:" |
| 170 | + echo "" |
| 171 | + |
| 172 | + echo "$results" | jq -r 'to_entries[] | "\(.key + 1). \(.value.content)\n Topics: \((.value.metadata.topics // []) | join(", "))" + (if (.value.metadata.people // []) | length > 0 then " | People: \((.value.metadata.people // []) | join(", "))" else "" end) + "\n Captured: \(.value.created_at)\n"' |
| 173 | +} |
| 174 | + |
| 175 | +cmd_stats() { |
| 176 | + # Total count |
| 177 | + local total |
| 178 | + total=$(curl -sf \ |
| 179 | + "$OB_SUPABASE_URL/rest/v1/thoughts?select=id" \ |
| 180 | + -H "apikey: $OB_SUPABASE_KEY" \ |
| 181 | + -H "Authorization: Bearer $OB_SUPABASE_KEY" \ |
| 182 | + -H "Prefer: count=exact" \ |
| 183 | + -H "Range: 0-0" \ |
| 184 | + -I 2>/dev/null | grep -i content-range | sed 's/.*\///' | tr -d '\r\n') || total="unknown" |
| 185 | + |
| 186 | + # Recent thoughts for date range and aggregation |
| 187 | + local all_thoughts |
| 188 | + all_thoughts=$(curl -sf \ |
| 189 | + "$OB_SUPABASE_URL/rest/v1/thoughts?select=metadata,created_at&order=created_at.asc" \ |
| 190 | + -H "apikey: $OB_SUPABASE_KEY" \ |
| 191 | + -H "Authorization: Bearer $OB_SUPABASE_KEY" \ |
| 192 | + ) || die "Failed to fetch thoughts" |
| 193 | + |
| 194 | + local oldest newest |
| 195 | + oldest=$(echo "$all_thoughts" | jq -r 'first.created_at // "N/A"' | cut -d'T' -f1) |
| 196 | + newest=$(echo "$all_thoughts" | jq -r 'last.created_at // "N/A"' | cut -d'T' -f1) |
| 197 | + |
| 198 | + local top_topics |
| 199 | + top_topics=$(echo "$all_thoughts" | jq -r '[.[].metadata.topics // [] | .[]] | group_by(.) | map({topic: .[0], count: length}) | sort_by(-.count) | .[0:5] | .[].topic') |
| 200 | + |
| 201 | + local top_people |
| 202 | + top_people=$(echo "$all_thoughts" | jq -r '[.[].metadata.people // [] | .[]] | group_by(.) | map({person: .[0], count: length}) | sort_by(-.count) | .[0:5] | .[].person') |
| 203 | + |
| 204 | + local type_dist |
| 205 | + type_dist=$(echo "$all_thoughts" | jq -r '[.[].metadata.type // "unknown"] | group_by(.) | map(" " + .[0] + ": " + (length | tostring)) | .[]') |
| 206 | + |
| 207 | + echo "Open Brain Statistics:" |
| 208 | + echo " Total thoughts: $total" |
| 209 | + echo " Oldest: $oldest" |
| 210 | + echo " Newest: $newest" |
| 211 | + echo " Top topics: $(echo "$top_topics" | tr '\n' ', ' | sed 's/,$//' | sed 's/,/, /g')" |
| 212 | + echo " Top people: $(echo "$top_people" | tr '\n' ', ' | sed 's/,$//' | sed 's/,/, /g')" |
| 213 | + echo " Type distribution:" |
| 214 | + echo "$type_dist" |
| 215 | +} |
| 216 | + |
| 217 | +cmd_version() { |
| 218 | + echo "ob v$OB_VERSION" |
| 219 | + if [[ -n "$OB_SUPABASE_URL" ]]; then |
| 220 | + echo "Supabase URL: $(echo "$OB_SUPABASE_URL" | sed 's|https://\([^.]*\)\..*|\1|')... (configured)" |
| 221 | + else |
| 222 | + echo "Supabase URL: not configured" |
| 223 | + fi |
| 224 | + if [[ -n "$OB_OPENROUTER_KEY" ]]; then |
| 225 | + echo "OpenRouter: configured" |
| 226 | + else |
| 227 | + echo "OpenRouter: not configured" |
| 228 | + fi |
| 229 | +} |
| 230 | + |
| 231 | +# ─── Main ───────────────────────────────────────────────────────────────────── |
| 232 | + |
| 233 | +main() { |
| 234 | + check_deps |
| 235 | + |
| 236 | + local cmd="${1:-help}" |
| 237 | + shift 2>/dev/null || true |
| 238 | + |
| 239 | + case "$cmd" in |
| 240 | + capture) check_config; cmd_capture "$@" ;; |
| 241 | + search) check_config; cmd_search "$@" ;; |
| 242 | + recent) check_config; cmd_recent "$@" ;; |
| 243 | + stats) check_config; cmd_stats ;; |
| 244 | + version) cmd_version ;; |
| 245 | + help|--help|-h) |
| 246 | + echo "ob — Open Brain CLI v$OB_VERSION" |
| 247 | + echo "" |
| 248 | + echo "Usage:" |
| 249 | + echo " ob capture \"thought text\" Save a thought with embedding + metadata" |
| 250 | + echo " ob search \"query\" [--threshold N] [--count N] [--json]" |
| 251 | + echo " Semantic search across thoughts" |
| 252 | + echo " ob recent [count] List recent thoughts (default: 10)" |
| 253 | + echo " ob stats Show knowledge base statistics" |
| 254 | + echo " ob version Print version and config status" |
| 255 | + echo "" |
| 256 | + echo "Environment variables:" |
| 257 | + echo " OB_SUPABASE_URL Supabase project URL (required)" |
| 258 | + echo " OB_SUPABASE_KEY Supabase service role key (required)" |
| 259 | + echo " OB_OPENROUTER_KEY OpenRouter API key (required)" |
| 260 | + echo " OB_THRESHOLD Default similarity threshold (default: 0.7)" |
| 261 | + echo " OB_COUNT Default result count (default: 10)" |
| 262 | + ;; |
| 263 | + *) |
| 264 | + die "Unknown command: $cmd (run 'ob help' for usage)" |
| 265 | + ;; |
| 266 | + esac |
| 267 | +} |
| 268 | + |
| 269 | +main "$@" |
0 commit comments