opencode-effect-enforcer 0.2.3 → 0.2.4

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 (47) hide show
  1. package/README.md +12 -10
  2. package/docs/effect-4.0.0-rc.112.md +316 -0
  3. package/guidance/effect-first-development.md +30 -17
  4. package/guidance/progressive-disclosure-guidance.md +13 -0
  5. package/package.json +3 -2
  6. package/patterns/avoid-direct-tag-checks.md +8 -2
  7. package/patterns/avoid-react-hooks.md +18 -37
  8. package/patterns/effect-run-in-body.md +1 -1
  9. package/patterns/require-effect-concurrency.md +11 -0
  10. package/patterns/use-console-service.md +6 -1
  11. package/skills/effect-ai-language-model/SKILL.md +10 -16
  12. package/skills/effect-ai-prompt/SKILL.md +36 -2
  13. package/skills/effect-ai-provider/SKILL.md +13 -0
  14. package/skills/effect-ai-streaming/SKILL.md +81 -108
  15. package/skills/effect-ai-tool/SKILL.md +50 -87
  16. package/skills/effect-atom-rpc/SKILL.md +9 -2
  17. package/skills/effect-atom-state/SKILL.md +5 -0
  18. package/skills/effect-cache/SKILL.md +32 -0
  19. package/skills/effect-cli/SKILL.md +22 -3
  20. package/skills/effect-concurrency-testing/SKILL.md +7 -9
  21. package/skills/effect-domain-modeling/SKILL.md +208 -1169
  22. package/skills/effect-domain-predicates/SKILL.md +5 -6
  23. package/skills/effect-error-handling/SKILL.md +5 -4
  24. package/skills/effect-http-api/SKILL.md +12 -1
  25. package/skills/effect-http-client/SKILL.md +1 -1
  26. package/skills/effect-http-server/SKILL.md +14 -3
  27. package/skills/effect-layer-design/SKILL.md +22 -56
  28. package/skills/effect-mcp-server/SKILL.md +1 -1
  29. package/skills/effect-pattern-matching/SKILL.md +44 -11
  30. package/skills/effect-platform-abstraction/SKILL.md +1 -1
  31. package/skills/effect-platform-layers/SKILL.md +1 -1
  32. package/skills/effect-rpc-api/SKILL.md +8 -1
  33. package/skills/effect-rpc-client/SKILL.md +20 -6
  34. package/skills/effect-rpc-cluster/SKILL.md +44 -14
  35. package/skills/effect-rpc-server/SKILL.md +32 -5
  36. package/skills/effect-scheduling/SKILL.md +1 -1
  37. package/skills/effect-schema-composition/SKILL.md +69 -15
  38. package/skills/effect-schema-v4/SKILL.md +43 -1
  39. package/skills/effect-scope/SKILL.md +30 -0
  40. package/skills/effect-service-implementation/SKILL.md +10 -4
  41. package/skills/effect-socket/SKILL.md +5 -5
  42. package/skills/effect-sql/SKILL.md +22 -0
  43. package/skills/effect-stream/SKILL.md +32 -1
  44. package/skills/effect-testing/SKILL.md +39 -31
  45. package/skills/effect-workflow/SKILL.md +6 -0
  46. package/patterns/vm-in-wrong-file.md +0 -51
  47. package/skills/effect-react-vm/SKILL.md +0 -675
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "opencode-effect-enforcer",
4
- "version": "0.2.3",
4
+ "version": "0.2.4",
5
5
  "description": "OpenCode V2 plugin for Effect v4 skills, guidance, and pattern enforcement",
6
6
  "keywords": [
7
7
  "opencode",
@@ -29,6 +29,7 @@
29
29
  "skills",
30
30
  "guidance",
31
31
  "patterns",
32
+ "docs",
32
33
  "README.md",
33
34
  "LICENSE"
34
35
  ],
@@ -45,7 +46,7 @@
45
46
  "@ast-grep/napi": "^0.42.3",
46
47
  "@opencode-ai/plugin": "0.0.0-next-17148",
47
48
  "diff": "^9.0.0",
48
- "effect": "4.0.0-rc.111",
49
+ "effect": "4.0.0-rc.112",
49
50
  "picomatch": "^4.0.3",
50
51
  "yaml": "^2.8.1"
51
52
  },
