ask-coding-harness 0.1.0 → 0.2.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: bb36f1a55f6b263fdbffd97383c1a744d9b4864a74d04d3003270134437bc3fd
4
- data.tar.gz: f2a6fd12ffb36d5fff6baf1d59151c913dd5596db22a5d6abd366494d57be328
3
+ metadata.gz: 3fef50d92fb06108330d8976a625372fb12ec8edca50ea5e777317da3e36e2b4
4
+ data.tar.gz: e8da881f33b685ca384653d9d5fcdc825f2cf954aa117b14e544d3c39f247a54
5
5
  SHA512:
6
- metadata.gz: 37ba3d010ae2ca1f044e919b2e82f3fc55e82bf96d792c65896e5e4141a225cecbcdc8e82c07d06eca24b12089d4e624b679b2a8fc5bd42d149b7e3047e37333
7
- data.tar.gz: 3cd3c97b80c739cd6b9a6182241083856d514a2aee9f1ee6c8758f48671847377efe19f67a3a232c4eb412c725b51b3b3acd0b9f2df59381177bdf21e211643b
6
+ metadata.gz: cd3e2adfef79ac48479349187ea5f1f10ee3a6a2f041235a3466f021ce25c3d1f0ac7b23145d42895a09fb5a9c7694246fee98bb73474ad8c57c9b965269fae6
7
+ data.tar.gz: a9ca377914655989f1524cc06a46380b046b6d0a9e22b53bb8a7575066f2fb3375a436574d30877e37b72e7b8f0c2df5256e905dbeeabf11f35141553a16ab4c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## [0.2.0] - 2026-08-10
4
4
 
5
+ ### Added
6
+
7
+ - **Universal workspaces.** The harness is no longer bound to one
8
+ directory: open any project from the UI (workspace switcher + Open
9
+ workspace dialog), switch between them, and every conversation runs
10
+ inside its own workspace with its own system prompt.
11
+ - `POST /api/workspaces` (open/register), `GET /api/workspaces`
12
+ (list with name/branch/counts), `GET /api/workspaces/:path/info`.
13
+ - `POST /api/chat` accepts a `workspace` param; conversations are
14
+ created and grouped per workspace.
15
+ - Turns execute inside their workspace directory (serialized via a
16
+ turn mutex, since the shell tools default to `Dir.pwd`).
17
+ - **Pi-style system prompts.** `SystemPrompt` builds a composable prompt:
18
+ default or custom base, guidelines, `<project_context>` from
19
+ AGENTS.md/CLAUDE.md (walked from the workspace up to the root, like the
20
+ pi coding agent), an append section, and a `Current working directory:`
21
+ footer. Config: `system_prompt`, `system_prompt_append`,
22
+ `system_prompt_guidelines` (env `ACH_SYSTEM_PROMPT` still appends).
23
+ - **ask-ui-kit sidebar/shell components** extracted from the popular
24
+ coding agents' UIs (openchamber, openwebui, t3code): `ask-dialog`,
25
+ `ask-menu`, `ask-search-input`, `ask-conversation-item`,
26
+ `ask-conversation-group`. The harness frontend now builds its switcher,
27
+ sidebar (search + grouped conversation items), and dialogs on them.
28
+
29
+ ### Fixed
30
+
31
+ - `DemoAdapter#create_session` accepts the `system_prompt` keyword (the
32
+ universal runner passes it to every adapter).
33
+
34
+
5
35
  ### Added
6
36
 
