melaya 0.2.0 → 0.3.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.
data/lib/melaya/evals.rb CHANGED
@@ -40,29 +40,55 @@ module Melaya
40
40
  @http.get("/api/v1/private/evals/compare", params)
41
41
  end
42
42
 
43
- # GET /api/v1/private/evals/memory-graph
43
+ # GET /api/v1/private/memory/graph
44
44
  # Get memory graph visualization data for eval runs.
45
45
  def memory_graph(params = {})
46
- @http.get("/api/v1/private/evals/memory-graph", params)
46
+ @http.get("/api/v1/private/memory/graph", params)
47
47
  end
48
48
 
49
- # GET /api/v1/private/evals/runs/:runId/memory
49
+ # GET /api/v1/private/memory/runs/:runId
50
50
  # Get memory usage for a specific eval run.
51
51
  # @param run_id [String]
52
52
  def run_memory(run_id)
53
- @http.get("/api/v1/private/evals/runs/#{enc(run_id)}/memory")
53
+ @http.get("/api/v1/private/memory/runs/#{enc(run_id)}")
54
54
  end
55
55
 
56
- # GET /api/v1/private/evals/crew-memory
56
+ # GET /api/v1/private/memory/crew
57
57
  # Get agent crew memory for a pipeline.
58
58
  # @param pipeline [String]
59
59
  # @param project [String]
60
60
  def crew_memory(pipeline:, project:)