@@ -21,7 +21,7 @@ suggestSkills:
21
21
  ```haskell
22
22
  -- Transformation
23
23
  directCheck :: Event → Bool
24
- directCheck e = e._tag == "FactRecorded" -- fragile, poor narrowing
24
+ directCheck e = e._tag == "FactRecorded" -- narrows, but not exhaustive
25
25
 
26
26
  -- Instead
27
27
  $is :: Tag → Event → Bool -- from TaggedEnum
@@ -51,4 +51,10 @@ isFactRecorded = $is "FactRecorded"
51
51
  -- Refactoring-safe: rename tag in one place
52
52
  ```
53
53
 
54
- Direct `_tag` checks don't narrow types correctly. Use `$is` for predicates or `$match` for exhaustive pattern matching via `Data.taggedEnum`.
54
+ TypeScript correctly narrows literal `_tag` checks. Prefer exported guards and
55
+ matching helpers for consistent semantics and exhaustiveness as variants evolve.
56
+ For schema-first models use union `.guards`, `.match`, or `Schema.is`; class
57
+ variants can also use `instanceof`. `Schema.toTaggedUnion` supports discriminator
58
+ keys beyond `_tag`. In rc.112, `.matchOrElse` adds partial matching with a typed
59
+ fallback. For trusted `Data.taggedEnum` values use `$is` / `$match`; `$is` checks
60
+ only the tag and is not structural validation of unknown input.
@@ -3,7 +3,7 @@ action: context
3
3
  tool: (edit|write)
4
4
  event: after
5
5
  name: avoid-react-hooks
6
- description: React hooks (useState, useEffect, useReducer, etc.) should be avoided - use View Models with Effect Atom instead
6
+ description: Review React hooks (useState, useEffect, useReducer, etc.) for state and effects better expressed with Effect Atom
7
7
  glob: '**/*.{ts,tsx}'
8
8
  detector: ast
9
9
  pattern:
@@ -29,45 +29,26 @@ pattern:
29
29
  - 'useInsertionEffect($$$)'
30
30
  level: high
31
31
  suggestSkills:
32
- - effect-react-vm
32
+ - effect-atom-state
33
33
  ---
34
34
 
35
- # Avoid React Hooks - Use View Models
35
+ # Review React Hooks - Prefer Effect Atom for Shared State
36
36
 
37
- ```haskell
38
- -- Transformation
39
- useState :: a → (a, a → ()) -- scattered state, untestable
40
- useEffect :: (() → ()) → [a] → () -- cleanup error-prone
37
+ Keep shared state in atoms and application effects in typed Effect services.
38
+ Components subscribe with `useAtomValue` and trigger actions with `useAtomSet`
39
+ or `useAtom`. Organize atoms in ordinary state modules alongside the feature.
41
40
 
42
- -- Instead: View Model pattern
43
- data VM = VM
44
- { state$ :: Atom State -- reactive state
45
- , action :: () → Effect () -- effectful actions
46
- }
41
+ | Hook usage | Effect Atom alternative |
42
+ | --- | --- |
43
+ | `useState` / `useReducer` for shared state | Writable `Atom.make` values |
44
+ | `useMemo` for shared derived state | `Atom.map` or a derived `Atom.make` |
45
+ | `useEffect` to fetch data | `Atom.runtime(layer).atom(effect)` |
46
+ | Async action callbacks with manual loading state | `Atom.fn` or runtime actions with `AsyncResult` |
47
+ | External subscriptions owned by an atom | `Atom.make` with `get.addFinalizer` |
48
+ | URL search state | `Atom.searchParam` |
47
49
 
48
- -- Component is pure renderer
49
- component :: VM → JSX
50
- component vm = useAtomValue (state$ vm) -- only reads atoms
51
- ```
50
+ The detector is advisory: hooks for DOM refs, layout, React scheduling, stable
51
+ IDs, or local component behavior may be appropriate. Review the hook's role
52
+ before replacing it; atoms are not substitutes for React-specific lifecycle APIs.
52
53
 