7
37
  - **Demo adapter and `ach demo`** — a scripted coding agent (todos, tool
data/README.md CHANGED
@@ -64,7 +64,7 @@ ach version
64
64
  | `ACH_PLAN_MODE` | off | plan mode (research first, then execute) |
65
65
  | `ACH_TODOS` | on | todo list tool |
66
66
  | `ACH_DB_PATH` | `./data/ask-coding-harness.db` | conversation database |
67
- | `ACH_SYSTEM_PROMPT` | — | extra system prompt lines |
67
+ | `ACH_SYSTEM_PROMPT` | — | extra system prompt lines (append section) |
68
68
 
69
69
  Programmatic use:
70
70
 
@@ -41,6 +41,7 @@ module Ask
41
41
  @adapter = nil
42
42
  @mutex = Mutex.new
43
43
  @adapter_mutex = Mutex.new
44
+ @turn_mutex = Mutex.new
44
45
  @sessions = {}
45
46
  @turns = {}
46
47
  end
@@ -127,7 +128,8 @@ module Ask
127
128
  private
128
129
 
129
130
  def run_turn(conversation, prompt, model: nil, &on_event)
130
- sid = session_for(conversation, model: model)
131
+ workspace = conversation["directory"] || @config.workspace
132
+ sid = session_for(conversation, workspace, model: model)
131
133
 
132
134
  conv = @store.load(conversation["id"]) || conversation
133
135
  conv["messages"] << { "role" => "user", "content" => prompt, "created_at" => Time.now.iso8601 }
@@ -135,24 +137,37 @@ module Ask
135
137
 
136
138
  outcome = :completed
137
139
  accumulated = +""
138
- adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
139
- translated = @translator.translate(event)
140
- next unless translated
141
-
142
- case translated[:type]
143
- when "message.delta"
144
- accumulated << translated[:data][:delta].to_s
145
- when "turn.failed"
146
- outcome = :failed
147
- when "turn.aborted"
148
- outcome = :aborted
140
+ with_workspace(workspace) do
141
+ adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
142
+ translated = @translator.translate(event)
143
+ next unless translated
144
+
145
+ case translated[:type]
146
+ when "message.delta"
147
+ accumulated << translated[:data][:delta].to_s
148
+ when "turn.failed"
149
+ outcome = :failed
150
+ when "turn.aborted"
151
+ outcome = :aborted
152
+ end
153
+ emit(on_event, translated)
149
154
  end
150
- emit(on_event, translated)
151
155
  end
152
156
 
153
157
  persist_outcome(conversation["id"], accumulated, outcome)
154
158
  end
155
159
 
160
+ # Run the block with the workspace as the process working directory.
161
+ # The shell tools default to Dir.pwd, so the turn must execute inside
162
+ # its workspace; a turn mutex keeps concurrent workspaces from racing
163
+ # on the process-global directory. Self-hosted single-user: turns
164
+ # serialize, which is both safe and simple.
165
+ def with_workspace(workspace)
166
+ @turn_mutex.synchronize do
167
+ Dir.chdir(workspace) { yield }
168
+ end
169
+ end
170
+
156
171
  # Persist the assistant turn outcome (response, error, or abort) so
157
172
  # the conversation reads correctly when reopened. Best-effort; the
158
173
  # streamed events are the source of truth for the live view.
@@ -175,16 +190,31 @@ module Ask
175
190
  nil
176
191
  end
177
192
 
178
- # One adapter session per conversation, created on first use.
179
- def session_for(conversation, model: nil)
193
+ # One adapter session per conversation, created on first use. Each
194
+ # session gets its workspace's system prompt (pi-style: default or
195
+ # custom base + guidelines + AGENTS.md/CLAUDE.md project context).
196
+ def session_for(conversation, workspace = nil, model: nil)
197
+ workspace ||= conversation["directory"] || @config.workspace
180
198
  @mutex.synchronize do
181
199
  @sessions[conversation["id"]] ||= adapter.create_session(
182
- conversation["directory"] || @config.workspace,
183
- model: model || @config.model
200
+ workspace,
201
+ model: model || @config.model,
202
+ system_prompt: system_prompt_for(workspace)
184
203
  )
185
204
  end
186
205
  end
187
206
 
207
+ # The system prompt for a workspace. Adapters that don't accept a
208
+ # per-session prompt (external ACP agents) ignore the extra keyword.
209
+ def system_prompt_for(workspace)
210
+ Ask::CodingHarness::SystemPrompt.build(
211
+ workspace: workspace,
212
+ custom: @config.system_prompt,
213
+ append: @config.system_prompt_append,
214
+ guidelines: @config.system_prompt_guidelines
215
+ )
216
+ end
217
+
188
218
  # Dispatch a control to the adapter, returning nil when the adapter
189
219
  # doesn't support it (e.g. external ACP agents without approvals).
190
220
  def send_adapter(conversation_id, method_name, *args)
@@ -217,8 +247,7 @@ module Ask
217
247
  approval: @config.approval,
218
248
  approval_required: @config.approval_policy_tools,
219
249
  plan_mode: @config.plan_mode,
220
- todos: @config.todos,
221
- system_prompt: default_system_prompt
250
+ todos: @config.todos
222
251
  )
223
252
  adapter.start
224
253
  # Tools operate relative to the process working directory; the
