brainchat 0.1.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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +132 -0
- data/Rakefile +12 -0
- data/exe/brainchat +6 -0
- data/lib/brainchat/chat.rb +78 -0
- data/lib/brainchat/cli.rb +80 -0
- data/lib/brainchat/retriever.rb +83 -0
- data/lib/brainchat/version.rb +5 -0
- data/lib/brainchat.rb +9 -0
- data/sig/brainchat.rbs +14 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '09e175edc9c5ee8494be30f5ae64384d38e493c029e212e363cb6ffea74eb435'
|
|
4
|
+
data.tar.gz: 75d5889ecf72f543c622585c49406b8066d566747aff824a565a4f0cb3762348
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '09e02f48285304b6e142e0f25b41f238874bb183e3dbd202485332a1d8a9343b8e0a6bbca750612df660d994329acf4edc32fdaf402b8cb94f664688a29342b1'
|
|
7
|
+
data.tar.gz: ac64f712e98f66a072c5a133880f0a54296ce754f95d16a41d288e683268a554e9dc32f5ed29502fb72bc448c8634fa0d1fa788095ba07ce006b7e0792e0519b
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.10
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-07-27
|
|
4
|
+
|
|
5
|
+
- Initial release: `brainchat ask QUESTION` answers from a knowledge-brain
|
|
6
|
+
vault with citations. Retrieval shells out to the hitgate `rag-query` CLI in
|
|
7
|
+
JSON mode (argv-array via `Open3.capture3`, no shell interpolation); the chat
|
|
8
|
+
is RubyLLM with retrieved chunks injected as numbered context and cited as
|
|
9
|
+
`[n]` in the reply, with a `[n] -> path:lines` sources list printed after the
|
|
10
|
+
answer.
|
|
11
|
+
- Streaming by default; `--no-stream` prints the complete answer at once.
|
|
12
|
+
- Provider configuration via `--model` / `--provider` / `--assume-model-exists`
|
|
13
|
+
and the `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OLLAMA_API_BASE`
|
|
14
|
+
environment variables. `--provider ollama` needs no setup.
|
|
15
|
+
- Retriever honors `BRAINCHAT_RAG_QUERY_BIN` and passes `RAG_*` index
|
|
16
|
+
configuration through to `rag-query`.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Santana
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# brainchat
|
|
2
|
+
|
|
3
|
+
[](https://github.com/LucasSantana-Dev/brainchat/actions/workflows/main.yml)
|
|
4
|
+
[](https://www.ruby-lang.org)
|
|
5
|
+
[](LICENSE.txt)
|
|
6
|
+
|
|
7
|
+
**Chat with your knowledge base from the terminal, with citations that resolve to real files.**
|
|
8
|
+
brainchat is a Ruby AI CLI that answers questions over a "knowledge-brain" vault (notes, ADRs,
|
|
9
|
+
plans, commit history, docs) and labels every claim with the exact chunk it came from.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ brainchat ask "what did we decide about hitgate caveat timing"
|
|
13
|
+
|
|
14
|
+
Ship the two-channel honesty check first, then the self-indexed caveat [1][2]. The
|
|
15
|
+
rationale: publishing the caveat first would anchor the evaluation story on a limitation
|
|
16
|
+
with no third-party evidence behind it [1].
|
|
17
|
+
|
|
18
|
+
Sources:
|
|
19
|
+
[1] /vault/knowledge-brain/docs/adr/0012-caveat-investment-timing.md:1-34 (repo-docs/hitgate)
|
|
20
|
+
[2] /vault/knowledge-brain/memory/caveat-timing-note.md:3-9 (memory/knowledge-brain)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
brainchat deliberately does **not** reimplement retrieval. It composes two tools that each do
|
|
26
|
+
one thing well:
|
|
27
|
+
|
|
28
|
+
1. Your question goes to [`rag-query`](https://github.com/LucasSantana-Dev/hitgate), the
|
|
29
|
+
hybrid BM25 + cosine retrieval CLI that hitgate's regression gate benchmarks. brainchat
|
|
30
|
+
shells out with `Open3.capture3` and an argv-array (never a shell string) and treats the
|
|
31
|
+
JSON output as the retrieval contract.
|
|
32
|
+
2. The retrieved chunks are injected into a [RubyLLM](https://rubyllm.com) chat as numbered
|
|
33
|
+
context. The system prompt requires `[n]` citations, and the CLI prints the
|
|
34
|
+
`[n] -> path:lines` mapping after the answer, so every citation resolves to a file and
|
|
35
|
+
line range you can open.
|
|
36
|
+
|
|
37
|
+
Provider-agnostic: Anthropic, OpenAI, or fully local with Ollama.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Ruby >= 3.2
|
|
42
|
+
- The `rag-query` executable from [hitgate](https://github.com/LucasSantana-Dev/hitgate) on
|
|
43
|
+
your `PATH` (or point `BRAINCHAT_RAG_QUERY_BIN` at it), plus an indexed vault. `rag-query`
|
|
44
|
+
is configured through its own `RAG_*` environment variables (e.g. `RAG_INDEX_DIR`), which
|
|
45
|
+
brainchat passes straight through.
|
|
46
|
+
- **`--scope-repo all` needs hitgate at or after
|
|
47
|
+
[#134](https://github.com/LucasSantana-Dev/hitgate/pull/134).** Earlier versions turned the
|
|
48
|
+
`all` sentinel into "no preference", which re-enabled cwd auto-scoping; since hitgate's
|
|
49
|
+
source roots default to the working directory, every query got scoped to a repo named after
|
|
50
|
+
whatever directory you were standing in and came back empty with exit 0. If `brainchat ask`
|
|
51
|
+
finds nothing in an index you know is populated, check this first.
|
|
52
|
+
- A provider: `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`, or a local Ollama.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
Not on RubyGems yet. Build from source:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
git clone https://github.com/LucasSantana-Dev/brainchat
|
|
60
|
+
cd brainchat && bin/setup
|
|
61
|
+
gem build brainchat.gemspec && gem install ./brainchat-0.1.0.gem
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or run it without installing: `bundle exec exe/brainchat ask "..."`.
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
brainchat ask "what did we decide about hitgate caveat timing"
|
|
70
|
+
brainchat ask "how does the reranker fall back" --top 8 --scope-repo all
|
|
71
|
+
brainchat ask "..." --provider ollama --model qwen3:4b --assume-model-exists # fully local
|
|
72
|
+
brainchat ask "..." --no-stream # print the answer only when complete
|
|
73
|
+
brainchat ask "..." --rerank # cross-encoder reranker (slower, better ranking)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Flag | Purpose |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `--top N` | number of chunks to retrieve (default 5) |
|
|
79
|
+
| `--scope-repo a,b` | restrict retrieval to named repos, or `all` to disable cwd scoping |
|
|
80
|
+
| `--rerank` | enable the cross-encoder reranker (default: fast mode) |
|
|
81
|
+
| `--provider NAME` | `anthropic`, `openai`, `ollama` |
|
|
82
|
+
| `--model ID` | model id (default: RubyLLM's configured default) |
|
|
83
|
+
| `--assume-model-exists` | skip model-registry validation, needed for local Ollama models |
|
|
84
|
+
| `--no-stream` | print the complete answer at once instead of streaming |
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
| Variable | Purpose |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `ANTHROPIC_API_KEY` | Anthropic provider key |
|
|
91
|
+
| `OPENAI_API_KEY` | OpenAI provider key |
|
|
92
|
+
| `OLLAMA_API_BASE` | Ollama endpoint (default `http://localhost:11434/v1`) |
|
|
93
|
+
| `BRAINCHAT_RAG_QUERY_BIN` | path to the `rag-query` executable (default: `rag-query` on `PATH`) |
|
|
94
|
+
| `RAG_INDEX_DIR` and other `RAG_*` | hitgate index configuration, passed through to `rag-query` |
|
|
95
|
+
|
|
96
|
+
## Under the hood
|
|
97
|
+
|
|
98
|
+
Built around Ruby idioms, not ported line-for-line from anything:
|
|
99
|
+
|
|
100
|
+
- **`Open3.capture3` with an argv-array, never a shell string.** The question travels as one
|
|
101
|
+
bare argv entry, so a query containing `$(...)`, backticks or `;` is inert data. The spec
|
|
102
|
+
suite proves this with a shell-injection marker test that fails if the query is ever
|
|
103
|
+
interpolated into a shell.
|
|
104
|
+
- **`Data.define` for the `Chunk` value object**: immutable, keyword-constructed, with a
|
|
105
|
+
`#location` helper that renders `path:start-end` (or bare `path` for line-less chunks like
|
|
106
|
+
git commits).
|
|
107
|
+
- **RubyLLM wired explicitly.** RubyLLM 1.16 reads no provider env vars on its own, so
|
|
108
|
+
`Chat.configure` feeds it `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OLLAMA_API_BASE` itself.
|
|
109
|
+
- **Streaming by default** via `chat.ask(prompt) { |chunk| ... }`; `--no-stream` prints the
|
|
110
|
+
complete answer at once.
|
|
111
|
+
- **Thor** for CLI dispatch.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bin/setup
|
|
117
|
+
bundle exec rake # rspec + rubocop
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Specs stub both boundaries: the retriever is tested against a fixture of real `rag-query`
|
|
121
|
+
JSON output (no subprocess), and the chat against a stubbed RubyLLM (no network).
|
|
122
|
+
|
|
123
|
+
## Related projects
|
|
124
|
+
|
|
125
|
+
- [hitgate](https://github.com/LucasSantana-Dev/hitgate): the retrieval engine and regression
|
|
126
|
+
gate brainchat queries.
|
|
127
|
+
- [leakless](https://github.com/LucasSantana-Dev/leakless): rule-driven secret scanner with
|
|
128
|
+
redact-always AI triage, same Ruby-idiom-first approach.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
data/exe/brainchat
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Brainchat
|
|
6
|
+
# Answers a question with an LLM, using retrieved knowledge-brain chunks as
|
|
7
|
+
# the only context. Chunks are numbered in the prompt and the model is asked
|
|
8
|
+
# to cite them as [n]; the CLI prints the [n] -> path:lines mapping after the
|
|
9
|
+
# answer, so every citation is resolvable to a file.
|
|
10
|
+
module Chat
|
|
11
|
+
# Ollama's conventional local endpoint, so --provider ollama needs no setup.
|
|
12
|
+
OLLAMA_DEFAULT_BASE = "http://localhost:11434/v1"
|
|
13
|
+
|
|
14
|
+
# A missing API key or an unknown model id raise off a different branch of
|
|
15
|
+
# RubyLLM's hierarchy than provider/HTTP faults, and they are the two most
|
|
16
|
+
# likely failures here, so both branches are caught.
|
|
17
|
+
FAILURES = [RubyLLM::Error, RubyLLM::ConfigurationError,
|
|
18
|
+
RubyLLM::ModelNotFoundError, Faraday::Error].freeze
|
|
19
|
+
|
|
20
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
21
|
+
You answer questions about the user's knowledge-brain: a vault of notes,
|
|
22
|
+
ADRs, plans, commit history and docs, retrieved for you by a hybrid
|
|
23
|
+
BM25 + cosine search. The retrieved chunks are numbered and included in
|
|
24
|
+
the user's message.
|
|
25
|
+
|
|
26
|
+
Answer only from those chunks. When the chunks do not contain the answer,
|
|
27
|
+
say so plainly instead of guessing. Cite every claim with the chunk
|
|
28
|
+
number in square brackets, e.g. [1] or [2][3]. Keep the answer tight;
|
|
29
|
+
quote file paths only via the citation numbers.
|
|
30
|
+
PROMPT
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Asks `question` with `chunks` as context. Streams each content delta to
|
|
35
|
+
# `on_chunk` when given. Returns the final RubyLLM::Message.
|
|
36
|
+
def call(question, chunks, on_chunk: nil, **chat_options)
|
|
37
|
+
configure
|
|
38
|
+
chat = RubyLLM.chat(**chat_options.compact)
|
|
39
|
+
.with_instructions(SYSTEM_PROMPT)
|
|
40
|
+
if on_chunk
|
|
41
|
+
chat.ask(prompt_for(question, chunks)) { |chunk| on_chunk.call(chunk.content) }
|
|
42
|
+
else
|
|
43
|
+
chat.ask(prompt_for(question, chunks))
|
|
44
|
+
end
|
|
45
|
+
rescue *FAILURES => e
|
|
46
|
+
raise Error, "chat failed: #{e.class}: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# RubyLLM reads no provider credentials from the environment on its own,
|
|
50
|
+
# and a CLI has nowhere else to get them.
|
|
51
|
+
def configure
|
|
52
|
+
RubyLLM.configure do |config|
|
|
53
|
+
config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
|
|
54
|
+
config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
|
|
55
|
+
config.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", OLLAMA_DEFAULT_BASE)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Chunks go over as a numbered list; the citation number the model emits is
|
|
60
|
+
# the position in this list, and the CLI prints the same numbering.
|
|
61
|
+
def prompt_for(question, chunks)
|
|
62
|
+
body = chunks.each_with_index.map do |chunk, index|
|
|
63
|
+
<<~CHUNK
|
|
64
|
+
[#{index + 1}] #{chunk.location} (#{chunk.source_type}/#{chunk.repo})
|
|
65
|
+
#{chunk.text}
|
|
66
|
+
CHUNK
|
|
67
|
+
end.join("\n")
|
|
68
|
+
|
|
69
|
+
<<~PROMPT
|
|
70
|
+
Retrieved chunks:
|
|
71
|
+
|
|
72
|
+
#{body}
|
|
73
|
+
|
|
74
|
+
Question: #{question}
|
|
75
|
+
PROMPT
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "retriever"
|
|
5
|
+
require_relative "chat"
|
|
6
|
+
|
|
7
|
+
module Brainchat
|
|
8
|
+
# Thor command-line interface. `ask` retrieves chunks via rag-query and
|
|
9
|
+
# streams the cited answer to stdout.
|
|
10
|
+
class CLI < Thor
|
|
11
|
+
def self.exit_on_failure? = true
|
|
12
|
+
|
|
13
|
+
desc "ask QUESTION", "answer a question from the knowledge-brain, citing retrieved chunks"
|
|
14
|
+
method_option :top,
|
|
15
|
+
type: :numeric,
|
|
16
|
+
default: Retriever::DEFAULT_TOP,
|
|
17
|
+
desc: "number of chunks to retrieve"
|
|
18
|
+
method_option :scope_repo,
|
|
19
|
+
type: :string,
|
|
20
|
+
desc: "comma-separated repo names to search, or 'all' to disable cwd scoping"
|
|
21
|
+
method_option :rerank,
|
|
22
|
+
type: :boolean,
|
|
23
|
+
default: false,
|
|
24
|
+
desc: "enable the cross-encoder reranker (slower, better ranking)"
|
|
25
|
+
method_option :model,
|
|
26
|
+
type: :string,
|
|
27
|
+
desc: "model id (default: RubyLLM's configured default)"
|
|
28
|
+
method_option :provider,
|
|
29
|
+
type: :string,
|
|
30
|
+
desc: "provider, e.g. anthropic, openai, ollama"
|
|
31
|
+
method_option :assume_model_exists,
|
|
32
|
+
type: :boolean,
|
|
33
|
+
default: false,
|
|
34
|
+
desc: "skip model-registry validation, needed for local Ollama models"
|
|
35
|
+
method_option :no_stream,
|
|
36
|
+
type: :boolean,
|
|
37
|
+
default: false,
|
|
38
|
+
desc: "print the answer only once it is complete"
|
|
39
|
+
def ask(question)
|
|
40
|
+
chunks = retrieve(question)
|
|
41
|
+
answer(question, chunks)
|
|
42
|
+
print_sources(chunks)
|
|
43
|
+
rescue Error => e
|
|
44
|
+
warn e.message
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
no_commands do
|
|
49
|
+
def retrieve(question)
|
|
50
|
+
chunks = Retriever.call(question,
|
|
51
|
+
top: options[:top],
|
|
52
|
+
scope_repo: options[:scope_repo],
|
|
53
|
+
fast: !options[:rerank])
|
|
54
|
+
return chunks unless chunks.empty?
|
|
55
|
+
|
|
56
|
+
raise Error, "No chunks retrieved for this question. Is the knowledge-brain indexed " \
|
|
57
|
+
"(RAG_INDEX_DIR) and is `rag-query` working?"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def answer(question, chunks)
|
|
61
|
+
on_chunk = options[:no_stream] ? nil : ->(content) { print content }
|
|
62
|
+
message = Chat.call(question, chunks,
|
|
63
|
+
on_chunk:,
|
|
64
|
+
model: options[:model],
|
|
65
|
+
provider: options[:provider]&.to_sym,
|
|
66
|
+
assume_model_exists: options[:assume_model_exists])
|
|
67
|
+
# In streaming mode the deltas already went to stdout; in --no-stream
|
|
68
|
+
# mode nothing has been printed yet.
|
|
69
|
+
puts(options[:no_stream] ? message.content : "")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def print_sources(chunks)
|
|
73
|
+
puts "\nSources:"
|
|
74
|
+
chunks.each_with_index do |chunk, index|
|
|
75
|
+
puts " [#{index + 1}] #{chunk.location} (#{chunk.source_type}/#{chunk.repo})"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Brainchat
|
|
7
|
+
# Retrieves knowledge-brain chunks by shelling out to the hitgate `rag-query`
|
|
8
|
+
# CLI in its JSON output mode:
|
|
9
|
+
#
|
|
10
|
+
# rag-query --top N --format json [--scope-repo all] [--fast] "<query>"
|
|
11
|
+
#
|
|
12
|
+
# stdout is a pure JSON array of chunks (warnings and model-load progress go
|
|
13
|
+
# to stderr), each shaped:
|
|
14
|
+
#
|
|
15
|
+
# { "rank", "rrf", "cos", "bm25", "reranked", "source_type", "repo",
|
|
16
|
+
# "language", "symbol", "path", "start_line", "end_line", "text" }
|
|
17
|
+
#
|
|
18
|
+
# The query and every flag travel as separate argv entries through
|
|
19
|
+
# Open3.capture3 — no shell is ever involved, so a query containing
|
|
20
|
+
# metacharacters (`$(...)`, backticks, `;`) is inert data, not a command.
|
|
21
|
+
module Retriever
|
|
22
|
+
# One retrieved chunk. `Data.define` gives an immutable value object with
|
|
23
|
+
# free keyword construction, matching the Finding idiom in leakless.
|
|
24
|
+
Chunk = Data.define(:rank, :source_type, :repo, :symbol, :path, :start_line, :end_line, :text) do
|
|
25
|
+
# "path:12-40", or just "path" for chunks without line information
|
|
26
|
+
# (git-commit chunks carry start_line/end_line of 0).
|
|
27
|
+
def location
|
|
28
|
+
return path.to_s if start_line.to_i.zero? && end_line.to_i.zero?
|
|
29
|
+
|
|
30
|
+
"#{path}:#{start_line}-#{end_line}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
DEFAULT_TOP = 5
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Returns an array of Chunk, empty when the index has no matches.
|
|
39
|
+
def call(query, top: DEFAULT_TOP, scope_repo: nil, fast: true, bin: default_bin)
|
|
40
|
+
stdout, stderr, status = Open3.capture3(*argv(bin, query, top:, scope_repo:, fast:))
|
|
41
|
+
raise Error, "rag-query failed (exit #{status.exitstatus}): #{stderr.strip}" unless status.success?
|
|
42
|
+
|
|
43
|
+
parse(stdout)
|
|
44
|
+
rescue Errno::ENOENT
|
|
45
|
+
raise Error, "`#{bin}` was not found on PATH. Install hitgate " \
|
|
46
|
+
"(https://github.com/LucasSantana-Dev/hitgate) or point " \
|
|
47
|
+
"BRAINCHAT_RAG_QUERY_BIN at a rag-query executable."
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def argv(bin, query, top:, scope_repo:, fast:)
|
|
51
|
+
args = [bin, "--top", top.to_s, "--format", "json"]
|
|
52
|
+
args += ["--scope-repo", scope_repo] if scope_repo
|
|
53
|
+
args << "--fast" if fast
|
|
54
|
+
args << query
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse(stdout)
|
|
58
|
+
rows = JSON.parse(stdout)
|
|
59
|
+
unless rows.is_a?(Array) && rows.all?(Hash)
|
|
60
|
+
raise Error, "rag-query returned unexpected JSON (expected an array of chunks)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
rows.map { |row| chunk_from(row) }
|
|
64
|
+
rescue JSON::ParserError => e
|
|
65
|
+
raise Error, "rag-query did not return valid JSON: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def chunk_from(row)
|
|
69
|
+
Chunk.new(rank: row["rank"],
|
|
70
|
+
source_type: row["source_type"],
|
|
71
|
+
repo: row["repo"],
|
|
72
|
+
symbol: row["symbol"],
|
|
73
|
+
path: row["path"],
|
|
74
|
+
start_line: row["start_line"],
|
|
75
|
+
end_line: row["end_line"],
|
|
76
|
+
text: row["text"])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def default_bin
|
|
80
|
+
ENV.fetch("BRAINCHAT_RAG_QUERY_BIN", "rag-query")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/brainchat.rb
ADDED
data/sig/brainchat.rbs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Brainchat
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module Retriever
|
|
8
|
+
def self.call: (String query, ?top: Integer, ?scope_repo: String?, ?fast: bool, ?bin: String) -> Array[Chunk]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Chat
|
|
12
|
+
def self.call: (String question, Array[Retriever::Chunk] chunks, ?on_chunk: ^(String) -> void, **untyped chat_options) -> untyped
|
|
13
|
+
end
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: brainchat
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Lucas Santana
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ruby_llm
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.16'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.16'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: thor
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.3'
|
|
41
|
+
description: A small Ruby CLI that answers questions about a knowledge-brain vault.
|
|
42
|
+
Retrieval shells out to the hitgate `rag-query` CLI (JSON mode, argv-array, no shell
|
|
43
|
+
interpolation); the chat is RubyLLM, with retrieved chunks injected as context and
|
|
44
|
+
cited in the reply.
|
|
45
|
+
email:
|
|
46
|
+
- lucas.diassantana@gmail.com
|
|
47
|
+
executables:
|
|
48
|
+
- brainchat
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- ".ruby-version"
|
|
53
|
+
- CHANGELOG.md
|
|
54
|
+
- LICENSE.txt
|
|
55
|
+
- README.md
|
|
56
|
+
- Rakefile
|
|
57
|
+
- exe/brainchat
|
|
58
|
+
- lib/brainchat.rb
|
|
59
|
+
- lib/brainchat/chat.rb
|
|
60
|
+
- lib/brainchat/cli.rb
|
|
61
|
+
- lib/brainchat/retriever.rb
|
|
62
|
+
- lib/brainchat/version.rb
|
|
63
|
+
- sig/brainchat.rbs
|
|
64
|
+
homepage: https://github.com/LucasSantana-Dev/brainchat
|
|
65
|
+
licenses:
|
|
66
|
+
- MIT
|
|
67
|
+
metadata:
|
|
68
|
+
allowed_push_host: https://rubygems.org
|
|
69
|
+
homepage_uri: https://github.com/LucasSantana-Dev/brainchat
|
|
70
|
+
source_code_uri: https://github.com/LucasSantana-Dev/brainchat/tree/main
|
|
71
|
+
changelog_uri: https://github.com/LucasSantana-Dev/brainchat/blob/main/CHANGELOG.md
|
|
72
|
+
rubygems_mfa_required: 'true'
|
|
73
|
+
post_install_message:
|
|
74
|
+
rdoc_options: []
|
|
75
|
+
require_paths:
|
|
76
|
+
- lib
|
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 3.2.0
|
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
requirements: []
|
|
88
|
+
rubygems_version: 3.0.3.1
|
|
89
|
+
signing_key:
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Chat over a knowledge-brain vault from the terminal.
|
|
92
|
+
test_files: []
|