scout-ai 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bf72e55ea7c1691cb13a5c2780e22d36d48fa36435804d3060f22b60bf98751
4
- data.tar.gz: 45b25598880785543bd7d1e955d024e430296de513ad17983272cc9bb81312d6
3
+ metadata.gz: d4c45ad3bd7a6219e9fe1e4e0611391f5751f7e3faa253f8db9b132ecc2e6bae
4
+ data.tar.gz: 7068ae89ef507efa50eadf36d9b563b7a596fda8b31bd29cd3f3f6c21c40aba3
5
5
  SHA512:
6
- metadata.gz: c409ce5d5a7fed4b9a04e4590afa0d66b5ead6396ae767a666238bcd0cde6c473e97dfae836285bf7a337c2206a71cbb42d0eafd582678e6b3ebf9259c225221
7
- data.tar.gz: 0ab6fc73d7e3903c9194bfa793baa750641f414d1fc1b4aca6928425e3a7204f9871fa8f91b172fce1550a29002080b5ad3562cb80a447edf99f980d8a96a5bd
6
+ metadata.gz: bb97d4db5f611da3302ccbecc5d1e567d50fe1fe96916150fb5b4445d5e74f887c8f68e35e1b0111667705491a0359802304ee99090b05cbde4401834c538657
7
+ data.tar.gz: 6877551eda1ebf3d916bc2b1f14347d3323410ee832e62838b11f137c3b5cdc86aaff4d41c01c546997312a387e7ec2d333bc85dde2fa586ec72201fc34221c9
data/.vimproject CHANGED
@@ -62,6 +62,10 @@ scout-ai=$PWD filter="*.rb *.rake Rakefile *.rdoc *.R *.sh *.js *.haml *.sass *.
62
62
  }
63
63
  }
64
64
  save_locations
65
+ provenance_cortex
66
+
67
+
68
+ test_abort
65
69
  }
