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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module Gemchat
|
|
6
|
+
# Indexes the gems in a lockfile. ri generation is the main path, because
|
|
7
|
+
# Bundler does not produce ri on install (§8.3); reading a pre-existing store
|
|
8
|
+
# is a fast path for `gem install` users and for anyone who ran `gem rdoc`.
|
|
9
|
+
class Indexer
|
|
10
|
+
# `status` is what the store did: :indexed, :updated or :unchanged. It is
|
|
11
|
+
# deliberately separate from `ri_source`, which describes where the ri came
|
|
12
|
+
# from. Cached ri plus an unchanged digest is a normal no-op, and conflating
|
|
13
|
+
# the two makes it look like work happened.
|
|
14
|
+
Result = Struct.new(:name, :version, :status, :chunks, :ri_source, :error) do
|
|
15
|
+
def ok?
|
|
16
|
+
error.nil?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def changed?
|
|
20
|
+
status == :indexed || status == :updated
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(store:, lockfile_path: "Gemfile.lock", include_transitive: false,
|
|
25
|
+
all_languages: false)
|
|
26
|
+
@store = store
|
|
27
|
+
@lockfile_path = lockfile_path
|
|
28
|
+
@include_transitive = include_transitive
|
|
29
|
+
@all_languages = all_languages
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def lockfile_specs
|
|
33
|
+
parser = ::Bundler::LockfileParser.new(File.read(@lockfile_path))
|
|
34
|
+
specs = parser.specs.reject { |s| s.name == "ruby-core-stdlib" }
|
|
35
|
+
unless @include_transitive
|
|
36
|
+
direct = parser.dependencies.keys - ["ruby-core-stdlib"]
|
|
37
|
+
specs = specs.select { |s| direct.include?(s.name) }
|
|
38
|
+
end
|
|
39
|
+
specs.map { |s| [s.name, s.version.to_s] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def index_gem(name, version, prefer_existing: true)
|
|
43
|
+
gem_dir = gem_dir_for(name, version)
|
|
44
|
+
return Result.new(name:, version:, status: :missing, chunks: 0, ri_source: "missing") unless gem_dir
|
|
45
|
+
|
|
46
|
+
ri_source = nil
|
|
47
|
+
ri_path = prefer_existing ? Ri.existing_store_path(name, version) : nil
|
|
48
|
+
ri_source = "existing" if ri_path
|
|
49
|
+
|
|
50
|
+
if ri_path.nil?
|
|
51
|
+
ri_path = Ri.generated_store_path(name, version)
|
|
52
|
+
if File.directory?(ri_path)
|
|
53
|
+
ri_source ||= "generated"
|
|
54
|
+
else
|
|
55
|
+
begin
|
|
56
|
+
ri_path = Ri.generate(name, version, gem_dir:, output_dir: ri_path)
|
|
57
|
+
ri_source = "generated"
|
|
58
|
+
rescue Ri::GenerationError => e
|
|
59
|
+
return Result.new(name:, version:, status: :failed, chunks: 0, ri_source: "failed", error: e.message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
chunks = Chunker.from_ri(name, version, Ri.read(ri_path, name))
|
|
65
|
+
attach_sources(chunks, Symbols.for(gem_dir))
|
|
66
|
+
outcome = @store.replace_gem(name, version, "ri", chunks)
|
|
67
|
+
|
|
68
|
+
# The prose half, indexed separately and labelled by source_type. Stored
|
|
69
|
+
# under its own source_type so a gem that gains or loses a README does not
|
|
70
|
+
# force the much larger ri row to be rewritten.
|
|
71
|
+
prose = Prose.chunks(name, version, gem_dir, all_languages: @all_languages)
|
|
72
|
+
prose_outcome = write_prose(name, version, prose)
|
|
73
|
+
|
|
74
|
+
Result.new(name:, version:, status: outcome.status,
|
|
75
|
+
chunks: outcome.chunks + prose_outcome.sum(&:chunks), ri_source: ri_source)
|
|
76
|
+
rescue => e
|
|
77
|
+
Result.new(name:, version:, status: :error, chunks: 0, ri_source: "error", error: "#{e.class}: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Each prose source is written under its own source_type so a gem that gains
|
|
81
|
+
# or loses a README never rewrites its much larger ri row.
|
|
82
|
+
def write_prose(name, version, chunks)
|
|
83
|
+
chunks.group_by(&:source_type).map do |source_type, group|
|
|
84
|
+
@store.replace_gem(name, version, source_type, group)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Stamps each ri chunk with the file and line its definition came from, using
|
|
89
|
+
# the Prism pass's table.
|
|
90
|
+
#
|
|
91
|
+
# Matching is on (class_name, method_name, method_type) because that is all an
|
|
92
|
+
# ri chunk knows and all a definition carries. It is deliberately not
|
|
93
|
+
# fuzzy: a chunk that cannot be matched keeps a nil location, and an invented
|
|
94
|
+
# path is worse than no path for a citation.
|
|
95
|
+
def attach_sources(chunks, table)
|
|
96
|
+
return chunks if table.empty?
|
|
97
|
+
|
|
98
|
+
chunks.each do |chunk|
|
|
99
|
+
definition = table[[chunk.class_name.to_s, chunk.method_name.to_s, chunk.method_type.to_s]]
|
|
100
|
+
next if definition.nil?
|
|
101
|
+
|
|
102
|
+
chunk.source_path = definition.source_path
|
|
103
|
+
chunk.source_line = definition.source_line
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def gem_dir_for(name, version)
|
|
108
|
+
spec = Gem::Specification.find_by_name(name, version)
|
|
109
|
+
spec&.gem_dir
|
|
110
|
+
rescue Gem::MissingSpecError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/gemchat/init.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Gemchat
|
|
6
|
+
# `gemchat init` — the once-per-project setup, and the only command that may
|
|
7
|
+
# modify a Gemfile.
|
|
8
|
+
#
|
|
9
|
+
# Separate from `index` on purpose. A command called `index` that edits your
|
|
10
|
+
# Gemfile is a surprise, and in CI or a pre-commit hook it would either block
|
|
11
|
+
# on a prompt — hanging the build — or silently rewrite a file it has no
|
|
12
|
+
# business touching. So `index` stays non-interactive and read-only, and this
|
|
13
|
+
# one is explicit about being the mutating one.
|
|
14
|
+
#
|
|
15
|
+
# Idempotent: running it twice changes nothing and says so. That matters
|
|
16
|
+
# because the two lines it adds are easy to get wrong by hand, and a
|
|
17
|
+
# half-configured plugin fails *silently* — the hook appears installed and
|
|
18
|
+
# never fires.
|
|
19
|
+
class Init
|
|
20
|
+
GEM_LINE = 'gem "gemchat", group: :development'
|
|
21
|
+
PLUGIN_LINE = 'plugin "gemchat"'
|
|
22
|
+
GEMFILE = "Gemfile"
|
|
23
|
+
COMMENT = "# gemchat: local documentation index"
|
|
24
|
+
|
|
25
|
+
Result = Struct.new(:added, :already_present, :indexed) do
|
|
26
|
+
def changed? = added.any?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(dir: Dir.pwd, stdout: $stdout, stderr: $stderr, assume_yes: false)
|
|
30
|
+
@dir = dir
|
|
31
|
+
@stdout = stdout
|
|
32
|
+
@stderr = stderr
|
|
33
|
+
@assume_yes = assume_yes
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def gemfile_path
|
|
37
|
+
File.join(@dir, GEMFILE)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
unless File.file?(gemfile_path)
|
|
42
|
+
raise Error, "no #{GEMFILE} in #{@dir} — run `bundle init` first"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless Lockfile.exist?(@dir)
|
|
46
|
+
raise Error, "no Gemfile.lock in #{@dir} — run `bundle install` first, " \
|
|
47
|
+
"so gemchat has a bundle to index"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result = Result.new(added: [], already_present: [], indexed: false)
|
|
51
|
+
|
|
52
|
+
pending = [GEM_LINE, PLUGIN_LINE].reject do |line|
|
|
53
|
+
present = declared?(line)
|
|
54
|
+
result.already_present << line if present
|
|
55
|
+
present
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
unless pending.empty?
|
|
59
|
+
confirm!(pending)
|
|
60
|
+
append(pending)
|
|
61
|
+
result.added.concat(pending)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Opting in by running init is deliberate, so trust is granted here. It
|
|
65
|
+
# also writes .gemchat.yml, which is what lets the hook skip the next
|
|
66
|
+
# bundle install for free.
|
|
67
|
+
Trust.grant!(@dir, via: "gemchat init")
|
|
68
|
+
result.indexed = true
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Matches the *directive* as well as the name, so a user's own
|
|
73
|
+
# `gem "gemchat", path: "..."` counts as declaring the dependency while a
|
|
74
|
+
# `plugin "gemchat"` counts for the plugin. Keying on the name alone
|
|
75
|
+
# reported both as present the moment either was written, and the second
|
|
76
|
+
# line was silently never added -- the plugin then never fires, which is the
|
|
77
|
+
# exact silent failure this command exists to prevent.
|
|
78
|
+
def declared?(line)
|
|
79
|
+
return false unless File.file?(gemfile_path)
|
|
80
|
+
|
|
81
|
+
directive = line[/\A(gem|plugin)\b/, 1]
|
|
82
|
+
gem_name = line[/"([^"]+)"/, 1]
|
|
83
|
+
return false unless directive && gem_name
|
|
84
|
+
|
|
85
|
+
File.readlines(gemfile_path).any? do |candidate|
|
|
86
|
+
candidate.match?(/^\s*#{Regexp.escape(directive)}\s+["']#{Regexp.escape(gem_name)}["']/)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def append(lines)
|
|
91
|
+
body = File.read(gemfile_path)
|
|
92
|
+
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
93
|
+
File.write(gemfile_path, "#{body}\n#{COMMENT}\n#{lines.join("\n")}\n")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def confirm!(lines)
|
|
97
|
+
return if @assume_yes || !@stdout.tty?
|
|
98
|
+
|
|
99
|
+
lines.each { |line| @stdout.puts("add to #{GEMFILE}: #{line}") }
|
|
100
|
+
@stdout.print " ok? [y/N] "
|
|
101
|
+
answer = $stdin.gets.to_s.strip.downcase
|
|
102
|
+
return if ["y", "yes"].include?(answer)
|
|
103
|
+
|
|
104
|
+
raise Error, "aborted; #{GEMFILE} unchanged"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Gemchat
|
|
6
|
+
# The lockfile digest, which is the whole basis of the staleness check.
|
|
7
|
+
#
|
|
8
|
+
# Every command that changes a bundle — `install`, `add`, `update` — rewrites
|
|
9
|
+
# Gemfile.lock, so comparing one sha256 covers all three. That is why the
|
|
10
|
+
# plugin never has to work out which command ran.
|
|
11
|
+
module Lockfile
|
|
12
|
+
FILENAME = "Gemfile.lock"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def path(dir = Dir.pwd)
|
|
17
|
+
File.join(dir, FILENAME)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exist?(dir = Dir.pwd)
|
|
21
|
+
File.file?(path(dir))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# nil when there is no lockfile, which callers must treat as "unknown"
|
|
25
|
+
# rather than "unchanged" — otherwise a project without a lockfile would
|
|
26
|
+
# silently report an up-to-date index forever.
|
|
27
|
+
def digest(dir = Dir.pwd)
|
|
28
|
+
file = path(dir)
|
|
29
|
+
return nil unless File.file?(file)
|
|
30
|
+
|
|
31
|
+
Digest::SHA256.file(file).hexdigest
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Gemchat
|
|
6
|
+
# `.gemchat.yml` — the per-project record of what the index was built from.
|
|
7
|
+
#
|
|
8
|
+
# This exists so the staleness check can answer "is anything to do?" without
|
|
9
|
+
# opening SQLite or reading ri. A plugin hook fires on every bundle install in
|
|
10
|
+
# a project, and the overwhelming majority of those change nothing; the digest
|
|
11
|
+
# is a 64-byte file read that keeps them free.
|
|
12
|
+
#
|
|
13
|
+
# It is also checked-in code-adjacent state, so it is covered by the same
|
|
14
|
+
# trust gate as the plugin itself (§17): a manifest arriving in a repository
|
|
15
|
+
# you cloned is not evidence of anything.
|
|
16
|
+
class Manifest
|
|
17
|
+
FILENAME = ".gemchat.yml"
|
|
18
|
+
|
|
19
|
+
attr_reader :dir, :lockfile_sha256, :gems, :hosted
|
|
20
|
+
|
|
21
|
+
def initialize(dir = Dir.pwd)
|
|
22
|
+
@dir = dir
|
|
23
|
+
@lockfile_sha256 = nil
|
|
24
|
+
@gems = {}
|
|
25
|
+
@hosted = false
|
|
26
|
+
load
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.path(dir = Dir.pwd)
|
|
30
|
+
File.join(dir, FILENAME)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def exist?
|
|
34
|
+
File.file?(self.class.path(dir))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load
|
|
38
|
+
file = self.class.path(dir)
|
|
39
|
+
return self unless File.file?(file)
|
|
40
|
+
|
|
41
|
+
data = YAML.safe_load_file(file, permitted_classes: [Symbol], aliases: true)
|
|
42
|
+
return self unless data.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
@lockfile_sha256 = data["lockfile_sha256"]
|
|
45
|
+
@gems = data["gems"].is_a?(Hash) ? data["gems"] : {}
|
|
46
|
+
@hosted = data["hosted"] == true
|
|
47
|
+
self
|
|
48
|
+
rescue
|
|
49
|
+
# A corrupt manifest must not be fatal. Treating it as absent means the
|
|
50
|
+
# next run re-indexes, which is slower but correct; refusing to start
|
|
51
|
+
# would leave the project permanently un-indexed.
|
|
52
|
+
@lockfile_sha256 = nil
|
|
53
|
+
@gems = {}
|
|
54
|
+
@hosted = false
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True only when the lockfile is byte-identical to the one the index was
|
|
59
|
+
# built from. A missing manifest, a missing lockfile, or a corrupt manifest
|
|
60
|
+
# all mean "do the work".
|
|
61
|
+
def up_to_date?(current_digest)
|
|
62
|
+
return false if current_digest.nil?
|
|
63
|
+
return false if lockfile_sha256.nil?
|
|
64
|
+
|
|
65
|
+
lockfile_sha256 == current_digest
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record(lockfile_digest, gem_versions = {})
|
|
69
|
+
@lockfile_sha256 = lockfile_digest
|
|
70
|
+
@gems = gem_versions
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def write
|
|
75
|
+
File.write(self.class.path(dir), to_yaml)
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_yaml
|
|
80
|
+
data = {"lockfile_sha256" => lockfile_sha256, "gems" => gems}
|
|
81
|
+
# Only written when true. A `hosted: false` in every project would churn
|
|
82
|
+
# every .gemchat.yml in the world to record a default nobody set.
|
|
83
|
+
data["hosted"] = true if @hosted
|
|
84
|
+
data.to_yaml
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The project-level default set by `gemchat init --hosted`. Separate from
|
|
88
|
+
# GEMCHAT_HOST and from a key being present: those mean the tier is
|
|
89
|
+
# *available*, this means this project asked for it.
|
|
90
|
+
def hosted=(value)
|
|
91
|
+
@hosted = value == true
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def hosted? = @hosted
|
|
95
|
+
|
|
96
|
+
def version_for(name)
|
|
97
|
+
gems.dig(name, "version")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemchat
|
|
4
|
+
# Splits a Markdown or RDoc document into heading-scoped sections.
|
|
5
|
+
#
|
|
6
|
+
# Heading scope is the right unit for prose, the same way a method is the right
|
|
7
|
+
# unit for ri. A README's value is concentrated in its sections — "how to
|
|
8
|
+
# configure the thread pool" lives under a heading, not smeared across the
|
|
9
|
+
# file — and a chunk that carries its heading path can be found by the words
|
|
10
|
+
# that heading uses.
|
|
11
|
+
#
|
|
12
|
+
# Handles all three heading styles found in gem READMEs: ATX (`## Title`),
|
|
13
|
+
# setext (a title underlined with `===`), and RDoc (`== Title`).
|
|
14
|
+
module Markdown
|
|
15
|
+
Section = Struct.new(:path, :body) do
|
|
16
|
+
# Breadcrumb, e.g. "Configuration > Rack handlers".
|
|
17
|
+
def title
|
|
18
|
+
path.join(" > ")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ATX = /\A(\#{1,6})\s+(.+?)\s*\#*\s*\z/
|
|
23
|
+
RDOC = /\A(={1,6})\s+(.+?)\s*\z/
|
|
24
|
+
SETEXT = /\A(={3,}|-{3,})\s*\z/
|
|
25
|
+
FENCE = /\A\s*(```+|~~~+)/
|
|
26
|
+
|
|
27
|
+
# A section under construction: the heading path plus the lines seen so far.
|
|
28
|
+
Pending = Struct.new(:path, :lines)
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def sections(text)
|
|
33
|
+
finished = []
|
|
34
|
+
pending = nil
|
|
35
|
+
stack = []
|
|
36
|
+
previous = nil
|
|
37
|
+
fenced = false
|
|
38
|
+
|
|
39
|
+
text.to_s.each_line do |line|
|
|
40
|
+
if line.match?(FENCE)
|
|
41
|
+
# Inside a fence a `#` is code, not a heading. Splitting a section at a
|
|
42
|
+
# comment inside an example is both wrong and confusing: the example
|
|
43
|
+
# and the prose introducing it become separate chunks.
|
|
44
|
+
fenced = !fenced
|
|
45
|
+
pending&.lines&.<< line
|
|
46
|
+
previous = line
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if fenced
|
|
51
|
+
pending&.lines&.<< line
|
|
52
|
+
previous = line
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A setext underline applies to the line *before* it, so that line has to
|
|
57
|
+
# be pulled back out of the body it was already collected into.
|
|
58
|
+
if setext?(line, previous)
|
|
59
|
+
pending&.lines&.pop
|
|
60
|
+
finished, pending, stack = flush(finished, pending, stack,
|
|
61
|
+
[line.start_with?("=") ? 1 : 2, previous.strip])
|
|
62
|
+
elsif (heading = heading_for(line))
|
|
63
|
+
finished, pending, stack = flush(finished, pending, stack, heading)
|
|
64
|
+
elsif pending
|
|
65
|
+
pending.lines << line
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
previous = line
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
finished << pending if pending
|
|
72
|
+
finished.filter_map { |p| to_section(p) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def heading_for(line)
|
|
76
|
+
if (m = line.match(ATX))
|
|
77
|
+
[m[1].length, m[2].strip]
|
|
78
|
+
elsif (m = line.match(RDOC))
|
|
79
|
+
[m[1].length, m[2].strip]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def setext?(line, previous)
|
|
84
|
+
line.match?(SETEXT) && previous && !previous.strip.empty? &&
|
|
85
|
+
!previous.start_with?("#", "=")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def flush(finished, pending, stack, heading)
|
|
89
|
+
finished << pending if pending
|
|
90
|
+
level, title = heading
|
|
91
|
+
stack.pop while stack.any? && stack.last[0] >= level
|
|
92
|
+
stack.push([level, title])
|
|
93
|
+
[finished, Pending.new(stack.map(&:last), []), stack]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_section(pending)
|
|
97
|
+
body = pending.lines.join.strip
|
|
98
|
+
return nil if pending.path.empty? && body.empty?
|
|
99
|
+
|
|
100
|
+
Section.new(pending.path, body)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Gemchat
|
|
9
|
+
# The embedder model: where it comes from, how it is fetched, and how it is
|
|
10
|
+
# verified.
|
|
11
|
+
#
|
|
12
|
+
# The plan originally specified `embeddinggemma-300M-Q8_0` as the default. That
|
|
13
|
+
# model is **gated: manual** on HuggingFace, so fetching it needs a license
|
|
14
|
+
# click and a personal token. An unattended `gemchat embed` cannot do that, and
|
|
15
|
+
# a default that fails for every user is not a default. `nomic-embed-text-v1.5`
|
|
16
|
+
# is ungated, Apache-2.0, 768-dimensional (matching gemchat_app's vectors, so
|
|
17
|
+
# the two agree on what a good retrieval looks like), and its Q8_0 build is
|
|
18
|
+
# 146MB rather than 300MB.
|
|
19
|
+
module Models
|
|
20
|
+
REGISTRY = {
|
|
21
|
+
"nomic-embed-text-v1.5" => {
|
|
22
|
+
file: "nomic-embed-text-v1.5.Q8_0.gguf",
|
|
23
|
+
url: "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q8_0.gguf",
|
|
24
|
+
# Measured, not assumed: the sha256 of the bytes this build actually
|
|
25
|
+
# serves. A pinned digest is what makes "checksum verification" in the
|
|
26
|
+
# plan mean something.
|
|
27
|
+
sha256: "3e24342164b3d94991ba9692fdc0dd08e3fd7362e0aacc396a9a5c54a544c3b7",
|
|
28
|
+
bytes: 146_146_432,
|
|
29
|
+
dimensions: 768,
|
|
30
|
+
license: "Apache-2.0",
|
|
31
|
+
# Nomic models are trained with task prefixes. Without them the query and
|
|
32
|
+
# document embeddings sit in slightly different regions and retrieval
|
|
33
|
+
# degrades: measured on this corpus, the correct hit's similarity rose
|
|
34
|
+
# from 0.515 to 0.580 once both prefixes were applied.
|
|
35
|
+
#
|
|
36
|
+
# Modelled per model rather than hardcoded, because a different default
|
|
37
|
+
# would have its own convention or none.
|
|
38
|
+
query_prefix: "search_query: ",
|
|
39
|
+
document_prefix: "search_document: "
|
|
40
|
+
}
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
DEFAULT = "nomic-embed-text-v1.5"
|
|
44
|
+
|
|
45
|
+
class Error < Gemchat::Error; end
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def default
|
|
50
|
+
ENV["GEMCHAT_EMBED_MODEL"] || DEFAULT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def spec(name = default)
|
|
54
|
+
REGISTRY.fetch(name) { raise Error, "unknown embedding model: #{name.inspect}" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dir
|
|
58
|
+
File.join(Gemchat.root, "models")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def path(name = default)
|
|
62
|
+
File.join(dir, spec(name)[:file])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Identifies vectors in the store. Versioned, because swapping the file
|
|
66
|
+
# behind the same name would silently mix incompatible vectors. The prefixes
|
|
67
|
+
# are part of the identity for the same reason: changing them changes what
|
|
68
|
+
# the vectors mean, so an existing index must be rebuilt, not extended.
|
|
69
|
+
def identity(name = default)
|
|
70
|
+
"#{name}@q8_0+prefixed"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def prefix(kind, name = default)
|
|
74
|
+
info = spec(name)
|
|
75
|
+
(kind.to_sym == :query) ? info[:query_prefix].to_s : info[:document_prefix].to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Size only, not the digest: SHA-256 over 146MB costs real time and this is
|
|
79
|
+
# called on every `status` and every `search`. `download` verifies the digest
|
|
80
|
+
# before the file is ever moved into place, so a wrong file cannot be
|
|
81
|
+
# installed by this path -- only by something writing there directly.
|
|
82
|
+
def installed?(name = default)
|
|
83
|
+
File.file?(path(name)) && size_of(name) == spec(name)[:bytes]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def size_of(name = default)
|
|
87
|
+
File.size(path(name))
|
|
88
|
+
rescue Errno::ENOENT
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Explains what to do when the model is absent. Returns true when it is
|
|
93
|
+
# there. This never blocks a download: `gemchat embed` downloads on demand,
|
|
94
|
+
# and the only thing standing between a missing model and a fetched one is
|
|
95
|
+
# this line, so anything that treats a false return as "give up" silently
|
|
96
|
+
# disables embedding forever.
|
|
97
|
+
def present(name = default)
|
|
98
|
+
return true if installed?(name)
|
|
99
|
+
|
|
100
|
+
warn <<~MSG
|
|
101
|
+
#{name} is not downloaded yet (#{megabytes(name)}MB).
|
|
102
|
+
|
|
103
|
+
gemchat embed # downloads it, then embeds the index
|
|
104
|
+
gemchat status # what is present
|
|
105
|
+
|
|
106
|
+
Until then, `gemchat search` uses BM25 only, which works offline with no
|
|
107
|
+
model at all. It finds exact identifiers well; it will not match a
|
|
108
|
+
question whose words never appear in the documentation.
|
|
109
|
+
MSG
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def megabytes(name = default)
|
|
114
|
+
(spec(name)[:bytes] / 1_000_000.0).round
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Streams to a .part file and renames on success, so an interrupted download
|
|
118
|
+
# can never leave a truncated file that later looks installed. Resumes with a
|
|
119
|
+
# Range request when a partial file is already there.
|
|
120
|
+
def download(name = default, stdout: $stdout, force: false)
|
|
121
|
+
info = spec(name)
|
|
122
|
+
target = path(name)
|
|
123
|
+
partial = "#{target}.part"
|
|
124
|
+
|
|
125
|
+
FileUtils.rm_f(target) if force && File.exist?(target)
|
|
126
|
+
return target if installed?(name)
|
|
127
|
+
|
|
128
|
+
FileUtils.mkdir_p(dir)
|
|
129
|
+
resume_from = File.exist?(partial) ? File.size(partial) : 0
|
|
130
|
+
if resume_from.positive? && resume_from >= info[:bytes]
|
|
131
|
+
FileUtils.rm_f(partial)
|
|
132
|
+
resume_from = 0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
uri = URI.parse(info[:url])
|
|
136
|
+
downloaded = resume_from
|
|
137
|
+
stream(uri, info, partial, resume_from) do |_chunk, total|
|
|
138
|
+
downloaded = total
|
|
139
|
+
report_progress(stdout, downloaded, info[:bytes])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unless File.size(partial) == info[:bytes]
|
|
143
|
+
raise Error, "incomplete download: got #{File.size(partial)} of #{info[:bytes]} bytes (kept #{File.basename(partial)} to resume)"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
actual = Digest::SHA256.file(partial).hexdigest
|
|
147
|
+
unless actual == info[:sha256]
|
|
148
|
+
FileUtils.rm_f(partial)
|
|
149
|
+
raise Error, "checksum mismatch for #{info[:file]}\n expected #{info[:sha256]}\n actual #{actual}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
FileUtils.mv(partial, target)
|
|
153
|
+
stdout.puts(" verified sha256 #{actual[0, 16]}…")
|
|
154
|
+
target
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Streams the body into `partial`, following redirects.
|
|
158
|
+
#
|
|
159
|
+
# Following them is not optional. HuggingFace answers a model URL with a 302
|
|
160
|
+
# to a CDN host, and Net::HTTP does not follow redirects, so a request that
|
|
161
|
+
# only accepts Net::HTTPSuccess fails on the very first response -- reported
|
|
162
|
+
# as "HTTP 302", which reads like a broken link rather than a normal,
|
|
163
|
+
# expected part of the fetch.
|
|
164
|
+
#
|
|
165
|
+
# The Authorization header is dropped once a redirect crosses to another
|
|
166
|
+
# host: the CDN does not need it, and resending a token to a host the user
|
|
167
|
+
# did not name is not something to do by accident.
|
|
168
|
+
def stream(uri, info, partial, resume_from, max_hops = 5)
|
|
169
|
+
File.open(partial, resume_from.positive? ? "ab" : "wb") do |file|
|
|
170
|
+
total = resume_from
|
|
171
|
+
|
|
172
|
+
loop do
|
|
173
|
+
request = Net::HTTP::Get.new(uri)
|
|
174
|
+
request["Range"] = "bytes=#{resume_from}-" if resume_from.positive?
|
|
175
|
+
request["Authorization"] = "Bearer #{ENV["HF_TOKEN"]}" if token?
|
|
176
|
+
|
|
177
|
+
redirect = nil
|
|
178
|
+
|
|
179
|
+
# A connection per hop, never one connection reused across a redirect.
|
|
180
|
+
# The redirect target is a different host, and replaying a request for
|
|
181
|
+
# a second host down the first host's TLS session is what turns a
|
|
182
|
+
# perfectly good signed CDN URL into a 403.
|
|
183
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
184
|
+
http.request(request) do |res|
|
|
185
|
+
if res.is_a?(Net::HTTPRedirection) && res["location"]
|
|
186
|
+
redirect = URI.join(uri, res["location"])
|
|
187
|
+
next
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
191
|
+
raise Error, "download failed: HTTP #{res.code} for #{info[:file]}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A server that ignores Range answers 200 with the whole file, so
|
|
195
|
+
# appending to an existing partial would corrupt it. Rewind instead.
|
|
196
|
+
if resume_from.positive? && res.code == "200"
|
|
197
|
+
file.truncate(0)
|
|
198
|
+
file.rewind
|
|
199
|
+
total = 0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
res.read_body do |chunk|
|
|
203
|
+
file.write(chunk)
|
|
204
|
+
total += chunk.bytesize
|
|
205
|
+
yield(chunk, total)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
break if redirect.nil?
|
|
211
|
+
|
|
212
|
+
max_hops -= 1
|
|
213
|
+
if max_hops <= 0
|
|
214
|
+
raise Error, "too many redirects fetching #{info[:file]}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
uri = redirect
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Only the origin gets the token. The CDN URL is already signed, and
|
|
223
|
+
# resending a bearer token to a host the user never named is not something
|
|
224
|
+
# to do by accident.
|
|
225
|
+
def token?
|
|
226
|
+
ENV["HF_TOKEN"] && !ENV["HF_TOKEN"].empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def report_progress(io, done, total)
|
|
230
|
+
return unless io.tty?
|
|
231
|
+
|
|
232
|
+
pct = (done * 100.0 / total).round
|
|
233
|
+
io.print("\r #{megabytes_of(done)}/#{megabytes_of(total)}MB #{pct}%")
|
|
234
|
+
io.flush
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def megabytes_of(bytes)
|
|
238
|
+
(bytes / 1_000_000.0).round(1)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|