@@ -247,14 +276,6 @@ module Ask
247
276
  end
248
277
  end
249
278
 
250
- def default_system_prompt
251
- base = "You are the coding agent for the workspace \"#{File.basename(@config.workspace)}\" " \
252
- "at #{@config.workspace}. You can read, write, and edit files, search the codebase, " \
253
- "and run shell commands to inspect and modify the project. Work autonomously: " \
254
- "investigate, make changes, and verify your work."
255
- extra = ENV["ACH_SYSTEM_PROMPT"]
256
- extra ? "#{base}\n\n#{extra}" : base
257
- end
258
279
  end
259
280
  end
260
281
  end
@@ -30,7 +30,8 @@ module Ask
30
30
 
31
31
  attr_accessor :host, :workspace, :db_path, :model,
32
32
  :max_turns, :turn_timeout, :approval_required,
33
- :plan_mode, :todos, :tools, :adapter, :adapter_opts
33
+ :plan_mode, :todos, :tools, :adapter, :adapter_opts,
34
+ :system_prompt, :system_prompt_append, :system_prompt_guidelines
34
35
 
35
36
  def initialize
36
37
  @host = ENV.fetch("ACH_HOST", "0.0.0.0")
@@ -48,6 +49,9 @@ module Ask
48
49
  @tools = DEFAULT_TOOLS.dup
49
50
  @adapter = ENV["ACH_ADAPTER"] || ENV["CODING_PROVIDER"] || "ask_agent"
50
51
  @adapter_opts = {}
52
+ @system_prompt = nil
53
+ @system_prompt_append = ENV["ACH_SYSTEM_PROMPT"]
54
+ @system_prompt_guidelines = []
51
55
  validate!
52
56
  end
53
57
 
@@ -39,7 +39,7 @@ module Ask
39
39
  @mutex.synchronize { @aborted = true; @cv.broadcast }
40
40
  end
41
41
 
42
- def create_session(workspace_path, mode: nil, model: nil)
42
+ def create_session(workspace_path, mode: nil, model: nil, system_prompt: nil, **)
43
43
  sid = "demo_#{@sessions.size + 1}"
44
44
  @sessions[sid] = workspace_path
45
45
  sid
@@ -88,6 +88,44 @@ module Ask
88
88
  workspace_info.to_json
89
89
  end
90
90
 
91
+ # GET /api/workspaces — known workspaces (registered + with
92
+ # conversations), enriched with name, branch, and counts
93
+ r.get "workspaces" do
94
+ store = harness_store
95
+ dirs = (store.workspaces + store.projects.map { |p| p["directory"] }).uniq
96
+ dirs.filter_map { |dir| workspace_info(dir, store) }.to_json
97
+ end
98
+
99
+ # POST /api/workspaces — open a workspace by path
100
+ r.post "workspaces" do
101
+ body = JSON.parse(r.body.read)
102
+ path = body["path"].to_s.strip
103
+ if path.empty?
104
+ response.status = 400
105
+ next { error: "path is required" }.to_json
106
+ end
107
+ unless File.directory?(path)
108
+ response.status = 404
109
+ next { error: "Not a directory: #{path}" }.to_json
110
+ end
111
+ dir = harness_store.register_workspace(path)
112
+ workspace_info(dir, harness_store).to_json
113
+ end
114
+
115
+ # GET /api/workspaces/:encoded_path/info
116
+ r.on "workspaces", String do |encoded|
117
+ r.get "info" do
118
+ dir = URI.decode_www_form_component(encoded)
119
+ info = workspace_info(dir, harness_store)
120
+ if info
121
+ info.to_json
122
+ else
123
+ response.status = 404
124
+ { error: "Not a directory: #{dir}" }.to_json
125
+ end
126
+ end
127
+ end
128
+
91
129
  # POST /api/chat — streaming turn
92
130
  r.post "chat" do
93
131
  body = JSON.parse(r.body.read)
@@ -101,8 +139,16 @@ module Ask
101
139
  end
102
140
 
103
141
  store = harness_store
142
+ workspace = body["workspace"].to_s.strip
143
+ workspace = harness_config.workspace if workspace.empty?
144
+ if !File.directory?(workspace)
145
+ response.status = 404
146
+ next { error: "Not a directory: #{workspace}" }.to_json
147
+ end
148
+ store.register_workspace(workspace)
149
+
104
150
  existing = conversation_id && store.load(conversation_id)
