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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3cea1445cb1e525a1be8c8b814b7b3f69a27aac4e4e42411b5eb993227fef373
4
- data.tar.gz: f672f8bfbded7feb9484dc8a214b945338c1a7c3136c1e80f539afe66bb1db0e
3
+ metadata.gz: af3a8dd685e760e4fa8d6ff50c7629efa91a10a2de35e660e5dd6aabd66d20e3
4
+ data.tar.gz: 4a84005c2ddc719c6af4b1da341f7c678c44201f58209a742a55a04678d725d1
5
5
  SHA512:
6
- metadata.gz: e051bd7d24a99544a65b6f537bfb5483980cc399a1977310383f5f3a6ed8c4381bfe3a6811b0de3dc359571f9128b093379b4cc8d4ad923b8252190c7a738fce
7
- data.tar.gz: 493fa21dffeb7d8aed1039e3aad0eaee0c3a4c4774129fbedf4293962923fcd456715b900b21dd64e1eee8e9167660bddffc968bb99aa0d8628c637215ed4a14
6
+ metadata.gz: 8578432f12067d085466bd1f5e754ff235f067ae258d604e1f6b69dc33ade3fe750f05564d34d53e355e474e0ae9499e9e26816ae526107c229808cdb81f2339
7
+ data.tar.gz: 88b50c6f1f98539540fadd1bdd26e61a54be0a0d54c7228d9881eb441176f2cbe97ceaa12144de08083086921057f864d0e65fabdf377cc576cd0c2b515051bf
@@ -91,7 +91,7 @@ module GitFit
91
91
  :yellow
92
92
  return
93
93
  end
94
- client = GitFit::LLM::Client.from_config(config)
94
+ client = GitFit::LLM.build(config)
95
95
  return if client.nil? # unreachable: disabled handled above
96
96
 
97
97
  conn = GitFit::DB::Connection.new(config.db_path)
@@ -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
- # gpx:
73
- # path: site/gpx_out
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GitFit
4
- VERSION = '0.26.4'
4
+ VERSION = '0.27.0'
5
5
  end
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.26.4
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