git-fit 0.26.4 → 0.27.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 +4 -4
- data/lib/git_fit/cli/export.rb +1 -1
- data/lib/git_fit/config.rb +4 -0
- data/lib/git_fit/config_template.rb +12 -2
- data/lib/git_fit/llm/opencode_client.rb +116 -0
- data/lib/git_fit/llm.rb +11 -0
- data/lib/git_fit/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af3a8dd685e760e4fa8d6ff50c7629efa91a10a2de35e660e5dd6aabd66d20e3
|
|
4
|
+
data.tar.gz: 4a84005c2ddc719c6af4b1da341f7c678c44201f58209a742a55a04678d725d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8578432f12067d085466bd1f5e754ff235f067ae258d604e1f6b69dc33ade3fe750f05564d34d53e355e474e0ae9499e9e26816ae526107c229808cdb81f2339
|
|
7
|
+
data.tar.gz: 88b50c6f1f98539540fadd1bdd26e61a54be0a0d54c7228d9881eb441176f2cbe97ceaa12144de08083086921057f864d0e65fabdf377cc576cd0c2b515051bf
|
data/lib/git_fit/cli/export.rb
CHANGED
data/lib/git_fit/config.rb
CHANGED
|
@@ -25,6 +25,7 @@ module GitFit
|
|
|
25
25
|
},
|
|
26
26
|
'ai' => {
|
|
27
27
|
'enabled' => false,
|
|
28
|
+
'transport' => 'openai', # "openai" (HTTP, needs endpoint+key) | "opencode" (CLI, zero credentials)
|
|
28
29
|
'endpoint' => nil, # OpenAI-compatible base URL, e.g. https://open.bigmodel.cn/api/paas/v4
|
|
29
30
|
'model' => nil,
|
|
30
31
|
'api_key_env' => 'AI_API_KEY',
|
|
@@ -127,6 +128,9 @@ module GitFit
|
|
|
127
128
|
if ENV['GIT_FIT_AI_ENABLED'] && !ENV['GIT_FIT_AI_ENABLED'].empty?
|
|
128
129
|
@data['ai']['enabled'] = typed_value(ENV['GIT_FIT_AI_ENABLED'])
|
|
129
130
|
end
|
|
131
|
+
if ENV['GIT_FIT_AI_TRANSPORT'] && !ENV['GIT_FIT_AI_TRANSPORT'].empty?
|
|
132
|
+
@data['ai']['transport'] = ENV['GIT_FIT_AI_TRANSPORT']
|
|
133
|
+
end
|
|
130
134
|
if ENV['GIT_FIT_AI_ENDPOINT'] && !ENV['GIT_FIT_AI_ENDPOINT'].empty?
|
|
131
135
|
@data['ai']['endpoint'] = ENV['GIT_FIT_AI_ENDPOINT']
|
|
132
136
|
end
|
|
@@ -69,8 +69,18 @@ module GitFit
|
|
|
69
69
|
# csv:
|
|
70
70
|
# path: site/workouts.csv
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
# gpx:
|
|
73
|
+
# path: site/gpx_out
|
|
74
|
+
|
|
75
|
+
# ai: # env: GIT_FIT_AI_ENABLED
|
|
76
|
+
# enabled: false
|
|
77
|
+
# transport: openai # env: GIT_FIT_AI_TRANSPORT
|
|
78
|
+
# # openai = OpenAI-compatible HTTP (needs endpoint + api key)
|
|
79
|
+
# # opencode = shell out to `opencode run` CLI (zero credentials,
|
|
80
|
+
# # built-in anonymous token, free models like opencode/big-pickle)
|
|
81
|
+
# endpoint: "" # env: GIT_FIT_AI_ENDPOINT (openai transport only)
|
|
82
|
+
# model: "" # env: GIT_FIT_AI_MODEL
|
|
83
|
+
# api_key_env: AI_API_KEY # env: GIT_FIT_AI_API_KEY_ENV (openai transport only)
|
|
74
84
|
|
|
75
85
|
privacy:
|
|
76
86
|
start_end_range: 200
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module GitFit
|
|
8
|
+
module LLM
|
|
9
|
+
# opencode CLI transport: shells out to `opencode run`, which carries the
|
|
10
|
+
# built-in anonymous device token — zero-credential access to free models
|
|
11
|
+
# (opencode zen free tier rejects direct HTTP callers outside the CLI).
|
|
12
|
+
#
|
|
13
|
+
# Same interface as LLM::Client (chat(messages:) -> String). git-fit keeps
|
|
14
|
+
# all generation logic; opencode is a transport only.
|
|
15
|
+
class OpencodeClient
|
|
16
|
+
attr_reader :model, :timeout
|
|
17
|
+
|
|
18
|
+
# Returns nil when ai.enabled is false (caller decides to warn+skip).
|
|
19
|
+
# No endpoint/api_key validation — the CLI needs no credentials.
|
|
20
|
+
def self.from_config(config)
|
|
21
|
+
ai = config.ai_config
|
|
22
|
+
return nil unless ai['enabled']
|
|
23
|
+
|
|
24
|
+
if ai['model'].to_s.strip.empty?
|
|
25
|
+
raise ConfigError,
|
|
26
|
+
"LLM: ai.model not configured\n" \
|
|
27
|
+
' Fix: set ai.model in config.yml ' \
|
|
28
|
+
'(e.g. opencode/big-pickle, opencode/mimo-v2.6-flash-free)'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
new(model: ai['model'], timeout: ai['timeout'])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(model:, timeout: 60)
|
|
35
|
+
@model = model
|
|
36
|
+
@timeout = timeout.is_a?(Numeric) ? timeout : 60
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# messages: [{ 'role' => 'system'|'user', 'content' => '...' }, ...]
|
|
40
|
+
# Merged into a single stdin prompt (system + blank line + user).
|
|
41
|
+
# temperature is ignored — opencode run has no such flag.
|
|
42
|
+
def chat(messages:, temperature: nil)
|
|
43
|
+
_temperature = temperature # deliberately ignored — opencode run has no such flag
|
|
44
|
+
prompt = build_prompt(messages)
|
|
45
|
+
status, stdout, stderr = run_opencode(prompt, args)
|
|
46
|
+
extract_text(status, stdout, stderr)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_prompt(messages)
|
|
52
|
+
messages.map { |m| m['content'].to_s }.reject(&:empty?).join("\n\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def args
|
|
56
|
+
['run', '--format', 'json', '--pure', '-m', @model]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Process seam — specs stub this method (zero process, zero network),
|
|
60
|
+
# mirroring the http_post stub convention of LLM::Client.
|
|
61
|
+
# Returns [status, stdout, stderr].
|
|
62
|
+
def run_opencode(stdin_data, cmd_args)
|
|
63
|
+
Dir.mktmpdir do |dir|
|
|
64
|
+
io = { out: nil, err: nil, status: nil }
|
|
65
|
+
Open3.popen3('opencode', *cmd_args, chdir: dir) do |stdin, stdout, stderr, wait_thr|
|
|
66
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
67
|
+
watchdog = Thread.new do
|
|
68
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
69
|
+
next sleep(remaining) if remaining.positive?
|
|
70
|
+
next if wait_thr.dead?
|
|
71
|
+
Process.kill('KILL', wait_thr.pid)
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
begin
|
|
76
|
+
stdin.write(stdin_data)
|
|
77
|
+
stdin.close
|
|
78
|
+
io[:out] = stdout.read
|
|
79
|
+
io[:err] = stderr.read
|
|
80
|
+
status = wait_thr.value
|
|
81
|
+
raise Timeout::Error if status.signaled? && status.termsig == Signal.list['KILL']
|
|
82
|
+
io[:status] = status.exitstatus
|
|
83
|
+
ensure
|
|
84
|
+
watchdog.kill
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
[io[:status], io[:out] || '', io[:err] || '']
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def extract_text(status, stdout, stderr)
|
|
92
|
+
tail = stderr.to_s[-200..] || stderr.to_s
|
|
93
|
+
if status.nil? || status != 0
|
|
94
|
+
raise ServerError,
|
|
95
|
+
"LLM: opencode run failed (exit #{status}) — #{tail}\n" \
|
|
96
|
+
' Fix: verify opencode is installed and runnable: opencode --version'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
lines = stdout.to_s.split("\n").filter_map do |line|
|
|
100
|
+
JSON.parse(line)
|
|
101
|
+
rescue JSON::ParserError, TypeError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
texts = lines.select { |ev| ev['type'] == 'text' }
|
|
105
|
+
text = texts.last&.dig('part', 'text')
|
|
106
|
+
return text unless text.to_s.strip.empty?
|
|
107
|
+
|
|
108
|
+
raise BadResponseError,
|
|
109
|
+
"LLM: opencode stdout has no text event (#{lines.size} JSON lines)" \
|
|
110
|
+
" — #{tail}\n" \
|
|
111
|
+
' Fix: retry; if persistent, run the prompt manually: ' \
|
|
112
|
+
'printf <prompt> | opencode run --format json --pure -m ' + @model
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/git_fit/llm.rb
CHANGED
|
@@ -8,8 +8,19 @@ require_relative 'llm/errors'
|
|
|
8
8
|
require_relative 'llm/template'
|
|
9
9
|
require_relative 'llm/prompts'
|
|
10
10
|
require_relative 'llm/client'
|
|
11
|
+
require_relative 'llm/opencode_client'
|
|
11
12
|
|
|
12
13
|
module GitFit
|
|
13
14
|
module LLM
|
|
15
|
+
# Transport selection (ai.transport): "opencode" shells out to the opencode
|
|
16
|
+
# CLI (zero credentials, free models inside the opencode client identity);
|
|
17
|
+
# anything else (default "openai") uses the OpenAI-compatible HTTP client.
|
|
18
|
+
def self.build(config)
|
|
19
|
+
if config.ai_config['transport'].to_s.strip == 'opencode'
|
|
20
|
+
OpencodeClient.from_config(config)
|
|
21
|
+
else
|
|
22
|
+
Client.from_config(config)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
14
25
|
end
|
|
15
26
|
end
|
data/lib/git_fit/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-fit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.27.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lax
|
|
@@ -350,6 +350,7 @@ files:
|
|
|
350
350
|
- lib/git_fit/llm.rb
|
|
351
351
|
- lib/git_fit/llm/client.rb
|
|
352
352
|
- lib/git_fit/llm/errors.rb
|
|
353
|
+
- lib/git_fit/llm/opencode_client.rb
|
|
353
354
|
- lib/git_fit/llm/prompts.rb
|
|
354
355
|
- lib/git_fit/llm/template.rb
|
|
355
356
|
- lib/git_fit/mcp.rb
|