105
- conversation = existing || store.build(directory: harness_config.workspace)
151
+ conversation = existing || store.build(directory: workspace)
106
152
  new_conversation = existing.nil?
107
153
  conversation = store.save(conversation) if new_conversation
108
154
 
@@ -292,18 +338,23 @@ module Ask
292
338
  raise NotImplementedError, "built via Server.build"
293
339
  end
294
340
 
295
- def workspace_info
296
- cfg = harness_config
297
- root = cfg.workspace
341
+ def workspace_info(root = nil, store = nil)
342
+ root ||= harness_config.workspace
343
+ return nil unless File.directory?(root)
298
344
  branch = nil
299
345
  head = File.join(root, ".git", "HEAD")
300
346
  if File.file?(head)
301
347
  ref = File.read(head).strip
302
348
  branch = ref.split("/").last if ref.start_with?("ref:")
303
349
  end
304
- { name: File.basename(root), root: root, gitBranch: branch }
350
+ count = 0
351
+ if store
352
+ conv = store.list_for_directory(root)
353
+ count = conv.length
354
+ end
355
+ { name: File.basename(root), root: root, gitBranch: branch, conversation_count: count }
305
356
  rescue SystemCallError
306
- { name: File.basename(root), root: root, gitBranch: nil }
357
+ nil
307
358
  end
308
359
 
309
360
  def models
@@ -15,7 +15,9 @@ module Ask
15
15
  # conversation records; the server and runner only read/write through it.
16
16
  class Store
17
17
  CONVERSATIONS_KEY = "__conversations__"
18
+ WORKSPACES_KEY = "__workspaces__"
18
19
  MAX_CONVERSATIONS = 500
20
+ MAX_WORKSPACES = 100
19
21
 
20
22
  # @param db_path [String] path to the SQLite database file
21
23
  def initialize(db_path:)
@@ -86,6 +88,22 @@ module Ask
86
88
  end.map { |dir, count| { "directory" => dir, "name" => File.basename(dir), "conversation_count" => count } }
87
89
  end
88
90
 
91
+ # ── Workspace registry ──
92
+
93
+ # Register a workspace path (idempotent). Workspaces the server has
94
+ # explicitly opened, even before any conversation exists.
95
+ def register_workspace(path)
96
+ dir = File.expand_path(path)
97
+ existing = db.list_range(WORKSPACES_KEY, 0, -1)
98
+ db.list_append(WORKSPACES_KEY, dir, max_length: MAX_WORKSPACES) unless existing.include?(dir)
99
+ dir
100
+ end
101
+
102
+ # Explicitly registered workspace paths.
103
+ def workspaces
104
+ db.list_range(WORKSPACES_KEY, 0, -1)
105
+ end
106
+
89
107
  def close
90
108
  @db&.close
