@orkestrel/scaffold 0.0.66 → 0.0.68

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 (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +8 -8
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1509 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +311 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +437 -6
  62. package/dist/src/core/index.cjs +44 -22
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +43 -23
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +9 -9
@@ -0,0 +1,120 @@
1
+ # NDJSON
2
+
3
+ > A stateful newline-delimited-JSON (NDJSON) stream parser: a self-contained handle
4
+ > that turns string chunks into the complete records decoded so far, and never throws
5
+ > on a malformed, blank, or non-record line.
6
+
7
+ `parse(chunk)` appends `chunk` to an internal buffer and splits it on `\n`: every
8
+ line before the last is `\n`-terminated, hence complete, and is parsed to a record,
9
+ while the final segment is held back for the next call, so a line split across chunk
10
+ boundaries is reassembled the moment its closing `\n` arrives. Each trimmed line is
11
+ filtered — a blank or whitespace-only line (including one whose only content was a
12
+ CRLF's trailing `\r`) is skipped, malformed JSON is skipped silently, and a
13
+ non-record value (an array, a primitive, `null`) is dropped — so only plain records
14
+ come back, and a line the stream never terminates is never emitted even when the
15
+ buffered text already happens to be valid JSON. `clear()` drops the buffered partial
16
+ line so a handle can be reused for a fresh stream. Nothing else is wired in: no
17
+ Emitter, no server, HTTP, or agent coupling. Pair it with a streaming `TextDecoder`
18
+ when reading a byte stream — the decoder handles partial characters, the parser
19
+ handles partial lines. The buffer has no size limit, so a caller fronting an
20
+ untrusted or unbounded upstream must enforce its own byte cap before feeding chunks
21
+ in. Source: [`src/core`](../src/core). Surfaced through the `@src/core` barrel.
22
+
23
+ ## Surface
24
+
25
+ Create a parser and feed it chunks as they arrive; each `parse(chunk)`
26
+ returns the records completed so far, and a trailing partial line is held
27
+ for the next call:
28
+
29
+ ```ts
30
+ import { createNDJSONParser } from '@orkestrel/ndjson'
31
+
32
+ const parser = createNDJSONParser()
33
+ parser.parse('{"a":1}\n{"b"') // [{ a: 1 }] - the second line is still partial
34
+ parser.parse(':2}\n') // [{ b: 2 }] - the split line reassembled
35
+ parser.clear() // drop any buffered partial - ready for a fresh stream
36
+ ```
37
+
38
+ ### Types
39
+
40
+ A `Shape` cell holds an interface's data members as bare names in braces, `?`
41
+ marking an optional member and `plus` introducing its call-signature members,
42
+ and a type alias's own type literal with a union's arms escaped as `\|`.
43
+
44
+ | Type | Kind | Shape | Summary |
45
+ | ----------------------- | --------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | `NDJSONParserInterface` | interface | `{} plus parse, clear` | Represents the stateful NDJSON (newline-delimited JSON) stream-parser contract a consumer holds — a `parse` that turns each string chunk into the complete records decoded so far, and a `clear` that drops the buffered partial line. |
47
+
48
+ Types a helper's parameter as `NDJSONParserInterface` and returns the records
49
+ its `parse` call completes:
50
+
51
+ ```ts
52
+ import type { NDJSONParserInterface } from '@orkestrel/ndjson'
53
+
54
+ function feed(parser: NDJSONParserInterface, chunk: string): readonly Record<string, unknown>[] {
55
+ return parser.parse(chunk)
56
+ }
57
+ ```
58
+
59
+ ### Factories
60
+
61
+ | API | Kind | Summary |
62
+ | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
63
+ | `createNDJSONParser` | function | Creates an NDJSON (newline-delimited JSON) stream parser and returns it as an `NDJSONParserInterface` — a fresh `NDJSONParser` holding the buffer, so a caller holds the published contract rather than the class. |
64
+
65
+ #### Create a parser
66
+
67
+ Creates a parser through the factory and feeds it two complete lines, then a
68
+ line that completes only once its own newline arrives:
69
+
70
+ ```ts
71
+ import { createNDJSONParser } from '@orkestrel/ndjson'
72
+
73
+ const parser = createNDJSONParser()
74
+ parser.parse('{"a":1}\n{"b":2}\n') // [{ a: 1 }, { b: 2 }]
75
+ parser.parse('{"c":3}') // [] — buffered until its trailing newline arrives
76
+ parser.parse('\n') // [{ c: 3 }]
77
+ ```
78
+
79
+ ### Classes
80
+
81
+ | API | Kind | Summary |
82
+ | -------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
+ | `NDJSONParser` | class | Decodes an NDJSON (newline-delimited JSON) stream statefully, implementing `NDJSONParserInterface` over a private buffer the instance owns — each `parse` call returns the records completed so far and reassembles a record split across chunk boundaries. |
84
+
85
+ ## Methods
86
+
87
+ The public methods of `NDJSONParserInterface` — the class's full method
88
+ surface.
89
+
90
+ #### `NDJSONParserInterface`
91
+
92
+ | Method | Returns | Summary |
93
+ | ------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
94
+ | `parse` | `readonly Record<string, unknown>[]` | Appends `chunk` to the buffer and returns every complete `\n`-terminated line parsed to a record, skipping a malformed or non-record line; the trailing partial line is retained for the next call, so a line split across a chunk boundary is reassembled when its closing `\n` arrives. |
95
+ | `clear` | `void` | Drops any buffered partial line, leaving the handle ready for a fresh stream. |
96
+
97
+ `parse` holds an unterminated line indefinitely and the buffer has no size
98
+ limit, so a caller fronting an untrusted upstream enforces its own byte cap.
99
+
100
+ ```ts
101
+ import { NDJSONParser } from '@orkestrel/ndjson'
102
+
103
+ const parser = new NDJSONParser()
104
+ parser.parse('{"a":1}\n{"b"') // [{ a: 1 }] - the second line is still partial
105
+ parser.parse(':2}\n') // [{ b: 2 }] - the split line reassembled
106
+ parser.clear() // drop any buffered partial - ready for a fresh stream
107
+ parser.parse('{"c":3}\n') // [{ c: 3 }]
108
+ ```
109
+
110
+ ## Tests
111
+
112
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection (value and type exports), the `NDJSONParserInterface` ↔ `NDJSONParser` method bijection, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Create a parser` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the flagship fences and asserts the values their comments claim.
113
+ - [`tests/src/core/NDJSONParser.test.ts`](../tests/src/core/NDJSONParser.test.ts) — that `parse` returns the same records however a stream is cut into chunks, skipping malformed, blank, and non-record lines, withholding a line the stream never terminates, and never throwing on any input.
114
+ - [`tests/policy.test.ts`](../tests/policy.test.ts) — repository coding law: source placement, exports, readonly contracts, syntax, and the prose this guide is written in.
115
+ - [`tests/config.test.ts`](../tests/config.test.ts) — the root configuration's aliases and projects, the policy Oxlint plugin's rules against its rule tester, and the configuration helpers.
116
+
117
+ ## See also
118
+
119
+ - [`AGENTS.md`](../AGENTS.md) — the pointer to the `@orkestrel/scaffold` coding contract this package is written to.
120
+ - [`README.md`](README.md) — the guides index.
@@ -0,0 +1,380 @@
1
+ # Ollama
2
+
3
+ > A typed local-LLM provider for the `@orkestrel` line: the Ollama daemon's `POST /api/chat`
4
+ > wire carried on the shared `AgentProvider` engine from `@orkestrel/agent`, with NDJSON
5
+ > streaming, tool calls, thinking, and usage accounting narrowed off the wire through
6
+ > `@orkestrel/contract` guards and no Ollama SDK dependency.
7
+
8
+ This provider lets an Agent run against a real model on `localhost` — one small local model, no cloud dependency, and no API key. It supplies the Ollama wire and nothing else. `AgentProvider`, the HTTP engine in `@orkestrel/agent`, owns the deadline, the transport, the `headers` hook, the request, the bounded error read, the chunk decoder, the reasoning separation, and the result assembly. `OllamaProvider` extends that engine and fills the wire seams: the `frame` seam returns a fresh NDJSON parser, the `body` seam projects a `ProviderRequest` onto the `/api/chat` request, the `read` seam decodes one record into a `ProviderIncrement`, and the `finish` seam recovers a final line the daemon left unterminated. What a caller drives — `generate`, `stream`, `id`, `format` — is the base's, and [`agent.md`](agent.md) documents it.
9
+
10
+ The design is deliberately spare — an external boundary kept honest. Every `unknown` wire value is narrowed through the `@orkestrel/contract` guards (`isRecord`, `isString`, `isNumber`) rather than a type assertion, and a missing or malformed field degrades to a sensible default (empty content, no usage, `{}` arguments) rather than a throw. Every call streams: the request always carries `stream: true`, and `generate` drains the same NDJSON path `stream` exposes, so neither call can report content the other would not. The wire `think` flag is configurable through `OllamaOptions.think` (default `false`) and overrideable per call through `ProviderStreamOptions.think`; with `think: true` the daemon returns reasoning on the separate `message.thinking` channel, streamed live as `thinking` deltas. Either way the base's splitter separates any `<think>` span the daemon inlines anyway, so the assembled `content` is clean and the reasoning surfaces as `ProviderResult.thinking`, never in the conversation. A per-call `ProviderStreamOptions.schema` (a JSON schema object, from `@orkestrel/agent`) forwards verbatim as the wire's structured-output `format` field, and is omitted from the request when no schema is supplied. Token usage reuses the `TokenUsage` shape rather than minting its own.
11
+
12
+ The dependency is strictly one-way: this surface imports the base and the contract types from `@orkestrel/agent` and reaches errors through that base, the parser factory from `@orkestrel/ndjson`, tool-call shapes from `@orkestrel/tool`, the usage shape from `@orkestrel/budget`, and the guards from `@orkestrel/contract` — those packages never import from here. The published face is core, so `src/core` reaches no `node:*` module and no DOM global and the same build serves a server process and a browser page. It is tested live against `qwen3.5:2b-q4_K_M` in a dedicated `service` test project that requires the daemon and warms the model first, with no `skipIf`, while the `src:core` project stays hermetic on canned-transport and recording-proxy assertions that pass with the daemon down. Source: [`src/core`](../src/core). Surfaced through the `@orkestrel/ollama` barrel, aliased `@src/core` inside this repo.
13
+
14
+ ## Surface
15
+
16
+ The 80% case: create a provider once, then `generate` a turn against a conversation, bounding the call with an `AbortSignal` (from `@orkestrel/abort`). Messages are the abstract `Message` shape from `@orkestrel/agent` (`{ id, role, content }`), so the same conversation drives any provider.
17
+
18
+ ```ts
19
+ import { createAbort } from '@orkestrel/abort'
20
+ import { createOllama } from '@orkestrel/ollama'
21
+
22
+ const provider = createOllama({ model: 'qwen3.5:2b-q4_K_M' })
23
+ const abort = createAbort()
24
+ const messages = [{ id: '1', role: 'user', content: 'Reply with exactly: ok' }] as const
25
+
26
+ const result = await provider.generate(messages, abort.signal)
27
+ result.content // the assistant's answer text
28
+ result.usage // { prompt, completion, total } when the wire reported counts — folds into a token budget
29
+ ```
30
+
31
+ `generate` resolves the assembled `ProviderResult` (content + any tool calls + any usage). For live output, `stream` is the same call streamed: it yields `ProviderDelta`s (`content` for answer text, `thinking` for live reasoning) and returns the assembled result when the stream completes. Drive the generator and read its terminal `value`:
32
+
33
+ ```ts
34
+ import { createAbort } from '@orkestrel/abort'
35
+ import { isProviderAbortError } from '@orkestrel/agent'
36
+ import { createOllama } from '@orkestrel/ollama'
37
+
38
+ const provider = createOllama({ model: 'qwen3.5:2b-q4_K_M' })
39
+ const abort = createAbort()
40
+ const messages = [{ id: '1', role: 'user', content: 'Say hello.' }] as const
41
+ const answer: string[] = []
42
+ const reasoning: string[] = []
43
+
44
+ try {
45
+ const generator = provider.stream(messages, abort.signal)
46
+ let step = await generator.next()
47
+ while (!step.done) {
48
+ if (step.value.channel === 'content') answer.push(step.value.text)
49
+ if (step.value.channel === 'thinking') reasoning.push(step.value.text)
50
+ step = await generator.next()
51
+ }
52
+ const result = step.value // the assembled ProviderResult
53
+ result.content // the answer — the settled content is the authoritative one
54
+ answer.join('') // what arrived on the content channel, which a reclassified <think> span leaves longer
55
+ } catch (error) {
56
+ if (isProviderAbortError(error)) {
57
+ const recovered = error.partial.content // everything that streamed before the cancel
58
+ answer.push(recovered)
59
+ } else throw error
60
+ }
61
+ ```
62
+
63
+ Read the answer from the settled `result.content`, never from the joined deltas. The deltas are what arrived on the content channel. Where the daemon opens its reasoning without a `<think>` marker, a later bare `</think>` reveals that prefix as reasoning: the base moves it to `result.thinking`, the deltas it already yielded cannot be recalled, and the join is longer than the settled content for that turn.
64
+
65
+ Pass `tools` (a non-empty `ToolDefinition[]` from `@orkestrel/tool`) to advertise callable tools for the turn; when the model calls one, `result.tools` is a `ToolCall[]` from that same package (each with a guaranteed `id`, the tool `name`, and parsed `arguments`). Aborting a `stream` mid-flight throws a `ProviderAbortError` whose `partial` holds whatever streamed before the cancel.
66
+
67
+ ### Surface
68
+
69
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after. A function row's `Shape` cell holds its signature, and a guard row's the type it narrows to. A class row's `Shape` cell holds the interface it implements, or its constructor signature where it implements none. A `Shape` cell holds the constant's declared type.
70
+
71
+ | API | Kind | Shape | Summary |
72
+ | -------------------- | --------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
+ | `createOllama` | function | `(options: OllamaOptions) => ProviderInterface` | Creates a local Ollama inference provider — a `ProviderInterface` over the daemon's `POST /api/chat`, assembling `generate` from the same NDJSON engine as `stream`. |
74
+ | `OllamaProvider` | class | `AgentProviderInterface` | Implements the Ollama `/api/chat` wire over the shared `AgentProvider` engine. |
75
+ | `OllamaOptions` | interface | `ProviderOptions plus { model, url?, keepAlive?, options?, think? }` | Represents the configuration `createOllama` accepts for the local Ollama backend. |
76
+ | `WireChatRequest` | interface | `{ model, messages, stream, keep_alive, think, options?, tools?, format? }` | Represents the exact `POST /api/chat` request body `OllamaProvider` sends — the internal typed wire contract. |
77
+ | `DEFAULT_OLLAMA_URL` | const | `string` | Names the local Ollama daemon base URL, `'http://localhost:11434'`, assumed when `OllamaOptions.url` is omitted. |
78
+ | `DEFAULT_KEEP_ALIVE` | const | `string` | Names how long the model stays resident after a call — `'5m'` when `OllamaOptions.keepAlive` is omitted, Ollama's own `keep_alive` default, expressed as a duration string. |
79
+ | `OLLAMA_CHAT_PATH` | const | `string` | Names the Ollama chat endpoint appended to the configured base URL. |
80
+ | `mapMessages` | function | `(messages: readonly Message[]) => WireChatRequest['messages']` | Maps conversation turns onto the `/api/chat` wire's minimal message shape. |
81
+ | `extractContent` | function | `(record: Readonly<Record<string, unknown>>) => string` | Extracts the assistant text of one wire record. |
82
+ | `extractThinking` | function | `(record: Readonly<Record<string, unknown>>) => string` | Extracts the daemon-side reasoning of one wire record. |
83
+ | `extractUsage` | function | `(record: Readonly<Record<string, unknown>>) => TokenUsage \| undefined` | Extracts the token usage of one wire record. |
84
+ | `extractTools` | function | `(record: Readonly<Record<string, unknown>>) => readonly ToolCall[]` | Extracts the tool calls of one wire record's `message.tool_calls`. |
85
+ | `extractArguments` | function | `(value: unknown) => Readonly<Record<string, unknown>>` | Extracts a wire `arguments` value as a record. |
86
+
87
+ `OllamaOptions` extends `ProviderOptions`, so a caller also passes the base's own keys through it: `timeout` (the per-call deadline), `fetch` (the transport), `headers` (the per-request header hook), and `format` (the context-framing default). [`agent.md`](agent.md) states each one.
88
+
89
+ The wire leaves — `mapMessages` and the `extract*` narrowing set — are the pure request projection and response extractions `OllamaProvider` composes. They are exported and unit-tested on their own so the package's highest-risk code, the boundary narrowing of every wire `unknown` through the `@orkestrel/contract` guards, is provable without a daemon. Everything they do not do lives in `@orkestrel/agent`: the deadline, the transport seam, the streaming spine, the splitter, and the assembly of a `ProviderResult`.
90
+
91
+ `OllamaProvider` declares `name` (`'ollama'`) and inherits the rest of its data members from the base: `id` is the stable per-instance trace label, and `format` is the context-framing default `OllamaOptions.format` supplies (see [Context framing](#context-framing)). Its wire seams are documented under [Methods](#methods), and the `generate` / `stream` boundary it inherits under `AgentProviderInterface` in [`agent.md`](agent.md). `AgentProvider` / `AgentProviderInterface` / `ProviderInterface` / `ProviderOptions` / `ProviderRequest` / `ProviderIncrement` / `ProviderParserInterface` / `ProviderResult` / `ProviderDelta` / `ProviderStreamOptions` / `ProviderError` / `isProviderError` / `ProviderAbortError` / `isProviderAbortError` / `Message` / `ContextFormat` are owned by `@orkestrel/agent`; `ToolDefinition` / `ToolCall` by `@orkestrel/tool`; the `TokenUsage` shape by `@orkestrel/budget`; `createNDJSONParser` by `@orkestrel/ndjson`. All are reused here, never redefined.
92
+
93
+ ## Methods
94
+
95
+ The wire seams `OllamaProvider` declares — exactly the members of `AgentProviderInterface` that the base leaves open. The runtime never calls them directly: a caller drives `generate` or `stream`, and the engine calls these to frame, project, decode, and drain the Ollama wire. Those inherited calls, the `id` / `name` / `format` data members, and the `ProviderParserInterface` the seam returns are documented in [`agent.md`](agent.md).
96
+
97
+ #### `OllamaProvider`
98
+
99
+ The `frame` method opens the call's framing state and the `finish` method drains it; the `body` method writes the request and the `read` method reads one record back.
100
+
101
+ | Method | Returns | Summary |
102
+ | -------- | -------------------------------------------------- | ------------------------------------------------------------------------------ |
103
+ | `frame` | `ProviderParserInterface` | Creates fresh NDJSON framing state for a call. |
104
+ | `body` | `WireChatRequest` | Projects conversation turns and per-call options onto the Ollama request body. |
105
+ | `read` | `ProviderIncrement` | Extracts a record's content, reasoning, tools, and completed usage report. |
106
+ | `finish` | `ReadonlyArray<Readonly<Record<string, unknown>>>` | Recovers a final NDJSON record that arrived without its line terminator. |
107
+
108
+ ## Contract
109
+
110
+ These invariants hold across `src/core` ↔ `ollama.md`. The engine contract itself — `ProviderInterface`, `AgentProvider`, `ProviderResult`, `ProviderError`, `ProviderAbortError` — is in `@orkestrel/agent`, and [`agent.md`](agent.md) states it.
111
+
112
+ 1. **Doc ↔ source bijection.** Every `function` / `class` / `const` / `interface` / `type` row in the `## Surface` table is a real export of the `src/core` surface, and every export appears as a Surface row — exhaustive, both directions.
113
+ 2. **Imports each boundary from its owner, no cycle.** `OllamaProvider` extends `AgentProvider` and implements `AgentProviderInterface`; that base, `ProviderRequest`, `ProviderIncrement`, `ProviderParserInterface`, `ProviderInterface`, `ProviderOptions`, and `Message` come from `@orkestrel/agent`. `createNDJSONParser` comes from `@orkestrel/ndjson`, `ToolCall` from `@orkestrel/tool`, `TokenUsage` from `@orkestrel/budget`, and the guards (`isRecord` / `isString` / `isNumber` / `parseJSONAs`) from `@orkestrel/contract`. The provider errors are owned by `@orkestrel/agent` and `ToolDefinition` by `@orkestrel/tool`: `src/core` imports neither, and both reach a caller through the base — the base throws the errors this wire's failures become, and it accepts the `ToolDefinition[]` a call advertises. Each dependency is one-way and never imports from `@src/core`. No module under `src/` imports `@orkestrel/timeout` — the base arms the deadline — and the manifest declares it as a development dependency for the guide's bounding pattern, which imports it as a consumer would.
114
+ 3. **The `/api/chat` wire body.** `OllamaProvider` POSTs to `${url}${OLLAMA_CHAT_PATH}`, defaulting `url` to `DEFAULT_OLLAMA_URL` and `keep_alive` to `DEFAULT_KEEP_ALIVE`. The body carries `model`, the mapped `messages`, `stream: true` on every call, `keep_alive`, and `think` (the per-call `ProviderStreamOptions.think` override when present, else `OllamaOptions.think`, default `false`), adding `options` only when configured, `format` only when the call supplies a `ProviderStreamOptions.schema`, and `tools` only when a non-empty `ToolDefinition[]` is passed. Each tool maps to `{ type: 'function', function: { name } }`, with `description` and `parameters` added only when the corresponding `ToolDefinition` fields are supplied. Messages map to the wire's minimal `{ role, content }` turn, with `tool_calls` added only on a turn that replays them and `images` only on a multimodal turn. A non-OK status is the base's failure, not this package's: it throws `ProviderError` with code `'HTTP'`, the response `status`, and a message bounded to an excerpt of the response body.
115
+ 4. **Configurable `think` on the wire; the base's splitter keeps the assembled content clean.** For a thinking-capable model (for example `qwen3`) the per-request `think` flag is the only wire-level reasoning control (its native renderer honours neither the qwen3 `/no_think` token nor a Modelfile `PARAMETER think false`). It is configurable through `OllamaOptions.think` (default `false` — so the general-purpose provider stays immediate for non-thinking models and tests fast) and overrideable per call through `ProviderStreamOptions.think`. Set `think: true` when the caller displays reasoning separately from the answer: the daemon then separates reasoning natively, returning it on the distinct `message.thinking` channel, which `read` reports and the engine yields as `ProviderDelta` `{ channel: 'thinking', text }` and accumulates onto `ProviderResult.thinking`. Either way the base's `split` behaviour stays armed, because a daemon may ignore `think: false` for a thinking model and render reasoning inline as `<think>…</think>` content; `OllamaProvider` passes no `split` to `super`, so the default separation applies and the assembled `content` is the splitter's clean accumulation. The reasoning never re-enters the conversation.
116
+ 5. **One NDJSON path.** Every call consumes NDJSON — one JSON object per `\n`-terminated line. `frame()` returns `createNDJSONParser()`, fresh per call, so no call inherits another's half-read record, and the base pairs it with a streaming `TextDecoder` so a record split across byte reads is reassembled. At end of input the base calls `finish(parser)`, which feeds a trailing `\n` through the parser: a non-conformant proxy's final unterminated line is recovered rather than silently dropped. `OllamaProvider` passes no `strict` to `super`, so a stream that ends without a settled record still assembles its result from the deltas it carried.
117
+ 6. **`stream` yields deltas and returns the assembled result.** The engine yields each non-empty clean content delta as `{ channel: 'content', text }` and each daemon-side reasoning delta as `{ channel: 'thinking', text }`; its return value is the assembled `ProviderResult` whose `content` is the splitter's authoritative clean accumulation — the concatenation of the yielded content deltas, except across an implicit-open reclassification, where the reasoning prefix had already streamed before a bare `</think>` revealed it and the yields cannot be recalled — plus any tool calls collected across lines, the usage from the `done` line, and the separated `thinking` when the turn produced any.
118
+ 7. **Usage from the `done` line (reuses `TokenUsage`).** `read` reports `usage` only for a record whose `done` is `true` and whose `prompt_eval_count` and `eval_count` are both numbers — mapped to `{ prompt, completion, total: prompt + completion }`, the `TokenUsage` shape, imported rather than redefined. A delta line carries no counts and a `done: false` line's counts are ignored, so neither contributes usage, and `ProviderResult.usage` is absent when the turn reported none.
119
+ 8. **Tool-call extraction with id-generation (no `as`).** `result.tools` is the model's `message.tool_calls`, each entry narrowed to `{ id, name, arguments }`: the entry and its `function` must be records and `name` a string (else the entry is dropped); `arguments` is the wire object as-is, a JSON string parsed to a record, or `{}` when neither; `id` is the wire's `id` when a string, else a freshly minted `crypto.randomUUID()`. An empty result `tools` is never surfaced — its absence means "no calls".
120
+ 9. **Boundary narrowing — all wire `unknown` through guards, never `as`.** Every value read off the wire (each NDJSON record, `message`, `content`, `thinking`, the usage counts, `tool_calls`, `arguments`) arrives as `unknown` and is narrowed through the `@orkestrel/contract` guards — never a type assertion. A missing or malformed field degrades to a sensible default (empty content, no usage, `{}` arguments), never a throw — the one external boundary kept honest.
121
+ 10. **The base owns everything that is not the wire.** The per-call deadline (`OllamaOptions.timeout`, `120_000`ms when omitted, folded with the caller's signal so either cancels the request and the timer is always cleared), the cancellation rule (a `stream` cancelled mid-flight throws `ProviderAbortError` carrying the partial, and the reader and parser are released in a `finally`), the transport seam (`OllamaOptions.fetch` and the per-request `OllamaOptions.headers` hook, awaited inside the deadline and raced against the combined signal), and the bounded error read all live in `AgentProvider`. `OllamaOptions` extends `ProviderOptions`, so those keys are the base's and behave identically for every provider built on it; [`agent.md`](agent.md) states each rule once.
122
+ 11. **Context-framing `format` (provider-default cascade level, expose-only).** `OllamaOptions.format` is an optional `ContextFormat` (from `@orkestrel/agent`) — the provider's context-framing default, passed to `super` and exposed as `provider.format`. It is the provider-default level of the `AgentContext` build cascade (beats the managers' built-in framing, beaten by a manager-options or per-item override; see [`agent.md`](agent.md)), read by the Agent when it assembles the prompt. Omitted ⇒ `undefined` (framing-agnostic; core's built-in framing applies unchanged). It is **never sent on the `/api/chat` wire**: it is consumed by core, absent from the request `body`, and unrelated to Ollama's structured-output `format` parameter — that one is sent in the request `body`, but only when a per-call `ProviderStreamOptions.schema` is supplied — the framing default and that wire parameter only share a word.
123
+ 12. **Event-free.** A pure functional boundary — no Emitter, no events. Each call is a function of its arguments.
124
+ 13. **A core face that runs in a browser.** The package publishes the `.` entry alone, built from `src/core`, which imports no `node:*` module and no DOM global; the scoped core typecheck compiles it with the `ESNext` and `WebWorker` libraries and no ambient `@types`, so neither Node's globals nor the DOM's are in scope, and the base binds `globalThis.fetch` to its own receiver so a browser does not throw `Illegal invocation` on a bare transport reference. On 2026-09-14 in Chrome 148 the built `@orkestrel/ollama` core entry and its whole `@orkestrel` import closure loaded as ES modules with no console error: `OllamaProvider` drove the local daemon directly for a settled answer carrying its usage counts, a cancel mid-stream returned `ProviderAbortError` holding the partial that had streamed, and the same page reached that daemon through a `createRelay` server, where a wrong bearer arrived as `ProviderError` with code `'HTTP'` and status `401`. The daemon is the remaining limit — a page reaches `/api/chat` directly only where the daemon, or a proxy in front of it, answers the page's origin; otherwise relay the call through your own server ([Relaying through your own server](#relaying-through-your-own-server)).
125
+ 14. **Tested live against a real local Ollama (no `skipIf`).** The live tests run against a real Ollama daemon — no mocks, only genuine third-party calls — model `qwen3.5:2b-q4_K_M`, with `OLLAMA_HOST` / `OLLAMA_MODEL` overridable. Unlike the other surfaces, the dedicated `service` project requires the daemon: `tests/setupService.ts` throws a clear error if it is unreachable and warms the model (a `num_predict: 1` chat) before the suite, so the live tests run unconditionally (no `describe.skipIf`). The project runs serially (`fileParallelism: false`) with a 120s test/hook timeout so a cold load cannot flake it; `keep_alive` keeps the model resident across files, and `bash scripts/ollama.sh` brings the daemon and model up before the battery in CI. Assertions are structural — they hold whatever wording a small model produces — and never pin exact output. The hermetic half sits in `tests/src/core/`, and the relay round trip is proved hermetically in [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts) and live in [`tests/service/relay.test.ts`](../tests/service/relay.test.ts).
126
+ 15. **Doc ↔ source method bijection.** The `## Methods` table lists exactly the members `OllamaProvider` declares — `frame`, `body`, `read`, and `finish` — and the class declares exactly those. `generate` and `stream` reach a caller through the same class but are declared once, in `AgentProvider`, and documented in [`agent.md`](agent.md).
127
+
128
+ ## Patterns
129
+
130
+ ### `createOllama` + `generate`
131
+
132
+ The dominant single-shot use: one prompt, one assembled result.
133
+
134
+ ```ts
135
+ import { createAbort } from '@orkestrel/abort'
136
+ import type { TokenUsage } from '@orkestrel/budget'
137
+ import { createOllama } from '@orkestrel/ollama'
138
+
139
+ declare function charge(usage: TokenUsage): void // your billing integration
140
+
141
+ const provider = createOllama({ model: 'qwen3.5:2b-q4_K_M', options: { temperature: 0 } })
142
+ const abort = createAbort()
143
+ const messages = [
144
+ { id: '1', role: 'user', content: 'Summarize the release notes for version 2.0.' },
145
+ ] as const
146
+
147
+ const result = await provider.generate(messages, abort.signal)
148
+ console.log(result.content)
149
+ if (result.usage) charge(result.usage) // fold into a token budget
150
+ ```
151
+
152
+ ### Bounding a call with a budget + timeout
153
+
154
+ `generate` / `stream` take a plain `AbortSignal`, so fold an abort, a timeout, and a token budget into one bound through `AbortSignal.any` — whichever trips first cancels the call. This caller-side bound stacks on top of the base's own armed deadline (`OllamaOptions.timeout`, `120_000`ms when omitted): the request dies when either fires, so you get an external cancel or budget and a hard per-call ceiling together.
155
+
156
+ ```ts
157
+ import { createAbort } from '@orkestrel/abort'
158
+ import { createTimeout } from '@orkestrel/timeout'
159
+ import { createTokenBudget } from '@orkestrel/budget'
160
+ import { createOllama } from '@orkestrel/ollama'
161
+
162
+ const provider = createOllama({ model: 'qwen3.5:2b-q4_K_M' })
163
+ const messages = [{ id: '1', role: 'user', content: 'Say hello.' }] as const
164
+ const abort = createAbort() // external cancel
165
+ const timeout = createTimeout({ ms: 30_000 }) // wall-clock deadline
166
+ const budget = createTokenBudget({ max: 50_000, scope: 'total' }) // cost ceiling
167
+ timeout.start()
168
+ budget.start()
169
+
170
+ const bound = AbortSignal.any([abort.signal, timeout.signal, budget.signal])
171
+ const result = await provider.generate(messages, bound)
172
+ budget.consume(result.usage ?? { prompt: 0, completion: 0, total: 0 })
173
+ ```
174
+
175
+ ### Projecting the wire without a daemon
176
+
177
+ The seam members are ordinary methods, so a test or a debugging session can read the exact request this provider would send and the exact contribution one daemon record makes — with no socket, no daemon, and no model. Construct `OllamaProvider` directly: `createOllama` returns the narrower `ProviderInterface`, which exposes the call boundary alone.
178
+
179
+ ```ts
180
+ import { OllamaProvider } from '@orkestrel/ollama'
181
+
182
+ const provider = new OllamaProvider({ model: 'qwen3.5:2b-q4_K_M', keepAlive: '9m' })
183
+ const request = provider.body({
184
+ messages: [{ id: '1', role: 'user', content: 'Say hello.' }],
185
+ options: { think: true },
186
+ })
187
+ request.stream // true — every call streams
188
+ request.think // true — the per-call override beats the constructed default
189
+ request.keep_alive // '9m'
190
+ request.messages // [{ role: 'user', content: 'Say hello.' }] — ids stay local
191
+
192
+ const parser = provider.frame()
193
+ parser.parse('{"message":{"content":"Hel"}}\n') // [{ message: { content: 'Hel' } }]
194
+ provider.read({ message: { content: 'lo' }, done: true, prompt_eval_count: 3, eval_count: 4 })
195
+ // { content: 'lo', thinking: '', tools: [], usage: { prompt: 3, completion: 4, total: 7 } }
196
+ provider.finish(parser) // [] — the parser held nothing back
197
+ ```
198
+
199
+ ### Running in the browser
200
+
201
+ The published face is core, so the same `createOllama` call runs unchanged in a browser module. Point `url` at whatever the page can actually reach: the daemon's own origin where it answers that origin, or your own server standing in front of it. A page that reaches a daemon directly is a development arrangement — it puts the model endpoint on the network the page runs on — so prefer one of the server patterns that follow for anything a user runs.
202
+
203
+ ```ts
204
+ import { createOllama } from '@orkestrel/ollama'
205
+
206
+ const provider = createOllama({
207
+ model: 'qwen3.5:2b-q4_K_M',
208
+ url: 'https://llm.example.com', // the daemon's origin, or your proxy in front of it
209
+ })
210
+ provider.name // 'ollama'
211
+ ```
212
+
213
+ ### Relaying through your own server
214
+
215
+ The agent-level relay keeps the whole provider on your server and gives the browser a `ProviderInterface` over your own route. The browser sends identified messages, tools, and per-call options; the server answers with NDJSON frames carrying the deltas and the settled result, so `thinking`, `tools`, and `usage` survive the hop and a server-side cancel arrives as a `ProviderAbortError` with its partial. Nothing about the model, the vendor, or the daemon's address reaches the page — which is what separates this from the transparent wire proxy that follows.
216
+
217
+ Each half that follows runs in its own process: the server half goes in your server, the browser half in your browser bundle.
218
+
219
+ On the server, mount `createRelay` over a real `OllamaProvider` on any fetch-standard router, and serve that router. This composition routes with an `@orkestrel/router` dispatcher and serves it with an `@orkestrel/server` listener: `start` binds the configured host and port and resolves the port it bound, and `stop` drains the turns still in flight and then closes. `authorize` is mandatory, and minting and validating the session credential stays your application's job.
220
+
221
+ ```ts
222
+ import { createRelay } from '@orkestrel/agent'
223
+ import { createOllama } from '@orkestrel/ollama'
224
+ import { createDispatcher } from '@orkestrel/router'
225
+ import { createServer } from '@orkestrel/server'
226
+
227
+ const handler = createRelay({
228
+ provider: createOllama({ model: 'qwen3.5:2b-q4_K_M' }),
229
+ authorize: (request) => request.headers.get('authorization') === 'Bearer session-token',
230
+ })
231
+ const dispatcher = createDispatcher({
232
+ routes: [{ method: 'POST', path: '/inference', handler }],
233
+ })
234
+ const server = createServer({ dispatcher, state: () => ({}), host: '127.0.0.1', port: 8787 })
235
+ const port = await server.start() // the port it bound — an ephemeral one when `port` is omitted
236
+ const url = `http://127.0.0.1:${port}/inference` // the route the browser half dials
237
+ // await server.stop() at shutdown — it drains the turns still in flight, then closes
238
+ ```
239
+
240
+ `createRelay` returns a plain `(request: Request) => Promise<Response>` handler and stops there, so a runtime adapter turns the runtime's inbound request into a `Request`, hands the dispatcher's `Response` back to the runtime, and aborts that request's signal when the client disconnects, so a reader that goes away cancels the daemon turn instead of leaving it running. `@orkestrel/server` supplies that adapter ([`server.md`](server.md)), and a runtime whose own handler already takes a `Request` and returns a `Response` needs none.
241
+
242
+ In the browser, `createRelayProvider` drives that route like a local provider over the page's own `fetch`. `@orkestrel/agent` declares no parser dependency, so the browser application supplies one — `createNDJSONParser` from `@orkestrel/ndjson` here.
243
+
244
+ ```ts
245
+ import { createAbort } from '@orkestrel/abort'
246
+ import { createRelayProvider } from '@orkestrel/agent'
247
+ import { createNDJSONParser } from '@orkestrel/ndjson'
248
+
249
+ declare const url: string // the route the server half bound
250
+ const abort = createAbort()
251
+ const messages = [{ id: '1', role: 'user', content: 'Say hello.' }] as const
252
+
253
+ const browser = createRelayProvider({
254
+ url,
255
+ parser: createNDJSONParser,
256
+ headers: () => ({ authorization: 'Bearer session-token' }),
257
+ })
258
+ const relayed = await browser.generate(messages, abort.signal)
259
+ relayed.content // the daemon's answer, reassembled from the relay's frames
260
+ ```
261
+
262
+ A refusal never becomes a frame: a wrong credential reaches the browser as a `ProviderError` with code `'HTTP'` and status `401`, and the provider on the server is never entered. [`agent.md`](agent.md) states the frame vocabulary, the byte limit, and the rest of the refusal statuses. A page calling the relay from another origin needs CORS permission headers the relay route does not send, so serve the page from the relay server's own origin (as the recorded Chrome 148 run did) or put an origin-checking, CORS-answering middleware in front of the route (see the server guide), naming the `OPTIONS` preflight the browser sends with `authorization` and `content-type` as the requested headers.
263
+
264
+ ### Routing through your own server (obfuscated tokens)
265
+
266
+ A transparent wire proxy is the lighter shape: leave the provider in the browser, point `url` at a thin server of your own that forwards `/api/chat` to the daemon, and use `headers` to attach a generated token that server validates. The client never handles the real key — the key lives only on that server, and the `headers` hook supplies whatever short-lived token it expects. The custom `fetch` swaps the transport (a browser fetch, an instrumented wrapper) without touching the wire protocol. Omitting the transport and the header hook leaves the bound global `fetch` and only `Content-Type: application/json`.
267
+
268
+ ```ts
269
+ import { createAbort } from '@orkestrel/abort'
270
+ import { createOllama } from '@orkestrel/ollama'
271
+
272
+ declare function mintToken(): Promise<string> // your short-lived token, minted per request
273
+
274
+ const provider = createOllama({
275
+ model: 'qwen3.5:2b-q4_K_M',
276
+ url: 'https://my-app.example.com/llm', // your server, which forwards to the real LLM
277
+ headers: async () => ({ authorization: `Bearer ${await mintToken()}` }), // server validates this
278
+ // fetch: myFetch, // optional: inject a custom transport
279
+ })
280
+ const abort = createAbort()
281
+ const messages = [{ id: '1', role: 'user', content: 'Say hello.' }] as const
282
+ const result = await provider.generate(messages, abort.signal)
283
+ ```
284
+
285
+ The `headers` hook is called once per request (async, so a token can be refreshed each call); its result merges on top of the base `Content-Type`, so it adds an authorization header without disturbing the JSON body. What this shape cannot do is hide the vendor: the page still speaks Ollama's `/api/chat`, still names the model, and still needs a proxy that speaks that same wire, so switching vendors means shipping a different provider to the browser. Relay instead when the page must stay ignorant of what answers it.
286
+
287
+ ### Context framing
288
+
289
+ `OllamaOptions.format` is the provider's context-framing default — a `ContextFormat` (from `@orkestrel/agent`) declaring how this provider's models prefer the context sections framed (for example XML group wrappers vs. Markdown headers). It is the **provider-default level** of the `AgentContext` build cascade (see [`agent.md`](agent.md)): it beats the managers' built-in framing but is beaten by a manager-options or per-item override. `createOllama` accepts it directly and `OllamaProvider` hands it to the base, which exposes it as `provider.format`; the Agent reads it when it assembles the prompt. Omit it and the provider is framing-agnostic — core's built-in section framing applies unchanged.
290
+
291
+ ```ts
292
+ import { createOllama } from '@orkestrel/ollama'
293
+
294
+ const provider = createOllama({
295
+ model: 'qwen3.5:2b-q4_K_M',
296
+ format: {
297
+ instructions: {
298
+ open: '<instructions>',
299
+ render: (instruction) => `<instruction>${instruction.content}</instruction>`,
300
+ close: '</instructions>',
301
+ },
302
+ },
303
+ })
304
+ provider.format // the ContextFormat preceding — read by AgentContext.build()
305
+ ```
306
+
307
+ **Not the wire `format`.** This is the prompt-context framing consumed by `AgentContext.build()` — it is **expose-only** and is never sent on the `/api/chat` request. It is unrelated to Ollama's `/api/chat` `format` parameter (structured output / JSON schema): that wire field is driven by a different, per-call source — `ProviderStreamOptions.schema` (from `@orkestrel/agent`) — forwarded verbatim as `format` and omitted from the body when `schema` is undefined. The framing default and the wire parameter share a word and nothing else: the framing default is a hint the daemon never sees, and the wire parameter is a per-call constraint the provider never exposes back.
308
+
309
+ ### Narrowing a failed call with `isProviderError`
310
+
311
+ Branch on the machine-readable `code` and the HTTP `status` rather than parsing the thrown message. The engine throws one error class for every provider built on it, so the same `catch` serves a local daemon, a proxy, and a relay.
312
+
313
+ ```ts
314
+ import { createAbort } from '@orkestrel/abort'
315
+ import { isProviderError } from '@orkestrel/agent'
316
+ import { createOllama } from '@orkestrel/ollama'
317
+
318
+ const provider = createOllama({ model: 'qwen3.5:2b-q4_K_M' })
319
+ const abort = createAbort()
320
+ const messages = [{ id: '1', role: 'user', content: 'Reply with exactly: ok' }] as const
321
+
322
+ try {
323
+ await provider.generate(messages, abort.signal)
324
+ } catch (error) {
325
+ if (isProviderError(error) && error.code === 'HTTP' && error.status === 404) {
326
+ // the configured model isn't pulled
327
+ } else if (isProviderError(error) && error.code === 'HTTP' && error.status === 400) {
328
+ // malformed request (for example an empty model string)
329
+ } else {
330
+ throw error
331
+ }
332
+ }
333
+ ```
334
+
335
+ A cancel is outside that taxonomy: the caller's signal and the deadline both throw `ProviderAbortError`, which `isProviderAbortError` narrows, and its `partial` carries what had streamed.
336
+
337
+ ### Practices
338
+
339
+ - **Import the engine from `@orkestrel/agent`, the wire from `@orkestrel/ollama`** — `ProviderInterface` / `ProviderResult` / `ProviderError` / `isProviderError` / `isProviderAbortError` live in `@orkestrel/agent`; `createOllama` / `OllamaProvider` / `OllamaOptions` live in `@orkestrel/ollama`.
340
+ - **Import tool-call shapes from `@orkestrel/tool`** — `ToolDefinition` / `ToolCall` live with the tool runtime, while the provider contract that consumes them stays in `@orkestrel/agent`.
341
+ - **Bound every call** — pass an `AbortSignal` (an abort, or an `AbortSignal.any` over abort + timeout + budget) so a request can be cancelled, deadlined, or capped.
342
+ - **Recover the stream's partial** — wrap a driven `stream` in `try`/`catch` and narrow with `isProviderAbortError` to keep the content that arrived before a cancel.
343
+ - **Branch on the code and the status, not the message** — narrow a caught value with `isProviderError` and read `error.code` and `error.status` (`404` for an unpulled model) instead of parsing `error.message`.
344
+ - **Fold usage into a budget** — `result.usage` is the `TokenUsage` shape; `consume` it per turn to enforce a token ceiling.
345
+ - **Give a thinking model headroom** — with `think: true`, a reasoning model (for example `qwen3.5:2b` at `temperature: 0`) can spend 200+ tokens on `message.thinking` before any `content` token arrives; a tight `num_predict` cap or a tight external token budget can exhaust before content starts, yielding empty `result.content` — and `@orkestrel/agent`'s budget enforcement can abort mid-stream with `partial: true` if the cap is hit while thinking is still draining it. Size `num_predict` and the budget with thinking overhead in mind for any model run with `think: true`.
346
+ - **Tune through `options`** — pass sampling parameters (`temperature`, `seed`, and `num_predict`) through `OllamaOptions.options`; they forward verbatim to the wire.
347
+ - **Observe at the call site** — the provider publishes no events; read `stream`'s deltas and its returned result for everything a turn produced.
348
+
349
+ ## Tests
350
+
351
+ The hermetic projects — `src:core`, `setup`, `guides`, `conformance`, `policy`, and `config` — run with no daemon and no network. The `service` project requires a warm local Ollama, and `distribution` packs and installs the artifact.
352
+
353
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection (value + type exports), the `OllamaProvider` method bijection, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `createOllama + generate` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the flagship fences and asserts the values their comments claim.
354
+ - [`tests/src/core/OllamaProvider.test.ts`](../tests/src/core/OllamaProvider.test.ts) — hermetic provider request-shape, framing, transport-seam, abort, deadline, and unreachable-upstream coverage, plus the streaming fold — deltas, tool-call accumulation, done-line usage, the unterminated tail flush, and think separation — driven over a canned NDJSON transport. Its recording proxy uses a deliberately unreachable default and asserts only captured requests and local provider behavior.
355
+ - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — the wire leaves: the request projection and every response extraction, including each malformed-wire degradation.
356
+ - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — hermetic `createOllama` shape, identity, defaults, passthrough, and unreachable-upstream coverage.
357
+ - [`tests/src/core/integration.test.ts`](../tests/src/core/integration.test.ts) — the `src/core` integration scope. A real `@orkestrel/server` + `@orkestrel/router` relay in front of this provider carries ordered content and thinking deltas, tools, and usage while keeping the browser and daemon credentials apart, refuses a wrong bearer with an empty `401` before the daemon transport is entered, crosses a browser cancel and a server deadline as an abort frame with its partial, reduces a daemon stream failure to the fixed public error frame, and replays a returned tool call. Beside those, hermetic context framing, recap, reference, cherry-pick, canonical assembly order, scope allow-lists, workspace injection, and conversation selection run through the unreachable recording proxy, with a compile-time drift gate asserting the wire request and response shapes stay compatible with the official `ollama` client's types; that gate's authoritative check is `npm run check` (root tsc), while its run under vitest is an incidental no-op (`expectTypeOf` performs no runtime assertions).
358
+ - [`tests/setup.test.ts`](../tests/setup.test.ts) — the shared host-independent test infrastructure, over the conversation padding, the throwing and recording summarizers, and the workspace seeder `tests/setup.ts` exports, and over the host-independent half of `tests/setupServer.ts`: its request-narrowing guards, its refusing and streaming transports, its tool fixtures, its scripted agent stream and agent-stream driver, and its environment readers.
359
+ - [`tests/setupServer.test.ts`](../tests/setupServer.test.ts) — the Node-resource half of `tests/setupServer.ts`: the recording proxy and the relay server on real loopback sockets, the captured and open transports, whose fixtures answer in memory, the capture wait, the provider-stream driver, and the shared wire tables `WEATHER_TOOL` and the insatiable tool's chunk line.
360
+ - [`tests/setupService.test.ts`](../tests/setupService.test.ts) — the hermetic half of the `service` project's setup: the environment readers, the sampling tables, and the readiness contract.
361
+ - [`tests/conformance.test.ts`](../tests/conformance.test.ts) — where this package's wire types drift from the official `ollama` client they are written against.
362
+ - [`tests/distribution.test.ts`](../tests/distribution.test.ts) — the packed package installed into a throwaway consumer: the exports map, the shipped declarations, and the module objects a real runtime hands that consumer.
363
+ - [`tests/policy.test.ts`](../tests/policy.test.ts) and [`tests/config.test.ts`](../tests/config.test.ts) — the vendored cross-cutting proofs: the path- and text-shaped repository laws, and that the root configuration resolves its aliases, projects, and outputs.
364
+ - [`tests/service/OllamaProvider.test.ts`](../tests/service/OllamaProvider.test.ts) — live provider generation, streaming, thinking, tool-call, usage, seeded, abort, deadline, and daemon-error coverage against the required warmed Ollama service.
365
+ - [`tests/service/factories.test.ts`](../tests/service/factories.test.ts) — live `createOllama` generation and streaming coverage.
366
+ - [`tests/service/relay.test.ts`](../tests/service/relay.test.ts) — the browser-to-server relay against the live daemon: a generated answer through the authenticated route, streamed deltas joining to the settled content, and a cancel after a live delta returning the relay's partial.
367
+ - [`tests/service/transport.test.ts`](../tests/service/transport.test.ts) — the transparent wire proxy, end-to-end. A `createRecordingProxy(OLLAMA_CONFIG.host)` — a real `@orkestrel/server` + `@orkestrel/router` HTTP server — records the inbound request, forwards it to the selected Ollama service, and streams the response back.
368
+ - [`tests/service/tools.test.ts`](../tests/service/tools.test.ts) and [`tests/service/authority.test.ts`](../tests/service/authority.test.ts) — live tool dispatch through the agent loop, and the authority gate's approval and denial paths.
369
+ - [`tests/service/budget.test.ts`](../tests/service/budget.test.ts) and [`tests/service/lifecycle.test.ts`](../tests/service/lifecycle.test.ts) — live token-budget enforcement mid-stream, and the agent's status, event, and abort lifecycle.
370
+ - [`tests/service/schema.test.ts`](../tests/service/schema.test.ts) and [`tests/service/scopes.test.ts`](../tests/service/scopes.test.ts) — a live structured-output `schema` constraining the answer, and a live scope filtering what reaches the wire.
371
+ - [`tests/service/compaction.test.ts`](../tests/service/compaction.test.ts) and [`tests/service/conversation.test.ts`](../tests/service/conversation.test.ts) — live recap retention with manual, automatic, and repeated compaction, and live non-fatal compaction errors and long-conversation instruction behavior, with proxy calls explicitly forwarding to `OLLAMA_CONFIG.host`.
372
+
373
+ ## See also
374
+
375
+ - `@orkestrel/agent` — the `AgentProvider` engine this package extends, the `ProviderInterface` contract, the errors, and the relay. Its guide is mirrored here as [`agent.md`](agent.md) and ships with that package.
376
+ - `@orkestrel/ndjson` — the parser `frame()` returns for each call. Its guide ships with that package.
377
+ - `@orkestrel/contract` — the `isRecord` / `isString` / `isNumber` / `parseJSONAs` helpers that narrow the wire `unknown`. Its guide ships with that package.
378
+ - `@orkestrel/router` and `@orkestrel/server` — the dispatcher the relay handler mounts on and the listener that serves it, mirrored here as [`router.md`](router.md) and [`server.md`](server.md). Each ships with its own package.
379
+ - [`AGENTS.md`](../AGENTS.md) — the coding rules this package is written to.
380
+ - [`README.md`](README.md) — the guides index.