61
- @http.get("/api/v1/private/evals/crew-memory",
61
+ @http.get("/api/v1/private/memory/crew",
62
62
  "pipeline" => pipeline,
63
63
  "project" => project)
64
64
  end
65
65
 
66
+ # POST /api/v1/private/memory/crew/edit
67
+ # Edit one persisted crew-memory entry (editor/owner-gated, tenant-scoped).
68
+ # @param pipeline [String]
69
+ # @param entry_id [String]
70
+ # @param patch [Hash] any of "topic", "content", "tags" (partial update)
71
+ # @param project [String, nil]
72
+ def edit_crew_memory_entry(pipeline:, entry_id:, patch:, project: nil)
73
+ body = compact(
74
+ "pipeline" => pipeline,
75
+ "entryId" => entry_id,
76
+ "project" => project,
77
+ "patch" => patch
78
+ )
79
+ @http.post("/api/v1/private/memory/crew/edit", body)
80
+ end
81
+
82
+ # POST /api/v1/private/memory/crew/delete
83
+ # Delete one persisted crew-memory entry (editor/owner-gated, tenant-scoped).
84
+ # @param pipeline [String]
85
+ # @param entry_id [String]
86
+ # @param project [String, nil]
87
+ def delete_crew_memory_entry(pipeline:, entry_id:, project: nil)
88
+ body = compact("pipeline" => pipeline, "entryId" => entry_id, "project" => project)
89
+ @http.post("/api/v1/private/memory/crew/delete", body)
90
+ end
91
+
66
92
  # GET /api/v1/private/evals/benchmarks
67
93
  # Get benchmark scores across eval runs.
68
94
  def benchmarks(params = {})
@@ -74,5 +100,9 @@ module Melaya
74
100
  def enc(s)
75
101
  URI.encode_www_form_component(s.to_s)
76
102
  end
103
+
104
+ def compact(hash)
105
+ hash.reject { |_, v| v.nil? }
106
+ end
77
107
  end
78
108
  end
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
6
  require "openssl"
7
+ require "securerandom"
7
8
 
8
9
  require_relative "errors"
9
10
 
@@ -42,29 +43,96 @@ module Melaya
42
43
  end
43
44
 
44
45
  # ── Public verb helpers ────────────────────────────────────────────────────
46
+ #
47
+ # Every verb accepts an optional trailing +timeout_s+ override for that
48
+ # single call (e.g. a slow RAG ingest job); it defaults to the client's own
49
+ # timeout. NOTE: it is a plain positional parameter, not a keyword — many
50
+ # call sites pass a bare `"key" => value` Hash literal as +body+/+params+,
51
+ # and Ruby 3's keyword/Hash separation would otherwise raise
52
+ # "unknown keyword" on every one of them if this were `timeout_s:`.
53
+
54
+ def get(path, params = {}, timeout_s = nil)
55
+ request(:get, path, params: params, timeout_s: timeout_s)
56
+ end
45
57
 
46
- def get(path, params = {})
47
- request(:get, path, params: params)
58
+ def post(path, body = nil, timeout_s = nil)
59
+ request(:post, path, body: body, timeout_s: timeout_s)
48
60
  end
49
61
 
50
- def post(path, body = nil)
51
- request(:post, path, body: body)
62
+ def put(path, body = nil, timeout_s = nil)
63
+ request(:put, path, body: body, timeout_s: timeout_s)
52
64
  end
53
65
 
54
- def put(path, body = nil)
55
- request(:put, path, body: body)
66
+ def patch(path, body = nil, timeout_s = nil)
67
+ request(:patch, path, body: body, timeout_s: timeout_s)
56
68
  end
57
69
 
58
- def patch(path, body = nil)
59
- request(:patch, path, body: body)
70
+ # +body+ is optional: some bridged DELETE routes take structured input
71
+ # (e.g. googleDisconnect's { accountId, capability? }) that should not be
72
+ # exposed in a URL, so it travels as a JSON body instead of query params.
73
+ def delete(path, params = {}, body = nil, timeout_s = nil)
74
+ request(:delete, path, params: params, body: body, timeout_s: timeout_s)
60
75
  end
61
76
 
62
- def delete(path, params = {})
63
- request(:delete, path, params: params)
77
+ # GET that returns the RAW response body (String, binary encoding) instead
78
+ # of JSON-parsing it — for binary downloads like +runInputFile+. Errors are
79
+ # still parsed and raised exactly like the JSON verb helpers. Retried like
80
+ # any other GET (idempotent).
81
+ def get_bytes(path, params = {}, timeout_s = nil)
82
+ request(:get, path, params: params, timeout_s: timeout_s, raw: true)
83
+ end
84
+
85
+ # Upload a single file as `multipart/form-data` with one file part named
86
+ # +field_name+. Never retried (a partial multipart re-send could double an
87
+ # upload with side effects), and the response is parsed exactly like the
88
+ # JSON POST helper (same error type).
89
+ #
90
+ # @param path [String]
91
+ # @param query [Hash] query-string params (e.g. { "key" => ..., "project" => ... })
92
+ # @param field_name [String] the multipart field name the server expects (e.g. "file")
93
+ # @param bytes [String] raw file content
94
+ # @param filename [String] filename reported in the part's Content-Disposition
95
+ # @param content_type [String, nil] defaults to "application/octet-stream"
96
+ def post_multipart(path, query, field_name, bytes, filename, content_type = nil)
97
+ uri = build_uri(path, query || {})
98
+ boundary = "MelayaFormBoundary#{SecureRandom.hex(16)}"
99
+
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = uri.scheme == "https"
102
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
103
+ http.open_timeout = @timeout_s
104
+ http.read_timeout = @timeout_s
105
+
106
+ req = Net::HTTP::Post.new(uri)
107
+ req["Authorization"] = "Bearer #{@_tok}"
108
+ req["Accept"] = "application/json"
109
+ req["User-Agent"] = "melaya-ruby/#{Melaya::VERSION}"
110
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
111
+ req.body = multipart_body(boundary, field_name, bytes, filename, content_type)
112
+
113
+ resp = http.request(req)
114
+ parse(resp)
64
115
  end
65
116
 
66
117
  private
67
118
 
119
+ # Builds a single-file multipart/form-data body by hand (no dependency on
120
+ # any multipart-encoding gem).
121
+ def multipart_body(boundary, field_name, bytes, filename, content_type)
122
+ ct = content_type || "application/octet-stream"
123
+ head =
124
+ "--#{boundary}\r\n" \
125
+ "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{escape_multipart_value(filename)}\"\r\n" \
126
+ "Content-Type: #{ct}\r\n\r\n"
127
+ tail = "\r\n--#{boundary}--\r\n"
128
+ (head.b + bytes.to_s.b + tail.b)
129
+ end
130
+
131
+ # Escapes double quotes / newlines out of a Content-Disposition value.
132
+ def escape_multipart_value(value)
133
+ value.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').tr("\r\n", " ")
134
+ end
135
+
68
136
  def build_uri(path, params = {})
69
137
  uri = URI.parse("#{@base_uri}#{path}")
70
138
  # SECURITY: the credential is never placed in the query string; it is
@@ -98,14 +166,16 @@ module Melaya
98
166
  req
99
167
  end
100
168
 
101
- def request(method, path, params: {}, body: nil)
169
+ def request(method, path, params: {}, body: nil, timeout_s: nil, raw: false)
102
170
  uri = build_uri(path, params)
103
171
 
172
+ eff_timeout = timeout_s ? [timeout_s.to_f, 0.001].max.ceil : @timeout_s
173
+
104
174
  http = Net::HTTP.new(uri.host, uri.port)
105
175
  http.use_ssl = uri.scheme == "https"
106
176
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER
107
- http.open_timeout = @timeout_s
108
- http.read_timeout = @timeout_s
177
+ http.open_timeout = eff_timeout
178
+ http.read_timeout = eff_timeout
109
179
 
110
180
  # Only GET requests are retried (idempotent); all mutating verbs fail fast.
111
181
  retryable = (method == :get)
@@ -120,7 +190,7 @@ module Melaya
120
190
  # Snapshot Retry-After before parse() consumes the response object,
121
191
  # so we can honour the header even after the MelayaError is raised.
122
192
  retry_after_hdr = resp["retry-after"] || resp["Retry-After"]
123
- parse(resp)
193
+ raw ? parse_raw(resp) : parse(resp)
124
194
  rescue MelayaError => e
125
195
  if retryable && RETRY_STATUSES.include?(e.status) && attempt <= MAX_GET_RETRIES
126
196
  # Build a minimal resp-like object carrying only the header we need,
@@ -192,26 +262,7 @@ module Melaya
192
262
  end
193
263
 
194
264
  status = resp.code.to_i
195
- if status >= 400
196
- # Two error envelope shapes:
197
- # 1. { error: 'tier_insufficient', tier: '...' } -> 403
198
- # 2. { error: '...', message: '...', code: '...' }
199
- # Extract error code safely — never echo raw body in message
200
- err_code = data.is_a?(Hash) ? data["error"] : nil
201
-
202
- if status == 403 && err_code == "tier_insufficient"
203
- raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
204
- end
205
- if status == 429
206
- # Raised here; the GET retry loop above may swallow-and-retry it —
207
- # callers only see it once retries are exhausted.
208
- ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
209
- raise RateLimitError.new(retry_after: ra, body: data)
210
- end
211
-
212
- msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
213
- raise MelayaError.new(msg, status: status, code: err_code, body: data)
214
- end
265
+ raise_for_status!(resp, data, status) if status >= 400
215
266
 
216
267
  # The API may wrap payload in { "ok": false, ... } for request-level failures.
217
268
  if data.is_a?(Hash) && data["ok"] == false
@@ -222,5 +273,44 @@ module Melaya
222
273
 
223
274
  data
224
275
  end
276
+
277
+ # Like +parse+, but for a raw binary body (a file download): on success
278
+ # the response body is returned unparsed; on error the same JSON error
279
+ # envelopes and exception types apply.
280
+ def parse_raw(resp)
281
+ status = resp.code.to_i
282
+ if status >= 400
283
+ text = resp.body.to_s.strip
284
+ data = begin
285
+ text.empty? ? nil : JSON.parse(text)
286
+ rescue JSON::ParserError
287
+ text
288
+ end
289
+ raise_for_status!(resp, data, status)
290
+ end
291
+ resp.body.to_s
292
+ end
293
+
294
+ # Shared 4xx/5xx handling for both +parse+ and +parse_raw+. Two error
295
+ # envelope shapes:
296
+ # 1. { error: 'tier_insufficient', tier: '...' } -> 403
297
+ # 2. { error: '...', message: '...', code: '...' }
298
+ # Extract error code safely — never echo raw body in message.
299
+ def raise_for_status!(resp, data, status)
300
+ err_code = data.is_a?(Hash) ? data["error"] : nil
301
+
302
+ if status == 403 && err_code == "tier_insufficient"
303
+ raise TierInsufficientError.new(tier: data.is_a?(Hash) ? data["tier"] : nil, body: data)
304
+ end
305
+ if status == 429
306
+ # Raised here; the GET retry loop above may swallow-and-retry it —
307
+ # callers only see it once retries are exhausted.
308
+ ra = _parse_retry_after_header(resp["retry-after"] || resp["Retry-After"])
309
+ raise RateLimitError.new(retry_after: ra, body: data)
310
+ end
311
+
312
+ msg = "Melaya API #{resp.code}" + (err_code ? " (#{err_code})" : "")
313
+ raise MelayaError.new(msg, status: status, code: err_code, body: data)
314
+ end
225
315
  end
226
316
  end
@@ -7,7 +7,7 @@ module Melaya
7
7
  # grouping the flat module accessors into logical planes:
8
8
  #
9
9
  # melaya.trading — market data, account, sim, strategies, backtest, stream, trade
10
- # melaya.agents — pipelines/runs, hitl, assistant, phone, evals, models
10
+ # melaya.agents — pipelines/runs, hitl, assistant, phone, evals, models, connector_tools
11
11
  # melaya.platform — projects, credentials, connectors, billing, team, templates,
12
12
  # overview (via pipelines), runner, auth, mfa (via auth),
13
13
  # accounts, bugs, events
@@ -60,6 +60,9 @@ module Melaya
60
60
  # - +models+ — AI model list (reached via credentials#list_models; this is
61
61
  # the CredentialsAPI instance filtered by convention — call
62
62
  # +models.list_models(provider: "anthropic")+ etc.)
63
+ # - +connector_tools+ — Call already-connected connector tools directly
64
+ # (Gmail, Slack, Stripe, ...): list, search, describe, test,
65
+ # connect, call, call_status, call_and_wait.
63
66
  AgentsNamespace = Struct.new(
64
67
  :pipelines,
65
68
  :hitl,
@@ -67,6 +70,7 @@ module Melaya
67
70
  :phone,
68
71
  :evals,
69
72
  :models,
73
+ :connector_tools,
70
74
  keyword_init: true
71
75
  ) do
72
76
  # +runs+ is an ergonomic alias for +pipelines+ (agents call them "runs").
data/lib/melaya/phone.rb CHANGED
@@ -66,10 +66,33 @@ module Melaya
66
66
  @http.post("/api/v1/private/phone/active-run", "runId" => run_id)
67
67
  end
68
68
 
69
+ # POST /api/v1/private/phone/apps/grant
70
+ # Grant ONE app into the agent allowlist (atomic append) — e.g. from an
71
+ # in-chat "allow this app" approval card, without replacing the whole list.
72
+ # @param package [String] Android package name
73
+ # @param label [String, nil]
74
+ def grant_app(package, label: nil)
75
+ body = compact("package" => package, "label" => label)
76
+ @http.post("/api/v1/private/phone/apps/grant", body)
77
+ end
78
+
79
+ # POST /api/v1/private/phone/request-cast
80
+ # Re-cast the phone screen (re-triggers the MediaProjection consent
81
+ # prompt) from the desktop mirror.
82
+ # @param device_id [String, nil]
83
+ def request_cast(device_id: nil)
84
+ body = compact("deviceId" => device_id)
85
+ @http.post("/api/v1/private/phone/request-cast", body.empty? ? nil : body)
86
+ end
87
+
69
88
  private
70
89
 
71
90
  def enc(s)
72
91
  URI.encode_www_form_component(s.to_s)
73
92
  end
93
+
94
+ def compact(hash)
95
+ hash.reject { |_, v| v.nil? }
96
+ end
74
97
  end
75
98
  end
@@ -115,6 +115,66 @@ module Melaya
115
115
  @http.delete("/api/v1/private/runs/#{enc(run_id)}/traces")
116
116
  end
117
117
 
118
+ # ── Tool-call audit ──────────────────────────────────────────────────────────
119
+ # Project-wide tool-invocation ledger — the same feed behind the Logs page.
120
+ # Every tool call across the project's runs, with HITL/connector/provider
121
+ # provenance. Argument/result previews are 4KB-truncated here; use
122
+ # +tool_call_detail+ for the untruncated pair on one call.
123
+
124
+ # GET /api/v1/private/projects/:project/tool-calls
125
+ # Keyset-paginated; pass the previous page's +nextCursor+ fields back as
126
+ # +before_created_at+/+before_id+ to continue.
127
+ # @param project [String]
128
+ # @param before_created_at [String, nil] keyset cursor (paired with before_id)
129
+ # @param before_id [String, nil]
130
+ # @param limit [Integer, nil] 1..100, default 30
131
+ # @param tool [String, nil] exact tool name
132
+ # @param agent [String, nil] invoking agent name (substring match)
133
+ # @param run_id [String, nil]
134
+ # @param status [String, nil] "ok" | "error"
135
+ # @param search [String, nil] tool-name search
136
+ # @param connector_source [String, nil] "project" | "personal"
137
+ # @param approval [String, nil] "auto" | "approved" | "by:<username>"
138
+ # @param provider [String, nil] AI provider that produced the tool call
139
+ # @param sort [String, nil] "recent" | "oldest" | "slowest" | "fastest"
140
+ # @return [Hash] { "items" => Array<Hash>, "nextCursor" => Hash|nil, "capped" => Boolean }
141
+ def project_tool_calls(project, before_created_at: nil, before_id: nil, limit: nil,
142
+ tool: nil, agent: nil, run_id: nil, status: nil, search: nil,
143
+ connector_source: nil, approval: nil, provider: nil, sort: nil)
144
+ params = compact(
145
+ "beforeCreatedAt" => before_created_at,
146
+ "beforeId" => before_id,
147
+ "limit" => limit,
148
+ "tool" => tool,
149
+ "agent" => agent,
150
+ "runId" => run_id,
151
+ "status" => status,
152
+ "search" => search,
153
+ "connectorSource" => connector_source,
154
+ "approval" => approval,
155
+ "provider" => provider,
156
+ "sort" => sort
157
+ )
158
+ @http.get("/api/v1/private/projects/#{enc(project)}/tool-calls", params)
159
+ end
160
+
161
+ # GET /api/v1/private/projects/:project/tool-calls/facets
162
+ # Distinct tools (with call counts) and agents seen in the project's
163
+ # tool-call ledger — powers the audit UI's filter dropdowns.
164
+ # @param project [String]
165
+ # @return [Hash] { "tools" => [{ "name" => String, "count" => Integer }], "agents" => Array<String> }
166
+ def project_tool_call_facets(project)
167
+ @http.get("/api/v1/private/projects/#{enc(project)}/tool-calls/facets")
168
+ end
169
+
170
+ # GET /api/v1/private/runs/:runId/tool-calls/:spanId
171
+ # Full (untruncated) arguments + result for a single tool-call span.
172
+ # @param run_id [String]
173
+ # @param span_id [String]
174
+ def tool_call_detail(run_id, span_id)
175
+ @http.get("/api/v1/private/runs/#{enc(run_id)}/tool-calls/#{enc(span_id)}")
176
+ end
177
+
118
178
  # ── Schedule ───────────────────────────────────────────────────────────────
119
179
 
120
180
  # GET /api/v1/private/pipeline-schedule
@@ -180,19 +240,52 @@ module Melaya
180
240
  end
181
241
 
182
242
  # GET /api/v1/private/pipelines/:name
183
- # Fetch a single pipeline config by name.
243
+ # Fetch a single pipeline by name.
244
+ #
245
+ # Returns an ENVELOPE, not a bare config:
246
+ # { "name" => String, "client" => ..., "config" => Hash, "code" => String, "docs" => ... }
247
+ # To edit and save, mutate +envelope["config"]+ and pass THAT to +update+ —
248
+ # see the example below.
249
+ #
184
250
  # @param name [String] pipeline name
185
251
  # @param project [String, nil] owning project (disambiguates when multiple projects share a name)
186
- # @return [Hash] pipeline config
252
+ # @return [Hash] envelope: { "name", "client", "config", "code", "docs" }
253
+ #
254
+ # @example Edit one agent's model, then save
255
+ # envelope = melaya.pipelines.get("daily-digest", project: "acme")
256
+ # config = envelope["config"]
257
+ # config["steps"][0]["agent"]["model"] = { "provider" => "anthropic", "name" => "claude-opus-4-8" }
258
+ # melaya.pipelines.update("daily-digest", config: config, project: "acme")
187
259
  def get(name, project: nil)
188
260
  params = compact("project" => project)
189
261
  @http.get("/api/v1/private/pipelines/#{enc(name)}", params)
190
262
  end
191
263
 
192
264
  # PUT /api/v1/private/pipelines/:name
193
- # Replace a pipeline's config.
265
+ # Replace a pipeline's config. Pass the FULL config Hash — typically
266
+ # +envelope["config"]+ returned by +get+, mutated in place. This is the
267
+ # path for editing a per-agent prompt/instruction or swapping a model on
268
+ # one or all agents.
269
+ #
270
+ # The run is generated ONLY from +config["steps"]+ — a top-level
271
+ # +config["agents"]+ list alone produces an EMPTY pipeline. Every agent a
272
+ # step runs must be embedded inline on that step, e.g.:
273
+ # { "kind" => "agent", "agent" => {
274
+ # "name" => "researcher", "role" => "...", "instruction" => "...",
275
+ # "model" => { "provider" => "anthropic", "name" => "claude-sonnet-4-6" },
276
+ # "agent_tools" => [...], "human_approval_tools" => [...] } }
277
+ # There is no +prompt+ field — the two prompt fields are +instruction+
278
+ # (the task) and, optionally, +system_prompt_override+.
279
+ #
280
+ # Other config fields worth knowing:
281
+ # "hitl_mode" — "safe" (default) | "autonomous" | "payments_only".
282
+ # Only "safe" honours each agent's +human_approval_tools+.
283
+ # "connector_source" — "personal" | "project"
284
+ # "force_local_runner" — Boolean
285
+ # "inputs" — Array of declared run-input fields (see +run+)
286
+ #
194
287
  # @param name [String] pipeline name
195
- # @param config [Hash] full pipeline config payload
288
+ # @param config [Hash] full pipeline config payload (see +get+)
196
289
  # @param project [String, nil] owning project
197
290
  # @return [Hash] updated pipeline config
198
291
  def update(name, config:, project: nil)
@@ -214,20 +307,84 @@ module Melaya
214
307
  # Enqueue a pipeline run.
215
308
  # @param name [String] pipeline name
216
309
  # @param project [String, nil]
217
- # @param execution_target [String, nil] runner target identifier
310
+ # @param execution_target [String, nil] used ONLY for the tier check made
311
+ # at enqueue time. Where the run actually EXECUTES is decided by the
312
+ # pipeline's own stored config (local model providers / +force_local_runner+),
313
+ # not by this value.
218
314
  # @param studio_url [String, nil] override studio URL
219
- # @param env_overrides [Hash, nil] environment variable overrides
220
- # @return [Hash] { "run_id" => String, "queued" => Boolean }
221
- def run(name, project: nil, execution_target: nil, studio_url: nil, env_overrides: nil)
315
+ # @param env_overrides [Hash, nil] per-run environment variable overrides,
316
+ # layered over the caller's stored credentials. +MEL_*+ and +MELAYA_*+
317
+ # keys are always stripped server-side — they can never be overridden
318
+ # from the client.
319
+ # @param run_inputs [Hash, nil] free-form run inputs:
320
+ # +{ brief: String, values: { key => value_or_file_ref } }+.
321
+ # A file value inside +values+ may be +{ "file_id" => ... }+ (from
322
+ # +upload_run_file+), +{ "url" => ... }+ (≤25 MB, https only), or
323
+ # +{ "base64" => ..., "name" => ... }+ (≤7 MB).
324
+ # @return [Hash] { "run_id" => String, "queued" => Boolean, "run_inputs" => Hash (optional echo) }
325
+ def run(name, project: nil, execution_target: nil, studio_url: nil, env_overrides: nil, run_inputs: nil)
222
326
  body = compact(
223
327
  "project" => project,
224
328
  "executionTarget" => execution_target,
225
329
  "studio_url" => studio_url,
226
- "env_overrides" => env_overrides
330
+ "env_overrides" => env_overrides,
331
+ "run_inputs" => run_inputs
227
332
  )
228
333
  @http.post("/api/v1/private/pipelines/#{enc(name)}/run", body.empty? ? nil : body)
229
334
  end
230
335
 
336
+ # POST /api/v1/private/pipelines/:name/run-files?key=...[&project=...]
337
+ # Upload a file for a LATER run (before calling +run+), as
338
+ # +multipart/form-data+ with a single field "file". Returns
339
+ # +{ "file_id" => String, ... }+ — single-use, valid 24 h. Reference it
340
+ # from +run+'s +run_inputs+ as +values: { <key> => { "file_id" => file_id } }+.
341
+ # @param name [String] pipeline name
342
+ # @param key [String] the declared run-input key this file is for
343
+ # @param file [String, IO] raw file bytes, or an IO/File-like object (must respond to +#read+)
344
+ # @param project [String, nil]
345
+ # @param filename [String, nil] defaults to the file's own name, else "file"
346
+ # @param content_type [String, nil] defaults to "application/octet-stream"
347
+ # @return [Hash] { "file_id" => String, ... }
348
+ def upload_run_file(name, key, file, project: nil, filename: nil, content_type: nil)
349
+ bytes, fname = file_payload(file, filename)
350
+ query = compact("key" => key, "project" => project)
351
+ @http.post_multipart(
352
+ "/api/v1/private/pipelines/#{enc(name)}/run-files", query,
353
+ "file", bytes, fname, content_type
354
+ )
355
+ end
356
+
357
+ # GET /api/v1/private/pipelines/:name/runs/:runId/inputs
358
+ # What a run was started with (brief, values, files echo).
359
+ # @param name [String] pipeline name
360
+ # @param run_id [String] 16 hex-char run id
361
+ # @return [Hash]
362
+ def run_inputs(name, run_id)
363
+ @http.get("/api/v1/private/pipelines/#{enc(name)}/runs/#{enc(run_id)}/inputs")
364
+ end
365
+
366
+ # GET /api/v1/private/pipelines/:name/runs/:runId/inputs/files/:index
367
+ # Download one input file attached to a run.
368
+ #
369
+ # Returns RAW BYTES — do not JSON-parse the result.
370
+ #
371
+ # @param name [String] pipeline name
372
+ # @param run_id [String] 16 hex-char run id
373
+ # @param index [Integer] file index, 0..99
374
+ # @return [String] raw binary file content
375
+ def run_input_file(name, run_id, index)
376
+ @http.get_bytes("/api/v1/private/pipelines/#{enc(name)}/runs/#{enc(run_id)}/inputs/files/#{index}")
377
+ end
378
+
379
+ # GET /api/v1/private/pipelines/:name/runs/:runId/active
380
+ # Liveness poll for a run (cloud-spawn process presence).
381
+ # @param name [String] pipeline name
382
+ # @param run_id [String] run identifier
383
+ # @return [Hash] { "active" => Boolean }
384
+ def run_active(name, run_id)
385
+ @http.get("/api/v1/private/pipelines/#{enc(name)}/runs/#{enc(run_id)}/active")
386
+ end
387
+
231
388
  # GET /api/v1/private/pipelines/:name/runs
232
389
  # List all run IDs for a pipeline.
233
390
  # @param name [String] pipeline name
@@ -317,6 +474,71 @@ module Melaya
317
474
  @http.post("/api/v1/private/ai/build-pipeline/sync", brief)
318
475
  end
319
476
 
477
+ # ── Static-context documents ────────────────────────────────────────────────
478
+ # Files an agent reads as part of its context (never chunked/embedded).
479
+ # See also "RAG (retrieval) documents" below for the embedded-search store.
480
+
481
+ # GET /api/v1/private/pipelines/:name/docs
482
+ # List static-context documents attached to a pipeline.
483
+ # @param name [String] pipeline name
484
+ def list_docs(name)
485
+ @http.get("/api/v1/private/pipelines/#{enc(name)}/docs")
486
+ end
487
+
488
+ # POST /api/v1/private/pipelines/:name/docs
489
+ # Upload one static-context document as +multipart/form-data+ (field
490
+ # "file"). Allowed extensions: .txt .md .pdf .csv .json .docx .doc .pptx .xlsx.
491
+ # @param name [String] pipeline name
492
+ # @param file [String, IO] raw file bytes, or an IO/File-like object
493
+ # @param filename [String, nil] defaults to the file's own name, else "file"
494
+ # @param content_type [String, nil] defaults to "application/octet-stream"
495
+ def upload_doc(name, file, filename: nil, content_type: nil)
496
+ bytes, fname = file_payload(file, filename)
497
+ @http.post_multipart("/api/v1/private/pipelines/#{enc(name)}/docs", {}, "file", bytes, fname, content_type)
498
+ end
499
+
500
+ # DELETE /api/v1/private/pipelines/:name/docs/:filename
501
+ # Remove one static-context document.
502
+ # @param name [String] pipeline name
503
+ # @param filename [String]
504
+ def delete_doc(name, filename)
505
+ @http.delete("/api/v1/private/pipelines/#{enc(name)}/docs/#{enc(filename)}")
506
+ end
507
+
508
+ # ── RAG (retrieval) documents ────────────────────────────────────────────────
509
+ # Files chunked and embedded into the pipeline's own retrieval store, for
510
+ # agents that search over a document set rather than reading it whole.
511
+
512
+ # POST /api/v1/private/pipelines/:name/docs/retrieval
513
+ # Upload one retrieval-mode document as +multipart/form-data+ (field "file").
514
+ # @param name [String] pipeline name
515
+ # @param file [String, IO] raw file bytes, or an IO/File-like object
516
+ # @param filename [String, nil] defaults to the file's own name, else "file"
517
+ # @param content_type [String, nil] defaults to "application/octet-stream"
518
+ def upload_retrieval_doc(name, file, filename: nil, content_type: nil)
519
+ bytes, fname = file_payload(file, filename)
520
+ @http.post_multipart("/api/v1/private/pipelines/#{enc(name)}/docs/retrieval", {}, "file", bytes, fname, content_type)
521
+ end
522
+
523
+ # POST /api/v1/private/pipelines/:name/docs/retrieval/ingest
524
+ # Embed changed retrieval documents with the pipeline's configured
525
+ # embedder. Can take minutes, so this defaults to a 300 s request timeout —
526
+ # pass +timeout_s:+ to override.
527
+ # @param name [String] pipeline name
528
+ # @param body [Hash] request body (empty by default)
529
+ # @param timeout_s [Numeric] per-call timeout override (default 300)
530
+ def ingest_retrieval(name, body: {}, timeout_s: 300)
531
+ @http.post("/api/v1/private/pipelines/#{enc(name)}/docs/retrieval/ingest", body, timeout_s)
532
+ end
533
+
534
+ # DELETE /api/v1/private/pipelines/:name/docs/retrieval/:filename
535
+ # Remove one retrieval-mode document (and its chunks).
536
+ # @param name [String] pipeline name
537
+ # @param filename [String]
538
+ def delete_retrieval_doc(name, filename)
539
+ @http.delete("/api/v1/private/pipelines/#{enc(name)}/docs/retrieval/#{enc(filename)}")
540
+ end
541
+
320
542
  # ── Misc ───────────────────────────────────────────────────────────────────
321
543
 
322
544
  # GET /api/v1/version (public)
@@ -338,5 +560,20 @@ module Melaya
338
560
  def stringify_keys(hash)
339
561
  hash.transform_keys(&:to_s)
340
562
  end
563
+
564
+ # Resolves a caller-supplied +file+ (raw bytes String, or an IO/File-like
565
+ # object responding to +#read+) into [bytes, filename] for a multipart
566
+ # upload. +filename_override+ wins when given; otherwise an IO's own
567
+ # +#path+ basename is used, falling back to "file".
568
+ def file_payload(file, filename_override)
569
+ if file.respond_to?(:read)
570
+ bytes = file.read
571
+ name = filename_override || (file.respond_to?(:path) ? File.basename(file.path) : "file")
572
+ else
573
+ bytes = file.to_s
574
+ name = filename_override || "file"
575
+ end
576
+ [bytes, name]
577
+ end
341
578
  end
342
579
  end