66
70
  lib=lib {
67
71
  scout-ai.rb
@@ -124,6 +128,7 @@ scout-ai=$PWD filter="*.rb *.rake Rakefile *.rdoc *.R *.sh *.js *.haml *.sass *.
124
128
  agent=agent{
125
129
  chat.rb
126
130
  delegate.rb
131
+ attach.rb
127
132
  iterate.rb
128
133
  save.rb
129
134
  workflow.rb
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0
1
+ 2.1.0
@@ -48,7 +48,7 @@ recognizes these roles:
48
48
  | `user` | User message | Yes |
49
49
  | `assistant` | Model response | Yes |
50
50
  | `function_call` | Tool invocation request from model | Yes (as provider-specific tool_call) |
51
- | `function_call_output` | Tool execution result. May carry a `meta` key: an Array of already-deserialized receipt field Hashes (delegated inference metadata such as `pt`/`ct`/`tt`/`inference_id`, or `job=<path>` producer references), embedded by `LLM.process_calls` when the tool returned an `LLM::Agent`. Legacy chats may instead carry a serialized `agent_meta` key; both are read, `meta` wins when both are present. See [Provenance.md](Provenance.md). | Yes (as provider-specific tool result) |
51
+ | `function_call_output` | Tool execution result. May carry a `meta` key: an Array of already-deserialized receipt field Hashes (delegated inference metadata such as `pt`/`ct`/`tt`/`inference_id`, or `{"job":"<path>"}` producer references), embedded by `LLM.process_calls` when the tool returned an `LLM::Agent`. Legacy chats may instead carry a serialized `agent_meta` key; both are read, `meta` wins when both are present. Outputs may also carry auxiliary `step`/`start_timestamp`/`timestamp` fields; `step` is bookkeeping, not a provenance edge. See [Provenance.md](Provenance.md). | Yes (as provider-specific tool result) |
52
52
  | `meta` | Provenance metadata (tokens, job references) | **No** — stripped before inference |
53
53
  | `tool` | Tool definition (inline in chat) | No — extracted into tool registry |
54
54
  | `introduce` | Workflow/tool introduction | No — extracted, introduces tools to the model context |
@@ -223,7 +223,7 @@ When a delegation tool returns an `LLM::Agent`, `LLM.process_calls` embeds
223
223
  the child agent's `meta` messages in the parent `function_call_output`
224
224
  envelope under the `meta` key, as an Array of **already-deserialized** field
225
225
  Hashes (one for the child's own inference metadata, one per producer
226
- reference). These receipts are provenance evidence, not parent-chat
226
+ reference: a `job` key holding the child job path). These receipts are provenance evidence, not parent-chat
227
227
  messages: the child's inference metadata and producer job reference are read
228
228
  from the paired tool output and never injected into the parent chat. Provenance
229
229
  tooling consumes them through `Chat.agent_meta_evidence` and the `:agent_job`
@@ -18,7 +18,7 @@ The structural relations are:
18
18
  | Parent | Relation | Child | Meaning |
19
19
  |---|---|---|---|
20
20
  | chat | `job` | job | A projected response was produced by a Workflow job. |
21
- | chat | `agent_job` | job | A delegated tool call returned an agent whose `job=` receipt names the producer job. |
21
+ | chat | `agent_job` | job | A delegated tool call returned an agent whose receipt entry carries a `job` field naming the producer job. |
22
22
  | job | `dependency` | job | A normal Scout Workflow dependency. |
23
23
  | job | `log` | chat | A persisted agent conversation under `.files/*.chat`, `.files/*.society/**/*.chat`, or the legacy `.files/log/**/*.chat`. |
24
24
  | chat | `log` | chat | A saved agent conversation under the chat's own `.files` sidecar, same three families (root copy excluded). |
@@ -195,10 +195,12 @@ Two receipt formats exist, and the reader accepts both:
195
195
  - **Current format — the `meta` key.** The writer deserializes the child agent's `meta` messages (`LLM.meta_receipt_from_messages`) and emits an Array of plain field Hashes, each already parsed:
196
196
 
197
197
  ```
198
- function_call_output: {"name":"ask","content":"child answer","id":"call_1","meta":[{"pt":100,"ct":50,"tt":150,"inference_id":"aaa"},{"job":"Worker/ask/Default_x"}]}
198
+ function_call_output: {"name":"ask","content":"child answer","id":"call_1","meta":[{"pt":100,"ct":50,"tt":150,"inference_id":"aaa"},{"job":"Cortex/continue/Default_x.chat"}]}
199
199
  ```
200
200
 
201
- An entry carries either the child's direct inference metadata (`pt`, `ct`, `tt`, ..., `inference_id`) or a producer reference (`job=<path>` as a field). Entries that would carry no fields are dropped by the writer.
201
+ An entry carries either the child's direct inference metadata (`pt`, `ct`, `tt`, ..., `inference_id`) or a producer reference: a `job` key holding the child job path, e.g. `{"job":"Cortex/continue/Default_x.chat"}`. Job references may be job-typed or chat-typed paths; both resolve to a Step. Entries that would carry no fields are dropped by the writer.
202
+
203
+ The same `function_call_output` may also carry auxiliary fields next to the receipt: `step` (the producing step of the same execution), `start_timestamp`, and `timestamp`. They are bookkeeping only: the sole receipt-driven provenance edge source is the `job` field of a receipt entry (`meta[].job`); the `step` field is never followed as a parent-child edge.
202
204
 
203
205
  - **Legacy format — the `agent_meta` key.** Older data stores serialized meta messages:
204
206
 
@@ -235,7 +237,7 @@ Malformed warnings mirror the persisted key in their `evidence_address`, and alw
235
237
 
236
238
  ### The `agent_job` relation
237
239
 
238
- `Chat::PROVENANCE_RELATIONS` includes `agent_job`: chat to delegated producer job, resolved from `job=` receipts. The child is a normal Step and follows `dependency`, `log`, and `result` as usual. A job reference whose Step path and `.info` sidecar both do not exist is not followed and is reported instead.
240
+ `Chat::PROVENANCE_RELATIONS` includes `agent_job`: chat to delegated producer job, resolved from the `job` field of receipt entries (`meta[].job`; the auxiliary `step` field on the tool output is not an edge source). The child is a normal Step and follows `dependency`, `log`, and `result` as usual. A job reference whose Step path and `.info` sidecar both do not exist is not followed and is reported instead.
239
241
 
240
242
  Diagnostics go through `Chat.provenance_error` with relation `:agent_job`; the error itself is a plain `ScoutException` whose message is built by `Chat.agent_meta_error_message`, and every structured fact (enclosing chat path, tool output address, receipt address, call id, tool name, malformed entry, reference, reason) travels in the `on_error` reference Hash. In strict mode (no `on_error`) a malformed receipt raises; with `on_error` each problem is reported once per receipt, while the rest of the chat's provenance still expands. Only output JSON that parses to a Hash carrying an explicit receipt key (current `meta`, legacy `agent_meta`) is ever inspected: unparseable tool outputs are never scanned for the substring `agent_meta`.
241
243
 
@@ -253,6 +255,8 @@ A missing `provider_response_id` in one evidence record and a present one in ano
253
255
 
254
256
  `Chat.provenance_token_totals(root, scope:)` sums event tokens by scope. Scopes are **evidence coverage**, not a partition of cost: an event stored both in a saved child log and in a receipt belongs to `:chat_evidence` and to `:receipt_evidence`, so those two must never be summed together. `:deduplicated_total` (default) counts every event once and `:receipt_only` is the disjoint delegated contribution with no saved-chat evidence. `Chat.tokens(root)` delegates to the collector, so provenance aggregates include receipt-only child usage without double counting when the same child inference is also persisted in a job.
255
257
 
258
+ Detailed usage fields come from `Chat.normalize_usage` through `USAGE_FIELD_MAP`, which currently recognizes the OpenAI/Glm/Anthropic spellings present in the map. The map is the inclusivity boundary: a provider that reports cache or reasoning numbers under a spelling the map does not list yields an event whose `cct`/`cwt`/`rt` stay nil, so the renderer omits `cache=`-axis detail for it while `pt`/`ct`/`tt` still count. The short keys are the prov vocabulary (`pt`/`ct`/`tt`/`cct`/`cwt`/`rt`); provider field names never appear in prov output.
259
+
256
260
  ## Workflow failures and partial provenance
257
261
 
258
262
  Traversal visits jobs regardless of `done?`. Error and aborted Steps may still have dependencies, Step info, results, or partial agent logs. Status and exception details remain authoritative in Step info.
@@ -279,7 +283,13 @@ Root classification uses the `.info` sidecar only: a path is a job iff `<path>.i
279
283
 
280
284
  The default tree is a spanning-tree presentation of a DAG. Repeated nodes are displayed as seen references rather than recursively expanded. Compact and graphical flows choose natural data-flow arrow direction during rendering without changing traversal semantics.
281
285
 
282
- Job token values describe direct chat logs owned by that job. They do not silently include the complete dependency subtree.
286
+ Default-tree numbers are labelled `evidence=`: each node carries the subtree-deduplicated evidence closure of everything reachable from it, so sibling and ancestor lines overlap and the values must never be read as per-part cost (continuation carriers make closures cumulative as well). Job nodes add `delta=`: the direct token totals of the job's persisted chat-typed result (`Chat.job_result_chat_file`), which is exactly the receipts-defined accounting delta for that delegated part; jobs whose result is not a saved chat omit the field; `delta=` is printed in both modes, because in `--component` the `direct=`/`delta=` contrast on one line is the very question the mode answers. Per-job `delta=` values sum to the root total on continuation chains but not on general DAGs (a job whose result is re-sent to several parents is one logical delta, not several).
287
+
288
+ Both modes end with a `root deduplicated_total=` footer: `root deduplicated_total=<tt> (<N> events) prompt=... cache=<abs>@<rate>% fresh=... [cache_write=...] cont=... reason=... (authoritative cost; per-node evidence=/direct= values overlap)`, reusing the already-computed root closure, followed by the identity-conflict caveat line whenever conflicts make it non-authoritative (that footer caveat is the single home of the conflict warning; the `--component` scope block does not repeat it). The event count lives in the footer, not on the root node line. `--component` relabels the per-node numbers `direct=` (own direct logs, still not per-part cost on continuation carriers).
289
+
290
+ The prompt axis is contiguous and uses one canonical field order in node lines and the footer: qualifier total, `[delta=]`, `prompt=`, `cache=<abs>@<rate>`, `[fresh=]`, `[cache_write=]`, `cont=`, `reason=`. `cache=` is the cache-hit share of prompt (`cct`, summed over the events behind the figure) printed as `cache=<absolute>@<rate>%`, with the rate computed as `100.0 * cct / pt` from the raw integer totals and formatted `%.1f`; it prints whenever `pt > 0`, including `cache=0@0.0%` (a run with no provider cache data at all). When `pt` is nil or 0 the whole prompt axis is omitted. Node lines carry the compact form; the footer additionally carries `fresh=` (`pt - cct` raw, the prompt tokens not served from cache, which includes the cache-write portion) and `cache_write=` (`cwt`, Anthropic cache-write tokens; printed only when positive). Because conflicting evidence sums can yield `cct > pt`, the raw ratio may exceed 100%; the renderer prints whatever the raw ratio gives instead of clamping, and the conflict caveat line is the guard that marks such a figure best-effort.
291
+
292
+ The bare unqualified `total=` printed inside `--component` scope lines and `--evidence` rows is retained deliberately as coverage-line vocabulary: it is never summed and is not a cost figure.
283
293
 
284
294
  Delegated calls are reported from receipts (current `meta` key or legacy `agent_meta` key; both formats are accepted transparently). The tree labels a job reached through a receipt as `delegated-job`, adds one `delegated receipt: N events, total=<tt>, <tools>` annotation line under chats that carry receipts, and `--component` prints `scope local:` / `scope receipt:` / `scope aggregate:` lines when receipt evidence exists. Flow and DOT render receipt edges as `delegated_result`.
285
295
 
@@ -0,0 +1,68 @@
1
+ module LLM
2
+ class Agent
3
+
4
+ ATACH_TYPES = %w(auto image pdf png jpeg)
5
+ def attachments
6
+ @other_options[:tools] ||= {}
7
+
8
+ task_name = :attach
9
+ block = Proc.new do |_name, parameters|
10
+ begin
11
+ path = Path.setup(parameters[:file]).find
12
+ raise ScoutException, "Path not found: #{path}" unless path.exists?
13
+
14
+ file_type = (parameters[:file_type] || 'image').to_s
15
+ if file_type == 'auto' && path.get_extension.downcase == 'pdf'
16
+ file_type = 'pdf'
17
+ else
18
+ file_type = 'image'
19
+ end
20
+
21
+ case
22
+ when 'image', 'png', 'jpeg'
23
+ self.image path
24
+ when 'pdf'
25
+ self.pdf path
26
+ when 'auto'
27
+ else
28
+ raise ScoutException, "Unkown file type: #{parameters[:file_type]}"
29
+ end
30
+ rescue ScoutException => e
31
+ e
32
+ end
33
+ end
34
+
35
+ properties = {
36
+ file: {
37
+ type: 'string',
38
+ description: 'Path to the file'
39
+ },
40
+ file_type: {
41
+ type: 'string',
42
+ description: 'File type of the file (e.g image, pdf), auto by default',
43
+ enum: ATACH_TYPES,
44
+ default: 'auto'
45
+ },
46
+ }
47
+
48
+ description = <<-EOF
49
+ Attach a file to the current chat, supports images and pdfs.
50
+ EOF
51
+
52
+ function = {
53
+ name: task_name,
54
+ description: description,
55
+ parameters: {
56
+ type: 'object',
57
+ properties: properties,
58
+ required: [:file],
59
+ additionalProperties: false
60
+ }
61
+ }
62
+
63
+ definition = IndiferentHash.setup(function.merge(type: 'function', function: function))
64
+ @other_options[:tools][task_name] = [block, definition]
65
+ end
66
+ end
67
+ end
68
+
@@ -7,6 +7,7 @@ module AgentWorkflow
7
7
  @chat ||= begin
8
8
  chat = recursive_inputs[:chat]
9
9
  chat = Chat.parse(chat) if String === chat
10
+ chat = [] if chat.nil?
10
11
  Chat.setup(chat)
11
12
  chat
12
13
  end
@@ -37,7 +38,7 @@ module AgentWorkflow
37
38
  helper :agent do |name = nil, chat: nil, options: nil, tooling: nil, files: nil, **kwargs|
38
39
  options = self.options if options.nil?
39
40
  tooling = self.tooling if tooling.nil?
40
- options = IndiferentHash.add_defaults options, kwargs
41
+ options = IndiferentHash.add_defaults kwargs, options
41
42
 
42
43
  agent = LLM.load_agent name, agent_options(options)
43
44
  agent.job = self
@@ -104,6 +104,13 @@ module LLM
104
104
  self.socialize(options.dup) if socialize && %w(true TRUE True T 1).include?(socialize.to_s)
105
105
  end
106
106
 
107
+ if (list = messages.select{|info| info[:role] == 'attachments'}).any?
108
+ attachments = list.last[:content]
109
+ messages.delete_if{|info| info[:role] == 'attachments' }
110
+ self.attachments if attachments && %w(true TRUE True T 1).include?(attachments.to_s)
111
+ end
112
+
113
+
107
114
  tools = options[:tools] || {}
108
115
  if other_tools = @other_options[:tools]
109
116
  other_tools = JSON.parse other_tools if String === other_tools
@@ -117,7 +124,7 @@ module LLM
117
124
  end
118
125
 
119
126
  options[:tools] = tools
120
- LLM.ask messages, @other_options.except(:no_ask_override).merge(log_errors: true).merge(options).merge(agent: false)
127
+ LLM.ask messages, @other_options.except(:no_ask_override).merge(log_errors: true, save_file: self.save_file).merge(options).merge(agent: false)
121
128
  end
122
129
  rescue
123
130
  exception = $!
@@ -219,5 +226,6 @@ end
219
226
  require_relative 'agent/chat'
220
227
  require_relative 'agent/iterate'
221
228
  require_relative 'agent/delegate'
229
+ require_relative 'agent/attach'
222
230
  require_relative 'agent/save'
223
231
  require_relative 'agent/workflow'
data/lib/scout/llm/ask.rb CHANGED
@@ -13,20 +13,9 @@ module LLM
13
13
  messages = LLM.chat(question)
14
14
  options = IndiferentHash.add_defaults options, LLM.options(messages)
15
15
 
16
- endpoint, persist, agent_save_file = IndiferentHash.process_options options, :endpoint, :persist, :agent_save_file, persist: true
17
-
18
- persist ||= Scout::Config.get :persist, :ask, :llm, env: 'ASK_PERSIST,LLM_PERSIST,PERSIST'
19
- endpoint ||= Scout::Config.get :endpoint, :ask, :llm, env: 'ASK_ENDPOINT,LLM_ENDPOINT,ENDPOINT,LLM,ASK'
20
- if endpoint && Scout.etc.AI[endpoint].find_with_extension(:yaml).exists?
21
- options = IndiferentHash.add_defaults options, Scout.etc.AI[endpoint].yaml
22
- elsif endpoint && endpoint != ""
23
- raise "Endpoint not found #{endpoint}"
24
- end
25
-
26
- agent_name = IndiferentHash.process_options options, :agent
16
+ agent_name, agent_save_file = IndiferentHash.process_options options, :agent, :agent_save_file
27
17
  agent_name = nil if %(none false nil).include?(agent_name.to_s)
28
18
  if agent_name
29
- options[:endpoint] ||= endpoint
30
19
  agent = LLM::Agent.load_agent agent_name
31
20
  agent.save_file = agent_save_file if agent_save_file
32
21
  agent.follow messages
@@ -34,6 +23,16 @@ module LLM
34
23
  return res
35
24
  end
36
25
 
26
+ endpoint, persist = IndiferentHash.process_options options, :endpoint, :persist, persist: true
27
+
28
+ persist ||= Scout::Config.get :persist, :ask, :llm, env: 'ASK_PERSIST,LLM_PERSIST,PERSIST'
29
+ endpoint ||= Scout::Config.get :endpoint, :ask, :llm, env: 'ASK_ENDPOINT,LLM_ENDPOINT,ENDPOINT,LLM,ASK'
30
+ if endpoint && Scout.etc.AI[endpoint].find_with_extension(:yaml).exists?
31
+ options = IndiferentHash.add_defaults options, Scout.etc.AI[endpoint].yaml
32
+ elsif endpoint && endpoint != ""
33
+ raise "Endpoint not found #{endpoint}"
34
+ end
35
+
37
36
  job_paths = messages.job_paths
38
37
  meta = Chat.meta(messages)
39
38
  options[:current_meta] = meta if meta and meta.any?
@@ -481,8 +481,8 @@ module LLM
481
481
  def ask(question, options = {}, &block)
482
482
  original_options = options.dup
483
483
 
484
- return_messages, log_response, current_meta, relay, process, prompt_strategies = IndiferentHash.process_options options,
485
- :return_messages, :log_response, :current_meta, :relay, :process, :prompt_strategies,
484
+ return_messages, log_response, current_meta, relay, process, prompt_strategies, save_file = IndiferentHash.process_options options,
485
+ :return_messages, :log_response, :current_meta, :relay, :process, :prompt_strategies, :save_file,
486
486
  return_messages: false, log_response: true
487
487
 
488
488
  messages = self.messages question, options
@@ -507,6 +507,7 @@ module LLM
507
507
  Log.debug 'Asking error. Options: ' + "\n" + JSON.pretty_generate(options.except(:tools))
508
508
  begin
509
509
  tmpfile = TmpFile.tmp_file
510
+ Open.write save_file + ".error", Chat.print(messages) if save_file
510
511
  Open.write tmpfile + ".chat", Chat.print(messages)
511
512
  Open.write tmpfile + ".options", options.except(:messages, :tools).to_json
512
513
  Open.write tmpfile + ".meta", current_meta.to_json
@@ -517,6 +518,8 @@ module LLM
517
518
  end
518
519
 
519
520
  raise e
521
+ ensure
522
+ Open.write save_file, Chat.print(messages) if save_file
520
523
  end
521
524
  end
522
525
 
@@ -544,6 +547,7 @@ module LLM
544
547
  if previous_response_id_error
545
548
  message.unshift IndiferentHash.setup({role: :previous_response_id, content: previous_response_id_error})
546
549
  end
550
+ Open.write save_file + ".error", Chat.print(messages) if save_file
547
551
  Open.write tmpfile + ".chat", Chat.print(messages)
548
552
  Open.write tmpfile + ".options", options.except(:messages, :tools).to_json
549
553
  Open.write tmpfile + ".meta", current_meta.to_json
@@ -553,6 +557,8 @@ module LLM
553
557
  rescue
554
558
  end
555
559
  raise e
560
+ ensure
561
+ Open.write save_file, Chat.print(messages) if save_file
556
562
  end
557
563
 
558
564
  if log_response
@@ -561,7 +567,7 @@ module LLM
561
567
  meta['timestamp'] = timestamp
562
568
  end
563
569
 
564
- output = chain_tools messages, output, tools, options.merge(client: client, tools: tools, log_response: log_response, current_meta: meta, relay: relay)
570
+ output = chain_tools messages, output, tools, options.merge(client: client, tools: tools, log_response: log_response, current_meta: meta, relay: relay, save_file: save_file)
565
571
 
566
572
  if log_response && meta && meta.any?
567
573
  # The meta is about to become the first message of a segment that
@@ -194,14 +194,11 @@ module Chat
194
194
  workflow = Workflow.require_workflow workflow_name
195
195
  end unless workflow
196
196
 
197
- raise "Workflow not found #{workflow_name}" if workflow.nil?
197
+ raise ScoutException, "Workflow '#{workflow_name}' not found" if workflow.nil?
198
198
 
199
199
  next if workflow.documentation.empty?
200
200
  content = <<-EOF
201
- You have access to tools from workflow '#{workflow.name}'.
202
- Below is the documentation of the workflow:
203
-
204
- # #{workflow.documentation[:title]}
201
+ # Documentation for the '#{workflow_name}' workflow: #{workflow.documentation[:title]}
205
202
 
206
203
  #{workflow.documentation[:description]}
207
204
  EOF
@@ -95,6 +95,55 @@ module Chat
95
95
  [kind.to_sym, provenance_path(kind, object)]
96
96
  end
97
97
 
98
+ # ------------------------------------------------------------------
99
+ # Run-scoped, parse-once chat cache (review theme 04, stage s1)
100
+ # ------------------------------------------------------------------
101
+ # A provenance "run" is one top-level call to a public provenance entry
102
+ # point (traverse_provenance, provenance_token_events,
103
+ # provenance_token_totals / tokens, and the collectors delegating to them).
104
+ # Inside a run every chat file is parsed at most once, keyed by the same
105
+ # realpath discipline as node identity (provenance_path), so a file reached
106
+ # through several addresses - symlinks, or a chat-typed job whose result
107
+ # path equals a chat node path - is parsed once and never double counted.
108
+ #
109
+ # Guards:
110
+ # * lifetime: the cache exists only while a run is active and is dropped
111
+ # when it ends, so a live session that appends between two runs is
112
+ # always re-parsed (no cross-run persistence);
113
+ # * identity: realpath keyed, exactly like provenance nodes;
114
+ # * transparency: no envelope shape, traversal order or deduplication
115
+ # semantics change - the same Chat object is simply reused within the
116
+ # run, and everything downstream only reads it.
117
+ def self.with_provenance_run_cache
118
+ previous = Thread.current[:scout_ai_provenance_run_cache]
119
+ Thread.current[:scout_ai_provenance_run_cache] ||= {}
120
+ begin
121
+ yield
122
+ ensure
123
+ Thread.current[:scout_ai_provenance_run_cache] = previous
124
+ end
125
+ end
126
+
127
+ # Manual scope control for linear, top-level callers such as the `prov`
128
+ # SOPT script, whose body cannot be wrapped in a block without reindenting
129
+ # the whole file. Open at the start of the run, close when it ends; an
130
+ # open scope makes nested with_provenance_run_cache calls reuse it.
131
+ def self.open_provenance_run_cache
132
+ Thread.current[:scout_ai_provenance_run_cache] ||= {}
133
+ end
134
+
135
+ def self.close_provenance_run_cache
136
+ Thread.current[:scout_ai_provenance_run_cache] = nil
137
+ end
138
+
139
+ # Chat.load with the run cache applied. Outside a run this parses
140
+ # directly, exactly like Chat.load.
141
+ def self.provenance_chat_load(path)
142
+ cache = Thread.current[:scout_ai_provenance_run_cache]
143
+ return Chat.load(path) unless cache
144
+ cache[provenance_path(:chat, path)] ||= Chat.load(path)
145
+ end
146
+
98
147
  def self.provenance_error(on_error, error, kind, object, relation, reference)
99
148
  raise error unless on_error
100
149
  on_error.call(error, kind, object, relation, reference)
@@ -206,6 +255,11 @@ module Chat
206
255
  # therefore never follows import, continue, or last references.
207
256
  def self.traverse_provenance(root, root_type: nil, follow: :all, on_error: nil, &block)
208
257
  return enum_for(__method__, root, root_type: root_type, follow: follow, on_error: on_error) unless block
258
+ unless Thread.current[:scout_ai_provenance_run_cache]
259
+ return with_provenance_run_cache do
260
+ traverse_provenance(root, root_type: root_type, follow: follow, on_error: on_error, &block)
261
+ end
262
+ end
209
263
  # Lambda blocks have strict arity; keep six-argument callbacks compatible
210
264
  # by only yielding the trailing detail when the block can receive it.
211
265
  detail_arity = lambda do
@@ -244,7 +298,7 @@ module Chat
244
298
 
245
299
  begin
246
300
  if kind == :chat
247
- chat = Chat.load(object)
301
+ chat = provenance_chat_load(object)
248
302
 
249
303
  if relations.include?(:job)
250
304
  chat.jobs.each do |reference|
@@ -497,6 +551,11 @@ module Chat
497
551
  #
498
552
  # Checkpoint fields (*_c, *_s) are never read or summed here.
499
553
  def self.provenance_token_events(root, warnings: nil, strict: false, **traversal_options)
554
+ unless Thread.current[:scout_ai_provenance_run_cache]
555
+ return with_provenance_run_cache do
556
+ provenance_token_events(root, warnings: warnings, strict: strict, **traversal_options)
557
+ end
558
+ end
500
559
  # Route traversal-stage agent_meta problems into the caller's warnings
501
560
  # Array instead of letting them raise. Other traversal errors stay strict
502
561
  # (raise), and an explicitly supplied on_error keeps being called.
@@ -519,7 +578,7 @@ module Chat
519
578
 
520
579
  files = provenance_chat_files(root, **traversal_options)
521
580
  sources = {}
522
- files.each { |file| sources[file] = Chat.load(file) }
581
+ files.each { |file| sources[file] = provenance_chat_load(file) }
523
582
 
524
583
  evidences = []
525
584