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,946 @@
1
+ > **Disclaimer:** This is an architectural investigation, not normative
2
+ > documentation. It was produced during a documentation-revamp effort and may
3
+ > be outdated relative to the current codebase. Treat it as supporting
4
+ > reference material. For maintained documentation, see
5
+ > [../../doc/](../../doc/).
6
+ >
7
+
8
+
9
+ # Chat Core: Data Model, Roles, Persistence, and Processing Pipeline
10
+
11
+ This artifact documents the Chat subsystem in `lib/scout/llm/chat.rb` and its supporting files. It covers the class structure, message schema, roles, parsing, persistence, processing pipeline, and provenance annotations.
12
+
13
+ ---
14
+
15
+ ## Chat Class Structure
16
+
17
+ ### What Chat is
18
+
19
+ `Chat` is **not a standalone class**. It is a **Ruby module** — `module Chat` — that appears in multiple files via Ruby's open-module mechanism:
20
+
21
+ - `lib/scout/llm/chat/annotation.rb` — `module Chat` (270 lines)
22
+ - `lib/scout/llm/chat/parse.rb` — `module Chat` (190 lines)
23
+ - `lib/scout/llm/chat/process.rb` — `module Chat` (17 lines)
24
+ - `lib/scout/llm/chat/process/*.rb` — each file opens `module Chat` and adds class methods
25
+
26
+ ### The Annotation pattern (Array extension)
27
+
28
+ The key to understanding Chat is the **`extend Annotation`** line at the top of `annotation.rb`:
29
+
30
+ ```ruby
31
+ module Chat
32
+ extend Annotation
33
+ # ...
34
+ end
35
+ ```
36
+
37
+ `Annotation` is a mixin from `scout-essentials` (`lib/scout/annotation.rb`). When a module or class calls `extend Annotation`:
38
+
39
+ 1. It includes `Annotation::AnnotatedObject` — provides `annotation_types`, `annotation_hash`, `purge`, `annotate`, etc.
40
+ 2. It extends `Annotation::AnnotationModule` — provides the `.setup`, `.annotation`, and `.annotations` class methods.
41
+ 3. When `Chat.setup(some_array)` is called, it **extends the Array instance with the `Chat` module**, so the Array gains all of Chat's instance methods (defined in `annotation.rb`) while remaining a plain Array.
42
+
43
+ This means:
44
+ - **A Chat IS an Array** of message hashes.
45
+ - All standard Array methods (`each`, `map`, `select`, `<<`, `push`, `reverse`, etc.) work on a Chat.
46
+ - Additional methods like `user()`, `assistant()`, `import()`, `ask()`, `save()`, etc. are available as instance methods.
47
+ - Calling `Chat.setup(messages)` annotates an Array, turning it into a Chat.
48
+
49
+ ### The `LLM` module as factory
50
+
51
+ The top-level `LLM` module (in `chat.rb`) serves as the **factory/entry point**:
52
+
53
+ ```ruby
54
+ module LLM
55
+ def self.chat(file = [], original = nil)
56
+ # ... parses, imports, clears, configures, processes files/tools
57
+ Chat.setup messages
58
+ end
59
+
60
+ def self.messages(question, role = nil)
61
+ # converts a string, Array of strings, or parsed messages
62
+ end
63
+ end
64
+ ```
65
+
66
+ Key factory methods on `LLM`:
67
+ | Method | Purpose |
68
+ |---|---|
69
+ | `LLM.chat(file_or_messages, original=nil)` | Main entry: parse → process → return Chat |
70
+ | `LLM.messages(question, role=nil)` | Convert string/Array into message hashes |
71
+ | `LLM.print(chat)` | Serialize a Chat back to text |
72
+ | `LLM.options(...)` / `LLM.tools(...)` / `LLM.associations(...)` | Delegate to Chat module methods |
73
+
74
+ ---
75
+
76
+ ## Message Hash Schema
77
+
78
+ Every message in a Chat is a **Hash** with two mandatory keys:
79
+
80
+ ```ruby
81
+ { role: String, content: String|Hash|Array|nil }
82
+ ```
83
+
84
+ ### Key: `:role` (String)
85
+
86
+ Always a string identifying the message type. See the Roles section below for the full list.
87
+
88
+ ### Key: `:content` (String, Hash, Array, or nil)
89
+
90
+ The message body. The type varies:
91
+ - **String** — most common; the text content of the message.
92
+ - **Hash or Array** — used for structured content (e.g., multimodal/image data). When serialized via `Chat.print`, Hash/Array content is emitted as JSON.
93
+ - **nil or ''** — some messages (like `system:` with no inline content) have empty content, serving as markers.
94
+
95
+ ### IndifferentHash
96
+
97
+ All message hashes are processed with `IndiferentHash.setup`, meaning keys can be accessed as either symbols (`msg[:role]`) or strings (`msg["role"]`):
98
+
99
+ ```ruby
100
+ def self.indiferent(messages)
101
+ messages.collect{|msg| IndiferentHash.setup msg }
102
+ end
103
+ ```
104
+
105
+ ### Metadata fields on messages
106
+
107
+ Messages do **not** have a separate `metadata` field. Instead, metadata is encoded **within the content** of special-role messages:
108
+
109
+ - **`meta:` messages** — content is a serialized key=value string (parsed by `Chat.parse_meta`). Example: `meta: job=/path/to/job pt_c=1000 ct_c=500`
110
+ - **`option:` messages** — content is `"key value"` pairs (parsed by partitioning on space).
111
+ - **`config:` messages** — content is space-separated `key value tokens...`
112
+
113
+ ### Tool calls and tool results in messages
114
+
115
+ Tool calls and results use two special roles with **JSON content**:
116
+
117
+ **Tool call (function_call):**
118
+ ```ruby
119
+ {
120
+ role: 'function_call',
121
+ content: '{"name":"task_name","arguments":{...},"id":"some-id"}'
122
+ }
123
+ ```
124
+
125
+ **Tool result (function_call_output):**
126
+ ```ruby
127
+ {
128
+ role: 'function_call_output',
129
+ content: '{"id":"some-id","content":"result text or data"}'
130
+ }
131
+ ```
132
+
133
+ These are generated in `Chat.jobs` (tools.rb, lines 69–116) when a `job:` or `inline_job:` message is processed:
134
+
135
+ ```ruby
136
+ tool_call = {
137
+ name: function_name,
138
+ arguments: step.provided_inputs,
139
+ id: id,
140
+ }
141
+ tool_output = {
142
+ id: id,
143
+ content: content # the job result, loaded from disk
144
+ }
145
+ # Produces two messages:
146
+ [{role: 'function_call', content: tool_call.to_json},
147
+ {role: 'function_call_output', content: tool_output.to_json}]
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Roles
153
+
154
+ ### Complete Role Catalog
155
+
156
+ The system supports a large number of roles. They fall into several categories:
157
+
158
+ #### Standard conversational roles
159
+ | Role | Semantics |
160
+ |---|---|
161
+ | `system` | System instructions/configuration for the model |
162
+ | `user` | User input / prompts |
163
+ | `assistant` | Model responses. Also triggers option reset in the options processor. |
164
+
165
+ #### Tool execution roles
166
+ | Role | Semantics |
167
+ |---|---|
168
+ | `function_call` | A tool/function invocation. Content is JSON with `name`, `arguments`, `id`. |
169
+ | `function_call_output` | The result of a function call. Content is JSON with `id`, `content`. |
170
+ | `tool` | Declares available workflow tools. Format: `"WorkflowName task_name [inputs]"` |
171
+ | `introduce` | Introduces a workflow's documentation to the model (auto-generates a user message with workflow docs) |
172
+ | `mcp` | Declares MCP (Model Context Protocol) tools. Format: `"url [tool1 tool2 ...]"` or `"stdio command [tools]"` |
173
+ | `kb` | Declares knowledge base tools. Format: `"kb_name [database1 database2]"` |
174
+ | `association` | Registers a knowledge base association as a tool |
175
+ | `clear_tools` | Clears all accumulated tool definitions. Content `false` means don't clear. |
176
+
177
+ #### Job/Task roles
178
+ | Role | Semantics |
179
+ |---|---|
180
+ | `task` | Schedules a workflow job; result loaded asynchronously via `Workflow.produce`. Replaced with `job:` message. |
181
+ | `inline_task` | Schedules a job; replaced with `inline_job:` message (inline content). |
182
+ | `exec_task` | Executes a job immediately inline; result placed as a `user:` message. |
183
+ | `job` | References a completed Step; resolved into `function_call` + `function_call_output` pair. |
184
+ | `inline_job` | References a Step as inline file content; resolved into `file:` message. |
185
+
186
+ #### File/resource roles
187
+ | Role | Semantics |
188
+ |---|---|
189
+ | `file` | Reads a file from disk and wraps content in `<file name="...">` tags as a `user:` message |
190
+ | `directory` | Reads all files in a directory recursively, each as a `file:` message |
191
+ | `pdf` | References a PDF file path (resolved but content handled by backend) |
192
+ | `image` | References an image file path (handled by backend for multimodal) |
193
+
194
+ #### Import/composition roles
195
+ | Role | Semantics |
196
+ |---|---|
197
+ | `import` | Imports another chat file, fully resolved and expanded inline |
198
+ | `continue` | Imports only the last non-empty message from another chat file |
199
+ | `last` | Imports the last message after purging `previous_response_id` messages |
200
+ | `step` | Loads a specific Step from the job referenced in meta; produces an assistant message with the step's answer |
201
+
202
+ #### Configuration/control roles
203
+ | Role | Semantics |
204
+ |---|---|
205
+ | `option` | Sets a per-request option (key value). Resets after assistant reply. |
206
+ | `sticky_option` | Sets an option that persists across assistant replies. |
207
+ | `endpoint` | Sets the LLM endpoint (sticky) |
208
+ | `model` | Sets the model name (sticky) |
209
+ | `backend` | Sets the backend type (sticky) |
210
+ | `agent` | Sets the agent name (sticky) |
211
+ | `persist` | Controls persistence behavior |
212
+ | `previous_response_id` | OpenAI Responses API continuation token (sticky) |
213
+ | `format` | Response format specification (JSON schema). Can be a filename to load from disk. |
214
+ | `config` | Sets Scout::Config values at runtime |
215
+ | `meta` | Internal bookkeeping metadata (token counts, job paths). Not sent to provider. |
216
+
217
+ #### Chat manipulation roles
218
+ | Role | Semantics |
219
+ |---|---|
220
+ | `clear` | Acts as a cut point: everything before the last `clear:` is discarded |
221
+ | `clear_role` / `clean_role` | Marks roles to be cleaned/removed during processing |
222
+ | `clean_role` | Used in `Chat.clean` to remove specific role messages |
223
+
224
+ #### Special parsing roles
225
+ | Role | Semantics |
226
+ |---|---|
227
+ | `agent` | In the parser, an `agent:` header sets the next block role to `user` |
228
+
229
+ ---
230
+
231
+ ## Parsing (parse.rb)
232
+
233
+ ### Overview
234
+
235
+ `Chat.parse(text, role = nil)` converts a text string into an Array of message hashes. It is a **line-oriented parser** with support for:
236
+
237
+ 1. **Role headers** — `role: content` lines
238
+ 2. **Protected blocks** — code fences, `[[...]]`, XML tags, command output markers
239
+ 3. **Escaped role headers** — `\role:` lines that are kept as literal content
240
+
241
+ ### Role header parsing
242
+
243
+ Each line is matched against `/^([a-z0-9_]+):(.*)$/`:
244
+
245
+ ```ruby
246
+ if line =~ /^([a-z0-9_]+):(.*)$/
247
+ role = $1
248
+ inline_content = $2.strip
249
+ # ...
250
+ end
251
+ ```
252
+
253
+ - **Block form**: `role:` with no inline content → sets current_role, accumulates subsequent lines as content
254
+ - **Inline form**: `role: some text` → immediately creates a message with that text
255
+
256
+ Special handling for `previous_response_id` and `agent` roles: after an inline message, the next block defaults to `user`:
257
+
258
+ ```ruby
259
+ current_role = 'user' if role == 'previous_response_id'
260
+ current_role = 'user' if role == 'agent'
261
+ ```
262
+
263
+ ### Escaped headers
264
+
265
+ A line like `\user: this is literal` (matching `/^\\([a-z0-9_]+):(.*)$/`) is treated as literal content, not a role header. This allows embedding role-like patterns in content.
266
+
267
+ ### Protected block types
268
+
269
+ The parser protects certain blocks from being interpreted as role headers:
270
+
271
+ 1. **Triple-backtick fences** (``` ... ```): Content between fences is preserved literally.
272
+ 2. **`[[ ... ]]` blocks**: Square-bracket delimited protected regions.
273
+ 3. **XML tags**: If a line matches `<tagname ...>` and the text contains a matching `</tagname>`, the parser enters XML protected mode with a stack-based tag matcher.
274
+ 4. **Command output markers**: Lines matching `something:-- cmd {{{` ... `something:-- cmd }}}` are converted to `<cmd_output cmd="cmd">` ... `</cmd_output>`.
275
+
276
+ ### `parse_json` helper
277
+
278
+ ```ruby
279
+ def self.parse_json(text)
280
+ re = /.*\`\`\`json\n(.*)\`\`\`\n?.*/sm
281
+ text = text.gsub(re, '\1') if text.include?('```json')
282
+ JSON.parse text
283
+ end
284
+ ```
285
+
286
+ Strips markdown JSON code fences and parses the inner JSON.
287
+
288
+ ### Serialization: `Chat.print(chat)`
289
+
290
+ The inverse of parse — converts an Array of messages back to text:
291
+
292
+ ```ruby
293
+ def self.print(chat)
294
+ "\n" + chat.collect do |message|
295
+ IndiferentHash.setup message
296
+ case message[:content]
297
+ when Hash, Array
298
+ message[:role].to_s + ":\n\n" + message[:content].to_json
299
+ when nil, ''
300
+ message[:role].to_s + ":"
301
+ else
302
+ if %w(option previous_response_id function_call function_call_output meta).include? message[:role].to_s
303
+ message[:role].to_s + ": " + message[:content].to_s # inline form for these roles
304
+ else
305
+ message[:role].to_s + ":\n\n" +
306
+ message[:content].to_s.gsub(re, '\\\\\1\2') # block form, escaped
307
+ end
308
+ end
309
+ end * "\n\n"
310
+ end
311
+ ```
312
+
313
+ Key serialization rules:
314
+ - Hash/Array content → JSON in block form
315
+ - nil/empty content → just `role:`
316
+ - `option`, `previous_response_id`, `function_call`, `function_call_output`, `meta` → **inline form** (`role: content`)
317
+ - Everything else → **block form** (`role:\n\ncontent`) with role-like patterns escaped with `\`
318
+
319
+ ### `Chat.print_brief(chat, expand = [])`
320
+
321
+ A compact serialization using `Log.fingerprint` for non-expanded roles (produces short hash-like summaries instead of full content).
322
+
323
+ ---
324
+
325
+ ## Persistence (persist.rb)
326
+
327
+ ### Persist driver registration
328
+
329
+ Chat persistence is registered with Scout's `Persist` system via custom save/load drivers:
330
+
331
+ ```ruby
332
+ Persist.save_drivers[:chat] = proc do |file, content|
333
+ case content
334
+ when LLM::Agent
335
+ new_chat = content.current_chat - content.start_chat
336
+ Open.sensible_write(file, LLM.print(new_chat))
337
+ when Array
338
+ Open.sensible_write(file, LLM.print(content))
339
+ else
340
+ # handles streams
341
+ stream = content.respond_to?(:stream) ? content.stream :
342
+ content.respond_to?(:dumper_stream) ? content.dumper_stream :
343
+ content
344
+ Open.sensible_write(file, stream)
345
+ end
346
+ end
347
+
348
+ Persist.load_drivers[:chat] = proc do |file|
349
+ String === file ? LLM.chat(file) : file
350
+ end
351
+
352
+ Workflow::TYPE_EXTENSIONS[:chat] = :chat
353
+ ```
354
+
355
+ ### File format
356
+
357
+ Chats are persisted as **plain text files** using `Chat.print` format — the same text format that `Chat.parse` reads. This creates a **round-trippable** text format.
358
+
359
+ ### How saving works on Chat instances
360
+
361
+ The annotation module provides `save`, `write`, and `write_answer` methods:
362
+
363
+ ```ruby
364
+ def save(path, force = true)
365
+ path = path.to_s if Symbol === path
366
+ if not (Open.exists?(path) || Path === path || Path.located?(path))
367
+ path = Scout.chats.find[path] # default to chats directory
368
+ end
369
+ return if Open.exists?(path) && ! force
370
+ Open.write path, LLM.print(self)
371
+ end
372
+ ```
373
+
374
+ - `save(path)` — writes full chat using `LLM.print(self)`
375
+ - `write(path)` — writes full chat using `self.print` (processed form)
376
+ - `write_answer(path)` — writes only the final answer text
377
+
378
+ ### Loading without re-processing
379
+
380
+ `Chat.load(file)` reads a chat file **without** compiling it (no task execution, file reads, or import resolution):
381
+
382
+ ```ruby
383
+ def self.load(file)
384
+ Chat.setup(LLM.messages(Open.read(file.to_s)))
385
+ end
386
+ ```
387
+
388
+ This is used for provenance inspection where side effects must be avoided.
389
+
390
+ ### Integration with Workflow
391
+
392
+ `Workflow::TYPE_EXTENSIONS[:chat] = :chat` registers `:chat` as a workflow task type extension, allowing Scout workflow tasks to declare `:chat` as their output type and get automatic serialization.
393
+
394
+ ---
395
+
396
+ ## Processing Pipeline
397
+
398
+ ### Entry point: `LLM.chat`
399
+
400
+ The full processing pipeline is orchestrated in `LLM.chat`:
401
+
402
+ ```ruby
403
+ def self.chat(file = [], original = nil)
404
+ original ||= (String === file and Open.exists?(file)) ? file : Path.setup($0.dup)
405
+ caller_lib_dir = Path.caller_lib_dir(nil, 'chats')
406
+
407
+ # 1. Parse into messages
408
+ if Path.is_filename? file
409
+ messages = self.messages Open.read(file), file
410
+ else
411
+ messages = self.messages file
412
+ end
413
+
414
+ # 2. Make all hashes IndiferentHash
415
+ messages = Chat.indiferent messages
416
+
417
+ # 3. Resolve imports/continues/lasts
418
+ messages = Chat.imports messages, original, caller_lib_dir
419
+
420
+ # 4. Apply clear/clean directives
421
+ messages = Chat.clear messages
422
+ messages = Chat.clean messages
423
+
424
+ # 5. Apply config options
425
+ messages = Chat.config messages
426
+
427
+ # 6. Execute tasks and schedule jobs
428
+ messages = Chat.tasks messages
429
+ messages = Chat.jobs messages
430
+
431
+ # 7. Resolve file/directory references
432
+ messages = Chat.files messages, original, caller_lib_dir
433
+
434
+ # 8. Annotate as Chat
435
+ Chat.setup messages
436
+ end
437
+ ```
438
+
439
+ ### Pipeline stages (in order)
440
+
441
+ #### 1. Parse → Messages
442
+ Converts text input to `[{role:, content:}, ...]` via `LLM.messages`/`Chat.parse`.
443
+
444
+ #### 2. `Chat.indiferent`
445
+ Applies `IndiferentHash.setup` to every message hash.
446
+
447
+ #### 3. `Chat.imports` (files.rb)
448
+ Resolves `import:`, `continue:`, and `last:` messages:
449
+ - **`import:`** — recursively calls `LLM.chat` on the referenced file, fully expanding it
450
+ - **`continue:`** — imports only the last non-empty message from the referenced file
451
+ - **`last:`** — imports the last message after purging `previous_response_id` messages
452
+ - Uses `Chat.find_file` to resolve paths across multiple search locations (relative to original, caller lib dir, Scout.chats, absolute)
453
+
454
+ #### 4. `Chat.clear` (clear.rb)
455
+ Implements chat truncation:
456
+ - Walks messages in **reverse** from the end
457
+ - Stops at the last `clear:` message (everything before it is discarded)
458
+ - Respects `clear_tools:` (controls whether function_call/function_call_output messages survive)
459
+ - Processes `clean_role:` / `clear_role:` markers to remove specific role messages
460
+
461
+ ```ruby
462
+ def self.clear(messages, role = 'clear')
463
+ new = []
464
+ clear_tools = false
465
+ clean_roles = []
466
+ messages.reverse.each do |message|
467
+ if message[:role].to_s == role.to_s
468
+ break # everything before this point is discarded
469
+ elsif message[:role].to_s == 'clear_tools'
470
+ clear_tools = message['content'].to_s != 'false'
471
+ elsif ['function_call', 'function_call_output'].include?(message[:role].to_s)
472
+ new << message unless clear_tools
473
+ elsif ['clean_role', 'clear_role'].include?(message[:role].to_s)
474
+ clean_roles << message[:content].strip
475
+ else
476
+ new << message
477
+ end
478
+ end
479
+ new = Chat.setup new.reverse
480
+ clean_roles.each { |role| new = self.clean(new, role) }
481
+ new
482
+ end
483
+ ```
484
+
485
+ #### 5. `Chat.clean` (clear.rb)
486
+ Removes messages with empty content and optionally messages of specified roles:
487
+
488
+ ```ruby
489
+ def self.clean(messages, role = ['skip', 'previous_response_id'])
490
+ messages.reject do |message|
491
+ ((String === message[:content]) && message[:content].empty?) ||
492
+ (Array === role ? false : message[:role].to_s == role.to_s)
493
+ end
494
+ end
495
+ ```
496
+
497
+ #### 6. `Chat.config` (options.rb)
498
+ Processes `config:` messages to set `Scout::Config` values at runtime:
499
+
500
+ ```ruby
501
+ def self.config(chat)
502
+ new = []
503
+ chat.select do |info|
504
+ if info[:role].to_s == 'config'
505
+ key, value, *tokens = info[:content].split(" ")
506
+ Scout::Config.set({key => value}, *tokens)
507
+ next # removed from chat
508
+ end
509
+ new << info
510
+ end
511
+ chat.replace new
512
+ end
513
+ ```
514
+
515
+ #### 7. `Chat.tasks` (tools.rb)
516
+ Processes `task:`, `inline_task:`, and `exec_task:` messages:
517
+ - Parses workflow name, task name, and options from content
518
+ - Creates workflow jobs via `Chat.load_workflow`
519
+ - `exec_task:` — executes immediately, result becomes a `user:` message
520
+ - `inline_task:` — replaced with `inline_job:` message (for later file processing)
521
+ - `task:` — replaced with `job:` message
522
+ - All non-exec tasks are batched and produced via `Workflow.produce(jobs)`
523
+
524
+ ```ruby
525
+ def self.tasks(messages, original = nil)
526
+ jobs = []
527
+ new = messages.collect do |message|
528
+ if ['task', 'inline_task', 'exec_task'].include?(message[:role])
529
+ workflow, task = info.split(" ").values_at 0, 1
530
+ options = IndiferentHash.parse_options info
531
+ job = workflow.job(task, jobname, options)
532
+ jobs << job unless message[:role] == 'exec_task'
533
+ # exec_task → immediate result as user message
534
+ # inline_task → inline_job message
535
+ # task → job message
536
+ else
537
+ message
538
+ end
539
+ end.flatten
540
+ Workflow.produce(jobs) if jobs.any?
541
+ new
542
+ end
543
+ ```
544
+
545
+ #### 8. `Chat.jobs` (tools.rb)
546
+ Resolves `job:` and `inline_job:` messages into tool call/result pairs or file references:
547
+ - `inline_job:` — produces a `file:` message pointing to the Step's path
548
+ - `job:` — loads the Step result and produces a `function_call` + `function_call_output` message pair
549
+
550
+ Handles job states: done (load result), streaming (join), error (optionally return exception as JSON).
551
+
552
+ #### 9. `Chat.files` (files.rb)
553
+ Resolves `file:`, `directory:`, `pdf:`, `image:`, and `step:` messages:
554
+ - **`file:`** — reads file content, wraps in `<file name="path">content</file>` tags, produces a `user:` message
555
+ - **`directory:`** — recursively expands all files in a directory as individual `file:` messages
556
+ - **`pdf:`/`image:`** — resolves the file path (content handled by the backend)
557
+ - **`step:`** — loads a specific Step from the meta-referenced job, produces an `assistant:` message with the step's answer
558
+
559
+ #### Tool extraction (called separately, not in `LLM.chat` pipeline)
560
+
561
+ `Chat.tools(messages)`, `Chat.options(messages)`, and `Chat.associations(messages, kb)` are called separately (typically by `LLM.ask` or the backend) to extract:
562
+ - **Tool definitions** from `tool:`, `introduce:`, `mcp:`, `kb:` messages
563
+ - **Options** from `option:`, `sticky_option:`, `endpoint:`, `model:`, etc.
564
+ - **Associations** from `association:` messages
565
+
566
+ These are side-channel extractions: they **remove** the control messages from the chat and return the extracted data separately.
567
+
568
+ ### `Chat.options` (options.rb)
569
+
570
+ ```ruby
571
+ def self.options(chat)
572
+ options = IndiferentHash.setup({})
573
+ sticky_options = IndiferentHash.setup({})
574
+ new = []
575
+
576
+ chat.each do |info|
577
+ role = info[:role].to_s
578
+ if %w(endpoint model backend agent).include? role # sticky
579
+ sticky_options[role] = info[:content]
580
+ next # removed
581
+ elsif role == 'persist'
582
+ options[role] = info[:content]
583
+ next
584
+ elsif role == 'previous_response_id'
585
+ sticky_options[role] = info[:content] # sticky, NOT removed
586
+ elsif role == 'format'
587
+ options[role] = resolve_format(info[:content])
588
+ next
589
+ elsif role == 'option'
590
+ options[key] = value # non-sticky
591
+ next
592
+ elsif role == 'sticky_option'
593
+ sticky_options[key] = value
594
+ next
595
+ elsif role == 'assistant'
596
+ options.clear # non-sticky options reset after assistant reply
597
+ end
598
+ new << info
599
+ end
600
+ chat.replace new
601
+ sticky_options.merge(options)
602
+ end
603
+ ```
604
+
605
+ ### `Chat.tools` (tools.rb)
606
+
607
+ ```ruby
608
+ def self.tools(messages)
609
+ tool_definitions = IndiferentHash.setup({})
610
+ new = messages.collect do |message|
611
+ role = message[:role]
612
+ case role
613
+ when 'mcp' # MCP tools (stdio or URL)
614
+ when 'tool' # Workflow task tools
615
+ when 'introduce' # Workflow documentation injection
616
+ when 'kb' # Knowledge base tools
617
+ when 'clear_tools' # Reset tool definitions
618
+ else
619
+ message # pass through
620
+ end
621
+ end.compact.flatten
622
+ messages.replace new
623
+ tool_definitions
624
+ end
625
+ ```
626
+
627
+ Each tool-related role is consumed (removed from chat) and its definition is accumulated in `tool_definitions`:
628
+ - **`mcp:`** — connects to an MCP server (stdio or HTTP) and loads tool definitions via `LLM.mcp_tools`
629
+ - **`tool:`** — loads a workflow and creates task tool definitions via `LLM.task_tool_definition` or `LLM.workflow_tools`
630
+ - **`introduce:`** — loads a workflow and injects its documentation as a `user:` message (NOT consumed silently)
631
+ - **`kb:`** — loads a knowledge base and creates tool definitions via `LLM.knowledge_base_tool_definition`
632
+
633
+ ---
634
+
635
+ ## Annotation/Provenance (annotation.rb)
636
+
637
+ ### The Annotation mixin
638
+
639
+ As described above, `Chat` uses `extend Annotation` to become an annotation module. This means any Array can be turned into a Chat via `Chat.setup(array)`.
640
+
641
+ ### Chat instance methods (defined in annotation.rb)
642
+
643
+ The annotation module adds a rich DSL of instance methods to Chat (Array) objects:
644
+
645
+ #### Message building methods
646
+ ```ruby
647
+ def message(role, content) # append {role:, content:}
648
+ def user(content) # append user message
649
+ def system(content) # append system message
650
+ def assistant(content) # append assistant message
651
+ def import(file) # append import: message
652
+ def import_last(file) # append last: message
653
+ def file(file) # append file: message
654
+ def introduce(workflow) # append introduce: message
655
+ def pdf(file) # append pdf: message
656
+ def directory(directory) # append directory: message
657
+ def continue(file) # append continue: message
658
+ def format(format) # append format: message
659
+ def tool(*parts) # append tool: message
660
+ def task(workflow, task_name, inputs = {})
661
+ def exec_task(workflow, task_name, inputs = {})
662
+ def inline_task(workflow, task_name, inputs = {})
663
+ def job(step) # append job: message
664
+ def inline_job(step) # append inline_job: message
665
+ def association(name, path, options = {})
666
+ def tag(content, name=nil, tag=:file, role=:user)
667
+ ```
668
+
669
+ #### Option/configuration methods
670
+ ```ruby
671
+ def option(name, value)
672
+ def endpoint(value)
673
+ def model(value)
674
+ def image(file)
675
+ ```
676
+
677
+ #### Inference methods
678
+ ```ruby
679
+ def ask(options = {}) # calls LLM.ask with this chat
680
+ def chat(options = {}) # ask + append response
681
+ def json(*args, only_ask:, **) # ask with JSON format, parse result
682
+ ```
683
+
684
+ #### Chat composition methods
685
+ ```ruby
686
+ def self.follow(intro, coda) # merges two chats, handling previous_response_id
687
+ def append(coda) # append messages to this chat
688
+ def follow(coda) # same as append (alias semantics)
689
+ def prepend(intro) # prepend messages to this chat
690
+ def branch # return a duplicate (annotated copy)
691
+ ```
692
+
693
+ #### Provenance and reporting methods
694
+ ```ruby
695
+ def print # serialize to text
696
+ def final # last message after purge
697
+ def purge # remove previous_response_id messages
698
+ def shed # return a new chat with just the final message
699
+ def answer # content of the final message
700
+ def save(path, force=true)
701
+ def write(path, force=true)
702
+ def write_answer(path, force=true)
703
+ ```
704
+
705
+ #### Meta/provenance inspection methods
706
+ ```ruby
707
+ def add_meta(key, value) # add/update meta key-value
708
+ def meta # parse last meta message
709
+ def job_paths / jobs # extract job paths from meta
710
+ def last_job # last job reference from meta
711
+ def message_index # lineage IDs for all messages
712
+ def job_chat_files # find chat files from referenced jobs
713
+ def job_chats # load those chat files
714
+ def job_agent_chat_files # find agent chat files from jobs
715
+ def job_agent_chats # load agent chat files
716
+ ```
717
+
718
+ ### Meta message system
719
+
720
+ Meta messages are the **provenance backbone** of the Chat system. They carry key=value pairs serialized as space-delimited strings:
721
+
722
+ ```ruby
723
+ def self.serialize_meta(meta)
724
+ keys = meta.keys.sort_by { |key| String === meta[key] ? meta[key].length : 0 }
725
+ keys.collect { |key| [key, meta[key]] * '=' } * ' '
726
+ end
727
+ ```
728
+
729
+ Example meta content: `job=/path/to/job pt_c=1000 ct_c=500 tt_c=2000`
730
+
731
+ Key meta fields:
732
+ | Key | Meaning |
733
+ |---|---|
734
+ | `job` | Path to the workflow job that produced this chat segment |
735
+ | `pt_c` | Prompt token count |
736
+ | `ct_c` | Completion token count |
737
+ | `tt_c` | Total token count |
738
+
739
+ ### Meta processing (meta.rb)
740
+
741
+ `Chat.meta(messages)` extracts and **removes** meta messages from the chat:
742
+
743
+ ```ruby
744
+ def self.meta(messages)
745
+ meta_messages = []
746
+ messages.reject! do |message|
747
+ match = message[:role].to_s == 'meta'
748
+ meta_messages << message if match
749
+ match
750
+ end
751
+ return nil if meta_messages.empty?
752
+
753
+ metas = meta_messages.collect { |message| parse_meta(message[:content]) }
754
+ current = IndiferentHash.setup(metas.last.dup)
755
+ # Find the last checkpoint with token counts
756
+ checkpoint = metas.reverse.find do |meta|
757
+ %w[pt_c ct_c tt_c].any? { |name| meta.include?(name) }
758
+ end
759
+ if checkpoint
760
+ %w[pt_c ct_c tt_c].each do |name|
761
+ current[name] = checkpoint[name] if checkpoint.include?(name)
762
+ end
763
+ end
764
+ current
765
+ end
766
+ ```
767
+
768
+ The meta system is designed so that:
769
+ - Meta messages are **local bookkeeping** and are **not sent to the provider**
770
+ - The last checkpoint with token counts supplies the running total for the next request
771
+ - Job metadata deliberately contributes no token counts
772
+
773
+ ### Message lineage and tracing
774
+
775
+ `message_index` assigns a unique digest-based ID to each message (excluding meta from the chain):
776
+
777
+ ```ruby
778
+ def message_index
779
+ previous = nil
780
+ collect do |message|
781
+ id = Misc.digest([previous, role, content])
782
+ info = { id: id, role: role.to_sym, prev: previous, fingerprint: Log.truncate_string(content) }
783
+ if role == 'meta'
784
+ info[:meta] = Chat.parse_meta(content)
785
+ else
786
+ previous = id # meta doesn't advance the lineage chain
787
+ end
788
+ info
789
+ end
790
+ end
791
+ ```
792
+
793
+ `trace_indices` segments the chat into response segments, each beginning with a meta marker:
794
+
795
+ ```ruby
796
+ def self.trace_indices(indices)
797
+ # A meta starts a segment; the segment continues until another meta,
798
+ # a new user/system turn, or the end of the chat.
799
+ # Consecutive/final metas with no covered messages are "orphan" records.
800
+ end
801
+ ```
802
+
803
+ ### Projection
804
+
805
+ `Chat.project(job, messages)` creates a chat segment from job output with a producer marker:
806
+
807
+ ```ruby
808
+ def self.project(job, messages)
809
+ projected = Array(messages).reject { |m| m[:role].to_s == 'meta' }.collect(&:dup)
810
+ return [] if projected.empty?
811
+ [{ role: :meta, content: serialize_meta(job: job.to_s) }] + projected
812
+ end
813
+ ```
814
+
815
+ ### Job chat file discovery
816
+
817
+ The system can trace provenance through job dependencies:
818
+
819
+ ```ruby
820
+ def self.job_chat_files(job, seen = Set.new)
821
+ # Collects result chats and logged agent chats for a job and ALL its dependencies
822
+ # Uses a Set to avoid infinite recursion on cycles and shared dependencies
823
+ end
824
+ ```
825
+
826
+ ---
827
+
828
+ ## Key Abstractions and Design Patterns
829
+
830
+ ### 1. Module-as-Annotation (Annotation mixin)
831
+
832
+ The most distinctive pattern. `Chat` is a module that extends `Annotation`, enabling any Array to be "annotated" as a Chat. This avoids subclassing Array (which is fragile) and allows Chat behavior to be mixed into existing arrays.
833
+
834
+ ```ruby
835
+ module Chat
836
+ extend Annotation
837
+ # instance methods become available on Arrays via Chat.setup(array)
838
+ end
839
+ ```
840
+
841
+ ### 2. Open Module Pattern
842
+
843
+ `module Chat` is opened across 10+ files, each adding class or instance methods. This is idiomatic Ruby for organizing large modules:
844
+
845
+ ```
846
+ chat.rb → LLM factory methods
847
+ chat/parse.rb → Chat.parse, Chat.print (class methods)
848
+ chat/process.rb → Chat.content_tokens, Chat.indiferent (class methods)
849
+ chat/process/clear.rb → Chat.clear, Chat.clean, Chat.purge (class methods)
850
+ chat/process/files.rb → Chat.imports, Chat.files (class methods)
851
+ chat/process/options.rb → Chat.config, Chat.options (class methods)
852
+ chat/process/tools.rb → Chat.tasks, Chat.jobs, Chat.tools (class methods)
853
+ chat/process/meta.rb → Chat.meta + provenance methods (class + instance methods)
854
+ chat/annotation.rb → Chat instance methods (message building, inference, reporting)
855
+ chat/prompt.rb → Chat.prepare_prompt, Chat.shorten_tools (class methods)
856
+ ```
857
+
858
+ ### 3. Text-as-Serialization Format
859
+
860
+ The entire chat is serialized to/from a human-readable text format. This is both the persistence format and the authoring format. Users can write chat files directly in a text editor:
861
+
862
+ ```
863
+ system: You are a helpful assistant.
864
+
865
+ user: What is 2+2?
866
+
867
+ assistant: 4
868
+ ```
869
+
870
+ ### 4. Side-Channel Extraction Pattern
871
+
872
+ Tool definitions, options, and associations are extracted from the chat **separately** from the main processing pipeline. They modify the message array in place (using `messages.replace new`) and return the extracted data:
873
+
874
+ ```ruby
875
+ def self.tools(messages)
876
+ tool_definitions = {}
877
+ new = messages.collect { ... }.compact.flatten
878
+ messages.replace new # mutate original array
879
+ tool_definitions # return extracted data
880
+ end
881
+ ```
882
+
883
+ ### 5. Clear/Cut Semantics
884
+
885
+ The `clear:` role implements a **cut point** — everything before the last `clear:` message is discarded. This allows chat templates to include setup that is stripped before the actual conversation:
886
+
887
+ ```
888
+ system: Setup instructions
889
+ clear:
890
+ user: Actual question
891
+ ```
892
+
893
+ ### 6. Sticky vs. Non-Sticky Options
894
+
895
+ Options have two lifetimes:
896
+ - **Non-sticky** (`option:`, `persist:`, `format:`) — reset after an `assistant:` reply
897
+ - **Sticky** (`endpoint:`, `model:`, `backend:`, `agent:`, `sticky_option:`, `previous_response_id:`) — persist across the entire conversation
898
+
899
+ ### 7. IndiferentHash everywhere
900
+
901
+ All message hashes use `IndiferentHash.setup` for symbol/string key indifference, avoiding `Symbol === String` comparison bugs.
902
+
903
+ ### 8. Role-Polymorphic Content
904
+
905
+ The same `content` field holds different data types depending on the role:
906
+ - String text for `user`/`assistant`/`system`
907
+ - JSON for `function_call`/`function_call_output`
908
+ - Space-delimited key=value for `meta`
909
+ - Space-delimited key value for `option`
910
+ - File paths for `file`/`pdf`/`image`/`import`
911
+ - Workflow specs for `task`/`tool`
912
+
913
+ ### 9. Protected Block Parsing
914
+
915
+ The parser's multi-format protected block handling (backtick fences, `[[...]]`, XML tags, command output markers) allows embedding code, structured data, and command output within chat text without false-positive role header detection.
916
+
917
+ ### 10. Thread-Local Access Control
918
+
919
+ Tool processing uses thread-local variables for directory access control:
920
+
921
+ ```ruby
922
+ def self.allow_dir(dir)
923
+ Thread.current['allowed_dirs'] ||= []
924
+ return if Thread.current['allowed_dirs'].include?(dir)
925
+ Thread.current['allowed_dirs'] << dir
926
+ end
927
+ ```
928
+
929
+ This provides per-thread sandboxing of file operations without explicit parameter passing.
930
+
931
+ ---
932
+
933
+ ## Summary Table: File Inventory
934
+
935
+ | File | Lines | Responsibility |
936
+ |---|---|---|
937
+ | `chat.rb` | ~70 | `LLM` module factory; orchestrates pipeline |
938
+ | `chat/parse.rb` | ~190 | Text ↔ messages parsing; `print`/`print_brief` |
939
+ | `chat/persist.rb` | ~24 | Persist save/load drivers for `:chat` type |
940
+ | `chat/process.rb` | ~17 | `content_tokens`, `indiferent` helpers |
941
+ | `chat/process/clear.rb` | ~63 | `clear`, `clean`, `purge`, `pull` |
942
+ | `chat/process/files.rb` | ~103 | `imports`, `files`, `find_file`, `tag` |
943
+ | `chat/process/options.rb` | ~72 | `config`, `options` (sticky/non-sticky) |
944
+ | `chat/process/tools.rb` | ~250 | `tasks`, `jobs`, `tools`, `associations`, access control |
945
+ | `chat/annotation.rb` | ~270 | Chat instance DSL (message building, inference, provenance) |
946
+ | `chat/prompt.rb` | ~190 | Context strategies (`shorten_tools`), prompt preparation |