@homeflare/seat-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/index.d.ts +20 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +508 -0
  6. package/dist/index.js.map +21 -0
  7. package/dist/mcp-connect.d.ts +72 -0
  8. package/dist/mcp-connect.d.ts.map +1 -0
  9. package/dist/mcp-error.d.ts +77 -0
  10. package/dist/mcp-error.d.ts.map +1 -0
  11. package/dist/mcp-pages.d.ts +18 -0
  12. package/dist/mcp-pages.d.ts.map +1 -0
  13. package/dist/mcp-render.d.ts +25 -0
  14. package/dist/mcp-render.d.ts.map +1 -0
  15. package/dist/mcp-tool.d.ts +61 -0
  16. package/dist/mcp-tool.d.ts.map +1 -0
  17. package/dist/mcp-toolkit.d.ts +61 -0
  18. package/dist/mcp-toolkit.d.ts.map +1 -0
  19. package/dist/mcp-toolset.d.ts +33 -0
  20. package/dist/mcp-toolset.d.ts.map +1 -0
  21. package/dist/rounds.d.ts +108 -0
  22. package/dist/rounds.d.ts.map +1 -0
  23. package/dist/seat-model.d.ts +59 -0
  24. package/dist/seat-model.d.ts.map +1 -0
  25. package/dist/seat-obs.d.ts +23 -0
  26. package/dist/seat-obs.d.ts.map +1 -0
  27. package/dist/seat-state.d.ts +33 -0
  28. package/dist/seat-state.d.ts.map +1 -0
  29. package/dist/stamp.d.ts +23 -0
  30. package/dist/stamp.d.ts.map +1 -0
  31. package/dist/state-dsn.d.ts +41 -0
  32. package/dist/state-dsn.d.ts.map +1 -0
  33. package/dist/state-postgres.d.ts +58 -0
  34. package/dist/state-postgres.d.ts.map +1 -0
  35. package/dist/state-valkey-connection.d.ts +49 -0
  36. package/dist/state-valkey-connection.d.ts.map +1 -0
  37. package/dist/state-valkey-scrub.d.ts +37 -0
  38. package/dist/state-valkey-scrub.d.ts.map +1 -0
  39. package/dist/state-valkey-send.d.ts +35 -0
  40. package/dist/state-valkey-send.d.ts.map +1 -0
  41. package/dist/state-valkey.d.ts +83 -0
  42. package/dist/state-valkey.d.ts.map +1 -0
  43. package/dist/state.d.ts +9 -0
  44. package/dist/state.d.ts.map +1 -0
  45. package/dist/state.js +327 -0
  46. package/dist/state.js.map +16 -0
  47. package/dist/version.d.ts +2 -0
  48. package/dist/version.d.ts.map +1 -0
  49. package/docs/mcp.md +40 -0
  50. package/docs/pairing.md +39 -0
  51. package/docs/state.md +174 -0
  52. package/package.json +45 -0
  53. package/src/index.ts +25 -0
  54. package/src/mcp-connect.ts +172 -0
  55. package/src/mcp-error.ts +150 -0
  56. package/src/mcp-pages.ts +37 -0
  57. package/src/mcp-render.ts +67 -0
  58. package/src/mcp-tool.ts +101 -0
  59. package/src/mcp-toolkit.ts +183 -0
  60. package/src/mcp-toolset.ts +91 -0
  61. package/src/rounds.ts +211 -0
  62. package/src/seat-model.ts +94 -0
  63. package/src/seat-obs.ts +103 -0
  64. package/src/seat-state.ts +53 -0
  65. package/src/stamp.ts +88 -0
  66. package/src/state-dsn.ts +112 -0
  67. package/src/state-postgres.ts +114 -0
  68. package/src/state-valkey-connection.ts +113 -0
  69. package/src/state-valkey-scrub.ts +60 -0
  70. package/src/state-valkey-send.ts +67 -0
  71. package/src/state-valkey.ts +193 -0
  72. package/src/state.ts +8 -0
  73. package/src/version.ts +2 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The one round loop every seat shares: model turn, tool results, model turn, until the model
