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 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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-09-26
4
+
5
+ - Initial release
@@ -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]