ask-coding-harness 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.
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Ask
8
+ module CodingHarness
9
+ # HTTP API + static file server for the coding harness.
10
+ #
11
+ # Endpoints:
12
+ # GET /api/config — models, adapter, workspace, features
13
+ # GET /api/workspace — workspace name, root, git branch
14
+ # GET /api/conversations — list conversations (archived filter)
15
+ # GET /api/conversations/:id — conversation with messages
16
+ # PATCH /api/conversations/:id — rename
17
+ # DELETE /api/conversations/:id — delete
18
+ # POST /api/conversations/:id/archive — toggle archive
19
+ # PATCH/DELETE /api/conversations/:id/messages/:index — edit/trim
20
+ # POST /api/chat — send a message, stream events via SSE
21
+ # POST /api/conversations/:id/approvals/:actionId/approve|reject
22
+ # POST /api/conversations/:id/approvals/approve-all
23
+ # POST /api/conversations/:id/plan/approve|reject
24
+ # POST /api/conversations/:id/abort
25
+ # GET /* — static frontend (PWA), SPA fallback
26
+ class Server < Roda
27
+ # Built frontend (PWA). Shipped inside the gem.
28
+ def self.public_dir
29
+ File.expand_path("../../../public", __dir__)
30
+ end
31
+
32
+ plugin :default_headers, {
33
+ "Access-Control-Allow-Origin" => "*",
34
+ "Access-Control-Allow-Methods" => "GET, POST, PATCH, DELETE, OPTIONS",
35
+ "Access-Control-Allow-Headers" => "Content-Type"
36
+ }
37
+ plugin :error_handler do |e|
38
+ if env["PATH_INFO"]&.start_with?("/api/")
39
+ response.status = 500
40
+ { error: e.message }.to_json
41
+ else
42
+ raise e
43
+ end
44
+ end
45
+ plugin :public, root: public_dir
46
+ plugin :streaming
47
+
48
+ # Build a server instance wired to the given components. Tests build
49
+ # servers with fakes; the CLI builds with the real store + runner.
50
+ #
51
+ # @param config [Config]
52
+ # @param store [Store]
53
+ # @param runner [AgentRunner]
54
+ def self.build(config:, store:, runner:)
55
+ Class.new(self) do
56
+ define_method(:harness_config) { config }
57
+ define_method(:harness_store) { store }
58
+ define_method(:harness_runner) { runner }
59
+ end
60
+ end
61
+
62
+ route do |r|
63
+ # CORS preflight
64
+ if r.request_method == "OPTIONS"
65
+ response.status = 204
66
+ next
67
+ end
68
+
69
+ r.on "api" do
70
+ # GET /api/config — models, adapter, workspace, features
71
+ r.get "config" do
72
+ cfg = harness_config
73
+ {
74
+ models: models,
75
+ defaultModel: cfg.model,
76
+ currentAdapter: cfg.adapter,
77
+ workspace: workspace_info,
78
+ features: {
79
+ approvals: cfg.approvals?,
80
+ planMode: cfg.plan_mode,
81
+ todos: cfg.todos
82
+ }
83
+ }.to_json
84
+ end
85
+
86
+ # GET /api/workspace — name, root, git branch
87
+ r.get "workspace" do
88
+ workspace_info.to_json
89
+ end
90
+
91
+ # POST /api/chat — streaming turn
92
+ r.post "chat" do
93
+ body = JSON.parse(r.body.read)
94
+ input = body["message"].to_s.strip
95
+ conversation_id = body["conversation_id"]
96
+ model = body["model"]
97
+
98
+ if input.empty?
99
+ response.status = 400
100
+ next { error: "message is required" }.to_json
101
+ end
102
+
103
+ store = harness_store
104
+ existing = conversation_id && store.load(conversation_id)
105
+ conversation = existing || store.build(directory: harness_config.workspace)
106
+ new_conversation = existing.nil?
107
+ conversation = store.save(conversation) if new_conversation
108
+
109
+ response.headers["Content-Type"] = "text/event-stream"
110
+ response.headers["Cache-Control"] = "no-cache"
111
+ response.headers["Connection"] = "keep-alive"
112
+
113
+ stream do |out|
114
+ out << "event: conversation.created\ndata: #{conversation["id"]}\n\n" if new_conversation
115
+
116
+ out << "event: turn.started\ndata: {}\n\n"
117
+ harness_runner.start_turn(conversation, input, model: model) do |ev|
118
+ begin
119
+ out << "event: #{ev[:type]}\ndata: #{JSON.generate(ev[:data])}\n\n"
120
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
121
+ # Client disconnected — the turn keeps running and the
122
+ # outcome is persisted by the runner.
123
+ end
124
+ end.join
125
+ end
126
+ end
127
+
128
+ # GET /api/conversations
129
+ r.get "conversations" do
130
+ harness_store.list(archived: r.params["archived"] == "true").to_json
131
+ end
132
+
133
+ r.on "conversations", String do |id|
134
+ store = harness_store
135
+
136
+ # GET /api/conversations/:id
137
+ r.get do
138
+ conv = store.load(id)
139
+ if conv
140
+ conv.to_json
141
+ else
142
+ response.status = 404
143
+ { error: "Conversation not found" }.to_json
144
+ end
145
+ end
146
+
147
+ # PATCH /api/conversations/:id — rename
148
+ r.is method: :patch do
149
+ body = JSON.parse(r.body.read)
150
+ title = body["title"].to_s.strip
151
+ if title.empty?
152
+ response.status = 400
153
+ next { error: "title is required" }.to_json
154
+ end
155
+ conv = store.load(id)
156
+ unless conv
157
+ response.status = 404
158
+ next { error: "Conversation not found" }.to_json
159
+ end
160
+ conv["title"] = title
161
+ store.save(conv)
162
+ { id: id, title: title }.to_json
163
+ end
164
+
165
+ # DELETE /api/conversations/:id
166
+ r.is method: :delete do
167
+ store.delete(id)
168
+ { deleted: true }.to_json
169
+ end
170
+
171
+ # POST /api/conversations/:id/archive
172
+ r.post "archive" do
173
+ conv = store.load(id)
174
+ unless conv
175
+ response.status = 404
176
+ next { error: "Conversation not found" }.to_json
177
+ end
178
+ conv["archived"] = !conv["archived"]
179
+ store.save(conv)
180
+ { id: id, archived: conv["archived"] }.to_json
181
+ end
182
+
183
+ # POST /api/conversations/:id/abort
184
+ r.post "abort" do
185
+ harness_runner.abort(id)
186
+ { aborted: true }.to_json
187
+ end
188
+
189
+ # ── Approvals ──
190
+ r.on "approvals" do
191
+ # POST /api/conversations/:id/approvals/approve-all
192
+ r.post "approve-all" do
193
+ harness_runner.approve_all(id)
194
+ { approved: true }.to_json
195
+ end
196
+
197
+ r.on Integer do |action_id|
198
+ r.post "approve" do
199
+ harness_runner.approve(id, action_id)
200
+ { approved: true }.to_json
201
+ end
202
+ r.post "reject" do
203
+ harness_runner.reject(id, action_id)
204
+ { rejected: true }.to_json
205
+ end
206
+ end
207
+ end
208
+
209
+ # ── Plan ──
210
+ r.on "plan" do
211
+ r.post "approve" do
212
+ harness_runner.approve_plan(id)
213
+ { approved: true }.to_json
214
+ end
215
+ r.post "reject" do
216
+ harness_runner.reject_plan(id)
217
+ { rejected: true }.to_json
218
+ end
219
+ end
220
+
221
+ # ── Messages ──
222
+ r.on "messages", Integer do |index|
223
+ # PATCH /api/conversations/:id/messages/:index — edit a user message
224
+ r.on method: :patch do
225
+ body = JSON.parse(r.body.read)
226
+ content = body["content"].to_s.strip
227
+ if content.empty?
228
+ response.status = 400
229
+ next { error: "content is required" }.to_json
230
+ end
231
+ conv = store.load(id)
232
+ unless conv
233
+ response.status = 404
234
+ next { error: "Conversation not found" }.to_json
235
+ end
236
+ msg = conv["messages"][index]
237
+ unless msg
238
+ response.status = 404
239
+ next { error: "Message not found" }.to_json
240
+ end
241
+ if msg["role"] != "user"
242
+ response.status = 400
243
+ next { error: "Only user messages can be edited" }.to_json
244
+ end
245
+ msg["content"] = content
246
+ store.save(conv)
247
+ conv.to_json
248
+ end
249
+
250
+ # DELETE /api/conversations/:id/messages/:index — trim from here
251
+ r.on method: :delete do
252
+ conv = store.load(id)
253
+ unless conv
254
+ response.status = 404
255
+ next { error: "Conversation not found" }.to_json
256
+ end
257
+ if index >= conv["messages"].length
258
+ response.status = 404
259
+ next { error: "Message not found" }.to_json
260
+ end
261
+ conv["messages"] = conv["messages"][0...index]
262
+ store.save(conv)
263
+ conv.to_json
264
+ end
265
+ end
266
+ end
267
+ end
268
+
269
+ # ── Static files (PWA frontend) ──
270
+ r.public
271
+ # SPA fallback: serve index.html for unmatched GET requests
272
+ r.get do
273
+ file = File.join(self.class.public_dir, "index.html")
274
+ if File.exist?(file)
275
+ response["Content-Type"] = "text/html"
276
+ File.read(file)
277
+ end
278
+ end
279
+ end
280
+
281
+ private
282
+
283
+ def harness_config
284
+ raise NotImplementedError, "built via Server.build"
285
+ end
286
+
287
+ def harness_store
288
+ raise NotImplementedError, "built via Server.build"
289
+ end
290
+
291
+ def harness_runner
292
+ raise NotImplementedError, "built via Server.build"
293
+ end
294
+
295
+ def workspace_info
296
+ cfg = harness_config
297
+ root = cfg.workspace
298
+ branch = nil
299
+ head = File.join(root, ".git", "HEAD")
300
+ if File.file?(head)
301
+ ref = File.read(head).strip
302
+ branch = ref.split("/").last if ref.start_with?("ref:")
303
+ end
304
+ { name: File.basename(root), root: root, gitBranch: branch }
305
+ rescue SystemCallError
306
+ { name: File.basename(root), root: root, gitBranch: nil }
307
+ end
308
+
309
+ def models
310
+ env_models = ENV["ACH_MODELS"]
311
+ if env_models&.length&.positive?
312
+ env_models.split(",").map(&:strip)
313
+ else
314
+ ["deepseek-v4-flash", "claude-sonnet-4", "gpt-4o", "o3-mini"]
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "fileutils"
5
+
6
+ module Ask
7
+ module CodingHarness
8
+ # Persistence for conversations, backed by ask-state-providers (SQLite).
9
+ #
10
+ # A conversation is a plain Hash with string keys:
11
+ # id, title, directory, archived, messages, created_at, updated_at
12
+ #
13
+ # Messages are { "role" => "user"|"assistant", "content" => String,
14
+ # "created_at" => String } hashes. The store is the single owner of
15
+ # conversation records; the server and runner only read/write through it.
16
+ class Store
17
+ CONVERSATIONS_KEY = "__conversations__"
18
+ MAX_CONVERSATIONS = 500
19
+
20
+ # @param db_path [String] path to the SQLite database file
21
+ def initialize(db_path:)
22
+ @db_path = db_path
23
+ dir = File.dirname(db_path)
24
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
25
+ end
26
+
27
+ # A fresh conversation record. Not persisted until {#save}.
28
+ def build(directory: nil, title: "New conversation")
29
+ now = Time.now.iso8601
30
+ {
31
+ "id" => SecureRandom.uuid,
32
+ "title" => title,
33
+ "directory" => directory,
34
+ "archived" => false,
35
+ "messages" => [],
36
+ "created_at" => now,
37
+ "updated_at" => now
38
+ }
39
+ end
40
+
41
+ # Persist a conversation, bumping updated_at and guessing a title when
42
+ # the conversation is still untitled and has messages.
43
+ def save(conv)
44
+ conv["updated_at"] = Time.now.iso8601
45
+ if conv["title"] == "New conversation" && conv["messages"].length >= 1
46
+ conv["title"] = guess_title(conv["messages"])
47
+ end
48
+ db.set("conv:#{conv["id"]}", conv)
49
+ index_conversation(conv["id"])
50
+ conv
51
+ end
52
+
53
+ # Load a conversation by id, or nil when missing.
54
+ def load(id)
55
+ data = db.get("conv:#{id}")
56
+ data&.transform_keys(&:to_s)
57
+ end
58
+
59
+ def delete(id)
60
+ db.delete("conv:#{id}")
61
+ db.list_remove(CONVERSATIONS_KEY, id)
62
+ end
63
+
64
+ # Conversation summaries (no messages), sorted by most recent first.
65
+ def list(archived: false)
66
+ ids = db.list_range(CONVERSATIONS_KEY, 0, -1)
67
+ summaries = ids.filter_map do |id|
68
+ data = db.get("conv:#{id}")
69
+ next unless data
70
+ next if data["archived"] == true && !archived
71
+ summary(data)
72
+ end
73
+ summaries.sort_by { |c| c["updated_at"].to_s }.reverse
74
+ end
75
+
76
+ # Summaries for one workspace directory, most recent first.
77
+ def list_for_directory(directory, archived: false)
78
+ list(archived: archived).select { |c| c["directory"] == directory }
79
+ end
80
+
81
+ # Workspace directories that have conversations, with counts.
82
+ def projects(archived: false)
83
+ list(archived: archived).each_with_object(Hash.new(0)) do |c, counts|
84
+ dir = c["directory"]
85
+ counts[dir] += 1 if dir
86
+ end.map { |dir, count| { "directory" => dir, "name" => File.basename(dir), "conversation_count" => count } }
87
+ end
88
+
89
+ def close
90
+ @db&.close
91
+ @db = nil
92
+ end
93
+
94
+ private
95
+
96
+ def db
97
+ @db ||= Ask::State::Providers::SQLite.new(path: @db_path)
98
+ end
99
+
100
+ def summary(data)
101
+ {
102
+ "id" => data["id"],
103
+ "title" => data["title"] || "New conversation",
104
+ "directory" => data["directory"],
105
+ "archived" => data["archived"] == true,
106
+ "message_count" => data["messages"]&.length || 0,
107
+ "created_at" => data["created_at"],
108
+ "updated_at" => data["updated_at"]
109
+ }
110
+ end
111
+
112
+ def guess_title(messages)
113
+ first = messages.find { |m| m["role"] == "user" }
114
+ text = (first && first["content"]).to_s
115
+ text.length > 40 ? text[0, 40] + "…" : text
116
+ end
117
+
118
+ # Append to the conversation index, skipping duplicates (saves happen
119
+ # on every message).
120
+ def index_conversation(id)
121
+ existing = db.list_range(CONVERSATIONS_KEY, 0, -1)
122
+ db.list_append(CONVERSATIONS_KEY, id, max_length: MAX_CONVERSATIONS) unless existing.include?(id)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module CodingHarness
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask/coding_harness/version"
4
+
5
+ module Ask
6
+ # Ask Coding Harness — a general-purpose coding agent in the browser.
7
+ #
8
+ # Self-hosted web coding agent for the ask-rb ecosystem. Runs
9
+ # Ask::Agent::Session against any workspace, streams every event to a
10
+ # mobile-first PWA over SSE, and comes with an `ach` utility CLI for
11
+ # headless runs.
12
+ #
13
+ # Ask::CodingHarness.configure do |c|
14
+ # c.workspace = "/path/to/project"
15
+ # c.model = "deepseek-v4-flash"
16
+ # end
17
+ #
18
+ # Ask::CodingHarness.run_server
19
+ module CodingHarness
20
+ class << self
21
+ # Global configuration. See Config for available settings.
22
+ def config
23
+ @config ||= Config.new
24
+ end
25
+
26
+ # Configure the harness. Yields the global Config instance.
27
+ def configure
28
+ yield config if block_given?
29
+ config
30
+ end
31
+
32
+ # Run the web server (blocking). See CLI for the non-blocking path.
33
+ def run_server(host: nil, port: nil)
34
+ require_relative "ask/coding_harness/server"
35
+ require "rackup"
36
+
37
+ store = Store.new(db_path: config.db_path)
38
+ runner = AgentRunner.new(config: config, store: store)
39
+ app = Server.build(config: config, store: store, runner: runner).freeze.app
40
+ Rackup::Server.start(app: app, Host: host || config.host, Port: port || config.port)
41
+ end
42
+
43
+ # Run a prompt headlessly against the workspace and return the result.
44
+ #
45
+ # @param prompt [String] the task to run
46
+ # @param workspace [String, nil] overrides config.workspace
47
+ # @return [Ask::CodingHarness::Runner::Result]
48
+ def run(prompt, workspace: nil, **opts)
49
+ require_relative "ask/coding_harness/runner"
50
+ Runner.new(config: config).run(prompt, workspace: workspace, **opts)
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ require "ask-tools-shell"
57
+ require "ask-state-providers"
58
+ require "ask/coding_harness/config"
59
+ require "ask/coding_harness/store"
60
+ require "ask/coding_harness/event_translator"
61
+ require "ask/coding_harness/agent_runner"
62
+ require "ask/coding_harness/runner"