53
- ```haskell
54
- -- Replacements
55
- useState → vmAtom :: Atom a
56
- useEffect → vmAction :: Effect ()
57
- useCallback → derivedAtom :: Atom (a → b)
58
- useMemo → derivedAtom :: Atom a
59
- useRef (DOM) → pass from parent ∨ VM trigger
60
- useSearchParams → Atom.searchParam
61
- useEffect (cleanup) → Atom.make with get.addFinalizer
62
-
63
- -- Architecture
64
- data Component = Component
65
- { view :: VM → JSX -- pure renderer
66
- , vm :: Layer VM -- testable, injectable
67
- }
68
-
69
- -- Invoke skill for implementation
70
- invoke "react-vm"
71
- ```
72
-
73
- React hooks scatter state across components. Use View Models: state in atoms, effects in actions, components as pure renderers.
54
+ Load `effect-atom-state` for implementation guidance.
@@ -44,7 +44,7 @@ good = do
44
44
 
45
45
  -- Entry points only
46
46
  main :: IO ()
47
- main = Effect.runMain program -- ✓ application boundary
47
+ main = BunRuntime.runMain program -- ✓ application boundary (@effect/platform-bun)
48
48
 
49
49
  handler :: Request → IO Response
50
50
  handler req = Effect.runPromise (handle req) -- ✓ API boundary
@@ -55,8 +55,14 @@ constraints:
55
55
  - kind: object
56
56
  - has:
57
57
  regex: '\bconcurrency\b'
58
+ - not:
59
+ has:
60
+ all:
61
+ - kind: pair
62
+ - regex: '^\s*["\x27]?concurrency["\x27]?\s*:\s*["\x27]inherit["\x27]\s*$'
58
63
  level: warning
59
64
  suggestSkills:
65
+ - effect-parallelization
60
66
  - effect-concurrency-testing
61
67
  ---
62
68
 
@@ -73,6 +79,7 @@ Effect.forEach xs f opts -- concurrency intent explicit
73
79
  Effect.forEach(items, processItem);
74
80
  Effect.all(tasks);
75
81
  Effect.validate(inputs, validateInput, { discard: true });
82
+ Effect.all(tasks, { concurrency: 'inherit' }); // removed in beta.102
76
83
 
77
84
  // Good
78
85
  Effect.forEach(items, processItem, { concurrency: 1 });
@@ -81,3 +88,7 @@ Effect.validate(inputs, validateInput, { concurrency: 4, discard: true });
81
88
  ```
82
89
 
83
90
  Even sequential execution is a concurrency decision. Specify `concurrency` on `Effect.forEach`, `Effect.all`, and `Effect.validate` so throughput and ordering intent are reviewable at the call site.
91
+
92
+ Current v4 accepts a number or `'unbounded'` (`Types.Concurrency`), not `'inherit'`.
93
+ The detector also flags that removed literal in an explicit options object;
94
+ TypeScript remains authoritative for computed or indirect option values.
@@ -47,8 +47,13 @@ structured = do
47
47
  test :: Effect () TestConsole
48
48
  test = do
49
49
  program
50
- logs ← TestConsole.output
50
+ logs ← TestConsole.logLines
51
51
  assert (logs `contains` "expected message")
52
52
  ```
53
53
 
54
54
  `console.*` in Effect code breaks the paradigm. Use `Console` service or `Effect.log*` for structured, testable logging.
55
+
56
+ `TestConsole.logLines` captures `Console.log` with `TestConsole.layer` provided;
57
+ `errorLines` captures `Console.error`. Structured `Effect.log*` records should be
58
+ asserted through a test logger (`Logger.make` / `Logger.layer`), rather than
59
+ assuming every logger writes to the test console.
@@ -226,12 +226,12 @@ Streaming text, reasoning, and tool parameters use matching `id` values across s
226
226
  const collectText = streamText.pipe(
227
227
  Stream.filter((part) => part.type === 'text-delta'),
228
228
  Stream.map((part) => part.delta),
229
- Stream.runFold('', (acc, delta) => acc + delta)
229
+ Stream.runFold(() => '', (acc, delta) => acc + delta)
230
230
  );
231
231
 
232
232
  // Process chunks efficiently