91
109
  @db = nil
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ask
6
+ module CodingHarness
7
+ # Builds the agent system prompt, in the style of the pi coding agent:
8
+ # a composable base (custom or default), tool-aware guidelines, project
9
+ # context files injected as <project_context> (AGENTS.md / CLAUDE.md
10
+ # walked from the workspace up to the filesystem root), an append
11
+ # section, and the working directory as a closing footer.
12
+ #
13
+ # SystemPrompt.build(workspace: "/path/to/project")
14
+ # SystemPrompt.build(workspace: "/p", custom: "...", append: "...",
15
+ # guidelines: ["Always run tests"])
16
+ class SystemPrompt
17
+ # Files picked up per directory when building project context.
18
+ CONTEXT_FILE_CANDIDATES = %w[AGENTS.md AGENTS.MD CLAUDE.md CLAUDE.MD].freeze
19
+
20
+ # Context files larger than this are skipped (token hygiene).
21
+ MAX_CONTEXT_FILE_SIZE = 64 * 1024
22
+
23
+ # Guidelines always present, regardless of configuration.
24
+ DEFAULT_GUIDELINES = [
25
+ "Be concise in your responses",
26
+ "Show file paths clearly when working with files",
27
+ "Use absolute paths rooted at your working directory for all file and command operations",
28
+ "Investigate before you act: read the relevant files before editing them",
29
+ "Verify your work: run tests or checks after changing code",
30
+ "Never claim an action you did not take"
31
+ ].freeze
32
+
33
+ DEFAULT_PROMPT = <<~PROMPT.chomp
34
+ You are an expert coding assistant operating inside ask-coding-harness, a coding agent
35
+ harness for the ask-rb ecosystem. You help users by reading files, executing commands,
36
+ editing code, and writing new files. Work autonomously: investigate the project, make
37
+ changes, and verify your work.
38
+ PROMPT
39
+
40
+ # Build the full system prompt for a workspace.
41
+ #
42
+ # @param workspace [String] the working directory (footer + context walk)
43
+ # @param custom [String, nil] custom base prompt (replaces the default;
44
+ # project context, append, and footer are still added)
45
+ # @param append [String, nil] extra text appended after project context
46
+ # @param guidelines [Array<String>] extra guideline bullets
47
+ # @param context_files [Array<Hash>, :auto, nil] explicit project context
48
+ # files ({path:, content:}), auto-discovered from the workspace walk,
49
+ # or none
50
+ # @return [String]
51
+ def self.build(workspace:, custom: nil, append: nil, guidelines: [],
52
+ context_files: :auto)
53
+ files =
54
+ case context_files
55
+ when :auto then load_project_context(workspace)
56
+ when nil then []
57
+ else context_files
58
+ end
59
+
60
+ parts = []
61
+ parts << (custom.nil? || custom.empty? ? DEFAULT_PROMPT : custom)
62
+ parts << render_guidelines(guidelines)
63
+ parts << render_project_context(files) unless files.empty?
64
+ parts << append unless append.nil? || append.empty?
65
+ parts << "Current working directory: #{workspace.to_s.tr("\\", "/")}"
66
+ parts.reject { |p| p.nil? || p.empty? }.join("\n\n")
67
+ end
68
+
69
+ # Walk from the workspace up to the filesystem root, collecting the
70
+ # nearest AGENTS.md / CLAUDE.md in each directory (nearest first,
71
+ # deduplicated by realpath). Mirrors the pi coding agent's project
72
+ # context discovery.
73
+ #
74
+ # @param workspace [String]
75
+ # @return [Array<Hash>] [{path:, content:}]
76
+ def self.load_project_context(workspace)
77
+ files = []
78
+ seen = {}
79
+
80
+ dir = File.expand_path(workspace)
81
+ loop do
82
+ candidate = context_file_in(dir)
83
+ if candidate && !seen.key?(candidate[:realpath])
84
+ seen[candidate[:realpath]] = true
85
+ files << candidate[:file]
86
+ end
87
+
88
+ parent = File.dirname(dir)
89
+ break if parent == dir
90
+ dir = parent
91
+ end
92
+
93
+ # Nearest first: the walk starts at the workspace.
94
+ files
95
+ end
96
+
97
+ # The first existing context file in a directory, or nil.
98
+ def self.context_file_in(dir)
99
+ CONTEXT_FILE_CANDIDATES.each do |name|
100
+ path = File.join(dir, name)
101
+ next unless File.file?(path)
102
+ size = File.size(path)
103
+ next if size > MAX_CONTEXT_FILE_SIZE
104
+ content = File.read(path, encoding: "UTF-8")
105
+ return { file: { path: path, content: content }, realpath: File.realpath(path) }
106
+ rescue SystemCallError, EncodingError
107
+ next
108
+ end
109
+ nil
110
+ end
111
+
112
+ def self.render_guidelines(extra)
113
+ list = DEFAULT_GUIDELINES.dup
114
+ Array(extra).each do |g|
115
+ normalized = g.to_s.strip
116
+ list << normalized unless normalized.empty? || list.include?(normalized)
117
+ end
118
+ "Guidelines:\n" + list.map { |g| "- #{g}" }.join("\n")
119
+ end
120
+
121
+ def self.render_project_context(files)
122
+ body = files.map do |f|
123
+ "<project_instructions path=\"#{f[:path]}\">\n#{f[:content]}\n</project_instructions>"
124
+ end.join("\n\n")
125
+ "<project_context>\n\nProject-specific instructions and guidelines:\n\n#{body}\n</project_context>"
126
+ end
127
+ end
128
+ end
129
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module CodingHarness
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -58,5 +58,6 @@ require "ask-state-providers"
58
58
  require "ask/coding_harness/config"
59
59
  require "ask/coding_harness/store"
60
60
  require "ask/coding_harness/event_translator"
61
+ require "ask/coding_harness/system_prompt"
61
62
  require "ask/coding_harness/agent_runner"
62
63
  require "ask/coding_harness/runner"