opencode-effect-enforcer 0.2.2 → 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.
- package/README.md +38 -10
- package/docs/effect-4.0.0-rc.112.md +316 -0
- package/guidance/effect-first-development.md +30 -17
- package/guidance/progressive-disclosure-guidance.md +13 -0
- package/package.json +3 -2
- package/patterns/avoid-direct-tag-checks.md +8 -2
- package/patterns/avoid-react-hooks.md +18 -37
- package/patterns/effect-run-in-body.md +1 -1
- package/patterns/require-effect-concurrency.md +11 -0
- package/patterns/use-console-service.md +6 -1
- package/skills/effect-ai-language-model/SKILL.md +10 -16
- package/skills/effect-ai-prompt/SKILL.md +36 -2
- package/skills/effect-ai-provider/SKILL.md +13 -0
- package/skills/effect-ai-streaming/SKILL.md +81 -108
- package/skills/effect-ai-tool/SKILL.md +50 -87
- package/skills/effect-atom-rpc/SKILL.md +9 -2
- package/skills/effect-atom-state/SKILL.md +5 -0
- package/skills/effect-cache/SKILL.md +32 -0
- package/skills/effect-cli/SKILL.md +22 -3
- package/skills/effect-concurrency-testing/SKILL.md +7 -9
- package/skills/effect-domain-modeling/SKILL.md +208 -1169
- package/skills/effect-domain-predicates/SKILL.md +5 -6
- package/skills/effect-error-handling/SKILL.md +5 -4
- package/skills/effect-http-api/SKILL.md +12 -1
- package/skills/effect-http-client/SKILL.md +1 -1
- package/skills/effect-http-server/SKILL.md +14 -3
- package/skills/effect-layer-design/SKILL.md +22 -56
- package/skills/effect-mcp-server/SKILL.md +1 -1
- package/skills/effect-pattern-matching/SKILL.md +44 -11
- package/skills/effect-platform-abstraction/SKILL.md +1 -1
- package/skills/effect-platform-layers/SKILL.md +1 -1
- package/skills/effect-rpc-api/SKILL.md +8 -1
- package/skills/effect-rpc-client/SKILL.md +20 -6
- package/skills/effect-rpc-cluster/SKILL.md +44 -14
- package/skills/effect-rpc-server/SKILL.md +32 -5
- package/skills/effect-scheduling/SKILL.md +1 -1
- package/skills/effect-schema-composition/SKILL.md +69 -15
- package/skills/effect-schema-v4/SKILL.md +43 -1
- package/skills/effect-scope/SKILL.md +30 -0
- package/skills/effect-service-implementation/SKILL.md +10 -4
- package/skills/effect-socket/SKILL.md +5 -5
- package/skills/effect-sql/SKILL.md +22 -0
- package/skills/effect-stream/SKILL.md +32 -1
- package/skills/effect-testing/SKILL.md +39 -31
- package/skills/effect-workflow/SKILL.md +6 -0
- package/patterns/vm-in-wrong-file.md +0 -51
- package/skills/effect-react-vm/SKILL.md +0 -675
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
# Agent Rules
|
|
2
2
|
|
|
3
|
+
The bundled guidance targets **Effect 4.0.0-rc.112**. Read the consuming project's
|
|
4
|
+
version before applying an API: stable v3, older prereleases, and unreleased main
|
|
5
|
+
can have different contracts. Keep directly used Effect-family packages on
|
|
6
|
+
compatible release versions.
|
|
7
|
+
|
|
3
8
|
Load all relevant skills before writing or planning any code. Effect is a massive ecosystem — without loading skills you will write outdated v3 code or miss high-leverage libraries. Load AT LEAST 4 `effect-*` skills before any Effect work.
|
|
4
9
|
|
|
5
10
|
When skills leave any ambiguity, or when you encounter unfamiliar APIs during implementation, read the OpenCode `effect` reference at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`. Treat this reference as the source of truth over `node_modules`, stale external docs, or memory.
|
|
6
11
|
|
|
12
|
+
Check the reference revision too. For this baseline, inspect the
|
|
13
|
+
`effect@4.0.0-rc.112` tag (for example with `git show
|
|
14
|
+
effect@4.0.0-rc.112:packages/effect/src/Schema.ts`) when main has moved ahead.
|
|
15
|
+
Source symbols and signatures at that tag take precedence over stale prose or
|
|
16
|
+
line-number links. Public exports marked `@internal` in source are not application APIs.
|
|
17
|
+
|
|
7
18
|
## Skill routing
|
|
8
19
|
|
|
9
20
|
Load every branch that the task crosses:
|
|
10
21
|
|
|
11
22
|
- Schemas, brands, variants, optionality, or decoding: `effect-schema-v4`, `effect-schema-composition`, and `effect-domain-modeling`.
|
|
23
|
+
- Binary schema codecs and framed binary streams: `effect-schema-composition` and `effect-stream`; RPC serialization also needs `effect-rpc-client` / `effect-rpc-server`.
|
|
12
24
|
- Services, layers, runtime wiring, or scoped lifetimes: `effect-service-implementation`, `effect-layer-design`, `effect-scope`, and `effect-fiber`.
|
|
13
25
|
- Configuration or secrets: `effect-config`.
|
|
14
26
|
- Retry, repeat, polling, backoff, pacing, or recurrence: `effect-scheduling` plus the relevant error, HTTP, or testing skill.
|
|
15
27
|
- Memoization, keyed caches, or request batching: `effect-cache` and `effect-batching`.
|
|
28
|
+
- Retained keyed resources or pooled checkout: `effect-cache`, `effect-layer-design`, and `effect-scope`.
|
|
16
29
|
- Streams, queues, pubsubs, pagination, or backpressure: `effect-stream` and the relevant concurrency skill.
|
|
17
30
|
- Outgoing HTTP: `effect-http-client` plus the relevant platform-layer skill.
|
|
18
31
|
- Effect tests, virtual time, or concurrent synchronization: `effect-testing` and `effect-concurrency-testing`.
|
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.
|
|
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.
|
|
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" --
|
|
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
|
-
|
|
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.)
|
|
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-
|
|
32
|
+
- effect-atom-state
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
#
|
|
35
|
+
# Review React Hooks - Prefer Effect Atom for Shared State
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
component
|
|
50
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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*
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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
|
|
125
|
-
semaphore.take(1)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
187
|
-
Prompt.fromResponseParts
|
|
188
|
-
|
|
189
|
-
// Pattern: checkpoint + accumulated response fold
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
292
|
-
|
|
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.
|
|
268
|
+
Effect.logInfo(delta)
|
|
297
269
|
),
|
|
298
270
|
Match.when({ type: 'finish' }, ({ usage }) =>
|
|
299
|
-
Effect.
|
|
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.
|
|
339
|
-
Stream.
|
|
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.
|
|
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
|