233
233
  const processChunks = streamText.pipe(
234
- Stream.mapChunksEffect((chunk) =>
234
+ Stream.mapArrayEffect((chunk) =>
235
235
  Effect.gen(function* () {
236
236
  const parts = Array.from(chunk);
237
237
  // Process batch of parts
@@ -241,20 +241,14 @@ const processChunks = streamText.pipe(
241
241
  )
242
242
  );
243
243
 
244
- // Aggregate response with side effects
245
- let combined: Array<StreamPart> = [];
246
- const aggregated = streamText.pipe(
247
- Stream.mapChunks((chunk) => {
248
- combined = [...combined, ...chunk];
249
- return chunk;
250
- }),
251
- Stream.ensuring(
252
- Effect.sync(() => {
253
- // Finalization logic with full response
254
- console.log('Total parts:', combined.length);
255
- })
256
- )
257
- );
244
+ // Allocate per stream run; keep side effects in tap/mapArrayEffect.
245
+ const aggregated = Stream.suspend(() => {
246
+ let count = 0;
247
+ return streamText.pipe(
248
+ Stream.tap(() => Effect.sync(() => { count += 1; })),
249
+ Stream.ensuring(Effect.suspend(() => Effect.logDebug('Total parts:', count)))
250
+ );
251
+ });
258
252
  ```
259
253
 
260
254
  ## toolChoice Options
@@ -431,7 +431,7 @@ import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
431
431
  const chat = Effect.gen(function* () {
432
432
  const history = yield* SubscriptionRef.make(Prompt.empty);
433
433
 
434
- function* generateText(userInput: string) {
434
+ const generateText = Effect.fn('Chat.generateText')(function* (userInput: string) {
435
435
  const currentHistory = yield* SubscriptionRef.get(history);
436
436
  const prompt = pipe(currentHistory, Prompt.concat(userInput));
437
437
 
@@ -445,7 +445,7 @@ const chat = Effect.gen(function* () {
445
445
  yield* SubscriptionRef.set(history, newHistory);
446
446
 
447
447
  return response;
448
- }
448
+ });
449
449
 
450
450
  return { generateText };
451
451
  });
@@ -516,6 +516,40 @@ const program = Effect.gen(function* () {
516
516
 
517
517
  ## Provider-Specific Options
518
518
 
519
+ ### OpenAI Responses explicit cache breakpoints (rc.112)
520
+
521
+ With `@effect/ai-openai` loaded, system-message and text-part options accept
522
+ `openai.promptCacheBreakpoint`. This requires GPT-5.6 or later; earlier models
523
+ may reject it. Provider config uses snake_case; Prompt metadata uses camelCase.
524
+
525
+ ```typescript
526
+ import { OpenAiLanguageModel } from '@effect/ai-openai';
527
+ import { Effect } from 'effect';
528
+ import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
529
+ import * as Prompt from 'effect/unstable/ai/Prompt';
530
+
531
+ const program = LanguageModel.generateText({
532
+ prompt: Prompt.make([
533
+ Prompt.systemMessage({
534
+ content: 'Stable instructions',
535
+ options: { openai: { promptCacheBreakpoint: { mode: 'explicit' } } }
536
+ }),
537
+ Prompt.userMessage({
538
+ content: [Prompt.textPart({ text: 'Question for this turn' })]
539
+ })
540
+ ])
541
+ }).pipe(Effect.provide(OpenAiLanguageModel.model('gpt-5.6', {
542
+ prompt_cache_key: 'assistant:v1',
543
+ prompt_cache_options: { mode: 'explicit', ttl: '30m' }
544
+ })));
545
+ ```
546
+
547
+ A text part can carry the same breakpoint after reusable user context. The
548
+ adapter forwards it as `prompt_cache_breakpoint` on input text. Supply the
549
+ OpenAI client layer at the runtime boundary (see `effect-ai-provider`).
550
+
551
+ ### Other provider metadata
552
+
519
553
  ```typescript
520
554
  // Augment options interfaces via module augmentation
521
555
  declare module 'effect/unstable/ai/Prompt' {
@@ -420,6 +420,19 @@ export class AiWriter extends Context.Service<
420
420
 
421
421
  ## Custom Error Wrapping
422
422
 
423
+ In rc.112, `AiError.AuthenticationError` accepts an optional `description` and
424
+ appends it after the kind-based remediation message. Anthropic, OpenAI,
425
+ OpenAI-compatible, and OpenRouter adapters propagate provider error text from
426
+ 401/403 responses. Preserve this reason rather than replacing it with a generic
427
+ authentication string. `AiError.buildErrorDescription` is available to adapter
428
+ authors; inspect its signature before building custom provider mappings.
429
+ Authentication failures generally require corrected credentials/permissions,
430
+ not a blanket transient retry. Keep redacted credentials out of logs.
431
+
432
+ OpenAI Responses additionally supports GPT-5.6+ explicit prompt cache breakpoints
433
+ through `Prompt` metadata and `prompt_cache_options` model config; see
434
+ `effect-ai-prompt` for the complete construction example.
435
+
423
436
  Wrap `AiError` into domain-specific tagged errors:
424
437
 
425
438
  ```typescript
@@ -81,34 +81,27 @@ Accumulate stream parts incrementally using mutable state for efficiency:
81
81
  import * as Stream from 'effect/Stream';
82
82
  import * as Effect from 'effect/Effect';
83
83
  import * as Prompt from 'effect/unstable/ai/Prompt';
84
+ import * as Response from 'effect/unstable/ai/Response';
85
+ import * as SubscriptionRef from 'effect/SubscriptionRef';
84
86
 
85
- const accumulated: Array<StreamPart> = [];
86
- let combined = Prompt.empty;
87
-
88
- stream.pipe(
89
- Stream.mapChunksEffect(
90
- Effect.fnUntraced(function* (chunk) {
91
- const parts = Array.from(chunk);
92
-
93
- // Append to mutable accumulator
94
- accumulated.push(...parts);
95
-
96
- // Fold the accumulated response so start/delta/end IDs are visible together
97
- combined = Prompt.fromResponseParts(accumulated);
98
-
99
- // Update history incrementally
100
- yield* SubscriptionRef.set(
101
- history,
102
- Prompt.concat(checkpoint, combined)
103
- );
104
-
105
- return chunk;
106
- })
107
- )
108
- );
87
+ const streamWithHistory = Stream.suspend(() => {
88
+ const accumulated: Array<Response.AnyPart> = [];
89
+ return stream.pipe(
90
+ Stream.mapArrayEffect(
91
+ Effect.fnUntraced(function* (parts) {
92
+ accumulated.push(...parts);
93
+
94
+ // Fold accumulated parts so start/delta/end IDs are visible together.
95
+ const combined = Prompt.fromResponseParts(accumulated);
96
+ yield* SubscriptionRef.set(history, Prompt.concat(checkpoint, combined));
97
+ return parts;
98
+ })
99
+ )
100
+ );
101
+ });
109
102
  ```
110
103
 
111
- Key insight: `Stream.mapChunksEffect` enables side-effectful accumulation while preserving stream semantics.
104
+ Key insight: `Stream.mapArrayEffect` enables side-effectful accumulation while preserving stream semantics. Its input/output batches are non-empty arrays, not v3 Chunks. Allocate mutable accumulators inside `Stream.suspend` so separate stream runs do not share history.
112
105
 
113
106
  ## Resource-Safe Streaming
114
107
 
@@ -121,19 +114,23 @@ import * as Stream from 'effect/Stream';
121
114
 
122
115
  const streamWithProtection = Stream.fromChannel(
123
116
  Channel.acquireUseRelease(
124
- // Acquire: Take semaphore, get checkpoint
125
- semaphore.take(1).pipe(
126
- Effect.zipRight(SubscriptionRef.get(history)),
127
- Effect.map((hist) => Prompt.concat(hist, newPrompt)),
128
- Effect.tap((checkpoint) => SubscriptionRef.set(history, checkpoint))
129
- ),
130
-
131
- // Use: Stream with accumulation
132
- (checkpoint) =>
133
- LanguageModel.streamText({ prompt: checkpoint }).pipe(
134
- Stream.mapChunksEffect(accumulateAndUpdate),
135
- Stream.toChannel
136
- ),
117
+ // Acquire only the permit so release covers checkpoint setup too.
118
+ semaphore.take(1),
119
+
120
+ // Use: Prepare history, then stream with per-run accumulation.
121
+ () => Stream.unwrap(Effect.gen(function* () {
122
+ const checkpoint = Prompt.concat(yield* SubscriptionRef.get(history), newPrompt);
123
+ yield* SubscriptionRef.set(history, checkpoint);
124
+ const accumulated: Array<Response.AnyPart> = [];
125
+ return LanguageModel.streamText({ prompt: checkpoint }).pipe(
126
+ Stream.mapArrayEffect(Effect.fnUntraced(function* (parts) {
127
+ accumulated.push(...parts);
128
+ yield* SubscriptionRef.set(history,
129
+ Prompt.concat(checkpoint, Prompt.fromResponseParts(accumulated)));
130
+ return parts;
131
+ }))
132
+ );
133
+ })).pipe(Stream.toChannel),
137
134
 
138
135
  // Release: Always release semaphore
139
136
  () => semaphore.release(1)
@@ -150,6 +147,9 @@ Resource acquisition order:
150
147
  5. Stream response (with incremental updates)
151
148
  6. Release semaphore (guaranteed via `acquireUseRelease`)
152
149
 
150
+ Keep steps 2–5 in the use phase. If checkpoint setup fails after acquisition,
151
+ the finalizer must already own the permit.
152
+
153
153
  ## Consumption Patterns
154
154
 
155
155
  runForEach :: (A → Effect<R, E>) → Stream<A, E, R> → Effect<Unit, E, R>
@@ -173,7 +173,7 @@ stream.pipe(Stream.tap(logPart), Stream.runDrain);
173
173
 
174
174
  // Get final accumulated value
175
175
  stream.pipe(
176
- Stream.runFold(initialState, (acc, part) => merge(acc, part)),
176
+ Stream.runFold(() => initialState, (acc, part) => merge(acc, part)),
177
177
  Effect.map(Option.some)
178
178
  );
179
179
  ```
@@ -183,28 +183,21 @@ stream.pipe(
183
183
  Incremental merge strategy for conversation history:
184
184
 
185
185
  ```typescript
186
- Prompt.concat :: Prompt → Prompt → Prompt
187
- Prompt.fromResponseParts :: Array<StreamPart> → Prompt
188
-
189
- // Pattern: checkpoint + accumulated response fold
190
- const accumulated: Array<StreamPart> = []
191
- let combined = Prompt.empty
192
-
193
- Stream.mapChunksEffect(function* (chunk) {
194
- const parts = Array.from(chunk)
195
- accumulated.push(...parts)
196
-
197
- // Fold accumulated parts, not only this chunk, so start/delta/end IDs align
198
- combined = Prompt.fromResponseParts(accumulated)
199
-
200
- // Update history: base checkpoint + accumulated response
201
- yield* SubscriptionRef.set(
202
- history,
203
- Prompt.concat(filteredCheckpoint, combined)
204
- )
205
-
206
- return chunk
207
- })
186
+ // Prompt.concat: (Prompt, RawInput) → Prompt
187
+ // Prompt.fromResponseParts: ReadonlyArray<Response.AnyPart> → Prompt
188
+
189
+ // Pattern: checkpoint + accumulated response fold, scoped to each stream run.
190
+ const streamWithHistory = Stream.suspend(() => {
191
+ const accumulated: Array<Response.AnyPart> = [];
192
+ return stream.pipe(Stream.mapArrayEffect(Effect.fnUntraced(function* (parts) {
193
+ accumulated.push(...parts);
194
+
195
+ // Fold accumulated parts, not only this batch, so start/delta/end IDs align.
196
+ const combined = Prompt.fromResponseParts(accumulated);
197
+ yield* SubscriptionRef.set(history, Prompt.concat(filteredCheckpoint, combined));
198
+ return parts;
199
+ })));
200
+ });
208
201
  ```
209
202
 
210
203
  Why checkpoint-based merging:
@@ -224,11 +217,13 @@ Why checkpoint-based merging:
224
217
 
225
218
  ## Complete Example
226
219
 
220
+ <!-- typecheck -->
227
221
  ```typescript
228
222
  import * as Prompt from 'effect/unstable/ai/Prompt';
229
223
  import * as Response from 'effect/unstable/ai/Response';
230
224
  import * as LanguageModel from 'effect/unstable/ai/LanguageModel';
231
225
  import * as Stream from 'effect/Stream';
226
+ import * as Channel from 'effect/Channel';
232
227
  import * as Effect from 'effect/Effect';
233
228
  import * as SubscriptionRef from 'effect/SubscriptionRef';
234
229
  import * as Semaphore from 'effect/Semaphore';
@@ -241,46 +236,21 @@ const Chat = Effect.gen(function* () {
241
236
  const streamText = (prompt: string) =>
242
237
  Stream.fromChannel(
243
238
  Channel.acquireUseRelease(
244
- // Acquire
245
- semaphore.take(1).pipe(
246
- Effect.zipRight(SubscriptionRef.get(history)),
247
- Effect.map((hist) =>
248
- Prompt.concat(hist, Prompt.make(prompt))
249
- ),
250
- Effect.tap((checkpoint) => {
251
- combined = Prompt.empty;
252
- return SubscriptionRef.set(history, checkpoint);
253
- })
254
- ),
255
-
256
- // Use
257
- (checkpoint) => {
258
- let combined = Prompt.empty;
259
- const accumulated: Array<Response.StreamPart> = [];
260
-
261
- return LanguageModel.streamText({
262
- prompt: checkpoint
263
- }).pipe(
264
- Stream.mapChunksEffect(
265
- Effect.fnUntraced(function* (chunk) {
266
- const parts = Array.from(chunk);
267
- accumulated.push(...parts);
268
-
269
- combined = Prompt.fromResponseParts(accumulated);
270
-
271
- yield* SubscriptionRef.set(
272
- history,
273
- Prompt.concat(checkpoint, combined)
274
- );
275
-
276
- return chunk;
277
- })
278
- ),
279
- Stream.toChannel
239
+ // Acquire only the permit; all following work is covered by release.
240
+ semaphore.take(1),
241
+ () => Stream.unwrap(Effect.gen(function* () {
242
+ const checkpoint = Prompt.concat(yield* SubscriptionRef.get(history), prompt);
243
+ yield* SubscriptionRef.set(history, checkpoint);
244
+ const accumulated: Array<Response.AnyPart> = [];
245
+ return LanguageModel.streamText({ prompt: checkpoint }).pipe(
246
+ Stream.mapArrayEffect(Effect.fnUntraced(function* (parts) {
247
+ accumulated.push(...parts);
248
+ yield* SubscriptionRef.set(history,
249
+ Prompt.concat(checkpoint, Prompt.fromResponseParts(accumulated)));
250
+ return parts;
251
+ }))
280
252
  );
281
- },
282
-
283
- // Release
253
+ })).pipe(Stream.toChannel),
284
254
  () => semaphore.release(1)
285
255
  )
286
256
  );
@@ -288,20 +258,23 @@ const Chat = Effect.gen(function* () {
288
258
  return { streamText };
289
259
  });
290
260
 
291
- // Consume stream
292
- chat.streamText('Hello').pipe(
261
+ // Consume with a LanguageModel layer provided by the application.
262
+ const consume = Effect.gen(function* () {
263
+ const chat = yield* Chat;
264
+ yield* chat.streamText('Hello').pipe(
293
265
  Stream.runForEach((part) =>
294
266
  Match.value(part).pipe(
295
267
  Match.when({ type: 'text-delta' }, ({ delta }) =>
296
- Effect.sync(() => console.log(delta))
268
+ Effect.logInfo(delta)
297
269
  ),
298
270
  Match.when({ type: 'finish' }, ({ usage }) =>
299
- Effect.sync(() => console.log(usage))
271
+ Effect.logDebug(usage)
300
272
  ),
301
273
  Match.orElse(() => Effect.void)
302
274
  )
303
275
  )
304
- );
276
+ );
277
+ });
305
278
  ```
306
279
 
307
280
  ## Anti-Patterns
@@ -335,8 +308,8 @@ Stream.map((chunk) => {
335
308
  return chunk
336
309
  })
337
310
 
338
- // ✓ Use Stream.mapChunksEffect
339
- Stream.mapChunksEffect(Effect.fnUntraced(function* (chunk) {
311
+ // ✓ Use Stream.mapArrayEffect
312
+ Stream.mapArrayEffect(Effect.fnUntraced(function* (chunk) {
340
313
  accumulated.push(...chunk)
341
314
  yield* updateHistory()
342
315
  return chunk
@@ -382,7 +355,7 @@ Set `preventFallbackOnPartialStream: true` when a provider failure after emitted
382
355
 
383
356
  - [ ] Use start/delta/end protocol for streaming content
384
357
  - [ ] Match stream parts with `Match.when({ type: ... })` or direct `part.type` checks (NOT `Match.tag` — parts use `type`, not `_tag`)
385
- - [ ] Accumulate using Stream.mapChunksEffect (not Stream.map)
358
+ - [ ] Accumulate using Stream.mapArrayEffect (not a side-effecting Stream.map)
386
359
  - [ ] Use SubscriptionRef for reactive history updates
387
360
  - [ ] Protect concurrent streams with Semaphore
388
361
  - [ ] Use Channel.acquireUseRelease for resource safety