3
+ * stops asking for tools or the cap fires — and a cap that fires is followed by ONE more turn
4
+ * with the toolkit taken away, so a run ends with an answer instead of a truncated transcript.
5
+ *
6
+ * ★ WHAT THE SDK DOES AND DOES NOT DO. `Chat.generateText` with a toolkit resolves the tool
7
+ * calls of ONE model turn and returns; it never re-prompts, and Effect AI ships no
8
+ * `maxRounds` or `stopWhen` (grep of LanguageModel.d.ts and Chat.d.ts, 2026-09-29). The
9
+ * documented multi-round agent is a `while` over `session.generateText({ prompt: [], toolkit })`
10
+ * (compat's ai-docs, 30_chat.ts). This file is that `while`, once, with the cap.
11
+ * ⛔ THE CAP IS HARD AND FINITE. `maxRounds` must be a positive integer: `Infinity`, `0`, a
12
+ * fraction or `NaN` is a programming error and dies with a `RangeError` before any model
13
+ * call, because a loop a bad config can un-cap is a loop that spends until something else
14
+ * stops it (LiteLLM's budget hit the claude2 fleet that way, 2026-09-28).
15
+ * ⚠️ THE FORCED TURN SENDS NO TOOLS. No toolkit, and `toolChoice: 'none'` stated anyway: the SDK
16
+ * already defaults `toolChoice` to `'none'` for a call with no toolkit (LanguageModel.js,
17
+ * `providerOptions`, measured by mutation 2026-09-29: dropping the option changed no test), so
18
+ * it is kept for what it says and for the `toolChoice` attribute on the turn's span. compat
19
+ * then sends neither `tools` nor `tool_choice` (prepareTools returns both undefined for an
20
+ * empty tool list, rc.115), because an OpenAI-shaped API refuses a `tool_choice` with no
21
+ * `tools`. The history still carries the earlier tool calls and results, and cf-code accepted
22
+ * exactly that: one live call through CT100's LiteLLM (:4100), 2026-09-29, a history of
23
+ * system, user, assistant tool call and tool result, no `tools`, `toolChoice: 'none'`, came
24
+ * back `finishReason: 'stop'` with text and no tool call. One sample, one alias.
25
+ * ⚠️ `capped` MEANS THE MODEL STILL WANTED TOOLS after `maxRounds` tool rounds. A model that
26
+ * answers on round `maxRounds` exactly is `capped: false`: the cap did not fire.
27
+ * 🔴 THE FORCED TURN CAN BE REFUSED, AND THEN THE RUN DOES NOT DIE OF IT. A provider that asks for
28
+ * a tool although none was offered (a gateway that adds a dummy tool for a history full of
29
+ * tool calls does exactly that) answers a turn the SDK cannot use, and the SDK says so with one
30
+ * of TWO `AiError` reasons, depending on WHO rejects the tool call:
31
+ * - `ToolNotFoundError`: @effect/ai-openai-compat (`SeatModel`'s provider) rejects it first,
32
+ * while it maps the reply (`transformToolCallParams`: "Tool ... not found. Available tools:
33
+ * none"). Measured 2026-09-29 (review of PR 328, of the round 2 fix), `SeatModel` against a loopback
34
+ * LiteLLM stand-in that answered every call with a tool call: 3 model calls, tools offered
35
+ * [true, true, false], then the whole run failed with every round already spent. Round 2's
36
+ * fix caught only the reason below and so did nothing for this provider.
37
+ * - `InvalidOutputError` raised by `LanguageModel` (module `LanguageModel`): the SDK's own decode
38
+ * of the provider's parts ("Expected ... text | reasoning ..."), reached by a provider that hands
39
+ * the SDK a tool-call part itself (the scripted model in tests/fake-model.ts). The same reason
40
+ * raised by `OpenAiClient` (an empty, truncated or non-completion body, a dropped stream) is NOT
41
+ * a refusal and fails the run (tests/seat-broken-forced.test.ts).
42
+ * Either is caught: the result is `capped` and `unanswered`, its `response` is the LAST TOOL
43
+ * ROUND's (whose calls did run), `rounds` stays `maxRounds`, and a warning, a span error and
44
+ * `seat_rounds_unanswered_total` say what happened. Any OTHER failure of the forced turn
45
+ * (network, rate limit, a handler) still fails the run.
46
+ */
47
+ import * as Effect from 'effect/Effect';
48
+ import type * as Chat from 'effect/unstable/ai/Chat';
49
+ import type * as LanguageModel from 'effect/unstable/ai/LanguageModel';
50
+ import type * as Tool from 'effect/unstable/ai/Tool';
51
+ import type * as Toolkit from 'effect/unstable/ai/Toolkit';
52
+ /**
53
+ * A turn's response: with the toolkit, or the forced one without.
54
+ * ⚠️ The two modes differ because the SDK's own types do: a call with a toolkit answers
55
+ * `'opaque'` tool parameters, a call without one answers the default `'decoded'`.
56
+ */
57
+ type Response<Tools extends Record<string, Tool.Any>> = LanguageModel.GenerateTextResponse<Tools, 'opaque'> | LanguageModel.GenerateTextResponse<{}, 'decoded'>;
58
+ /** One model turn, as `onRound` and the result see it. A refused forced final turn is not reported. */
59
+ export type Round<Tools extends Record<string, Tool.Any>> = {
60
+ /** 1-based. The forced final turn, when there is one, is `maxRounds + 1`. */
61
+ readonly round: number;
62
+ /** True only for the forced final turn: no toolkit, `toolChoice: 'none'`. */
63
+ readonly forced: boolean;
64
+ readonly response: Response<Tools>;
65
+ };
66
+ export type RoundsOptions<Tools extends Record<string, Tool.Any>> = {
67
+ /**
68
+ * The conversation, already holding the opening prompt (`Chat.fromPrompt`) or a resumed
69
+ * history that ends in a user message or tool results. Every round sends an EMPTY prompt:
70
+ * `Chat` appends the model's turn and its tool results to `history` itself.
71
+ */
72
+ readonly chat: Chat.Chat;
73
+ /**
74
+ * Tools WITH their handlers. A `Toolkit.make(...)` is an Effect that needs its handlers from
75
+ * context: `yield*` it where they are provided, and pass the result. (Typing it as the
76
+ * Effect would hide its requirements, which `generateText` itself cannot track either.)
77
+ */
78
+ readonly toolkit: Toolkit.WithHandler<Tools>;
79
+ /** The most tool rounds allowed. A positive integer; see the header. */
80
+ readonly maxRounds: number;
81
+ /** After every model turn, the forced one included. Runs inside that turn's span. */
82
+ readonly onRound?: ((round: Round<Tools>) => Effect.Effect<void>) | undefined;
83
+ };
84
+ export type RoundsResult<Tools extends Record<string, Tool.Any>> = {
85
+ /** The last model turn: the answer, or the forced final turn when the cap fired. */
86
+ readonly response: Response<Tools>;
87
+ /** Model turns that returned, the forced one included when it did. */
88
+ readonly rounds: number;
89
+ /** The cap fired and `response` is the forced final turn (or see `unanswered`). */
90
+ readonly capped: boolean;
91
+ /**
92
+ * The forced turn was refused (a provider that still asks for a tool) and `response` is the
93
+ * last TOOL round's: it holds no answer, and its tool calls ran. Only ever true with `capped`;
94
+ * `rounds` then counts the turns that returned, so it is `maxRounds`. See the header.
95
+ */
96
+ readonly unanswered: boolean;
97
+ };
98
+ /**
99
+ * Run the loop. Fails with whatever a model turn fails with (`AiError`, a tool handler's own
100
+ * failure); nothing is retried here — the caller wraps `Effect.retry` where it wants one.
101
+ */
102
+ export declare function runRounds<Tools extends Record<string, Tool.Any>>(options: RoundsOptions<Tools>): Effect.Effect<RoundsResult<Tools>, LanguageModel.ExtractError<{
103
+ readonly toolkit: Toolkit.WithHandler<Tools>;
104
+ }>, LanguageModel.LanguageModel | LanguageModel.ExtractServices<{
105
+ readonly toolkit: Toolkit.WithHandler<Tools>;
106
+ }>>;
107
+ export {};
108
+ //# sourceMappingURL=rounds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rounds.d.ts","sourceRoot":"","sources":["../src/rounds.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,KAAK,IAAI,MAAM,yBAAyB,CAAC;AACrD,OAAO,KAAK,KAAK,aAAa,MAAM,kCAAkC,CAAC;AAEvE,OAAO,KAAK,KAAK,IAAI,MAAM,yBAAyB,CAAC;AAErD,OAAO,KAAK,KAAK,OAAO,MAAM,4BAA4B,CAAC;AA6B3D;;;;GAIG;AACH,KAAK,QAAQ,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,IAChD,aAAa,CAAC,oBAAoB,CAAC,KAAK,EAAE,QAAQ,CAAC,GACnD,aAAa,CAAC,oBAAoB,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;AAEtD,uGAAuG;AACvG,MAAM,MAAM,KAAK,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI;IAC1D,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,aAAa,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI;IAClE;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IAC7C,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,qFAAqF;IACrF,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,CAAC;CAC/E,CAAC;AAEF,MAAM,MAAM,YAAY,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI;IACjE,oFAAoF;IACpF,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;IACnC,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,EAC9D,OAAO,EAAE,aAAa,CAAC,KAAK,CAAC,GAC5B,MAAM,CAAC,MAAM,CACd,YAAY,CAAC,KAAK,CAAC,EACnB,aAAa,CAAC,YAAY,CAAC;IAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAA;CAAE,CAAC,EAC1E,aAAa,CAAC,aAAa,GAC3B,aAAa,CAAC,eAAe,CAAC;IAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAA;CAAE,CAAC,CAClF,CAiEA"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * LiteLLM as an Effect `LanguageModel` and `EmbeddingModel`, on the chat-completions wire.
3
+ *
4
+ * ★ ONE CLIENT SHAPE FOR EVERY SEAT. `@effect/ai-openai-compat` builds the HTTP client;
5
+ * this file adds only what LiteLLM needs to attribute and control a seat's calls (tags,
6
+ * the response cache, retries, metadata — see stamp.ts) and pins `strictJsonSchema: false`.
7
+ * ⛔ THE COMPAT PACKAGE POSTS `/chat/completions` AND `/embeddings`, NOT `/responses`. The
8
+ * responses wire fails on cf-code with `missing field sequence_number` (landscape PR 165),
9
+ * so nothing here points at it.
10
+ */
11
+ import { OpenAiClient, OpenAiEmbeddingModel, OpenAiLanguageModel } from '@effect/ai-openai-compat';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as Redacted from 'effect/Redacted';
14
+ import * as EmbeddingModel from 'effect/unstable/ai/EmbeddingModel';
15
+ import type * as LanguageModel from 'effect/unstable/ai/LanguageModel';
16
+ export type SeatClientOptions = {
17
+ /** LiteLLM's OpenAI-compatible base, e.g. `http://127.0.0.1:4100/v1`. Required: no host default. */
18
+ readonly apiUrl: string;
19
+ /** A per-seat LiteLLM virtual key. Held `Redacted`; this package never logs or reads it from disk. */
20
+ readonly apiKey: string | Redacted.Redacted<string>;
21
+ /** Spend-log attribution, e.g. `['host:ct100', 'lane:cfcode', 'seat:cf-coding']`. */
22
+ readonly tags: string | ReadonlyArray<string>;
23
+ /**
24
+ * Skip LiteLLM's response cache, read and write. Defaults to TRUE.
25
+ * ⚠️ LiteLLM caches every completion for every key, so a seat that repeats a call gets the
26
+ * old answer back at a tenth of the latency — an agent loop would replay itself.
27
+ */
28
+ readonly noCache?: boolean | undefined;
29
+ /** Written into the request body only when the body carries none (compat drops it). */
30
+ readonly metadata?: Readonly<Record<string, string>> | undefined;
31
+ };
32
+ /** Compat's own per-model config (`temperature`, `max_output_tokens`, custom body keys). */
33
+ type ModelConfig = NonNullable<Parameters<typeof OpenAiLanguageModel.layer>[0]['config']>;
34
+ type EmbeddingConfig = NonNullable<Parameters<typeof OpenAiEmbeddingModel.layer>[0]['config']>;
35
+ export type SeatModelOptions = SeatClientOptions & {
36
+ /** The LiteLLM alias, e.g. `cf-code`. */
37
+ readonly model: string;
38
+ readonly config?: ModelConfig | undefined;
39
+ };
40
+ export type SeatEmbeddingOptions = SeatClientOptions & {
41
+ /** The LiteLLM alias, e.g. `embeddings`. */
42
+ readonly model: string;
43
+ /**
44
+ * What `EmbeddingModel.Dimensions` reports to a vector store. NOT sent to LiteLLM.
45
+ * ⚠️ compat's own `OpenAiEmbeddingModel.model(name, { dimensions })` puts the number in the
46
+ * request body too, and a provider that has no `dimensions` parameter (a llama.cpp bge-m3
47
+ * behind LiteLLM) can refuse it. Pass `config: { dimensions }` to send it deliberately.
48
+ */
49
+ readonly dimensions: number;
50
+ readonly config?: EmbeddingConfig | undefined;
51
+ };
52
+ /** The client: bearer key, tag header, cache and retry fields, over `fetch`. */
53
+ export declare function clientLayer(options: SeatClientOptions): Layer.Layer<OpenAiClient.OpenAiClient>;
54
+ /** `LanguageModel` for one LiteLLM alias. */
55
+ export declare function layer(options: SeatModelOptions): Layer.Layer<LanguageModel.LanguageModel>;
56
+ /** `EmbeddingModel` (and its `Dimensions`) for one LiteLLM alias. */
57
+ export declare function embeddingLayer(options: SeatEmbeddingOptions): Layer.Layer<EmbeddingModel.EmbeddingModel | EmbeddingModel.Dimensions>;
58
+ export {};
59
+ //# sourceMappingURL=seat-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seat-model.d.ts","sourceRoot":"","sources":["../src/seat-model.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACnG,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,cAAc,MAAM,mCAAmC,CAAC;AACpE,OAAO,KAAK,KAAK,aAAa,MAAM,kCAAkC,CAAC;AAKvE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,sGAAsG;IACtG,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpD,qFAAqF;IACrF,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;CAClE,CAAC;AAEF,4FAA4F;AAC5F,KAAK,WAAW,GAAG,WAAW,CAAC,UAAU,CAAC,OAAO,mBAAmB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAC1F,KAAK,eAAe,GAAG,WAAW,CAAC,UAAU,CAAC,OAAO,oBAAoB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAE/F,MAAM,MAAM,gBAAgB,GAAG,iBAAiB,GAAG;IACjD,yCAAyC;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CAC3C,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,iBAAiB,GAAG;IACrD,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,gFAAgF;AAChF,wBAAgB,WAAW,CAAC,OAAO,EAAE,iBAAiB,GAAG,KAAK,CAAC,KAAK,CAAC,YAAY,CAAC,YAAY,CAAC,CAY9F;AAED,6CAA6C;AAC7C,wBAAgB,KAAK,CAAC,OAAO,EAAE,gBAAgB,GAAG,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,aAAa,CAAC,CAQzF;AAED,qEAAqE;AACrE,wBAAgB,cAAc,CAC5B,OAAO,EAAE,oBAAoB,GAC5B,KAAK,CAAC,KAAK,CAAC,cAAc,CAAC,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC,CAKxE"}
@@ -0,0 +1,23 @@
1
+ import * as Layer from 'effect/Layer';
2
+ import { type OtlpExporter } from 'effect/unstable/observability';
3
+ /**
4
+ * CT100's Victoria services, one path each. Verified 2026-09-29 by GET against the live
5
+ * ports (no payload sent): a mounted path answers, an unmounted sibling answers
6
+ * `unsupported path requested`; the counters carry `format="protobuf"` for traces and logs.
7
+ */
8
+ export declare const CT100_ENDPOINTS: {
9
+ readonly traces: string;
10
+ readonly logs: string;
11
+ readonly metrics: string;
12
+ };
13
+ /**
14
+ * Shown when a process names itself neither with `OTEL_SERVICE_NAME` nor with a `service.name`
15
+ * in `OTEL_RESOURCE_ATTRIBUTES`; set one per seat.
16
+ */
17
+ export declare const DEFAULT_SERVICE_NAME = "seat-runtime";
18
+ /**
19
+ * OTLP tracer, logger and metrics over `fetch`, protobuf on the wire (what VictoriaTraces,
20
+ * VictoriaLogs and VictoriaMetrics ingest, and what the Claude Code seats already send).
21
+ */
22
+ export declare const layer: Layer.Layer<OtlpExporter.Flusher>;
23
+ //# sourceMappingURL=seat-obs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seat-obs.d.ts","sourceRoot":"","sources":["../src/seat-obs.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAGtC,OAAO,EACL,KAAK,YAAY,EAKlB,MAAM,+BAA+B,CAAC;AAEvC;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAK1B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,oBAAoB,iBAAiB,CAAC;AA6CnD;;;GAGG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,YAAY,CAAC,OAAO,CAQnD,CAAC"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `SeatState`: a seat's Postgres and Valkey as Effect services, one layer each, in the trace of
3
+ * the run that uses them.
4
+ *
5
+ * ⛔ THE LIBRARY HOLDS NO HOST. Every URL comes from the consumer (a value, or an environment
6
+ * variable it names), as `Redacted`; there is no default address here to point a seat at the
7
+ * wrong store, and nothing reads a credential from disk.
8
+ * ★ THE SERVICES ARE EFFECT'S, NOT OURS: `SqlClient` (`effect/unstable/sql/SqlClient`) for
9
+ * Postgres and `Redis` (`effect/unstable/persistence/Redis`) for Valkey, so a consumer writes
10
+ * ordinary Effect SQL and Redis code and this package adds only how they are built.
11
+ */
12
+ import * as Layer from 'effect/Layer';
13
+ import type * as Redis from 'effect/unstable/persistence/Redis';
14
+ import type * as SqlClient from 'effect/unstable/sql/SqlClient';
15
+ import type { SqlError } from 'effect/unstable/sql/SqlError';
16
+ import type { Config } from 'effect';
17
+ import type { PgClient } from '@effect/sql-pg';
18
+ import { type PostgresFromEnvOptions, type PostgresOptions } from './state-postgres.ts';
19
+ import { type ValkeyFromEnvOptions, type ValkeyOptions } from './state-valkey.ts';
20
+ export * from './state-postgres.ts';
21
+ export * from './state-valkey.ts';
22
+ export { PgClient } from '@effect/sql-pg';
23
+ /** Both stores, each built from an explicit URL. */
24
+ export declare const layer: (options: {
25
+ readonly postgres: PostgresOptions;
26
+ readonly valkey: ValkeyOptions;
27
+ }) => Layer.Layer<PgClient.PgClient | SqlClient.SqlClient | Redis.Redis, SqlError | Redis.RedisError>;
28
+ /** Both stores, each URL read from its environment variable (`SEAT_POSTGRES_URL`, `SEAT_VALKEY_URL`). */
29
+ export declare const layerFromEnv: (options?: {
30
+ readonly postgres?: PostgresFromEnvOptions | undefined;
31
+ readonly valkey?: ValkeyFromEnvOptions | undefined;
32
+ }) => Layer.Layer<PgClient.PgClient | SqlClient.SqlClient | Redis.Redis, SqlError | Redis.RedisError | Config.ConfigError>;
33
+ //# sourceMappingURL=seat-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seat-state.d.ts","sourceRoot":"","sources":["../src/seat-state.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,KAAK,KAAK,MAAM,mCAAmC,CAAC;AAChE,OAAO,KAAK,KAAK,SAAS,MAAM,+BAA+B,CAAC;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAC7D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,eAAe,EAGrB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAGnB,MAAM,mBAAmB,CAAC;AAE3B,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAGlC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,oDAAoD;AACpD,eAAO,MAAM,KAAK,YAAa;IAC7B,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;CAChC,KAAG,KAAK,CAAC,KAAK,CACb,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAC,SAAS,GAAG,KAAK,CAAC,KAAK,EACrD,QAAQ,GAAG,KAAK,CAAC,UAAU,CAC0C,CAAC;AAExE,yGAAyG;AACzG,eAAO,MAAM,YAAY,aAAc;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,sBAAsB,GAAG,SAAS,CAAC;IACvD,QAAQ,CAAC,MAAM,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC;CACpD,KAAG,KAAK,CAAC,KAAK,CACb,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAC,SAAS,GAAG,KAAK,CAAC,KAAK,EACrD,QAAQ,GAAG,KAAK,CAAC,UAAU,GAAG,MAAM,CAAC,WAAW,CACqC,CAAC"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The per-request marks a seat puts on every LiteLLM call, applied as an `HttpClient`
3
+ * transform because `@effect/ai-openai-compat` gives no other place to set them.
4
+ *
5
+ * ★ WHY A TRANSFORM AND NOT MODEL CONFIG. The compat mapper forwards unknown config keys
6
+ * into the body but DROPS `metadata` (it is a known Responses-API field with no
7
+ * chat-completions place), and no config key can set a header. Measured in
8
+ * packages/seat-runtime/tests/stamp.test.ts against the installed rc.115.
9
+ * ★ IT IS THE SAME TRANSFORM AS cf-harness (landscape PR 165, cf-harness/src/request.ts),
10
+ * which is where the header and the body fields were measured against LiteLLM.
11
+ */
12
+ import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
13
+ export type SeatStamp = {
14
+ /** Already validated and joined: LiteLLM reads one comma-separated header. */
15
+ readonly tags: string;
16
+ readonly noCache: boolean;
17
+ readonly metadata: Readonly<Record<string, string>> | undefined;
18
+ };
19
+ /** Validate and join. Throws at layer construction, where the mistake is one line away. */
20
+ export declare function joinTags(tags: string | ReadonlyArray<string>): string;
21
+ /** Headers and body fields for one request. Pure: the same request in, a new one out. */
22
+ export declare function stampRequest(request: HttpClientRequest.HttpClientRequest, stamp: SeatStamp): HttpClientRequest.HttpClientRequest;
23
+ //# sourceMappingURL=stamp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stamp.d.ts","sourceRoot":"","sources":["../src/stamp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,iBAAiB,MAAM,wCAAwC,CAAC;AAE5E,MAAM,MAAM,SAAS,GAAG;IACtB,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;CACjE,CAAC;AAUF,2FAA2F;AAC3F,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,GAAG,MAAM,CAWrE;AAuBD,yFAAyF;AACzF,wBAAgB,YAAY,CAC1B,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,EAC5C,KAAK,EAAE,SAAS,GACf,iBAAiB,CAAC,iBAAiB,CAoBrC"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reading a connection string without ever printing it.
3
+ *
4
+ * ⛔ A DSN CARRIES A PASSWORD, so nothing here returns, throws or formats the string itself: a
5
+ * caller gets back the few non-secret fields (host, port, database, user name) or `undefined`.
6
+ * 🔴 WHY THIS EXISTS AT ALL. Measured 2026-09-29 (`@effect/sql-pg` rc.115, Bun 1.4.0): a DSN that
7
+ * `new URL` cannot parse fails as `SqlError` -> `ConnectionError` whose `cause` is the URL
8
+ * `TypeError`, and that error's text carries the WHOLE string, password included. Three of the
9
+ * six bad DSNs tried leaked it through `JSON.stringify` and `Bun.inspect` alike. Parsing here
10
+ * first, with the same `new URL`, means a string that would leak never reaches the driver:
11
+ * what the driver still rejects (a wrong scheme, an `sslmode` it lacks) echoes only that one
12
+ * token, not the string.
13
+ * 🔴 THE SECOND REASON: `@effect/sql-pg` labels every query span from its discrete config fields
14
+ * and IGNORES the URL for that. A client built from `url` alone exports
15
+ * `server.address: localhost`, `server.port: 5432` and `db.namespace: postgres` for EVERY
16
+ * query whatever it is connected to (measured 2026-09-29 against CT100's Postgres through a
17
+ * tunnel), so a trace would point at the wrong database. `postgresFields` hands the driver the
18
+ * same host, port, database and user the URL names, decoded the way the driver decodes them.
19
+ */
20
+ /** The non-secret fields a Postgres URL names; a field the URL leaves out is absent. */
21
+ export type PostgresFields = {
22
+ readonly host?: string | undefined;
23
+ readonly port?: number | undefined;
24
+ readonly database?: string | undefined;
25
+ readonly username?: string | undefined;
26
+ };
27
+ /** The non-secret fields a Valkey URL names; `port` defaults to 6379 for a TCP scheme. */
28
+ export type ValkeyFields = {
29
+ readonly host?: string | undefined;
30
+ readonly port?: number | undefined;
31
+ /** The logical database index (`redis://host/1`), when there is one. */
32
+ readonly database?: string | undefined;
33
+ };
34
+ /**
35
+ * `undefined` when `dsn` is not a URL at all (the one case the driver would leak on); otherwise
36
+ * the fields it names, or none when the URL is one the driver should read alone.
37
+ */
38
+ export declare function postgresFields(dsn: string): PostgresFields | undefined;
39
+ /** `undefined` when `raw` is not a URL of a scheme `Bun.RedisClient` takes. */
40
+ export declare function valkeyFields(raw: string): ValkeyFields | undefined;
41
+ //# sourceMappingURL=state-dsn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-dsn.d.ts","sourceRoot":"","sources":["../src/state-dsn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,wFAAwF;AACxF,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AAEF,0FAA0F;AAC1F,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AA2BF;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAqBtE;AAaD,+EAA+E;AAC/E,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAWlE"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Postgres as Effect's own `SqlClient`, through `@effect/sql-pg`.
3
+ *
4
+ * ★ THE SDK'S OWN LAYER, NOT A WRAPPER OF IT. `PgClient` opens the pool and creates one
5
+ * `sql.execute` client span per statement carrying `db.system.name: postgresql`,
6
+ * `db.namespace`, `server.address`, `server.port` and `db.query.text` (measured 2026-09-29
7
+ * against CT100's Postgres), so state lands in the seat's trace with no `withSpan` of ours.
8
+ * ⚠️ `db.query.text` is the statement with its `$1` placeholders: parameter VALUES are not in
9
+ * the span (tests/state-postgres.test.ts asserts a canary value never leaves).
10
+ * ⛔ WHAT THIS ADDS is the two things the driver leaves to the caller, both measured
11
+ * 2026-09-29 and both in state-dsn.ts: a DSN that will not parse never reaches the driver (its
12
+ * error carries the string, password included), and the URL's host, port, database and user are
13
+ * passed as discrete fields so the spans name the database that was actually reached (from `url`
14
+ * alone they say `localhost:5432`, database `postgres`).
15
+ * ⛔ BUILDING THE LAYER RUNS `select 1`. The pool is lazy (a bad host or password otherwise fails
16
+ * at the first query), so a seat that starts against a dead or refusing Postgres is told at
17
+ * startup, as `SqlError`, within `connectTimeout`.
18
+ * ⚠️ PORTABLE. `@effect/sql-pg` rc.115 speaks the wire protocol itself over `node:net`, with no
19
+ * driver package, so this half runs under Bun and Node alike (the Valkey half needs Bun).
20
+ */
21
+ import { PgClient } from '@effect/sql-pg';
22
+ import * as Config from 'effect/Config';
23
+ import * as Duration from 'effect/Duration';
24
+ import * as Layer from 'effect/Layer';
25
+ import * as Redacted from 'effect/Redacted';
26
+ import type * as SqlClient from 'effect/unstable/sql/SqlClient';
27
+ import { SqlError } from 'effect/unstable/sql/SqlError';
28
+ export type PostgresOptions = {
29
+ /**
30
+ * `postgres://<user>:<password>@<host>:<port>/<database>` (or `postgresql://`), the user and
31
+ * password percent-encoded. Held `Redacted`; never in a span, log or error. Required: this
32
+ * package holds no host. `sslmode=require|verify-ca|verify-full|disable` is read; the driver
33
+ * refuses `prefer` and `allow`.
34
+ */
35
+ readonly url: string | Redacted.Redacted<string>;
36
+ /** Pool ceiling; the driver's default when absent. */
37
+ readonly maxConnections?: number | undefined;
38
+ /** How long to wait for a connection, and for the startup `select 1`. Driver default 5 s. */
39
+ readonly connectTimeout?: Duration.Input | undefined;
40
+ /** Shown in `pg_stat_activity`; the driver's default when absent. */
41
+ readonly applicationName?: string | undefined;
42
+ };
43
+ /** The environment variable `postgresFromEnv` reads unless told another. Not a host. */
44
+ export declare const POSTGRES_URL_VARIABLE = "SEAT_POSTGRES_URL";
45
+ /**
46
+ * `SqlClient` and `PgClient` against one Postgres. The pool closes with the layer's scope.
47
+ */
48
+ export declare const postgres: (options: PostgresOptions) => Layer.Layer<PgClient.PgClient | SqlClient.SqlClient, SqlError>;
49
+ export type PostgresFromEnvOptions = Omit<PostgresOptions, 'url'> & {
50
+ /** The variable holding the URL. Default `SEAT_POSTGRES_URL`. */
51
+ readonly variable?: string | undefined;
52
+ };
53
+ /**
54
+ * `postgres`, its URL read from an environment variable as a `Redacted` secret. A missing
55
+ * variable fails as `ConfigError`, naming the variable and nothing else.
56
+ */
57
+ export declare const postgresFromEnv: (options?: PostgresFromEnvOptions) => Layer.Layer<PgClient.PgClient | SqlClient.SqlClient, SqlError | Config.ConfigError>;
58
+ //# sourceMappingURL=state-postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-postgres.d.ts","sourceRoot":"","sources":["../src/state-postgres.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,KAAK,SAAS,MAAM,+BAA+B,CAAC;AAChE,OAAO,EAAmB,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AAGzE,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACjD,sDAAsD;IACtD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,6FAA6F;IAC7F,QAAQ,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,KAAK,GAAG,SAAS,CAAC;IACrD,qEAAqE;IACrE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,wFAAwF;AACxF,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAwCzD;;GAEG;AACH,eAAO,MAAM,QAAQ,YACV,eAAe,KACvB,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAC,SAAS,EAAE,QAAQ,CACJ,CAAC;AAE7D,MAAM,MAAM,sBAAsB,GAAG,IAAI,CAAC,eAAe,EAAE,KAAK,CAAC,GAAG;IAClE,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,aAChB,sBAAsB,KAC/B,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAC,SAAS,EAAE,QAAQ,GAAG,MAAM,CAAC,WAAW,CAOpF,CAAC"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Keeping one `Bun.RedisClient` connected for as long as its layer lives.
3
+ *
4
+ * 🔴 BUN GIVES UP, AND A CLIENT THAT HAS GIVEN UP STAYS DEAD. Measured 2026-09-29 (Bun 1.4.0, a raw
5
+ * client with `enableOfflineQueue: false` and the default `maxRetries` of 20, the server killed
6
+ * for 60 s, then restarted): Bun retried on its own for 31 s (commands failed `Connection is
7
+ * closed and offline queue is disabled`), then GAVE UP: `onclose` ran once and every command
8
+ * after failed `Connection has failed`, still so 10 s after the server was back. Only calling
9
+ * `connect()` again revived it (it resolved at once and `PING` answered). The budget is a sum of
10
+ * backoffs, so it is not one number: the review that found this measured recovery after 30 s and
11
+ * none after 45 s. A seat whose Valkey restarts for a minute (a container restart, CT100 booting)
12
+ * would keep a dead client until the process restarted, and no retry in Effect could help, since
13
+ * every retry hit the same dead client.
14
+ * ★ SO THE LAYER RECONNECTS ITSELF, off `onclose`: single-flight, each attempt bounded by
15
+ * `connectionTimeout` (which does not bound DNS; see state-valkey.ts), backing off 250 ms up to
16
+ * 5 s between attempts, one `valkey reconnect` span per attempt and a warning and an info line
17
+ * around the outage. It stops when the layer's scope closes.
18
+ * ⚠️ `onclose` IS A HINT AND `connected` IS THE FACT. Measured on the same client: `onclose` also
19
+ * runs for every `connect()` that fails (each rejected after ~155 ms with `Connection closed`)
20
+ * and for the client's own `close()`, so a reconnect that trusted it would start itself. The
21
+ * loop therefore runs only while `connected` is false, and a wake-up that arrives after a
22
+ * successful reconnect finds nothing to do.
23
+ * ⚠️ COMMANDS STILL FAIL AT ONCE WHILE IT IS DOWN (the offline queue is off on purpose): the
24
+ * caller's retry is what spans an outage, and from now on the retry is answered once this loop
25
+ * has reconnected, within the pause plus one attempt of the server coming back.
26
+ */
27
+ import type { RedisClient } from 'bun';
28
+ import * as Duration from 'effect/Duration';
29
+ import * as Effect from 'effect/Effect';
30
+ import type * as Scope from 'effect/Scope';
31
+ /** What this file needs of `Bun.RedisClient`. */
32
+ export type Reconnectable = Pick<RedisClient, 'connected' | 'connect' | 'onclose'>;
33
+ export type KeepOptions = {
34
+ /** How long one reconnect attempt may take before it is abandoned and retried. */
35
+ readonly connectionTimeout: Duration.Input;
36
+ /** Span attributes every attempt carries: the server address, no secrets. */
37
+ readonly attributes: Readonly<Record<string, unknown>>;
38
+ /** The pause after the first failed attempt, doubled up to `longestPause`. Default 250 ms. */
39
+ readonly firstPause?: Duration.Input | undefined;
40
+ /** The longest pause between attempts. Default 5 s. */
41
+ readonly longestPause?: Duration.Input | undefined;
42
+ };
43
+ /**
44
+ * Reconnect `client` whenever Bun gives up on it, for the life of the enclosing scope. Call it
45
+ * AFTER the first `connect()` succeeded, so a wrong URL or password still fails the layer's build
46
+ * instead of being retried forever.
47
+ */
48
+ export declare const keepConnected: (client: Reconnectable, options: KeepOptions) => Effect.Effect<void, never, Scope.Scope>;
49
+ //# sourceMappingURL=state-valkey-connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-valkey-connection.d.ts","sourceRoot":"","sources":["../src/state-valkey-connection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,KAAK,KAAK,KAAK,MAAM,cAAc,CAAC;AAE3C,iDAAiD;AACjD,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,SAAS,GAAG,SAAS,CAAC,CAAC;AAEnF,MAAM,MAAM,WAAW,GAAG;IACxB,kFAAkF;IAClF,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,CAAC,KAAK,CAAC;IAC3C,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACvD,8FAA8F;IAC9F,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,KAAK,GAAG,SAAS,CAAC;IACjD,uDAAuD;IACvD,QAAQ,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,KAAK,GAAG,SAAS,CAAC;CACpD,CAAC;AAKF;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,CAC1B,MAAM,EAAE,aAAa,EACrB,OAAO,EAAE,WAAW,KACjB,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAuDzC,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What a Valkey error may say once it has left the layer.
3
+ *
4
+ * 🔴 THE SERVER'S ERROR TEXT ECHOES THE ARGUMENTS OF THE CALL THAT FAILED. Measured 2026-09-29
5
+ * (Valkey 9.1.1, through `Bun.RedisClient`, with canary values):
6
+ * ERR unknown command 'JSON.SET', with args beginning with: 'seat:key-…' '$' '{"secret":…}'
7
+ * ERR unknown subcommand 'seat:key-…'. Try OBJECT HELP.
8
+ * Keys are seat data and values are whatever the seat stored, and Effect's `OtlpTracer` exports
9
+ * every FAILED span's error as `exception.message` and `exception.stacktrace` (with the whole
10
+ * `cause` chain, `includeCauseInStack: true`), so those two errors put a key and a value into
11
+ * the exported trace: the OTLP payload a Victoria service would receive held both (a wire test
12
+ * in tests/state-error-text.test.ts asserts it now does not). A `Logger` that prints a failed
13
+ * call does the same.
14
+ * ★ SO THE ERROR THAT LEAVES THE LAYER IS A NEW `Error` WITH THE ARGUMENTS CUT OUT, and the
15
+ * original is not chained (a `cause` would carry its text straight back into the stack). Kept:
16
+ * Bun's `code` and `name`, and the server's own words up to the first quote that opens
17
+ * something other than the command that was sent. That keeps `NOPERM User seat has no
18
+ * permissions to run the 'flushall' command` whole (so `isPermissionDenied` and the wording are
19
+ * unchanged) and cuts `unknown command 'JSON.SET', with args beginning with: ` at the first
20
+ * argument, even when an argument holds a quote itself.
21
+ * ⚠️ WHAT THIS DOES NOT COVER, said plainly: a server text that echoes an argument WITHOUT quotes,
22
+ * and the text a Lua script raises itself (`error(...)` comes back as `ERR user_script:1: <text>`;
23
+ * measured). Bun's own errors (`Connection closed`, a timeout) quote nothing.
24
+ */
25
+ /** What replaces the arguments. */
26
+ export declare const OMITTED = "[arguments omitted]";
27
+ /**
28
+ * `message` up to the first quoted thing that is not `command` (or one of its subcommands, which
29
+ * the server writes `command|sub`), with `OMITTED` where the rest was.
30
+ */
31
+ export declare function cutArguments(message: string, command: string): string;
32
+ /**
33
+ * The client's rejection as an `Error` that is safe to put in a span, a log or a `RedisError`:
34
+ * a fresh one, keeping Bun's `name` and `code`, with the arguments cut out of the text.
35
+ */
36
+ export declare function scrubbedError(cause: unknown, command: string): Error;
37
+ //# sourceMappingURL=state-valkey-scrub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-valkey-scrub.d.ts","sourceRoot":"","sources":["../src/state-valkey-scrub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,mCAAmC;AACnC,eAAO,MAAM,OAAO,wBAAwB,CAAC;AAE7C;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAYrE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CASpE"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One Valkey command as a span, with a deadline, failing as `RedisError`.
3
+ *
4
+ * ⛔ A SPAN NAMES THE COMMAND, NEVER ITS ARGUMENTS. Keys are seat data and values are whatever the
5
+ * seat stored; `AUTH` and `HELLO ... AUTH` carry a password. Only the upper-cased command name
6
+ * (`SET`, `GET`) is recorded, so the span is low-cardinality and holds nothing to redact.
7
+ * 🔴 NOR IS THE ERROR TEXT: the server quotes the arguments of a call it refuses (`unknown command
8
+ * 'X', with args beginning with: '<key>' '<value>'`), and a failed span exports its error, so
9
+ * what a caller receives is `scrubbedError`, never Bun's own (state-valkey-scrub.ts).
10
+ * 🔴 THE DEADLINE IS NOT OPTIONAL. Measured 2026-09-29 (Bun 1.4.0): a `Bun.RedisClient` on its
11
+ * defaults that has lost its server QUEUES every command and never answers it until the server
12
+ * is back (a `send` sat unresolved past 8 s against a dead port; after a restart the queued
13
+ * command completed ~5 s later). With the offline queue off (`state-valkey.ts` sets it) a dropped
14
+ * connection fails at once, but a peer that holds the socket open and says nothing would still
15
+ * wedge a seat forever, so every command carries `commandTimeout` too.
16
+ */
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Redis from 'effect/unstable/persistence/Redis';
19
+ import type * as Duration from 'effect/Duration';
20
+ /** What this file needs of a client: `Bun.RedisClient` has this exact `send`. */
21
+ export type ValkeyCall = {
22
+ readonly send: (command: string, args: Array<string>) => Promise<unknown>;
23
+ };
24
+ /** `send` as `Redis.make` wants it. */
25
+ export type Send = <A = unknown>(command: string, ...args: ReadonlyArray<string>) => Effect.Effect<A, Redis.RedisError>;
26
+ export type SendOptions = {
27
+ /** How long one command may take before it fails as `RedisError`. */
28
+ readonly commandTimeout: Duration.Input;
29
+ /** The command deadline in milliseconds, for the message only. */
30
+ readonly commandTimeoutMs: number;
31
+ /** Span attributes every command carries: `db.system.name`, the server address, no secrets. */
32
+ readonly attributes: Readonly<Record<string, unknown>>;
33
+ };
34
+ export declare function instrumentedSend(client: ValkeyCall, options: SendOptions): Send;
35
+ //# sourceMappingURL=state-valkey-send.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-valkey-send.d.ts","sourceRoot":"","sources":["../src/state-valkey-send.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,MAAM,mCAAmC,CAAC;AAC3D,OAAO,KAAK,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAGjD,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC3E,CAAC;AAEF,uCAAuC;AACvC,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,GAAG,OAAO,EAC7B,OAAO,EAAE,MAAM,EACf,GAAG,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,KAC3B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC;AAExC,MAAM,MAAM,WAAW,GAAG;IACxB,qEAAqE;IACrE,QAAQ,CAAC,cAAc,EAAE,QAAQ,CAAC,KAAK,CAAC;IACxC,kEAAkE;IAClE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,+FAA+F;IAC/F,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACxD,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,GAAG,IAAI,CAyB/E"}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Valkey as Effect's own `Redis` service, over `Bun.RedisClient`.
3
+ *
4
+ * ★ `Redis.make` OVER THE BUILT-IN CLIENT, NOT `@effect/platform-bun`'s `BunRedis`. Rc.115 ships
5
+ * `BunRedis.layer`, and it would do; but a dependency on `@effect/platform-bun` drags
6
+ * `@effect/platform-node-shared ^rc.115` in behind it, which resolves to rc.118 on a fresh
7
+ * install and kills the process at import, and only a ROOT `overrides` fixes that (docs/pairing.md,
8
+ * measured 2026-09-29). A library cannot ship one. So this is the same ~30 lines (`send` over
9
+ * `client.send`), with the two things `BunRedis` lacks: a connection that must succeed before the
10
+ * layer is built, and a deadline on every command.
11
+ * ⛔ THE URL CARRIES THE ACL USER (`redis://seat:<password>@host:port`, percent-encoded), measured
12
+ * 2026-09-29 against a scratch Valkey with `user default off`: an unauthenticated client is
13
+ * refused `NOAUTH`; the seat user reads and writes its own prefix; a write outside it fails
14
+ * `NOPERM No permissions to access a key`, and a command outside its categories fails
15
+ * `NOPERM User seat has no permissions to run the 'flushall' command`. All of them arrive as
16
+ * `RedisError`, and `isPermissionDenied` names the two `NOPERM` ones.
17
+ * ⛔ NO `subscribe`. `Redis.subscribe` here fails with `RedisError`: a Valkey subscriber needs a
18
+ * connection of its own that this layer does not open. (`BunRedis` opens one, with no reconnect;
19
+ * a caller that needs pub/sub can use it beside this layer.)
20
+ * ⚠️ BUN ONLY, AND SAID SO AT THE FIRST USE. `Bun.RedisClient` is loaded with `import('bun')`, so
21
+ * importing this module under Node still works (the Postgres half is portable); BUILDING the
22
+ * Valkey layer there fails with a `RedisError` naming the reason.
23
+ * 🔴 `connectionTimeout` DOES NOT BOUND DNS. Measured 2026-09-29: with `connectionTimeout: 700`, a
24
+ * host name that does not resolve failed after 31 s. `connect()` therefore also runs under an
25
+ * Effect timeout of the same length.
26
+ * 🔴 BUN'S OWN RECONNECT ENDS, AND THE CLIENT THEN STAYS DEAD (about 31 s of outage here): the layer
27
+ * reconnects it itself, off `onclose` (state-valkey-connection.ts holds the measurement).
28
+ */
29
+ import * as Config from 'effect/Config';
30
+ import * as Duration from 'effect/Duration';
31
+ import * as Layer from 'effect/Layer';
32
+ import * as Redacted from 'effect/Redacted';
33
+ import * as Redis from 'effect/unstable/persistence/Redis';
34
+ export type ValkeyOptions = {
35
+ /**
36
+ * `redis://<user>:<password>@<host>:<port>[/<db>]` (or `valkey://`, `rediss://` for TLS), the
37
+ * user and password percent-encoded. Held `Redacted`; never in a span, log or error. Required:
38
+ * this package holds no host.
39
+ */
40
+ readonly url: string | Redacted.Redacted<string>;
41
+ /**
42
+ * How long to wait to connect and authenticate when the layer is built, and for each reconnect
43
+ * attempt after Bun gives up. Default 5 s (Bun's own is 10 s). ⚠️ A finite positive duration of at
44
+ * most 2 ** 31 - 1 ms, or a `RangeError` defect.
45
+ */
46
+ readonly connectionTimeout?: Duration.Input | undefined;
47
+ /** How long one command may take. Default 10 s; same limits as `connectionTimeout`. */
48
+ readonly commandTimeout?: Duration.Input | undefined;
49
+ /**
50
+ * How many times Bun retries a lost connection before it gives up (its default is 20, about 30
51
+ * s of outage). After that THE LAYER reconnects until the server is back, so this only sets
52
+ * when its own loop takes over. A non-negative integer, or a `RangeError` defect.
53
+ */
54
+ readonly maxRetries?: number | undefined;
55
+ };
56
+ export declare const DEFAULT_CONNECTION_TIMEOUT: Duration.Duration;
57
+ export declare const DEFAULT_COMMAND_TIMEOUT: Duration.Duration;
58
+ /**
59
+ * Whether `error` is the server's `NOPERM`: a key outside the user's prefix, or a command outside
60
+ * its categories. ⚠️ It reads the message, because Bun reports both as one `code`
61
+ * (`ERR_REDIS_SERVER_ERROR`) and the server's own text is the only thing that tells them apart
62
+ * from a real server fault.
63
+ */
64
+ export declare function isPermissionDenied(error: unknown): boolean;
65
+ /**
66
+ * The `Redis` service against one Valkey. ⛔ BUILDING IT CONNECTS AND AUTHENTICATES, so a wrong
67
+ * URL, user or password fails at startup as `RedisError`, not at the first command (Bun reports a
68
+ * refused password as `Connection closed`, the same text as a server that is down).
69
+ * The client is closed when the layer's scope closes.
70
+ */
71
+ export declare const valkey: (options: ValkeyOptions) => Layer.Layer<Redis.Redis, Redis.RedisError>;
72
+ /** The environment variable `valkeyFromEnv` reads unless told another. Not a host. */
73
+ export declare const VALKEY_URL_VARIABLE = "SEAT_VALKEY_URL";
74
+ export type ValkeyFromEnvOptions = Omit<ValkeyOptions, 'url'> & {
75
+ /** The variable holding the URL. Default `SEAT_VALKEY_URL`. */
76
+ readonly variable?: string | undefined;
77
+ };
78
+ /**
79
+ * `valkey`, its URL read from an environment variable as a `Redacted` secret. A missing variable
80
+ * fails as `ConfigError`, naming the variable and nothing else.
81
+ */
82
+ export declare const valkeyFromEnv: (options?: ValkeyFromEnvOptions) => Layer.Layer<Redis.Redis, Redis.RedisError | Config.ConfigError>;
83
+ //# sourceMappingURL=state-valkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-valkey.d.ts","sourceRoot":"","sources":["../src/state-valkey.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAE5C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,KAAK,QAAQ,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,KAAK,MAAM,mCAAmC,CAAC;AAK3D,MAAM,MAAM,aAAa,GAAG;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACjD;;;;OAIG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,QAAQ,CAAC,KAAK,GAAG,SAAS,CAAC;IACxD,uFAAuF;IACvF,QAAQ,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,KAAK,GAAG,SAAS,CAAC;IACrD;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1C,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,QAA8B,CAAC;AACjF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,QAA+B,CAAC;AAe/E;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAK1D;AAuED;;;;;GAKG;AACH,eAAO,MAAM,MAAM,YAAa,aAAa,KAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,CAItF,CAAC;AAEJ,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,aAAa,EAAE,KAAK,CAAC,GAAG;IAC9D,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,aAAa,aACd,oBAAoB,KAC7B,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,UAAU,GAAG,MAAM,CAAC,WAAW,CAOhE,CAAC"}