ask-coding-harness 0.1.0 → 0.2.1
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/CHANGELOG.md +45 -1
- data/README.md +1 -1
- data/lib/ask/coding_harness/agent_runner.rb +90 -29
- data/lib/ask/coding_harness/config.rb +5 -1
- data/lib/ask/coding_harness/demo_adapter.rb +1 -1
- data/lib/ask/coding_harness/server.rb +75 -6
- data/lib/ask/coding_harness/store.rb +23 -1
- data/lib/ask/coding_harness/system_prompt.rb +129 -0
- data/lib/ask/coding_harness/version.rb +1 -1
- data/lib/ask-coding-harness.rb +1 -0
- data/public/assets/index-DTf-hWlF.css +1 -0
- data/public/assets/index-DUraJvrW.js +2563 -0
- data/public/index.html +2 -2
- metadata +4 -3
- data/public/assets/index-Cs2ZprmP.js +0 -2113
- data/public/assets/index-D2px4HJ9.css +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69387c402be4baf7feac38128c4ba8c8e5ef9b45990fb1e2ac6c6c88ae2475a4
|
|
4
|
+
data.tar.gz: 195abd511e806b952d02a1b17da3c3373371d6e7c54b294a571390684e74e6e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6631d79089e2d2c94d78431f6c2b831d63a76dd9e03e76ba1976ffe58153b48c09fb88c1976ee1699e47ae32a29952a3457a85ba986c1a05749828ef971e7030
|
|
7
|
+
data.tar.gz: 75c4e27cf9e1ed89cfae4f0db8973aeb5ce18c29ad553e8d875eac7b05f7aa6372d7f6dc4208ff781a7c7c1aa734caba15991c269cbc328118ab47cc8e8dea92
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.2.
|
|
3
|
+
## [0.2.1] - 2026-08-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Declarative agents (ask-agent `agents/` convention).** Each workspace
|
|
8
|
+
can carry an `agents/<name>/` directory (`agent.rb` +
|
|
9
|
+
`instructions.md`): the harness lists them per workspace
|
|
10
|
+
(`GET /api/workspaces/:path/agents`), the composer has an agent picker,
|
|
11
|
+
and conversations remember their agent. When an agent is selected, the
|
|
12
|
+
session is built via `Ask::Agent.new` — the definition's tools, skills,
|
|
13
|
+
and model apply — and the agent's `instructions.md` becomes the system
|
|
14
|
+
prompt base (project context, guidelines, append, and footer still
|
|
15
|
+
apply, pi-style). Requires ask-coding-providers >= 0.3.2.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Universal workspaces.** The harness is no longer bound to one
|
|
22
|
+
directory: open any project from the UI (workspace switcher + Open
|
|
23
|
+
workspace dialog), switch between them, and every conversation runs
|
|
24
|
+
inside its own workspace with its own system prompt.
|
|
25
|
+
- `POST /api/workspaces` (open/register), `GET /api/workspaces`
|
|
26
|
+
(list with name/branch/counts), `GET /api/workspaces/:path/info`.
|
|
27
|
+
- `POST /api/chat` accepts a `workspace` param; conversations are
|
|
28
|
+
created and grouped per workspace.
|
|
29
|
+
- Turns execute inside their workspace directory (serialized via a
|
|
30
|
+
turn mutex, since the shell tools default to `Dir.pwd`).
|
|
31
|
+
- **Pi-style system prompts.** `SystemPrompt` builds a composable prompt:
|
|
32
|
+
default or custom base, guidelines, `<project_context>` from
|
|
33
|
+
AGENTS.md/CLAUDE.md (walked from the workspace up to the root, like the
|
|
34
|
+
pi coding agent), an append section, and a `Current working directory:`
|
|
35
|
+
footer. Config: `system_prompt`, `system_prompt_append`,
|
|
36
|
+
`system_prompt_guidelines` (env `ACH_SYSTEM_PROMPT` still appends).
|
|
37
|
+
- **ask-ui-kit sidebar/shell components** extracted from the popular
|
|
38
|
+
coding agents' UIs (openchamber, openwebui, t3code): `ask-dialog`,
|
|
39
|
+
`ask-menu`, `ask-search-input`, `ask-conversation-item`,
|
|
40
|
+
`ask-conversation-group`. The harness frontend now builds its switcher,
|
|
41
|
+
sidebar (search + grouped conversation items), and dialogs on them.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- `DemoAdapter#create_session` accepts the `system_prompt` keyword (the
|
|
46
|
+
universal runner passes it to every adapter).
|
|
47
|
+
|
|
4
48
|
|
|
5
49
|
### Added
|
|
6
50
|
|
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
|
|
@@ -124,10 +125,24 @@ module Ask
|
|
|
124
125
|
end
|
|
125
126
|
end
|
|
126
127
|
|
|
128
|
+
# The system prompt for a workspace. When a declarative agent is
|
|
129
|
+
# selected, its instructions.md become the custom base prompt
|
|
130
|
+
# (project context, guidelines, append, and footer still apply).
|
|
131
|
+
# Adapters that don't accept a per-session prompt (external ACP
|
|
132
|
+
# agents) ignore the extra keywords.
|
|
133
|
+
def system_prompt_for(workspace, agent = nil)
|
|
134
|
+
Ask::CodingHarness::SystemPrompt.build(
|
|
135
|
+
workspace: workspace,
|
|
136
|
+
custom: @config.system_prompt || (agent_instructions(workspace, agent) if agent),
|
|
137
|
+
append: @config.system_prompt_append,
|
|
138
|
+
guidelines: @config.system_prompt_guidelines
|
|
139
|
+
)
|
|
140
|
+
end
|
|
127
141
|
private
|
|
128
142
|
|
|
129
143
|
def run_turn(conversation, prompt, model: nil, &on_event)
|
|
130
|
-
|
|
144
|
+
workspace = conversation["directory"] || @config.workspace
|
|
145
|
+
sid = session_for(conversation, workspace, model: model)
|
|
131
146
|
|
|
132
147
|
conv = @store.load(conversation["id"]) || conversation
|
|
133
148
|
conv["messages"] << { "role" => "user", "content" => prompt, "created_at" => Time.now.iso8601 }
|
|
@@ -135,24 +150,37 @@ module Ask
|
|
|
135
150
|
|
|
136
151
|
outcome = :completed
|
|
137
152
|
accumulated = +""
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
with_workspace(workspace) do
|
|
154
|
+
adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
|
|
155
|
+
translated = @translator.translate(event)
|
|
156
|
+
next unless translated
|
|
157
|
+
|
|
158
|
+
case translated[:type]
|
|
159
|
+
when "message.delta"
|
|
160
|
+
accumulated << translated[:data][:delta].to_s
|
|
161
|
+
when "turn.failed"
|
|
162
|
+
outcome = :failed
|
|
163
|
+
when "turn.aborted"
|
|
164
|
+
outcome = :aborted
|
|
165
|
+
end
|
|
166
|
+
emit(on_event, translated)
|
|
149
167
|
end
|
|
150
|
-
emit(on_event, translated)
|
|
151
168
|
end
|
|
152
169
|
|
|
153
170
|
persist_outcome(conversation["id"], accumulated, outcome)
|
|
154
171
|
end
|
|
155
172
|
|
|
173
|
+
# Run the block with the workspace as the process working directory.
|
|
174
|
+
# The shell tools default to Dir.pwd, so the turn must execute inside
|
|
175
|
+
# its workspace; a turn mutex keeps concurrent workspaces from racing
|
|
176
|
+
# on the process-global directory. Self-hosted single-user: turns
|
|
177
|
+
# serialize, which is both safe and simple.
|
|
178
|
+
def with_workspace(workspace)
|
|
179
|
+
@turn_mutex.synchronize do
|
|
180
|
+
Dir.chdir(workspace) { yield }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
156
184
|
# Persist the assistant turn outcome (response, error, or abort) so
|
|
157
185
|
# the conversation reads correctly when reopened. Best-effort; the
|
|
158
186
|
# streamed events are the source of truth for the live view.
|
|
@@ -175,16 +203,58 @@ module Ask
|
|
|
175
203
|
nil
|
|
176
204
|
end
|
|
177
205
|
|
|
178
|
-
# One adapter session per conversation, created on first use.
|
|
179
|
-
|
|
206
|
+
# One adapter session per conversation, created on first use. Each
|
|
207
|
+
# session gets its workspace's system prompt (pi-style: default or
|
|
208
|
+
# custom base + guidelines + AGENTS.md/CLAUDE.md project context),
|
|
209
|
+
# plus the conversation's declarative agent when one is selected.
|
|
210
|
+
# Runs inside the workspace (the mutex makes the chdir safe) so the
|
|
211
|
+
# ask-agent agents/ discovery finds this workspace's definitions.
|
|
212
|
+
def session_for(conversation, workspace = nil, model: nil)
|
|
213
|
+
workspace ||= conversation["directory"] || @config.workspace
|
|
180
214
|
@mutex.synchronize do
|
|
181
|
-
|
|
182
|
-
conversation["
|
|
183
|
-
|
|
184
|
-
|
|
215
|
+
Dir.chdir(workspace) do
|
|
216
|
+
@sessions[conversation["id"]] ||= adapter.create_session(
|
|
217
|
+
workspace,
|
|
218
|
+
model: model || @config.model,
|
|
219
|
+
system_prompt: system_prompt_for(workspace, conversation["agent"]),
|
|
220
|
+
agent: conversation["agent"]
|
|
221
|
+
)
|
|
222
|
+
end
|
|
185
223
|
end
|
|
186
224
|
end
|
|
187
225
|
|
|
226
|
+
|
|
227
|
+
# Declarative agents in a workspace (ask-agent convention):
|
|
228
|
+
# [{name:, instructions:}] discovered without loading the
|
|
229
|
+
# definitions. Safe for listing in the UI.
|
|
230
|
+
def agent_definitions(workspace)
|
|
231
|
+
Dir.chdir(workspace) do
|
|
232
|
+
Ask::Agent.default_agent_paths.filter_map do |base|
|
|
233
|
+
next unless File.directory?(base)
|
|
234
|
+
Dir["#{base}/*/agent.rb"].sort.filter_map do |file|
|
|
235
|
+
dir = File.dirname(file)
|
|
236
|
+
name = File.basename(dir)
|
|
237
|
+
next if name == "shared"
|
|
238
|
+
instructions = File.file?(File.join(dir, "instructions.md")) ? File.read(File.join(dir, "instructions.md")) : nil
|
|
239
|
+
{ "name" => name, "instructions" => instructions }
|
|
240
|
+
end
|
|
241
|
+
end.flatten
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The instructions.md content of a named agent, loaded through the
|
|
246
|
+
# ask-agent registry (which also validates the definition).
|
|
247
|
+
def agent_instructions(workspace, agent)
|
|
248
|
+
Dir.chdir(workspace) do
|
|
249
|
+
Ask::Agent.rediscover!
|
|
250
|
+
klass = Ask::Agent.definitions[agent.to_s]&.first
|
|
251
|
+
klass&.instructions_content
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The server lists agents per workspace.
|
|
256
|
+
public :agent_definitions, :agent_instructions
|
|
257
|
+
|
|
188
258
|
# Dispatch a control to the adapter, returning nil when the adapter
|
|
189
259
|
# doesn't support it (e.g. external ACP agents without approvals).
|
|
190
260
|
def send_adapter(conversation_id, method_name, *args)
|
|
@@ -217,8 +287,7 @@ module Ask
|
|
|
217
287
|
approval: @config.approval,
|
|
218
288
|
approval_required: @config.approval_policy_tools,
|
|
219
289
|
plan_mode: @config.plan_mode,
|
|
220
|
-
todos: @config.todos
|
|
221
|
-
system_prompt: default_system_prompt
|
|
290
|
+
todos: @config.todos
|
|
222
291
|
)
|
|
223
292
|
adapter.start
|
|
224
293
|
# Tools operate relative to the process working directory; the
|
|
@@ -247,14 +316,6 @@ module Ask
|
|
|
247
316
|
end
|
|
248
317
|
end
|
|
249
318
|
|
|
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
319
|
end
|
|
259
320
|
end
|
|
260
321
|
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,56 @@ 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
|
+
|
|
128
|
+
# GET /api/workspaces/:encoded_path/agents — declarative agents
|
|
129
|
+
# (ask-agent convention: agents/<name>/agent.rb +
|
|
130
|
+
# instructions.md), listed without loading them.
|
|
131
|
+
r.get "agents" do
|
|
132
|
+
dir = URI.decode_www_form_component(encoded)
|
|
133
|
+
unless File.directory?(dir)
|
|
134
|
+
response.status = 404
|
|
135
|
+
next { error: "Not a directory: #{dir}" }.to_json
|
|
136
|
+
end
|
|
137
|
+
harness_runner.agent_definitions(dir).to_json
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
91
141
|
# POST /api/chat — streaming turn
|
|
92
142
|
r.post "chat" do
|
|
93
143
|
body = JSON.parse(r.body.read)
|
|
@@ -101,8 +151,22 @@ module Ask
|
|
|
101
151
|
end
|
|
102
152
|
|
|
103
153
|
store = harness_store
|
|
154
|
+
workspace = body["workspace"].to_s.strip
|
|
155
|
+
workspace = harness_config.workspace if workspace.empty?
|
|
156
|
+
if !File.directory?(workspace)
|
|
157
|
+
response.status = 404
|
|
158
|
+
next { error: "Not a directory: #{workspace}" }.to_json
|
|
159
|
+
end
|
|
160
|
+
store.register_workspace(workspace)
|
|
161
|
+
|
|
104
162
|
existing = conversation_id && store.load(conversation_id)
|
|
105
|
-
conversation = existing || store.build(directory:
|
|
163
|
+
conversation = existing || store.build(directory: workspace, agent: body["agent"])
|
|
164
|
+
# An explicit agent param updates the conversation (an agent
|
|
165
|
+
# picker can switch the persona for the next turn).
|
|
166
|
+
if existing && body["agent"]
|
|
167
|
+
conversation["agent"] = body["agent"]
|
|
168
|
+
conversation = store.save(conversation)
|
|
169
|
+
end
|
|
106
170
|
new_conversation = existing.nil?
|
|
107
171
|
conversation = store.save(conversation) if new_conversation
|
|
108
172
|
|
|
@@ -292,18 +356,23 @@ module Ask
|
|
|
292
356
|
raise NotImplementedError, "built via Server.build"
|
|
293
357
|
end
|
|
294
358
|
|
|
295
|
-
def workspace_info
|
|
296
|
-
|
|
297
|
-
|
|
359
|
+
def workspace_info(root = nil, store = nil)
|
|
360
|
+
root ||= harness_config.workspace
|
|
361
|
+
return nil unless File.directory?(root)
|
|
298
362
|
branch = nil
|
|
299
363
|
head = File.join(root, ".git", "HEAD")
|
|
300
364
|
if File.file?(head)
|
|
301
365
|
ref = File.read(head).strip
|
|
302
366
|
branch = ref.split("/").last if ref.start_with?("ref:")
|
|
303
367
|
end
|
|
304
|
-
|
|
368
|
+
count = 0
|
|
369
|
+
if store
|
|
370
|
+
conv = store.list_for_directory(root)
|
|
371
|
+
count = conv.length
|
|
372
|
+
end
|
|
373
|
+
{ name: File.basename(root), root: root, gitBranch: branch, conversation_count: count }
|
|
305
374
|
rescue SystemCallError
|
|
306
|
-
|
|
375
|
+
nil
|
|
307
376
|
end
|
|
308
377
|
|
|
309
378
|
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:)
|
|
@@ -25,12 +27,15 @@ module Ask
|
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# A fresh conversation record. Not persisted until {#save}.
|
|
28
|
-
|
|
30
|
+
# @param agent [String, nil] declarative agent name (ask-agent
|
|
31
|
+
# convention) or nil for the default agent
|
|
32
|
+
def build(directory: nil, title: "New conversation", agent: nil)
|
|
29
33
|
now = Time.now.iso8601
|
|
30
34
|
{
|
|
31
35
|
"id" => SecureRandom.uuid,
|
|
32
36
|
"title" => title,
|
|
33
37
|
"directory" => directory,
|
|
38
|
+
"agent" => agent,
|
|
34
39
|
"archived" => false,
|
|
35
40
|
"messages" => [],
|
|
36
41
|
"created_at" => now,
|
|
@@ -86,6 +91,22 @@ module Ask
|
|
|
86
91
|
end.map { |dir, count| { "directory" => dir, "name" => File.basename(dir), "conversation_count" => count } }
|
|
87
92
|
end
|
|
88
93
|
|
|
94
|
+
# ── Workspace registry ──
|
|
95
|
+
|
|
96
|
+
# Register a workspace path (idempotent). Workspaces the server has
|
|
97
|
+
# explicitly opened, even before any conversation exists.
|
|
98
|
+
def register_workspace(path)
|
|
99
|
+
dir = File.expand_path(path)
|
|
100
|
+
existing = db.list_range(WORKSPACES_KEY, 0, -1)
|
|
101
|
+
db.list_append(WORKSPACES_KEY, dir, max_length: MAX_WORKSPACES) unless existing.include?(dir)
|
|
102
|
+
dir
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Explicitly registered workspace paths.
|
|
106
|
+
def workspaces
|
|
107
|
+
db.list_range(WORKSPACES_KEY, 0, -1)
|
|
108
|
+
end
|
|
109
|
+
|
|
89
110
|
def close
|
|
90
111
|
@db&.close
|
|
91
112
|
@db = nil
|
|
@@ -102,6 +123,7 @@ module Ask
|
|
|
102
123
|
"id" => data["id"],
|
|
103
124
|
"title" => data["title"] || "New conversation",
|
|
104
125
|
"directory" => data["directory"],
|
|
126
|
+
"agent" => data["agent"],
|
|
105
127
|
"archived" => data["archived"] == true,
|
|
106
128
|
"message_count" => data["messages"]&.length || 0,
|
|
107
129
|
"created_at" => data["created_at"],
|
|
@@ -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
|
data/lib/ask-coding-harness.rb
CHANGED
|
@@ -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"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{color-scheme:dark;--bg: #0b0b0c;--fg: #e5e5e5;--surface: #131316;--surface-muted: #161618;--border: #26262a;--border-strong: #3f3f46;--text-muted: #a3a3a3;--text-faint: #737373;--accent: #2f6feb;--danger: #dc2626;--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif}html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:var(--font);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}::selection{background:#2f6feb59}*{scrollbar-width:thin;scrollbar-color:#3f3f46 transparent}*::-webkit-scrollbar{width:8px;height:8px}*::-webkit-scrollbar-thumb{background:#3f3f46;border-radius:4px}*::-webkit-scrollbar-track{background:transparent}.tool-stream.svelte-1l486hy{display:flex;flex-direction:column;gap:.75rem;margin-top:.75rem}.block.svelte-1l486hy{display:flex;flex-direction:column;gap:.5rem}.tool.svelte-1l486hy{border:1px solid #26262a;border-radius:.625rem;background:#131316;overflow:hidden}.tool-head.svelte-1l486hy{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;font-size:.8125rem}.tool-icon.svelte-1l486hy{font-size:.875rem}.tool-name.svelte-1l486hy{font-weight:600;color:#d4d4d4;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.75rem}.tool-status.svelte-1l486hy{margin-left:auto;font-size:.6875rem;text-transform:uppercase;letter-spacing:.04em}.tool-status.running.svelte-1l486hy{color:#fbbf24;animation:svelte-1l486hy-pulse 1.2s infinite}.tool-status.done.svelte-1l486hy{color:#4ade80}.tool-status.failed.svelte-1l486hy{color:#f87171}@keyframes svelte-1l486hy-pulse{0%,to{opacity:1}50%{opacity:.4}}.tool-args.svelte-1l486hy{margin:0;padding:.375rem .75rem;border-top:1px solid #1f1f22;background:#0d0d0f;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.6875rem;line-height:1.6;color:#a3a3a3;white-space:pre-wrap;word-break:break-word;max-height:8rem;overflow-y:auto}.approvals.svelte-1l486hy{gap:.5rem}.approve-all.svelte-1l486hy{align-self:flex-start;font:inherit;font-size:.75rem;font-weight:600;padding:.375rem .75rem;border-radius:.5rem;border:1px solid #ea580c;background:#ea580c;color:#fff;cursor:pointer}.assistant.svelte-kpbn6d{max-width:46rem;margin:0 auto 1.5rem}.thinking.svelte-kpbn6d{margin-bottom:.75rem;border:1px solid #26262a;border-radius:.625rem;background:#131316}.thinking.svelte-kpbn6d summary:where(.svelte-kpbn6d){display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;cursor:pointer;list-style:none;font-size:.75rem}.thinking.svelte-kpbn6d summary:where(.svelte-kpbn6d)::-webkit-details-marker{display:none}.thinking-label.svelte-kpbn6d{color:#a3a3a3}.thinking-toggle.svelte-kpbn6d{margin-left:auto;font-size:.6875rem;color:#737373}.thinking-body.svelte-kpbn6d{padding:0 .75rem .75rem;color:#a3a3a3;font-size:.8125rem;line-height:1.6;white-space:pre-wrap}.markdown.svelte-kpbn6d{font-size:.9375rem;line-height:1.7;color:#e5e5e5;overflow-wrap:break-word}.markdown.svelte-kpbn6d p{margin:0 0 .75rem}.markdown.svelte-kpbn6d p:last-child{margin-bottom:0}.markdown.svelte-kpbn6d code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8125rem;background:#1a1a1d;padding:.125rem .375rem;border-radius:.375rem;border:1px solid #26262a}.markdown.svelte-kpbn6d .code-block{margin:.75rem 0;padding:.75rem;border-radius:.625rem;background:#0d0d0f;border:1px solid #26262a;overflow-x:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8125rem;line-height:1.6;white-space:pre}.typing.svelte-kpbn6d{display:flex;gap:.25rem;padding:.5rem 0}.typing.svelte-kpbn6d span:where(.svelte-kpbn6d){width:.375rem;height:.375rem;border-radius:50%;background:#52525b;animation:svelte-kpbn6d-blink 1.2s infinite}.typing.svelte-kpbn6d span:where(.svelte-kpbn6d):nth-child(2){animation-delay:.2s}.typing.svelte-kpbn6d span:where(.svelte-kpbn6d):nth-child(3){animation-delay:.4s}@keyframes svelte-kpbn6d-blink{0%,to{opacity:.25}50%{opacity:1}}.user.svelte-10oznxe{display:flex;justify-content:flex-end;max-width:46rem;margin:0 auto 1.5rem}.bubble.svelte-10oznxe{max-width:85%;padding:.625rem .9375rem;border-radius:1rem 1rem .25rem;background:#1f4f8a;color:#f5f9ff;font-size:.9375rem;line-height:1.6;overflow-wrap:break-word}.bubble.svelte-10oznxe code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.8125rem;background:#ffffff26;padding:.125rem .375rem;border-radius:.375rem}.chat.svelte-xdaci2{flex:1;overflow-y:auto;padding:1.5rem 1rem 2rem;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}.empty.svelte-xdaci2{max-width:30rem;margin:12vh auto 0;text-align:center;color:#a3a3a3;padding:0 1rem}.empty-logo.svelte-xdaci2{font-size:2.5rem;margin-bottom:.75rem}.empty.svelte-xdaci2 h1:where(.svelte-xdaci2){font-size:1.375rem;margin:0 0 .5rem;color:#e5e5e5}.empty.svelte-xdaci2 p:where(.svelte-xdaci2){font-size:.875rem;line-height:1.6;margin:0}.sidebar.svelte-181dlmc{display:flex;flex-direction:column;height:100%}.head.svelte-181dlmc{display:flex;align-items:center;gap:.5rem;padding:.75rem;border-bottom:1px solid #1f1f22}.ws-name.svelte-181dlmc{font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#737373;padding:0 .125rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.new.svelte-181dlmc{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:.5rem;font:inherit;font-size:.8125rem;font-weight:600;padding:.5rem;border-radius:.625rem;border:1px solid #2a2a2e;background:#161618;color:#e5e5e5;cursor:pointer}.new.svelte-181dlmc:hover{background:#1f1f22}.new.svelte-181dlmc:disabled{opacity:.5;cursor:default}.close.svelte-181dlmc{display:none;width:2.25rem;height:2.25rem;border:none;border-radius:.5rem;background:transparent;color:#a3a3a3;cursor:pointer}@media(max-width:767px){.close.svelte-181dlmc{display:inline-flex;align-items:center;justify-content:center}}.search.svelte-181dlmc{padding:.625rem .75rem .25rem}.list.svelte-181dlmc{flex:1;overflow-y:auto;padding:.375rem .5rem .75rem;display:flex;flex-direction:column}.empty.svelte-181dlmc{padding:1rem .5rem;font-size:.8125rem;color:#737373;text-align:center}.rename-row.svelte-181dlmc{padding:.375rem .75rem}.rename-row.svelte-181dlmc input:where(.svelte-181dlmc){width:100%;font:inherit;font-size:.8125rem;padding:.25rem .5rem;border-radius:.375rem;border:1px solid #3f3f46;background:#0d0d0f;color:#e5e5e5;outline:none}.composer.svelte-1n8df3y{flex-shrink:0;padding:.75rem 1rem calc(.75rem + env(safe-area-inset-bottom));background:linear-gradient(transparent,#0b0b0c 40%)}.box.svelte-1n8df3y{display:flex;align-items:flex-end;gap:.5rem;max-width:46rem;margin:0 auto;padding:.5rem;background:#161618;border:1px solid #2a2a2e;border-radius:1rem;box-shadow:0 4px 24px #0006}.box.svelte-1n8df3y:focus-within{border-color:#3f3f46}textarea.svelte-1n8df3y{flex:1;resize:none;border:none;outline:none;background:transparent;color:#e5e5e5;font:inherit;font-size:.9375rem;line-height:1.5;padding:.375rem .5rem;max-height:180px}textarea.svelte-1n8df3y::placeholder{color:#737373}.send.svelte-1n8df3y{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:none;border-radius:.75rem;background:#2f6feb;color:#fff;cursor:pointer;flex-shrink:0}.send.svelte-1n8df3y:disabled{background:#26262a;color:#737373;cursor:default}.send.stop.svelte-1n8df3y{background:#b91c1c}.hint.svelte-1n8df3y{display:flex;align-items:center;gap:.5rem;max-width:46rem;margin:.5rem auto 0;padding:0 .25rem;font-size:.6875rem;color:#737373}.model-chip.svelte-1n8df3y,.agent-chip.svelte-1n8df3y{color:#a3a3a3;border:1px solid #26262a;border-radius:999px;padding:.125rem .5rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.agent-chip.svelte-1n8df3y .trigger{padding:0;border:none;font-family:inherit;font-size:inherit}.agent-chip.svelte-1n8df3y .trigger:hover{background:transparent}.shortcut.svelte-1n8df3y{margin-left:auto}section.svelte-1u3w06f{padding:.75rem 1.25rem 1rem;border-top:1px solid #1f1f22}section.svelte-1u3w06f h3:where(.svelte-1u3w06f){margin:0 0 .5rem;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#a3a3a3}.row.svelte-1u3w06f{display:flex;gap:.75rem;padding:.25rem 0;font-size:.8125rem}.label.svelte-1u3w06f{color:#737373;width:6rem;flex-shrink:0}.value.svelte-1u3w06f{color:#e5e5e5;overflow-wrap:anywhere}.mono.svelte-1u3w06f{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.75rem}.models.svelte-1u3w06f{display:flex;flex-wrap:wrap;gap:.375rem}.model.svelte-1u3w06f{font:inherit;font-size:.75rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;padding:.375rem .625rem;border-radius:.5rem;border:1px solid #2a2a2e;background:#161618;color:#d4d4d4;cursor:pointer}.model.active.svelte-1u3w06f{border-color:#2f6feb;background:#1d3a63;color:#fff}.hint.svelte-1u3w06f{font-size:.75rem;color:#737373;margin:.5rem 0 0}.switcher.svelte-1xx308a{min-width:0;flex:1}.trigger-name.svelte-1xx308a{font-weight:600;font-size:.9375rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.menu-footer.svelte-1xx308a{padding:.25rem .125rem 0}.open-ws.svelte-1xx308a{width:100%;text-align:left;font:inherit;font-size:.8125rem;padding:.5rem .625rem;border:none;border-radius:.5rem;background:transparent;color:inherit;cursor:pointer}.open-ws.svelte-1xx308a:hover{background:var(--ask-surface-hover, #f5f5f5)}.hint.svelte-1xx308a{margin:0 0 .75rem;font-size:.75rem;line-height:1.5;color:var(--ask-text-muted, #a3a3a3)}.path-input.svelte-1xx308a{width:100%;font:inherit;font-size:.875rem;padding:.5rem .75rem;border-radius:.5rem;border:1px solid var(--ask-border-strong, #d4d4d4);background:var(--ask-surface-muted, #fafafa);color:inherit;outline:none}.path-input.svelte-1xx308a:focus{border-color:var(--ask-focus, #a3a3a3)}.error.svelte-1xx308a{margin-top:.5rem;font-size:.75rem;color:var(--ask-danger, #dc2626)}.btn.svelte-1xx308a{font:inherit;font-size:.8125rem;font-weight:600;padding:.5rem .875rem;border-radius:.5rem;cursor:pointer}.cancel.svelte-1xx308a{border:1px solid var(--ask-border-strong, #d4d4d4);background:transparent;color:inherit}.go.svelte-1xx308a{border:1px solid var(--ask-accent, #c2410c);background:var(--ask-accent, #c2410c);color:var(--ask-accent-text, #fafafa)}.go.svelte-1xx308a:disabled{opacity:.5;cursor:default}*{box-sizing:border-box}html,body{margin:0;height:100%;background:var(--bg, #0b0b0c);color:var(--fg, #e5e5e5);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}#app{height:100%}.app.svelte-1n46o8q{display:flex;flex-direction:column;height:100dvh}.topbar.svelte-1n46o8q{display:flex;align-items:center;gap:.75rem;padding:.625rem 1rem;padding-top:calc(.625rem + env(safe-area-inset-top));background:#111113;border-bottom:1px solid #1f1f22;flex-shrink:0;-webkit-user-select:none;user-select:none}.workspace.svelte-1n46o8q{display:flex;align-items:center;gap:.5rem;min-width:0;flex:1}.topbar-actions.svelte-1n46o8q{display:flex;align-items:center;gap:.25rem}.icon-btn.svelte-1n46o8q{display:inline-flex;align-items:center;justify-content:center;width:2.25rem;height:2.25rem;border:none;border-radius:.5rem;background:transparent;color:#d4d4d4;cursor:pointer}.icon-btn.svelte-1n46o8q:hover{background:#1f1f22}.icon-btn.stop.svelte-1n46o8q{color:#f87171}.body.svelte-1n46o8q{display:flex;flex:1;min-height:0}.sidebar-wrap.svelte-1n46o8q{width:min(85vw,19rem);flex-shrink:0;border-right:1px solid #1f1f22;background:#0e0e10;z-index:20}@media(max-width:767px){.sidebar-wrap.svelte-1n46o8q{position:fixed;inset:0 auto 0 0;height:100dvh;box-shadow:0 0 40px #00000080}}.main.svelte-1n46o8q{flex:1;display:flex;flex-direction:column;min-width:0;position:relative}.banner-error.svelte-1n46o8q{margin:.75rem 1rem 0;padding:.625rem .875rem;border-radius:.625rem;background:#3f0d0d;color:#fca5a5;font-size:.8125rem;border:1px solid #7f1d1d}
|