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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +1 -1
- data/lib/ask/coding_harness/agent_runner.rb +48 -27
- 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 +57 -6
- data/lib/ask/coding_harness/store.rb +18 -0
- 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-C7-TBuu4.js +2563 -0
- data/public/assets/index-CiU9kYlS.css +1 -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: 3fef50d92fb06108330d8976a625372fb12ec8edca50ea5e777317da3e36e2b4
|
|
4
|
+
data.tar.gz: e8da881f33b685ca384653d9d5fcdc825f2cf954aa117b14e544d3c39f247a54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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"
|