scout-ai 1.2.3 → 2.0.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.
Files changed (174) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +138 -50
  3. data/README.md +171 -290
  4. data/Rakefile +17 -1
  5. data/VERSION +1 -1
  6. data/doc/Improvements.md +325 -0
  7. data/doc/StartHere.md +110 -0
  8. data/doc/developer/Architecture.md +126 -0
  9. data/doc/developer/Backends.md +199 -0
  10. data/doc/developer/ChatLifecycle.md +183 -0
  11. data/doc/developer/DelegationInternals.md +295 -0
  12. data/doc/developer/DesignPrinciples.md +245 -0
  13. data/doc/developer/PromptProcessing.md +292 -0
  14. data/doc/developer/Provenance.md +317 -0
  15. data/doc/user/BuildingAgents.md +345 -0
  16. data/doc/user/Cookbook.md +333 -0
  17. data/doc/user/CoreConcepts.md +181 -0
  18. data/doc/user/Delegation.md +191 -0
  19. data/doc/user/GettingStarted.md +159 -0
  20. data/doc/user/ManagingContext.md +163 -0
  21. data/doc/user/MultiAgentWorkflows.md +256 -0
  22. data/doc/user/Python.md +159 -0
  23. data/doc/user/RunningInference.md +200 -0
  24. data/doc/user/ToolCalling.md +193 -0
  25. data/doc/user/WritingChats.md +197 -0
  26. data/lib/scout/llm/agent/chat.rb +61 -11
  27. data/lib/scout/llm/agent/delegate.rb +274 -65
  28. data/lib/scout/llm/agent/iterate.rb +2 -2
  29. data/lib/scout/llm/agent/save.rb +273 -0
  30. data/lib/scout/llm/agent/workflow.rb +164 -0
  31. data/lib/scout/llm/agent.rb +86 -61
  32. data/lib/scout/llm/ask.rb +62 -17
  33. data/lib/scout/llm/backends/anthropic.rb +9 -2
  34. data/lib/scout/llm/backends/bedrock.rb +15 -3
  35. data/lib/scout/llm/backends/default.rb +183 -99
  36. data/lib/scout/llm/backends/glm.rb +58 -0
  37. data/lib/scout/llm/backends/huggingface.rb +196 -26
  38. data/lib/scout/llm/backends/ollama.rb +13 -1
  39. data/lib/scout/llm/backends/openai.rb +0 -2
  40. data/lib/scout/llm/backends/openwebui.rb +20 -13
  41. data/lib/scout/llm/backends/relay.rb +22 -22
  42. data/lib/scout/llm/backends/responses.rb +1 -1
  43. data/lib/scout/llm/chat/agent_meta.rb +264 -0
  44. data/lib/scout/llm/chat/annotation.rb +39 -10
  45. data/lib/scout/llm/chat/parse.rb +28 -6
  46. data/lib/scout/llm/chat/persist.rb +25 -0
  47. data/lib/scout/llm/chat/process/clear.rb +41 -6
  48. data/lib/scout/llm/chat/process/files.rb +21 -6
  49. data/lib/scout/llm/chat/process/meta.rb +421 -34
  50. data/lib/scout/llm/chat/process/options.rb +21 -1
  51. data/lib/scout/llm/chat/process/tools.rb +56 -15
  52. data/lib/scout/llm/chat/process.rb +4 -0
  53. data/lib/scout/llm/chat/prompt/shorten_tools.rb +125 -0
  54. data/lib/scout/llm/chat/prompt/shorten_tools_epoch.rb +365 -0
  55. data/lib/scout/llm/chat/prompt.rb +48 -0
  56. data/lib/scout/llm/chat/provenance.rb +775 -0
  57. data/lib/scout/llm/chat/tool_calls.rb +76 -0
  58. data/lib/scout/llm/chat.rb +18 -2
  59. data/lib/scout/llm/embed.rb +11 -3
  60. data/lib/scout/llm/image.rb +86 -0
  61. data/lib/scout/llm/mcp.rb +10 -2
  62. data/lib/scout/llm/rag.rb +3 -3
  63. data/lib/scout/llm/tools/call.rb +160 -11
  64. data/lib/scout/llm/tools/knowledge_base.rb +1 -1
  65. data/lib/scout/llm/tools/workflow.rb +32 -16
  66. data/lib/scout/model/python/huggingface/causal.rb +23 -5
  67. data/lib/scout/model/python/huggingface.rb +2 -1
  68. data/lib/scout-ai.rb +1 -0
  69. data/python/README.md +197 -14
  70. data/python/scout_ai/huggingface/eval.py +245 -34
  71. data/python/tests/test_huggingface_eval.py +58 -0
  72. data/research/ChatAnalyst-required-changes.md +167 -0
  73. data/research/agent-delegation-analysis.md +810 -0
  74. data/research/agent-meta-provenance-integration-plan.md +622 -0
  75. data/research/agent-workflow-analysis.md +1120 -0
  76. data/research/backends-analysis.md +836 -0
  77. data/research/chat-core-analysis.md +946 -0
  78. data/research/chatanalyst-provenance/00-baseline.md +30 -0
  79. data/research/chatanalyst-provenance/01-repo-map.md +60 -0
  80. data/research/chatanalyst-provenance/02-event-reconstruction.md +55 -0
  81. data/research/chatanalyst-provenance/03-duplication-evidence.md +45 -0
  82. data/research/chatanalyst-provenance/04-tooling-root-cause.md +57 -0
  83. data/research/chatanalyst-provenance/05-fix-plan.md +46 -0
  84. data/research/chatanalyst-provenance/07-critic-review.md +25 -0
  85. data/research/chatanalyst-provenance/final-report.md +45 -0
  86. data/research/chatanalyst-provenance/resumption.md +37 -0
  87. data/research/coding-philosophy-analysis.md +928 -0
  88. data/research/commands-analysis.md +947 -0
  89. data/research/multi-agent-patterns-analysis.md +853 -0
  90. data/research/prompt-strategies-analysis.md +630 -0
  91. data/research/prov-verbosity-fix-notes.md +77 -0
  92. data/research/provenance-analysis.md +469 -0
  93. data/research/provenance-navigation-design.md +640 -0
  94. data/research/synthesis-report.md +487 -0
  95. data/research/tools-system-analysis.md +779 -0
  96. data/scout-ai.gemspec +100 -11
  97. data/scout_commands/agent/ask +13 -3
  98. data/scout_commands/agent/kb +2 -0
  99. data/scout_commands/llm/ask +11 -4
  100. data/scout_commands/llm/md +76 -0
  101. data/scout_commands/llm/process_queries +48 -0
  102. data/scout_commands/llm/prov +602 -0
  103. data/scout_commands/llm/word +71 -0
  104. data/scout_commands/workflow/mcp +43 -0
  105. data/share/word/reference.docx +0 -0
  106. data/test/etc/AI/mock.yaml +11 -0
  107. data/test/fixtures/backends/anthropic.json +19 -0
  108. data/test/fixtures/backends/anthropic_tool_use.json +24 -0
  109. data/test/fixtures/backends/bedrock.json +8 -0
  110. data/test/fixtures/backends/bedrock_embedding.json +3 -0
  111. data/test/fixtures/backends/bedrock_tool_use.json +17 -0
  112. data/test/fixtures/backends/ollama.json +16 -0
  113. data/test/fixtures/backends/ollama_tool_call.json +27 -0
  114. data/test/fixtures/backends/openai_chat.json +21 -0
  115. data/test/fixtures/backends/openai_chat_tool_call.json +31 -0
  116. data/test/fixtures/backends/responses.json +33 -0
  117. data/test/fixtures/backends/responses_tool_call.json +28 -0
  118. data/test/integration/README.md +32 -0
  119. data/test/integration/scout/llm/backends/test_endpoints.rb +34 -0
  120. data/test/integration/scout/llm/backends/test_openwebui.rb +61 -0
  121. data/test/integration/scout/llm/backends/test_relay.rb +52 -0
  122. data/test/integration/scout/llm/test_infrastructure.rb +74 -0
  123. data/test/{scout → integration/scout}/llm/test_mcp.rb +1 -1
  124. data/test/integration/scout/llm/tools/test_mcp.rb +42 -0
  125. data/test/integration/scout/model/test_base.rb +91 -0
  126. data/test/scout/llm/agent/test_chat.rb +8 -2
  127. data/test/scout/llm/agent/test_save.rb +413 -0
  128. data/test/scout/llm/agent/test_workflow.rb +110 -0
  129. data/test/scout/llm/backends/test_anthropic.rb +93 -10
  130. data/test/scout/llm/backends/test_bedrock.rb +118 -2
  131. data/test/scout/llm/backends/test_huggingface.rb +137 -42
  132. data/test/scout/llm/backends/test_ollama.rb +70 -20
  133. data/test/scout/llm/backends/test_openwebui.rb +42 -40
  134. data/test/scout/llm/backends/test_relay.rb +4 -2
  135. data/test/scout/llm/chat/agent_meta_fixtures.rb +131 -0
  136. data/test/scout/llm/chat/process/test_meta.rb +518 -0
  137. data/test/scout/llm/chat/process/test_normalize_usage.rb +183 -0
  138. data/test/scout/llm/chat/test_agent_meta.rb +357 -0
  139. data/test/scout/llm/chat/test_agent_meta_provenance.rb +467 -0
  140. data/test/scout/llm/chat/test_agent_meta_tokens.rb +594 -0
  141. data/test/scout/llm/chat/test_parse.rb +70 -15
  142. data/test/scout/llm/chat/test_prov_cli.rb +274 -0
  143. data/test/scout/llm/chat/test_provenance.rb +240 -0
  144. data/test/scout/llm/chat/test_tool_calls.rb +38 -0
  145. data/test/scout/llm/test_agent.rb +13 -36
  146. data/test/scout/llm/test_ask.rb +75 -52
  147. data/test/scout/llm/test_chat.rb +107 -13
  148. data/test/scout/llm/test_embed.rb +48 -0
  149. data/test/scout/llm/test_rag.rb +23 -16
  150. data/test/scout/llm/test_tools.rb +12 -1
  151. data/test/scout/llm/tools/test_knowledge_base.rb +0 -1
  152. data/test/scout/llm/tools/test_mcp.rb +5 -3
  153. data/test/scout/llm/tools/test_workflow.rb +23 -2
  154. data/test/scout/model/python/huggingface/causal/test_next_token.rb +11 -5
  155. data/test/scout/model/python/huggingface/test_causal.rb +9 -3
  156. data/test/scout/model/python/huggingface/test_classification.rb +11 -2
  157. data/test/scout/model/python/test_torch.rb +2 -0
  158. data/test/scout/model/python/torch/test_helpers.rb +4 -0
  159. data/test/scout/model/test_base.rb +4 -2
  160. data/test/support/availability.rb +231 -0
  161. data/test/support/fake_clients.rb +138 -0
  162. data/test/support/fixtures.rb +21 -0
  163. data/test/support/infrastructure_probes.rb +136 -0
  164. data/test/support/mock_backend.rb +215 -0
  165. data/test/test_helper.rb +32 -2
  166. metadata +99 -10
  167. data/doc/Agent.md +0 -327
  168. data/doc/Chat.md +0 -458
  169. data/doc/LLM.md +0 -340
  170. data/doc/RAG.md +0 -129
  171. data/scout_commands/documenter +0 -148
  172. data/test/scout/llm/backends/test_openai.rb +0 -192
  173. data/test/scout/llm/backends/test_responses.rb +0 -238
  174. data/test/scout/llm/test_parse.rb +0 -98
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'scout-ai'
5
+ require 'json'
6
+ require 'set'
7
+ require 'tempfile'
8
+
9
+ cmd = $previous_commands ?
10
+ "scout #{$previous_commands.any? ? "#{$previous_commands * ' '} " : ''}#{File.basename(__FILE__)}" :
11
+ $PROGRAM_NAME
12
+
13
+ options = SOPT.setup <<~EOF
14
+
15
+ Examine the provenance of a chat or workflow job
16
+
17
+ $ #{cmd} [<options>] <filename>
18
+
19
+ -h--help Print this help
20
+ -c--component Show per-component direct costs instead of aggregate totals
21
+ -f--flow Print a compact provenance flow
22
+ -l--long Show full filesystem paths instead of abbreviated names
23
+ -e--evidence List the deduplicated direct inference events and their evidence
24
+ --dot* Write the flow as Graphviz DOT
25
+ -p--plot* Render the flow as svg, png, or pdf
26
+ EOF
27
+ if options[:help]
28
+ defined?(scout_usage) ? scout_usage : puts(SOPT.doc)
29
+ exit 0
30
+ end
31
+
32
+ component = options.delete(:component)
33
+ flow = options.delete(:flow)
34
+ dot_file = options.delete(:dot)
35
+ plot_file = options.delete(:plot)
36
+ long = options.delete(:long)
37
+ evidence = options.delete(:evidence)
38
+ filename = ARGV.first
39
+ raise MissingParameterException, :filename if filename.nil?
40
+
41
+ # A persisted Step always has an info sidecar. A .files directory alone is NOT
42
+ # evidence: saved agent chats carry one too. Explicitly loading the Step after
43
+ # this evidence check avoids treating every readable chat as a job.
44
+ job_evidence = Open.exists?(filename + '.info')
45
+ root_type = job_evidence ? :job : :chat
46
+ root = root_type == :job ? Step.load(filename) : Path.setup(File.expand_path(filename))
47
+ warnings = []
48
+ records = Chat.traverse_provenance(
49
+ root,
50
+ root_type: root_type,
51
+ on_error: lambda do |error, kind, object, relation, reference|
52
+ warnings << {
53
+ error: error,
54
+ kind: kind,
55
+ object: object,
56
+ relation: relation,
57
+ reference: reference
58
+ }
59
+ end
60
+ ).to_a
61
+
62
+ # ------------------------------------------------------------------
63
+ # Build graph: nodes keyed by [kind, path], edges as {from, to, relation}
64
+ # ------------------------------------------------------------------
65
+ nodes = {}
66
+ edges = []
67
+ records.each do |kind, object, parent_kind, parent, relation, first_visit, _detail|
68
+ key = Chat.provenance_key(kind, object)
69
+ nodes[key] ||= { kind: kind, object: object, path: Chat.provenance_path(kind, object) }
70
+ next unless parent
71
+
72
+ parent_key = Chat.provenance_key(parent_kind, parent)
73
+ nodes[parent_key] ||= {
74
+ kind: parent_kind,
75
+ object: parent,
76
+ path: Chat.provenance_path(parent_kind, parent)
77
+ }
78
+ edge = { from: parent_key, to: key, relation: relation, first_visit: first_visit }
79
+ edges << edge unless edges.any? do |other|
80
+ other[:from] == edge[:from] && other[:to] == edge[:to] && other[:relation] == relation
81
+ end
82
+ end
83
+ root_key = Chat.provenance_key(root_type, root)
84
+
85
+ # Adjacency: parent -> children, sorted for readability
86
+ adjacency = Hash.new { |h, k| h[k] = [] }
87
+ edges.each do |edge|
88
+ adjacency[edge[:from]] << edge
89
+ end
90
+ RELATION_ORDER = { dependency: 0, log: 1, job: 2, agent_job: 3, result: 4 }
91
+ adjacency.each_value do |children|
92
+ children.sort_by! { |e| RELATION_ORDER[e[:relation]] || 99 }
93
+ end
94
+
95
+ # ------------------------------------------------------------------
96
+ # Token computation
97
+ # ------------------------------------------------------------------
98
+ chat_cache = {}
99
+
100
+ # Direct tokens for a single node:
101
+ # chat -> its own direct inference tokens
102
+ # job -> sum of tokens from its direct log chats (agent.chat + society chats)
103
+ direct_tokens = lambda do |key|
104
+ node = nodes[key]
105
+ if node[:kind] == :chat
106
+ chat = chat_cache[node[:path]] ||= Chat.load(node[:path])
107
+ Chat.token_totals([chat])
108
+ else
109
+ logs = edges.select { |e| e[:from] == key && e[:relation] == :log }
110
+ .collect { |e| nodes[e[:to]][:path] }.uniq
111
+ chats = logs.collect { |path| chat_cache[path] ||= Chat.load(path) }
112
+ Chat.token_totals(chats)
113
+ end
114
+ rescue => error
115
+ warnings << { error: error, kind: node[:kind], object: node[:object], relation: :tokens }
116
+ Chat::TOKEN_KEYS.each_with_object({}) { |name, totals| totals[name.to_sym] = 0 }
117
+ end
118
+
119
+ # ------------------------------------------------------------------
120
+ # Receipt (agent_meta) evidence: delegated inference events embedded in
121
+ # function_call_output envelopes. Problems are collected, never fatal.
122
+ # ------------------------------------------------------------------
123
+ receipt_warnings = []
124
+ token_events = nil
125
+ token_events_for = lambda do
126
+ return token_events unless token_events.nil?
127
+ token_events = begin
128
+ Chat.provenance_token_events(root, warnings: receipt_warnings)
129
+ rescue => error
130
+ warnings << { error: error, kind: root_type, object: root, relation: :tokens }
131
+ []
132
+ end
133
+ end
134
+
135
+ def receipt_evidence(event)
136
+ event[:evidence].select { |evidence| evidence[:origin] == :agent_meta }
137
+ end
138
+
139
+ # Receipt evidence grouped by the chat file that carries the envelope; used
140
+ # for the one-line delegated annotations and to decide whether scope lines
141
+ # are needed in component mode.
142
+ receipt_events_by_source = Hash.new { |hash, key| hash[key] = [] }
143
+ has_receipt_evidence = false
144
+ token_events_for.call.each do |event|
145
+ next if receipt_evidence(event).empty?
146
+ has_receipt_evidence = true
147
+ receipt_evidence(event).collect { |evidence| evidence[:source] }.compact.uniq.each do |source|
148
+ receipt_events_by_source[source] << event
149
+ end
150
+ end
151
+
152
+ # Aggregate tokens: provenance-aware totals of the subtree reachable from the
153
+ # node. Direct inference events are deduplicated by identity, so usage that
154
+ # exists only inside agent_meta receipts (delegated children whose logs were
155
+ # never saved) is included without double counting.
156
+ aggregate_cache = {}
157
+ aggregate_tokens = lambda do |key|
158
+ return aggregate_cache[key] if aggregate_cache.key?(key)
159
+ node = nodes[key]
160
+ subtree = node[:kind] == :job ? node[:object] : node[:path]
161
+ aggregate_cache[key] = Chat.provenance_token_totals(subtree, warnings: receipt_warnings)
162
+ rescue => error
163
+ Chat::TOKEN_KEYS.each_with_object({}) { |name, totals| totals[name.to_sym] = 0 }
164
+ end
165
+
166
+ # Whole-tree totals per scope; only meaningful when receipts exist.
167
+ scope_totals = lambda do |scope|
168
+ Chat.provenance_token_totals(root, scope: scope, warnings: receipt_warnings)
169
+ rescue => error
170
+ Chat::TOKEN_KEYS.each_with_object({}) { |name, totals| totals[name.to_sym] = 0 }
171
+ end
172
+
173
+ # Select mode: aggregate (default) or component (direct)
174
+ node_tokens = component ? direct_tokens : aggregate_tokens
175
+
176
+ # ------------------------------------------------------------------
177
+ # Visibility: which nodes should be shown?
178
+ # ------------------------------------------------------------------
179
+ # Hidden nodes are skipped entirely — not printed and not traversed.
180
+ # - result-relation chats: they duplicate the job node and create cycles
181
+ # - top-level agent.chat log file: its cost is subsumed by the parent job.
182
+ # ScoutCoder: DUAL-LAYOUT. The root copy of the agent conversation sits
183
+ # directly under the files dir in BOTH layouts:
184
+ # new <job>.files/agent.chat
185
+ # legacy <job>.files/log/agent.chat
186
+ # Any basename agent.chat whose parent is a `<something>.files` directory
187
+ # (top level of the files dir) or the legacy `.../log` directory is such a
188
+ # copy. NESTED society chats (agent.society/<agent>/<conv>/agent.chat,
189
+ # log/society/<agent>/<conv>/agent.chat) are real independent inferences
190
+ # and must be shown.
191
+ def top_level_agent_chat?(path)
192
+ return false unless File.basename(path) == 'agent.chat'
193
+ parent = File.dirname(path)
194
+ parent.end_with?('/log') || parent.end_with?('/log/') ||
195
+ parent.end_with?('.files')
196
+ end
197
+
198
+ def hidden_node?(node, relation)
199
+ return true if relation == :result
200
+ return true if relation == :log && top_level_agent_chat?(node[:path])
201
+ false
202
+ end
203
+
204
+ # ------------------------------------------------------------------
205
+ # Formatting helpers
206
+ # ------------------------------------------------------------------
207
+ def token_str(tokens)
208
+ parts = []
209
+ parts << "total=#{Misc.human_number(tokens[:tt])}" if tokens[:tt] && tokens[:tt].to_i > 0
210
+ parts << "prompt=#{Misc.human_number(tokens[:pt])}" if tokens[:pt] && tokens[:pt].to_i > 0
211
+ parts << "cont=#{Misc.human_number(tokens[:ct])}" if tokens[:ct] && tokens[:ct].to_i > 0
212
+ parts << "cache=#{Misc.human_number(tokens[:cct])}" if tokens[:cct] && tokens[:cct].to_i > 0
213
+ parts << "reason=#{Misc.human_number(tokens[:rt])}" if tokens[:rt] && tokens[:rt].to_i > 0
214
+ parts * ' '
215
+ end
216
+
217
+ def report_line(kind, label, tokens, offset: 0)
218
+ color = kind == :job ? :yellow : :green
219
+ parts = [' ' * (offset * 2)]
220
+ parts << Log.color(color, kind.to_s)
221
+ tok = token_str(tokens)
222
+ parts << tok unless tok.empty?
223
+ parts << Log.color(:blue, label.to_s) unless label.to_s.empty?
224
+ parts * ' '
225
+ end
226
+
227
+ def job_short_id(path)
228
+ basename = File.basename(path)
229
+
230
+ parts = basename.split('_')
231
+ if parts.length == 2 && parts.last.length > 10
232
+ parts.last[0,8]
233
+ else
234
+ basename
235
+ end
236
+ end
237
+
238
+ def node_label(node, parent_node = nil, root_key = nil, key = nil, long = false)
239
+ path = node[:path]
240
+ return path if long
241
+ if node[:kind] == :job
242
+ job = node[:object]
243
+ workflow = job.info[:workflow] || job.info['workflow']
244
+ task = job.info[:task_name] || job.info['task_name']
245
+ short_id = job_short_id(path)
246
+ workflow && task ? "#{workflow}/#{task} #{short_id}" : short_id
247
+ elsif parent_node && parent_node[:kind] == :job && path.include?('.files/log/')
248
+ # Log chat under a job: show path relative to the job's log directory.
249
+ # Derive log dir from parent_node path (which is the realpath) to avoid
250
+ # symlink-resolution mismatches between the Step object and node path.
251
+ parent_log_dir = File.join(parent_node[:path] + ".files", "log")
252
+ rel = Misc.path_relative_to(parent_log_dir, path)
253
+ rel.nil? || rel.empty? ? File.basename(path) : rel
254
+ elsif parent_node && parent_node[:kind] == :job && path.include?(parent_node[:path] + '.files/')
255
+ # ScoutCoder: DUAL-LAYOUT sibling of the legacy branch above. Log chat
256
+ # under a job in the NEW layout (no log/ component): show the path
257
+ # relative to the job's files dir, so
258
+ # agent.society/Direct/harness_test/agent.chat is shown shortened.
259
+ parent_files_dir = parent_node[:path] + '.files'
260
+ rel = Misc.path_relative_to(parent_files_dir, path)
261
+ rel.nil? || rel.empty? ? File.basename(path) : rel
262
+ elsif key == root_key && node[:kind] == :chat
263
+ # Root chat: show basename
264
+ File.basename(path)
265
+ else
266
+ path
267
+ end
268
+ end
269
+
270
+ # ------------------------------------------------------------------
271
+ # Tree rendering (default mode)
272
+ # ------------------------------------------------------------------
273
+ unless flow || dot_file || plot_file
274
+ printed = Set.new
275
+ print_tree = lambda do |key, offset, relation, parent_key|
276
+ # Skip already-visited nodes entirely (no "(seen)" line)
277
+ return if printed.include?(key)
278
+
279
+ node = nodes[key]
280
+ parent_node = parent_key ? nodes[parent_key] : nil
281
+
282
+ # Skip hidden nodes entirely — don't print, don't traverse children
283
+ return if hidden_node?(node, relation)
284
+
285
+ printed << key
286
+ label = node_label(node, parent_node, root_key, key, long)
287
+ # A job reached through an agent_meta receipt is a delegated producer.
288
+ label = "delegated-job #{label}" if node[:kind] == :job && relation == :agent_job
289
+ puts report_line(node[:kind], label, node_tokens.call(key), offset: offset)
290
+
291
+ # One compact annotation line for the delegated usage embedded in this
292
+ # chat's own outputs; never one line per receipt.
293
+ if node[:kind] == :chat
294
+ events = receipt_events_by_source.key?(node[:path]) ? receipt_events_by_source[node[:path]] : []
295
+ if events && events.any?
296
+ delegated_tt = events.inject(0) { |sum, event| sum + event[:tokens][:tt].to_i }
297
+ tools = events.flat_map do |event|
298
+ receipt_evidence(event).collect { |evidence| evidence[:tool_name] }
299
+ end.compact.uniq.sort
300
+ annotation = "delegated receipt: #{events.length} events, total=#{Misc.human_number(delegated_tt)}"
301
+ annotation += ", #{tools * ','}" unless tools.empty?
302
+ puts [(' ' * ((offset + 1) * 2)), Log.color(:cyan, annotation)] * ' '
303
+ end
304
+ end
305
+
306
+ (adjacency[key] || []).each do |edge|
307
+ print_tree.call(edge[:to], offset + 1, edge[:relation], key)
308
+ end
309
+ end
310
+ print_tree.call(root_key, 0, nil, nil)
311
+
312
+ # Component mode: make the evidence coverage behind the numbers explicit
313
+ # once receipts are present; chats without receipts keep the previous output
314
+ # exactly. These are COVERAGE figures, not additive cost categories:
315
+ # chat_evidence + receipt_evidence double counts every event that exists in
316
+ # both a saved child log and a receipt. Only deduplicated_total is the
317
+ # cost, and receipt_only is the disjoint delegated contribution.
318
+ if component && has_receipt_evidence
319
+ coverage = [[:deduplicated_total, 'total'],
320
+ [:chat_evidence, 'events with saved chat/log evidence'],
321
+ [:receipt_evidence, 'events inside agent_meta receipts (overlaps chat_evidence)'],
322
+ [:receipt_only, 'receipt evidence with no saved chat/log']]
323
+ coverage.each do |scope, note|
324
+ totals = scope_totals.call(scope)
325
+ rendered = token_str(totals)
326
+ rendered = 'total=0' if rendered.empty?
327
+ puts Log.color(:cyan, "evidence #{scope}:") + ' ' + rendered + ' ' + Log.color(:cyan, "(#{note})")
328
+ end
329
+
330
+ conflict_info = {}
331
+ Chat.provenance_token_totals(root, warnings: [], conflicts: conflict_info)
332
+ unless conflict_info[:authoritative]
333
+ puts Log.color(:red, 'unresolved identity conflicts: ') +
334
+ "#{conflict_info[:events]} conflicting event(s); totals above are best-effort (canonical evidence only), not exact"
335
+ end
336
+ end
337
+ end
338
+
339
+ # ------------------------------------------------------------------
340
+ # Flow / DOT / SVG mode
341
+ # ------------------------------------------------------------------
342
+ # Convert root-outward discovery relations into natural data-flow arrows.
343
+ flow_edges = edges.collect do |edge|
344
+ case edge[:relation]
345
+ when :job
346
+ { from: edge[:to], to: edge[:from], relation: :result }
347
+ when :agent_job
348
+ { from: edge[:to], to: edge[:from], relation: :delegated_result }
349
+ when :dependency
350
+ { from: edge[:to], to: edge[:from], relation: :dependency }
351
+ else
352
+ edge.slice(:from, :to, :relation)
353
+ end
354
+ end.uniq
355
+
356
+ if flow || dot_file || plot_file
357
+ # Determine visible nodes using same rules as tree mode.
358
+ hidden_keys = Set.new
359
+ nodes.each do |key, node|
360
+ # top-level agent.chat is hidden; nested ones are visible
361
+ hidden_keys << key if node[:kind] == :chat && top_level_agent_chat?(node[:path])
362
+ # result-relation chats that duplicate a job (same path)
363
+ if node[:kind] == :chat
364
+ has_job_same_path = nodes.any? do |k, n|
365
+ n[:kind] == :job && n[:path] == node[:path]
366
+ end
367
+ hidden_keys << key if has_job_same_path
368
+ end
369
+ end
370
+ visible_keys = nodes.keys.reject { |k| hidden_keys.include?(k) }
371
+ visible_set = Set.new(visible_keys)
372
+
373
+ # Jobs in dependency order, then chat files
374
+ job_keys = visible_keys.select { |kind, _path| kind == :job }
375
+ predecessors = Hash.new { |hash, k| hash[k] = [] }
376
+ flow_edges.each do |edge|
377
+ predecessors[edge[:to]] << edge[:from] if edge[:relation] == :dependency
378
+ end
379
+ ordered_jobs = []
380
+ visited_jobs = Set.new
381
+ visit_job = lambda do |key|
382
+ next if visited_jobs.include?(key)
383
+ visited_jobs << key
384
+ predecessors[key].sort_by(&:last).each { |dep| visit_job.call(dep) }
385
+ ordered_jobs << key
386
+ end
387
+ job_keys.sort_by(&:last).each { |key| visit_job.call(key) }
388
+ chat_keys = (visible_keys - job_keys).sort_by(&:last)
389
+ ordered_keys = ordered_jobs + chat_keys
390
+ indexes = ordered_keys.each_with_index.to_h { |key, index| [key, index + 1] }
391
+
392
+ def short_number(number)
393
+ number = number.to_i
394
+ return format('%.1fM', number / 1_000_000.0) if number >= 1_000_000
395
+ return format('%.1fk', number / 1_000.0) if number >= 1_000
396
+ number.to_s
397
+ end
398
+
399
+ if flow
400
+ puts 'Flow'
401
+ puts '===='
402
+ ordered_keys.each do |key|
403
+ node = nodes[key]
404
+ name = node_label(node, nil, nil, key, long)
405
+ tokens = short_number(node_tokens.call(key)[:tt])
406
+ id = node[:kind] == :job ? job_short_id(node[:path]) : Misc.digest(node[:path])[0, 8]
407
+ puts format('[%2d] %-5s %-52s %8s %s', indexes[key], node[:kind].to_s.capitalize, name, tokens, id)
408
+ end
409
+ puts
410
+ flow_edges.each do |edge|
411
+ next unless visible_set.include?(edge[:from]) && visible_set.include?(edge[:to])
412
+ puts format('[%2d] --%-10s--> [%2d]', indexes[edge[:from]], edge[:relation], indexes[edge[:to]])
413
+ end
414
+ end
415
+
416
+ if dot_file || plot_file
417
+ ids = ordered_keys.each_with_index.to_h { |key, index| [key, "n#{index}"] }
418
+ lines = [
419
+ 'digraph scout_ai_provenance {',
420
+ ' graph [rankdir=LR, bgcolor="white", pad=0.2, nodesep=0.35, ranksep=0.6];',
421
+ ' node [fontname="Helvetica", fontsize=10];',
422
+ ' edge [fontname="Helvetica", fontsize=8, arrowsize=0.7];'
423
+ ]
424
+ ordered_keys.each do |key|
425
+ node = nodes[key]
426
+ label = "#{node[:kind].to_s.capitalize}\n#{node_label(node, nil, nil, key, long)}\n#{short_number(node_tokens.call(key)[:tt])} tokens"
427
+ shape = node[:kind] == :job ? 'box' : 'note'
428
+ lines << %( #{ids[key]} [shape=#{shape}, style="rounded,filled", fillcolor="#f7f7f7", label=#{JSON.generate(label)}];)
429
+ end
430
+ styles = {
431
+ result: 'color="#39804a", penwidth=1.5, label="result"',
432
+ delegated_result: 'color="#8a6d3b", penwidth=1.2, label="delegated_result"',
433
+ dependency: 'color="#333333", label="dependency"',
434
+ log: 'color="#2864a5", style="dashed", label="log"'
435
+ }
436
+ flow_edges.each do |edge|
437
+ next unless visible_set.include?(edge[:from]) && visible_set.include?(edge[:to])
438
+ lines << %( #{ids[edge[:from]]} -> #{ids[edge[:to]]} [#{styles[edge[:relation]] || "label=#{JSON.generate(edge[:relation].to_s)}"}];)
439
+ end
440
+ lines << '}'
441
+ dot = lines * "\n"
442
+ Open.write(dot_file, dot) if dot_file
443
+
444
+ if plot_file
445
+ extension = File.extname(plot_file).sub(/^\./, '').downcase
446
+ extension = 'svg' if extension.empty?
447
+ raise ParameterException, "Unsupported plot format: #{extension}" unless %w[svg png pdf].include?(extension)
448
+ Tempfile.create(['scout-ai-provenance', '.dot']) do |file|
449
+ file.write(dot)
450
+ file.flush
451
+ raise ScoutException, 'Graphviz dot failed' unless system('dot', "-T#{extension}", file.path, '-o', plot_file.to_s)
452
+ end
453
+ end
454
+ end
455
+ end
456
+
457
+ # ------------------------------------------------------------------
458
+ # Evidence mode: the deduplicated direct inference events behind the totals
459
+ # ------------------------------------------------------------------
460
+ if evidence
461
+ def evidence_location_str(address)
462
+ return '' unless Array === address && address.any?
463
+ rest = address[1..-1]
464
+ base = address[0].nil? ? '?' : File.basename(address[0].to_s)
465
+ if rest.length >= 3 && (rest[1] == :agent_meta || rest[1] == :meta)
466
+ "#{base}:#{rest[0]}[#{rest[1]},#{rest[2]}]"
467
+ else
468
+ "#{base}:#{rest[0]}"
469
+ end
470
+ end
471
+
472
+ def event_id_str(event)
473
+ return event[:inference_id] if event[:inference_id]
474
+ identity = event[:identity]
475
+ # Legacy receipts have no stable id: show the receipt location instead of
476
+ # the raw address array.
477
+ return "receipt=#{evidence_location_str(identity[1])}" if identity.first == :receipt
478
+ identity.length == 2 ? "#{identity[0]}=#{identity[1]}" : identity * '='
479
+ end
480
+
481
+ def event_status(event)
482
+ if event[:conflict]
483
+ 'conflict (counted once, not authoritative)'
484
+ elsif event[:incomplete_evidence]
485
+ 'incomplete evidence'
486
+ elsif event[:deduplication] == :receipt_unresolved
487
+ 'legacy unresolved'
488
+ elsif event[:evidence].all? { |item| item[:origin] == :agent_meta }
489
+ 'receipt-only'
490
+ else
491
+ 'counted once'
492
+ end
493
+ end
494
+
495
+ events = token_events_for.call
496
+ puts
497
+ puts 'Direct inference events'
498
+ puts '======================='
499
+ if events.empty?
500
+ puts '(none)'
501
+ else
502
+ # Raw integers here: every number must stay directly comparable with the
503
+ # meta records at the evidence addresses.
504
+ rows = events.collect do |event|
505
+ tokens = event[:tokens]
506
+ token_parts = ["total=#{tokens[:tt].to_i}"]
507
+ token_parts << "prompt=#{tokens[:pt].to_i}" if tokens[:pt].to_i > 0
508
+ token_parts << "cont=#{tokens[:ct].to_i}" if tokens[:ct].to_i > 0
509
+ token_parts << "cache=#{tokens[:cct].to_i}" if tokens[:cct].to_i > 0
510
+ token_parts << "reason=#{tokens[:rt].to_i}" if tokens[:rt].to_i > 0
511
+ evidence_parts = event[:evidence].collect do |item|
512
+ location = evidence_location_str(item[:evidence_address] || item[:meta_address])
513
+ call = item[:call_id] ? " call=#{item[:call_id]}" : ''
514
+ "#{item[:origin]} #{location}#{call}"
515
+ end
516
+ [event_id_str(event), token_parts * ' ', evidence_parts * '; ', event_status(event)]
517
+ end
518
+ puts format('%-26s %-36s %-56s %s', 'Inference ID', 'Tokens', 'Evidence', 'Status')
519
+ rows.each { |row| puts format('%-26s %-36s %-56s %s', *row) }
520
+ end
521
+
522
+ receipt_only = events.select { |event| event_status(event) == 'receipt-only' }
523
+ legacy_unresolved = events.select { |event| event[:deduplication] == :receipt_unresolved }
524
+
525
+ unless receipt_only.empty?
526
+ puts
527
+ puts "Receipt-only events (no saved child log): #{receipt_only.collect { |event| event_id_str(event) } * ', '}"
528
+ end
529
+
530
+ unless legacy_unresolved.empty?
531
+ puts
532
+ puts 'Legacy unresolved events (no inference_id; one per receipt, may overcount):'
533
+ legacy_unresolved.each do |event|
534
+ location = evidence_location_str(event[:evidence].first[:evidence_address])
535
+ puts " #{event_id_str(event)} at #{location}"
536
+ end
537
+ end
538
+
539
+ seen_conflicts = Set.new
540
+ conflicts = receipt_warnings.select do |warning|
541
+ warning[:reason] == :identity_conflict && seen_conflicts.add?(warning[:identity])
542
+ end
543
+ unless conflicts.empty?
544
+ puts
545
+ puts 'Identity conflicts (counted once from canonical evidence; totals containing these are NOT authoritative):'
546
+ conflicts.each do |conflict|
547
+ values = conflict[:values].collect { |field, list| "#{field}=#{Array(list).uniq * '|'}" } * ' '
548
+ puts " #{conflict[:identity] * '='}: disputed #{conflict[:fields] * ','} (#{values})"
549
+ end
550
+ end
551
+
552
+ incomplete = events.select { |event| event[:incomplete_evidence] }
553
+ unless incomplete.empty?
554
+ puts
555
+ puts 'Incomplete evidence (one copy lacks provider_response_id; no conflict):'
556
+ incomplete.each do |event|
557
+ puts " #{event_id_str(event)}"
558
+ end
559
+ end
560
+
561
+ # Agent-job references come straight from the traversal edge details, so no
562
+ # parent chat has to be re-parsed here: the edge already carries the receipt
563
+ # record (call id, tool name, output address, receipt address, reference).
564
+ agent_job_edges = records.select do |kind, _object, parent_kind, _parent, relation, _first|
565
+ relation == :agent_job && parent_kind == :chat && kind == :job
566
+ end
567
+ unless agent_job_edges.empty?
568
+ puts
569
+ puts 'Job projection references (no direct tokens):'
570
+ agent_job_edges.each do |_kind, object, _parent_kind, _parent, _relation, _first, detail|
571
+ detail ||= {}
572
+ location = evidence_location_str(detail[:evidence_address])
573
+ tool = detail[:tool_name] || '?'
574
+ puts " job=#{object.path} at #{location} call=#{detail[:call_id]} tool=#{tool} (delegated_result edge)"
575
+ end
576
+ end
577
+ end
578
+
579
+ warnings.each do |warning|
580
+ error = warning[:error]
581
+ # Receipt problems are reported in detail by the receipt_warnings block
582
+ # below (reason, location, call id); skip the generic line for them. They
583
+ # are recognized by their structured reference, not by a class: agent_meta
584
+ # diagnostics travel as ordinary ScoutException plus a reference Hash.
585
+ next if warning[:relation] == :agent_job && Hash === warning[:reference] && warning[:reference][:reason]
586
+ Log.warn "Incomplete provenance at #{warning[:kind]} #{warning[:object]} (#{warning[:relation]}): #{error.message}"
587
+ end
588
+
589
+ # Receipt problems and identity conflicts collected by the token collector.
590
+ # The same problem may be reported once per aggregate subtree, so printed
591
+ # lines are deduplicated by their location.
592
+ seen_receipt_warnings = Set.new
593
+ receipt_warnings.each do |warning|
594
+ key = warning.values_at(:reason, :source, :output_address, :call_id, :agent_meta_index, :identity)
595
+ next unless seen_receipt_warnings.add?(key)
596
+ if warning[:reason] == :identity_conflict
597
+ Log.warn "identity conflict: #{warning[:identity] * '='} disagrees on #{warning[:fields] * ','}"
598
+ else
599
+ location = warning[:evidence_address] || warning[:output_address]
600
+ Log.warn "agent_meta receipt: #{warning[:reason]} in #{warning[:source]} at #{location.inspect} call=#{warning[:call_id]}#{warning[:message] ? ' - ' + warning[:message] : ''}"
601
+ end
602
+ end
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'scout'
5
+
6
+ cmd = $previous_commands ?
7
+ "scout #{$previous_commands.any? ? "#{$previous_commands * ' '} " : ''}#{File.basename(__FILE__)}" :
8
+ $PROGRAM_NAME
9
+
10
+ options = SOPT.setup <<~EOF
11
+
12
+ Format a chat in word
13
+
14
+ $ #{cmd} [<options>] <chat> <word>
15
+
16
+ -h--help Print this help
17
+ -r--reference* Use a different reference.docx
18
+ -l--last Format only the last interaction
19
+ EOF
20
+ if options[:help]
21
+ if defined? scout_usage
22
+ scout_usage
23
+ else
24
+ puts SOPT.doc
25
+ end
26
+ exit 0
27
+ end
28
+
29
+ help, reference, last = IndiferentHash.process_options options, :help, :reference, :last
30
+
31
+ reference ||= Scout.share.word["reference.docx"].find
32
+
33
+ chat, word, _ = ARGV
34
+
35
+ raise MissingParameterException, :chat if chat.nil?
36
+
37
+ if word.nil?
38
+ word = chat.sub(/\.(txt|chat|md)$/i, '') + '.docx'
39
+ end
40
+
41
+ messages = Chat.parse Open.read(chat)
42
+
43
+ if last
44
+ messages = messages.select{|m| %w(user assistant).include? m[:role].to_s }[-2..-1]
45
+ end
46
+
47
+ out = []
48
+ messages.each do |message|
49
+ IndiferentHash.setup(message)
50
+ case message['role']
51
+ when 'user'
52
+ out.push <<-EOF
53
+
54
+ ::: {custom-style="UserBlock"}
55
+
56
+ #{message['content']}
57
+
58
+ :::
59
+
60
+ EOF
61
+ when 'assistant'
62
+ out.push message['content']
63
+ else
64
+ next
65
+ end
66
+ end
67
+
68
+ TmpFile.with_file out * "\n", extension: :md do |markdown|
69
+ CMD.cmd(:pandoc, "'#{markdown}' --reference-doc=#{reference} -o #{word}")
70
+ end
71
+ puts word