gemchat 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/AGENTS.md +354 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +14 -0
- data/docs/plans/gemchat-local-bundle-index.md +1900 -0
- data/exe/gemchat +7 -0
- data/lib/gemchat/chunker.rb +120 -0
- data/lib/gemchat/cli.rb +731 -0
- data/lib/gemchat/embedder.rb +309 -0
- data/lib/gemchat/env.rb +27 -0
- data/lib/gemchat/errors.rb +10 -0
- data/lib/gemchat/hook.rb +113 -0
- data/lib/gemchat/hosted.rb +195 -0
- data/lib/gemchat/indexer.rb +114 -0
- data/lib/gemchat/init.rb +107 -0
- data/lib/gemchat/lockfile.rb +34 -0
- data/lib/gemchat/manifest.rb +100 -0
- data/lib/gemchat/markdown.rb +103 -0
- data/lib/gemchat/models.rb +241 -0
- data/lib/gemchat/paths.rb +33 -0
- data/lib/gemchat/plugin_index.rb +63 -0
- data/lib/gemchat/prose.rb +211 -0
- data/lib/gemchat/ri.rb +130 -0
- data/lib/gemchat/stopwords.rb +65 -0
- data/lib/gemchat/store.rb +555 -0
- data/lib/gemchat/symbols.rb +159 -0
- data/lib/gemchat/trust.rb +79 -0
- data/lib/gemchat/version.rb +5 -0
- data/lib/gemchat.rb +47 -0
- data/plugins.rb +74 -0
- data/sig/gemchat.rbs +4 -0
- data/skills/gemchat/SKILL.md +158 -0
- metadata +160 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3d1cb20bde07c3cbb881a02a2fe6caace2e8a464a13e53b1471439b038bf35c8
|
|
4
|
+
data.tar.gz: 92dee0546046145083489dbf54e1690849f92cd756f909ed976f4afa395526e7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 59cb4d314bede1b9b453bc75e477dd2f1c2c4667638df955bef65ebec95301371c1e5adca5f0ab4324338937de6715fd6ffcb8550caabef8773f9a4a53fd60e6
|
|
7
|
+
data.tar.gz: d0eaa1e6ca369a7da8fc5154ca8287250b41bd39262db96cc0fef70f9c30c5ab114e4cbe47e3ef638f8c9f144f1a232acda462ca1ddb3ebb686c51ab34800b3d
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guidance for coding agents working in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
`gemchat` is a standalone Ruby gem and CLI. It indexes the documentation for the
|
|
8
|
+
gems in the current project's own `Gemfile.lock` and serves BM25 search over it,
|
|
9
|
+
offline, with no account and no API key.
|
|
10
|
+
|
|
11
|
+
It is a **local** tool. The hosted `gemchat.org` service is a separate,
|
|
12
|
+
explicitly opt-in tier — never a silent fallback. See
|
|
13
|
+
`docs/plans/gemchat-local-bundle-index.md` for the full design; that plan is the
|
|
14
|
+
source of truth for anything not settled here.
|
|
15
|
+
|
|
16
|
+
This repository is **not** the Rails app. The app lives separately, and changes
|
|
17
|
+
there should not land here.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bin/setup # or: bundle install
|
|
23
|
+
bundle exec rake test # Minitest
|
|
24
|
+
bundle exec rake # test + standard, the default task
|
|
25
|
+
bundle exec standardrb lib exe test
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Smoke test against a throwaway lockfile:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
D=$(mktemp -d); export GEMCHAT_HOME="$D"
|
|
32
|
+
printf 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.4.2)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rake\n\nBUNDLED WITH\n 4.0.21\n' > "$D/Gemfile.lock"
|
|
33
|
+
(cd "$D" && bundle exec --gemfile=$OLDPWD/Gemfile $OLDPWD/exe/gemchat index)
|
|
34
|
+
(cd "$D" && bundle exec --gemfile=$OLDPWD/Gemfile $OLDPWD/exe/gemchat search "Rake::Task#enhance")
|
|
35
|
+
rm -rf "$D"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Always set `GEMCHAT_HOME` when testing. The default root is the developer's real
|
|
39
|
+
`~/.gemchat`.
|
|
40
|
+
|
|
41
|
+
## Trying the gem locally
|
|
42
|
+
|
|
43
|
+
Auto-index is opt-in by running `gemchat init` once in the project: that adds
|
|
44
|
+
both Gemfile lines, grants trust and writes `.gemchat.yml`. (`gemchat index`
|
|
45
|
+
alone also grants trust, for people who prefer to wire it up by hand.) After that, any `bundle install`/`add`/`update`
|
|
46
|
+
that moves the lockfile digest indexes automatically and says one line; anything
|
|
47
|
+
else is completely silent.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
To exercise the packaged gem and the Bundler plugin against real installs —
|
|
51
|
+
rather than the library in-process — use the sandbox:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
bin/sandbox # temp project, prints the commands to drive it
|
|
55
|
+
bin/sandbox keep # leave it at tmp/sandbox so you can poke around
|
|
56
|
+
bin/sandbox keep --reset # wipe and recreate
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It writes a Gemfile that declares gemchat twice, as a dependency and as a
|
|
60
|
+
plugin, both `path:`-sourced at this checkout, and runs `bundle install`. Then
|
|
61
|
+
`gemchat index`, `gemchat search`, `bundle add` and `bundle update` all work in
|
|
62
|
+
there, and the plugin hook fires on the last two.
|
|
63
|
+
|
|
64
|
+
Two details that matter, and cost real time to find:
|
|
65
|
+
|
|
66
|
+
- **The Gemfile has no `source` line.** With one, Bundler fetches remote
|
|
67
|
+
metadata for gems it does not need, and the sandbox needs the network.
|
|
68
|
+
Without one, a full install is well under a second and offline.
|
|
69
|
+
- **`bin/sandbox` must be run directly, not under `bundle exec`.** Bundler
|
|
70
|
+
injects `BUNDLE_GEMFILE`, `BUNDLE_LOCKFILE`, `RUBYOPT` and `RUBYLIB` into its
|
|
71
|
+
children, and a subprocess inherits them — so `bundle install` then installs
|
|
72
|
+
the *caller's* bundle into the sandbox instead of the sandbox's own. It exits
|
|
73
|
+
0, writes no lockfile, and installs no plugin, so it looks like it worked.
|
|
74
|
+
|
|
75
|
+
`test/support/sandbox.rb` is the programmatic form of this, used by the
|
|
76
|
+
lifecycle tests. Getting the child environment right needs **all three** of:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
clean = ENV.to_h.reject { |k, _| k.match?(/\ABUNDLE|RUBYOPT|RUBYLIB|RUBYGEMS_/) }
|
|
80
|
+
Open3.capture2e(clean, *argv, chdir: dir, unsetenv_others: true)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two of these are easy to miss and each one fails silently:
|
|
84
|
+
|
|
85
|
+
- **Stripping only `BUNDLE*` is not enough.** `RUBYOPT=-rbundler/setup` then
|
|
86
|
+
survives, `bundler/setup` runs *inside the child*, and it re-derives
|
|
87
|
+
`BUNDLE_GEMFILE` from the caller's directory. The child looks clean in the env
|
|
88
|
+
hash and is dirty in practice. `unsetenv_others: true` alone does not help,
|
|
89
|
+
because the variable is genuinely present to be inherited.
|
|
90
|
+
- **`Open3` merges, it does not replace.** Omitting a variable does not unset it;
|
|
91
|
+
only `unsetenv_others: true` does. Re-merge the real `ENV` afterwards so
|
|
92
|
+
`PATH` survives.
|
|
93
|
+
|
|
94
|
+
## Architecture
|
|
95
|
+
|
|
96
|
+
| Path | Role |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `lib/gemchat.rb` | requires, `Gemchat.root`, `Gemchat.config_home` |
|
|
99
|
+
| `lib/gemchat/ri.rb` | ri store lookup, generation via RDoc, reading back to plain Ruby |
|
|
100
|
+
| `lib/gemchat/chunker.rb` | ri structs → indexable chunks (one per method, one per class) |
|
|
101
|
+
| `lib/gemchat/markdown.rb` | Markdown/RDoc → heading-scoped sections |
|
|
102
|
+
| `lib/gemchat/prose.rb` | README, `guides/` and `doc/` discovery, one `source_type` each |
|
|
103
|
+
| `lib/gemchat/store.rb` | SQLite schema, FTS5, BM25 search, vectors, hybrid fusion |
|
|
104
|
+
| `lib/gemchat/symbols.rb` | the Prism pass: file:line and signature per method |
|
|
105
|
+
| `skills/gemchat/SKILL.md` | the agent-facing skill; ships in the gem, guarded by `test/skill_test.rb` |
|
|
106
|
+
| `lib/gemchat/stopwords.rb` | the 127-word list the `:any` lexical mode filters against |
|
|
107
|
+
| `lib/gemchat/indexer.rb` | lockfile parsing and per-gem orchestration |
|
|
108
|
+
| `lib/gemchat/lockfile.rb` | the sha256 that is the whole staleness check |
|
|
109
|
+
| `lib/gemchat/manifest.rb` | `.gemchat.yml`: what the index was built from |
|
|
110
|
+
| `lib/gemchat/trust.rb` | `trusted.json`: which projects may auto-index |
|
|
111
|
+
| `lib/gemchat/hook.rb` | the plugin's logic, reached through the bundled copy |
|
|
112
|
+
| `lib/gemchat/init.rb` | `gemchat init`: the only command that may touch a Gemfile |
|
|
113
|
+
| `lib/gemchat/plugin_index.rb` | reads Bundler's record of which hooks are armed |
|
|
114
|
+
| `lib/gemchat/errors.rb` | `Gemchat::Error`, in its own file so subclasses can be required first |
|
|
115
|
+
| `lib/gemchat/cli.rb` | `init`, `index`, `reindex`, `search`, `vsearch`, `query`, `embed`, `status`, `version` |
|
|
116
|
+
| `lib/gemchat/models.rb` | model registry, pinned digest, streamed download with resume |
|
|
117
|
+
| `lib/gemchat/embedder.rb` | Null/Gguf/Supervised embedders, batching, backoff, the child |
|
|
118
|
+
| `lib/gemchat/env.rb` | boolean env parsing |
|
|
119
|
+
| `plugins.rb` | the Bundler entry point — `add_hook` and nothing else |
|
|
120
|
+
|
|
121
|
+
Data flow: `Gemfile.lock` → `Indexer#lockfile_specs` → `Ri` (generate or reuse)
|
|
122
|
+
→ `Chunker.from_ri` → `Symbols` stamps each chunk with its defining file and
|
|
123
|
+
line → `Store#replace_gem` → FTS5 → `Store#search`.
|
|
124
|
+
|
|
125
|
+
ri says a method exists; only the Prism pass says where. That is the one thing a
|
|
126
|
+
search result cannot be used without and cannot be inferred from, and it is
|
|
127
|
+
printed after every ri hit. Matched on (class, method, method_type) because that
|
|
128
|
+
is all either side knows; an unmatched chunk keeps a nil location rather than
|
|
129
|
+
borrowing someone else's path.
|
|
130
|
+
|
|
131
|
+
Vectors are a parallel, entirely optional path. `gemchat embed` →
|
|
132
|
+
`Store#unembedded_chunks` → `Embedder::Supervised` (child process) →
|
|
133
|
+
`Store#store_embeddings` → float32 BLOBs; `gemchat vsearch` → one embedding →
|
|
134
|
+
`Store#vector_search` → `dot()`. `gemchat query` fuses both by reciprocal rank via
|
|
135
|
+
`Store#hybrid_search`. Nothing in the `search` path touches either, and
|
|
136
|
+
`gemchat search` must keep working with no model file on disk at all.
|
|
137
|
+
|
|
138
|
+
`GEMCHAT_HOME` (data) and `GEMCHAT_CONFIG_HOME` (settings, trust) are separate on
|
|
139
|
+
purpose: wiping the cache must not discard trust decisions. Both live in
|
|
140
|
+
`Gemchat::Paths`, its own file so the plugin shim can resolve one without
|
|
141
|
+
requiring the whole gem.
|
|
142
|
+
|
|
143
|
+
## Hard-won constraints
|
|
144
|
+
|
|
145
|
+
These were each measured or discovered the hard way. Re-deriving them wastes a
|
|
146
|
+
cycle, and several look like reasonable ideas until they are not.
|
|
147
|
+
|
|
148
|
+
- **Plain Ruby only. No ActiveSupport.** It would add 13 gems
|
|
149
|
+
(`concurrent-ruby`, `i18n`, `tzinfo`, `drb`, `connection_pool`, …) to a CLI that
|
|
150
|
+
needs none of them. No `presence`, `compact_blank`, `squish`, or
|
|
151
|
+
`String#first`.
|
|
152
|
+
- **RDoc refuses to write into an existing output directory.** Create only the
|
|
153
|
+
parent and leave the target alone.
|
|
154
|
+
- **`RDoc::RI::Driver` takes `--doc-dir`**, not `--ri`/`--op`.
|
|
155
|
+
- **Generate with `Dir.chdir(gem_dir)` and relative source dirs** (`lib`, `ext`).
|
|
156
|
+
Absolute paths make RDoc derive wrong class names.
|
|
157
|
+
- **`SystemStackError` descends from `Exception`, not `StandardError`.** RDoc
|
|
158
|
+
overflows the stack on symlink cycles; a bare `rescue` misses it.
|
|
159
|
+
- **FTS5 parses `:` as a column filter.** An unquoted `Puma::Server` fails with
|
|
160
|
+
"no such column". Every term must be quoted — see `Store.fts_query`. This is a
|
|
161
|
+
systematic collision with Ruby naming, not a one-off.
|
|
162
|
+
- **`search` is an exact-phrase matcher, not a keyword search.** Quoting each
|
|
163
|
+
term escapes `:` but joining the quoted terms with a space is FTS5's
|
|
164
|
+
AND-of-a-phrase. Measured on a ~22k-chunk corpus: **0 of 5** known-answer
|
|
165
|
+
questions located, one exact hit for an identifier. So it is precise and has
|
|
166
|
+
no recall — keep that in mind before "fixing" it.
|
|
167
|
+
- **OR-ing the terms is not the fix, and neither is dropping stopwords.** Four
|
|
168
|
+
combinations of FTS5's boolean semantics were measured on known-answer
|
|
169
|
+
queries: phrase 0/5, OR 1/5, OR-with-stopwords 1/5, AND 0/5. The reason is not
|
|
170
|
+
common-word noise — it is that a rare term in a short ri signature outranks
|
|
171
|
+
the prose that answers the question, because BM25 length normalisation rewards
|
|
172
|
+
exactly that shape. A paraphrase question and its answer share no vocabulary,
|
|
173
|
+
so no amount of term selection fixes it. See §11.3 of the plan.
|
|
174
|
+
- **Source priors measured worse than none, so there are none.** Reusing the
|
|
175
|
+
app's 1.15 prose prior, 1.30, and a 1.15 ri prior against an evenly-split
|
|
176
|
+
ri/prose eval: hit@1 6/10 with no prior, 6/10 at 1.15 prose, 5/10 at 1.30, 4/10
|
|
177
|
+
at ri 1.15 — and MRR fell in every case (0.744 → 0.705 → 0.655 → 0.547) while
|
|
178
|
+
recall@10 stayed 10/10. A prior is blunt: it shifts a whole class, and cannot
|
|
179
|
+
tell a good prose chunk from a bad one. Do not add one without re-measuring.
|
|
180
|
+
- **The hosted tier is a row API, not MCP.** The MCP endpoint is a plain JSON-RPC
|
|
181
|
+
POST — the transport was never the problem — but `query_docs` returns *Markdown*,
|
|
182
|
+
which a CLI cannot parse without breaking silently. So the client speaks
|
|
183
|
+
`POST /api/v1/query`; MCP stays the agent surface, and both call one
|
|
184
|
+
`HostedQuery` in `gemchat_app` so the ranking exists once.
|
|
185
|
+
- **The hosted tier never falls back.** No route leads from a failed, unconfigured
|
|
186
|
+
or `lex:` hosted query to a local answer. A user who asked for hosted results and
|
|
187
|
+
got local ones cannot tell the difference, so that is a privacy bug.
|
|
188
|
+
- **This bites fusion harder than search.** RRF trusts rank, so a bad arm gets
|
|
189
|
+
promoted rather than ignored. The lexical arm is for identifiers, where query
|
|
190
|
+
terms appear literally; the vector arm is for questions. Do not wire `:any` in
|
|
191
|
+
as the default without re-running the eval in §11.4, and do not try to fix the
|
|
192
|
+
lexical arm with term selection — that has been tried and falsified.
|
|
193
|
+
- **`results_as_hash` gives every row integer keys too.** `{**row}` leaks `0, 1,
|
|
194
|
+
2…` into results and mixes symbol and string key styles. Select an explicit
|
|
195
|
+
column list instead.
|
|
196
|
+
- **sqlite3 2.x discards a UDF's return value.** It reads `FunctionProxy#result`;
|
|
197
|
+
the block's own return value is thrown away. Returning the float from a
|
|
198
|
+
`create_function` block makes every rank `NULL`, which sorts as `0.0` — so
|
|
199
|
+
`vsearch` returns an arbitrary slice of the corpus in a plausible-looking
|
|
200
|
+
order. Assign `fp.result =` instead. This is a silent failure: no error, and
|
|
201
|
+
results that look like output.
|
|
202
|
+
- **A vector query costs ~915ms at 22k chunks, not milliseconds.** Measured on a
|
|
203
|
+
real 22,141-chunk index: 20s to index, 494s to embed (99.97% success), 915ms per
|
|
204
|
+
vector search, 13.5MB store. The bottleneck is the UDF, which unpacks two
|
|
205
|
+
3072-byte blobs per row -- ~34M array elements a query. The 236-chunk index
|
|
206
|
+
reports ~5ms, which is how the wrong number survived in the plan.
|
|
207
|
+
- **Narrowing candidates in SQL IS expressible** -- a candidate CTE joined to
|
|
208
|
+
`chunks` limits which rows the UDF runs on, so two-stage retrieval is one
|
|
209
|
+
statement, not something that has to be materialised in Ruby first. Measured:
|
|
210
|
+
915ms -> 22ms at a pool of 500, with the top-1 result identical on 8 of 8
|
|
211
|
+
questions and recall@200 of the full-scan top-200 at 48.8%. Deliberately not
|
|
212
|
+
enabled: the candidate generator is lexical, so a paraphrase sharing no
|
|
213
|
+
vocabulary returns nothing where a full scan would have found it. See 11.1.
|
|
214
|
+
- **rllama aborts the process instead of raising.** SIGILL descends from
|
|
215
|
+
nothing `StandardError` catches, so in-process there is no way to detect,
|
|
216
|
+
retry, or degrade. Measured intermittently: the same command failed four times
|
|
217
|
+
running and later passed 20 of 20 unchanged, and `GGML_METAL_DISABLE=1` did
|
|
218
|
+
not help. Embedding runs in a supervised child, retried once with Metal off.
|
|
219
|
+
- **The native embedder has a size cliff whose position cannot be pinned.** Real
|
|
220
|
+
prose fails at 1665 bytes and passes at 1634, while 2500 bytes of `"word "`
|
|
221
|
+
is fine and stripping to pure ASCII does not help. Do not tune to a measured
|
|
222
|
+
number: keep the 1200-byte per-item ceiling, the 16KB per-batch budget, and
|
|
223
|
+
the halve-and-retry backoff, and treat all three as defence in depth.
|
|
224
|
+
- **The download 302s to a CDN, and each hop needs its own connection.**
|
|
225
|
+
Replaying the redirect target down the origin's open TLS session produces a 403
|
|
226
|
+
on a perfectly good signed URL. `use_ssl` also has to follow the scheme, or a
|
|
227
|
+
plain-HTTP mirror is unreachable. A 200 answer to a `Range` request means the
|
|
228
|
+
whole file is coming — rewind rather than append, or you get the right length
|
|
229
|
+
and the wrong bytes.
|
|
230
|
+
- **`Models.present` is advisory.** It explains a missing model and returns
|
|
231
|
+
false. Treating that false as "cannot continue" makes `gemchat embed` exit 1 on
|
|
232
|
+
a clean machine and never fetch anything. Nothing stands between a missing
|
|
233
|
+
model and a downloaded one except that call.
|
|
234
|
+
- **`unembedded_chunks` only returns rows where the vector `IS NULL`.** So a
|
|
235
|
+
model change cannot be reconciled by re-running `embed`; `embed --force` has
|
|
236
|
+
to clear the vectors, and the mismatch error has to name a command that does.
|
|
237
|
+
- **Nomic needs task prefixes**, and omitting them is a quality loss rather than
|
|
238
|
+
a crash: the correct hit's similarity measured 0.515 unprefixed and 0.580
|
|
239
|
+
prefixed. They are part of the stored identity string for that reason.
|
|
240
|
+
- **`define_singleton_method` rebinds `self` to the receiver.** A lambda installed
|
|
241
|
+
as a `with_stub` cannot call helper methods on the test instance. Bind what it
|
|
242
|
+
needs up front: `basis = method(:unit)`.
|
|
243
|
+
- **`benchmark` is not a default gem in Ruby 4.0.** Use
|
|
244
|
+
`Process.clock_gettime(Process::CLOCK_MONOTONIC)`.
|
|
245
|
+
- **Minitest 6 removed `Object#stub` and ships no `minitest/mock`.** Use the
|
|
246
|
+
`with_stub` helper in `test/test_helper.rb`.
|
|
247
|
+
- **`Gemchat.root` must not be memoised.** A value derived from the environment
|
|
248
|
+
that is cached means the first reader wins for the life of the process; setting
|
|
249
|
+
`GEMCHAT_HOME` later silently does nothing.
|
|
250
|
+
- **Only `lib/` and `ext/` are documented, and `--all` is deliberately omitted.**
|
|
251
|
+
`--all` adds 3–46% more chunks of private and internal machinery.
|
|
252
|
+
- **Bundler does not generate ri on install; `gem install` does.** So ri
|
|
253
|
+
generation is the main path, not a fallback.
|
|
254
|
+
- **A malformed `plugins.rb` fails the install**: exit 29, no `Gemfile.lock`
|
|
255
|
+
written, even though the gems themselves are already on disk. Caught by
|
|
256
|
+
raising, by a bad event name, and by the file being absent.
|
|
257
|
+
- **`plugins.rb` must live in the gem, never the project root.** A project-root
|
|
258
|
+
`plugins.rb` is silently never executed — Bundler only ever loads
|
|
259
|
+
`<installed gem>/plugins.rb`. Same filename, opposite outcome, and it looks
|
|
260
|
+
installed while doing nothing.
|
|
261
|
+
- **A `plugins.rb` that only runs, without `add_hook`, fires once and never
|
|
262
|
+
again.** Bundler records *registered hooks* in `.bundle/plugin/index` and
|
|
263
|
+
gates later calls on that record, so `bundle add`/`update` go silent.
|
|
264
|
+
- **Root-scope every Bundler reference in `plugins.rb` as `::Bundler`.** Bundler
|
|
265
|
+
loads it with `load(path, true)` (anonymous wrapper) mid-flight inside
|
|
266
|
+
`module Bundler::Plugin`; a bare constant can raise `NameError` and kill the
|
|
267
|
+
install.
|
|
268
|
+
- **Never gate the hook on `RAILS_ENV`.** Copied from Hyperdrive, whose host is a
|
|
269
|
+
Rails engine. gemchat is not a Rails tool, so in any plain Ruby project
|
|
270
|
+
`RAILS_ENV` is unset and the hook would be permanently silent.
|
|
271
|
+
- **The shim's logic lives in `lib/`, never in `plugins.rb`.** Dual declaration
|
|
272
|
+
installs two copies and nothing pins them, so anything implemented in the shim
|
|
273
|
+
exists twice and can disagree with the version the project depends on.
|
|
274
|
+
- **Use `require_relative` for sibling files.** Under a `path:`-sourced checkout
|
|
275
|
+
`lib/` is *not* on `$LOAD_PATH`, so `require "gemchat/paths"` fails while
|
|
276
|
+
`require_relative "paths"` works. Mixing the two broke the plugin end to end.
|
|
277
|
+
- **The hook must never propagate an exception**, including from parsing the
|
|
278
|
+
subprocess output. It also has to be robust to `gemchat` not being on `PATH`
|
|
279
|
+
under `bundle install` — it runs the *bundled* `exe/gemchat` directly.
|
|
280
|
+
- **`doc/` in an installed gem is hand-written prose, not generated ri.** An
|
|
281
|
+
earlier version of this file claimed the opposite and it cost real content:
|
|
282
|
+
measured across every installed gem with a `doc/`, 12 gems, 96 files, **83
|
|
283
|
+
prose, 0 generated ri**. `irb/Index.md` (23KB), `irb/EXTEND_IRB.md`,
|
|
284
|
+
`rake/glossary.rdoc` and `test/how-to.md` are all there and none are
|
|
285
|
+
derivable from method comments. Do not exclude it. What *is* excluded is the
|
|
286
|
+
generated shape specifically — `doc/**/ri/**` and rdoc's HTML/JS/CSS tree.
|
|
287
|
+
- **A `#` inside a fenced code block is not a heading.** Without tracking fence
|
|
288
|
+
state, a comment in a README example splits the section that introduces it.
|
|
289
|
+
- **Long prose sections are split, not truncated.** `MAX_BODY_CHARS` is tuned for
|
|
290
|
+
ri method bodies; applied to prose it dropped 13% of `doc/` content, worst on
|
|
291
|
+
files like `tutorial.rdoc` (22%), cutting mid-sentence.
|
|
292
|
+
- **`readme`, `guides` and `doc` are separate `source_type`s, grouped only at
|
|
293
|
+
query time.** That is `gemchat_app`'s vocabulary: it gives `guides` a 1.15
|
|
294
|
+
ranking prior and derives a `readme_guides` group via `source_group` purely for
|
|
295
|
+
prose diversity. Folding `guides/` into `readme` would make local `guides` mean
|
|
296
|
+
something different from hosted `guides`.
|
|
297
|
+
- **Bundler 4 writes no `PLUGINS` lockfile section**, so nothing pins the plugin
|
|
298
|
+
copy. Dual `gem` + `plugin` declaration installs two physical copies — but only
|
|
299
|
+
for a *published* gem; a `path:` source is referenced in place, so a local
|
|
300
|
+
harness cannot reproduce it.
|
|
301
|
+
- **`after-install-all` takes a string**, and it does fire on `bundle install`,
|
|
302
|
+
`bundle add`, and `bundle update` — all three measured.
|
|
303
|
+
- **The plugin failure mode is silence, not an error.** `test/plugin_shim_test.rb`
|
|
304
|
+
asserts each of these mechanically, because all of them are invisible in review.
|
|
305
|
+
- **To test the plugin locally**, use a path source —
|
|
306
|
+
`gem "gemchat", path: …` / `plugin "gemchat", path: …`.
|
|
307
|
+
`bundle config set --local local.gemchat …` does not work for an unpublished
|
|
308
|
+
gem, and `bundle config set --local path …` sets the install location, which
|
|
309
|
+
breaks unrelated gems. Full details in §15.6a.
|
|
310
|
+
|
|
311
|
+
## Conventions
|
|
312
|
+
|
|
313
|
+
- Standard Ruby (standardrb), not rubocop. `ruby_version: 3.2` in
|
|
314
|
+
`.standard.yml` — write for 3.2, not for the local interpreter.
|
|
315
|
+
- Minitest, one file per component, `test/test_helper.rb` holds shared fixtures.
|
|
316
|
+
- Tests must not depend on a specific gem being installed. `IndexerTest::REAL_GEM`
|
|
317
|
+
discovers one from the running bundle; prefer that pattern over hardcoding.
|
|
318
|
+
- Every test that touches the filesystem must set `GEMCHAT_HOME` to a tmpdir.
|
|
319
|
+
- Do not commit `Gemfile.lock` changes you did not intend.
|
|
320
|
+
- Comments explain *why*, especially where a workaround looks wrong. Do not
|
|
321
|
+
restate what the code does.
|
|
322
|
+
|
|
323
|
+
## Commit style
|
|
324
|
+
|
|
325
|
+
Subject line in the imperative, prefixed with the phase or theme, then a body
|
|
326
|
+
that explains what was measured or discovered and why — not a list of changed
|
|
327
|
+
files. Recent examples:
|
|
328
|
+
|
|
329
|
+
- `Phase 1 spike: measure ri cost, settle plugin mechanics, working index+search`
|
|
330
|
+
- `Correct the rubydex rationale: the cost is musl-specific, not universal`
|
|
331
|
+
- `gemchat embed and vsearch: vectors in a supervised child, or not at all`
|
|
332
|
+
|
|
333
|
+
## Status
|
|
334
|
+
|
|
335
|
+
Working today: `init`, `index`, `reindex`, `search`, `vsearch`, `query`,
|
|
336
|
+
`embed`, `status`, `version` (with `--hosted`) over ri plus README/`guides/`/`doc/` prose, a
|
|
337
|
+
trust-gated, sha256-gated auto-index plugin, local vectors from an
|
|
338
|
+
auto-downloaded `nomic-embed-text-v1.5`, RRF fusion in `query`, Prism source
|
|
339
|
+
locations on every ri hit, and `skills/gemchat/SKILL.md`.
|
|
340
|
+
|
|
341
|
+
Nothing in §20 is outstanding. The hosted tier (§12) shipped: `query --hosted`
|
|
342
|
+
speaks `POST /api/v1/query` for rows, not MCP for prose, and never falls back to
|
|
343
|
+
the local index.
|
|
344
|
+
|
|
345
|
+
Two things were investigated and deliberately not built, so they do not get
|
|
346
|
+
re-attempted by someone assuming they were simply forgotten:
|
|
347
|
+
|
|
348
|
+
- **A keyword arm for the fusion's lexical side.** The current one is an
|
|
349
|
+
exact-phrase matcher that scores **0 of 5** on known-answer questions;
|
|
350
|
+
OR-ing the terms scores **1 of 5** and dropping stopwords does not move it,
|
|
351
|
+
because the failure is a vocabulary mismatch between a paraphrase and its
|
|
352
|
+
answer rather than term selection. Term selection was tried and falsified —
|
|
353
|
+
see the two FTS5 bullets above and §11.4.
|
|
354
|
+
- **Source priors.** Measured worse than none; see the bullet above.
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"gemchat" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["zoras@users.noreply.github.com"](mailto:"zoras@users.noreply.github.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saroj Maharjan (zoras)
|
|
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,144 @@
|
|
|
1
|
+
# Gemchat
|
|
2
|
+
|
|
3
|
+
Searches the documentation of the gems in **your own bundle**, offline, from a
|
|
4
|
+
local index. No account, no API key, nothing leaves the machine.
|
|
5
|
+
|
|
6
|
+
```console
|
|
7
|
+
$ gemchat search "Rake::Task#enhance"
|
|
8
|
+
1. [rake 13.4.2 ri ] Rake::Task#enhance
|
|
9
|
+
enhance(deps=nil, &block) (lib/rake/task.rb:115)
|
|
10
|
+
|
|
11
|
+
$ gemchat query "how do I list all the available tasks"
|
|
12
|
+
1. [rake 13.4.2 doc ] rake › doc › Rake Command Line Usage · 1/3
|
|
13
|
+
The -T option displays the tasks...
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
You are reading `sidekiq`'s source to answer a question, and the answer is in a
|
|
19
|
+
README you do not have open, or in a method whose only documentation is a
|
|
20
|
+
sentence in an `.rdb` file nobody has generated. gemchat indexes the
|
|
21
|
+
documentation of every gem in your `Gemfile.lock` — generated ri, plus each gem's
|
|
22
|
+
README, `guides/` and `doc/` — and searches it in milliseconds.
|
|
23
|
+
|
|
24
|
+
It is a local tool. There is a hosted tier, and it is opt-in per invocation, but
|
|
25
|
+
by default your queries never leave the machine.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
$ gemchat init # the only command that edits your Gemfile
|
|
31
|
+
$ bundle install # Bundler activates a newly declared plugin on the next install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`init` adds gemchat as a development dependency and as a Bundler plugin, and
|
|
35
|
+
trusts the project. After that, any `bundle install`, `bundle add` or
|
|
36
|
+
`bundle update` that moves your lockfile re-indexes automatically and prints one
|
|
37
|
+
line. Anything else is silent — including on CI, and including in a repository
|
|
38
|
+
you cloned, because you did not ask.
|
|
39
|
+
|
|
40
|
+
## The three search commands
|
|
41
|
+
|
|
42
|
+
They fail in **opposite** directions, so picking the wrong one returns nothing at
|
|
43
|
+
all:
|
|
44
|
+
|
|
45
|
+
| Question | Command |
|
|
46
|
+
|---|---|
|
|
47
|
+
| An exact identifier, an error string, a flag | `gemchat search` |
|
|
48
|
+
| A natural-language question | `gemchat query` |
|
|
49
|
+
| A paraphrase, vectors only | `gemchat vsearch` |
|
|
50
|
+
|
|
51
|
+
`gemchat search` is BM25 but matches **exact phrases**. A question like *"how do I
|
|
52
|
+
list all the available tasks"* returns no results, because those words do not
|
|
53
|
+
appear together in any chunk. That is the design, not a broken index — reach for
|
|
54
|
+
`query`.
|
|
55
|
+
|
|
56
|
+
`gemchat query` fuses both engines by reciprocal rank, and takes a leading type:
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
gemchat query "how do I list every task" # both, fused (default)
|
|
60
|
+
gemchat query "lex: Rake::Task#enhance" # BM25 only, never loads a model
|
|
61
|
+
gemchat query "vec: stop a process on a signal" # vectors only
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Always check `gemchat status` first. It says what is indexed, whether the index
|
|
65
|
+
matches the current lockfile, and whether vectors exist:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
index: 5 gems · 1095 chunks
|
|
69
|
+
manifest: up to date
|
|
70
|
+
vectors: not downloaded (146MB) → gemchat embed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Semantic search
|
|
74
|
+
|
|
75
|
+
`gemchat query` and `gemchat vsearch` need vectors, and **only `gemchat embed`
|
|
76
|
+
downloads them** — a one-time 146MB fetch of `nomic-embed-text-v1.5`. They never
|
|
77
|
+
download on their own, and they never silently answer from the local index
|
|
78
|
+
instead; they tell you and stop.
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
$ gemchat embed
|
|
82
|
+
$ gemchat query "why does rake say 'Don't know how to build task'"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every ri result carries a `path:line` from a Prism pass over the gem's source, so
|
|
86
|
+
you can open the actual definition rather than trusting a summary.
|
|
87
|
+
|
|
88
|
+
## Hosted tier (opt-in)
|
|
89
|
+
|
|
90
|
+
`gemchat query --hosted` asks [gemchat.org](https://gemchat.org) instead of your
|
|
91
|
+
local index. It needs a key — `GEMCHAT_API_KEY`, or a `credentials` file under
|
|
92
|
+
the gemchat config home — and `gemchat init --hosted` records it as a project's
|
|
93
|
+
default.
|
|
94
|
+
|
|
95
|
+
It sends your lockfile's exact gem versions, and reports which ones it could
|
|
96
|
+
scope to, so version fidelity is disclosed rather than assumed.
|
|
97
|
+
|
|
98
|
+
What it costs you, stated plainly:
|
|
99
|
+
|
|
100
|
+
- **Version drift is reduced, not eliminated.** A gem with no per-version index on
|
|
101
|
+
the server is answered from its full docs, which may not be your pinned release.
|
|
102
|
+
The response says so.
|
|
103
|
+
- **The dependency graph leaves the machine.** That is the whole point of opting
|
|
104
|
+
in.
|
|
105
|
+
- **A key and a network are required.** It is not zero-config.
|
|
106
|
+
- **Different ranking.** PostgreSQL hybrid against a larger, cross-project index
|
|
107
|
+
versus local RRF over your bundle. Results will not be identical, and that is
|
|
108
|
+
expected.
|
|
109
|
+
- **`search` and `vsearch` are local-only.** So is `lex:`, which is BM25.
|
|
110
|
+
|
|
111
|
+
Nothing falls back: there is no route from a failed, unconfigured or `lex:` hosted
|
|
112
|
+
query to a local answer, because you could not tell the difference.
|
|
113
|
+
|
|
114
|
+
## What gets indexed
|
|
115
|
+
|
|
116
|
+
| Source | `source_type` |
|
|
117
|
+
|---|---|
|
|
118
|
+
| ri generated from the installed gem's source | `ri` |
|
|
119
|
+
| `README*` | `readme` |
|
|
120
|
+
| `guides/` | `guides` |
|
|
121
|
+
| `doc/` | `doc` |
|
|
122
|
+
|
|
123
|
+
`doc/` is hand-written prose, not generated ri, and it is included. Generated
|
|
124
|
+
ri under `doc/` and rdoc's HTML tree are excluded. Long prose sections are split
|
|
125
|
+
at paragraph boundaries rather than truncated.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```console
|
|
130
|
+
bin/setup # or: bundle install
|
|
131
|
+
bundle exec rake # test + standardrb
|
|
132
|
+
bin/sandbox # a throwaway project with gemchat installed from this checkout
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`bin/sandbox` is the fastest way to try a change against a real `bundle
|
|
136
|
+
install`. Run it **directly, not under `bundle exec`** — see the note in
|
|
137
|
+
`AGENTS.md`.
|
|
138
|
+
|
|
139
|
+
`docs/plans/gemchat-local-bundle-index.md` is the design document and the source
|
|
140
|
+
of truth; `AGENTS.md` records the constraints that are easy to get wrong.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
The gem is available as open source under the terms of the MIT License.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
require "standard/rake"
|
|
9
|
+
|
|
10
|
+
# `rake standard` shells out to standardrb with no file list, which defaults to
|
|
11
|
+
# ./ and so already covers plugins.rb. Verified with `--list-target-files`:
|
|
12
|
+
# the top-level plugins.rb is executable code that ships to other people's
|
|
13
|
+
# machines, so it must not fall outside the default task.
|
|
14
|
+
task default: %i[test standard]
|