@dxos/pipeline-transcription 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/lib/neutral/chunk-Y44GOMGN.mjs +22 -0
  2. package/dist/lib/neutral/chunk-Y44GOMGN.mjs.map +7 -0
  3. package/dist/lib/neutral/index.mjs +886 -0
  4. package/dist/lib/neutral/index.mjs.map +7 -0
  5. package/dist/lib/neutral/meta.json +1 -0
  6. package/dist/lib/neutral/testing/index.mjs +192 -0
  7. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  8. package/dist/types/src/PipelineConfig.d.ts +49 -0
  9. package/dist/types/src/PipelineConfig.d.ts.map +1 -0
  10. package/dist/types/src/PipelineConfig.test.d.ts +2 -0
  11. package/dist/types/src/PipelineConfig.test.d.ts.map +1 -0
  12. package/dist/types/src/PipelineRuntime.d.ts +46 -0
  13. package/dist/types/src/PipelineRuntime.d.ts.map +1 -0
  14. package/dist/types/src/PipelineRuntime.test.d.ts +2 -0
  15. package/dist/types/src/PipelineRuntime.test.d.ts.map +1 -0
  16. package/dist/types/src/Stage.d.ts +80 -0
  17. package/dist/types/src/Stage.d.ts.map +1 -0
  18. package/dist/types/src/TranscriptEvent.d.ts +23 -0
  19. package/dist/types/src/TranscriptEvent.d.ts.map +1 -0
  20. package/dist/types/src/asr/audio-recorder.d.ts +47 -0
  21. package/dist/types/src/asr/audio-recorder.d.ts.map +1 -0
  22. package/dist/types/src/asr/index.d.ts +5 -0
  23. package/dist/types/src/asr/index.d.ts.map +1 -0
  24. package/dist/types/src/asr/pipeline.d.ts +42 -0
  25. package/dist/types/src/asr/pipeline.d.ts.map +1 -0
  26. package/dist/types/src/asr/sentence-buffer.d.ts +25 -0
  27. package/dist/types/src/asr/sentence-buffer.d.ts.map +1 -0
  28. package/dist/types/src/asr/sentence-buffer.test.d.ts +2 -0
  29. package/dist/types/src/asr/sentence-buffer.test.d.ts.map +1 -0
  30. package/dist/types/src/asr/transcriber.d.ts +115 -0
  31. package/dist/types/src/asr/transcriber.d.ts.map +1 -0
  32. package/dist/types/src/asr/transcriber.test.d.ts +2 -0
  33. package/dist/types/src/asr/transcriber.test.d.ts.map +1 -0
  34. package/dist/types/src/asr/util.d.ts +2 -0
  35. package/dist/types/src/asr/util.d.ts.map +1 -0
  36. package/dist/types/src/dispatch.d.ts +19 -0
  37. package/dist/types/src/dispatch.d.ts.map +1 -0
  38. package/dist/types/src/dispatch.test.d.ts +2 -0
  39. package/dist/types/src/dispatch.test.d.ts.map +1 -0
  40. package/dist/types/src/index.d.ts +11 -0
  41. package/dist/types/src/index.d.ts.map +1 -0
  42. package/dist/types/src/live.d.ts +24 -0
  43. package/dist/types/src/live.d.ts.map +1 -0
  44. package/dist/types/src/live.test.d.ts +2 -0
  45. package/dist/types/src/live.test.d.ts.map +1 -0
  46. package/dist/types/src/lookup.d.ts +35 -0
  47. package/dist/types/src/lookup.d.ts.map +1 -0
  48. package/dist/types/src/lookup.test.d.ts +2 -0
  49. package/dist/types/src/lookup.test.d.ts.map +1 -0
  50. package/dist/types/src/model-routing.d.ts +12 -0
  51. package/dist/types/src/model-routing.d.ts.map +1 -0
  52. package/dist/types/src/pipeline-integration.test.d.ts +2 -0
  53. package/dist/types/src/pipeline-integration.test.d.ts.map +1 -0
  54. package/dist/types/src/stages/correction-llm.d.ts +14 -0
  55. package/dist/types/src/stages/correction-llm.d.ts.map +1 -0
  56. package/dist/types/src/stages/correction-llm.test.d.ts +2 -0
  57. package/dist/types/src/stages/correction-llm.test.d.ts.map +1 -0
  58. package/dist/types/src/stages/correction.d.ts +29 -0
  59. package/dist/types/src/stages/correction.d.ts.map +1 -0
  60. package/dist/types/src/stages/correction.test.d.ts +2 -0
  61. package/dist/types/src/stages/correction.test.d.ts.map +1 -0
  62. package/dist/types/src/stages/diarization.d.ts +11 -0
  63. package/dist/types/src/stages/diarization.d.ts.map +1 -0
  64. package/dist/types/src/stages/extraction.d.ts +40 -0
  65. package/dist/types/src/stages/extraction.d.ts.map +1 -0
  66. package/dist/types/src/stages/extraction.test.d.ts +2 -0
  67. package/dist/types/src/stages/extraction.test.d.ts.map +1 -0
  68. package/dist/types/src/stages/index.d.ts +18 -0
  69. package/dist/types/src/stages/index.d.ts.map +1 -0
  70. package/dist/types/src/stages/summarization.d.ts +12 -0
  71. package/dist/types/src/stages/summarization.d.ts.map +1 -0
  72. package/dist/types/src/stages/translation.d.ts +11 -0
  73. package/dist/types/src/stages/translation.d.ts.map +1 -0
  74. package/dist/types/src/testing/conversation-parser.d.ts +26 -0
  75. package/dist/types/src/testing/conversation-parser.d.ts.map +1 -0
  76. package/dist/types/src/testing/conversation.test.d.ts +2 -0
  77. package/dist/types/src/testing/conversation.test.d.ts.map +1 -0
  78. package/dist/types/src/testing/fixtures.d.ts +6 -0
  79. package/dist/types/src/testing/fixtures.d.ts.map +1 -0
  80. package/dist/types/src/testing/index.d.ts +5 -0
  81. package/dist/types/src/testing/index.d.ts.map +1 -0
  82. package/dist/types/src/testing/scripted-source.d.ts +16 -0
  83. package/dist/types/src/testing/scripted-source.d.ts.map +1 -0
  84. package/dist/types/src/testing/stream-simulator.d.ts +38 -0
  85. package/dist/types/src/testing/stream-simulator.d.ts.map +1 -0
  86. package/dist/types/tsconfig.tsbuildinfo +1 -0
  87. package/package.json +61 -0
  88. package/src/PipelineConfig.test.ts +39 -0
  89. package/src/PipelineConfig.ts +71 -0
  90. package/src/PipelineRuntime.test.ts +74 -0
  91. package/src/PipelineRuntime.ts +192 -0
  92. package/src/Stage.ts +86 -0
  93. package/src/TranscriptEvent.ts +23 -0
  94. package/src/asr/audio-recorder.ts +56 -0
  95. package/src/asr/index.ts +8 -0
  96. package/src/asr/pipeline.ts +105 -0
  97. package/src/asr/sentence-buffer.test.ts +53 -0
  98. package/src/asr/sentence-buffer.ts +88 -0
  99. package/src/asr/transcriber.test.ts +104 -0
  100. package/src/asr/transcriber.ts +337 -0
  101. package/src/asr/util.ts +14 -0
  102. package/src/dispatch.test.ts +37 -0
  103. package/src/dispatch.ts +56 -0
  104. package/src/index.ts +14 -0
  105. package/src/live.test.ts +63 -0
  106. package/src/live.ts +56 -0
  107. package/src/lookup.test.ts +41 -0
  108. package/src/lookup.ts +60 -0
  109. package/src/model-routing.ts +25 -0
  110. package/src/pipeline-integration.test.ts +70 -0
  111. package/src/stages/correction-llm.test.ts +46 -0
  112. package/src/stages/correction-llm.ts +40 -0
  113. package/src/stages/correction.test.ts +26 -0
  114. package/src/stages/correction.ts +72 -0
  115. package/src/stages/diarization.ts +24 -0
  116. package/src/stages/extraction.test.ts +79 -0
  117. package/src/stages/extraction.ts +151 -0
  118. package/src/stages/index.ts +44 -0
  119. package/src/stages/summarization.ts +37 -0
  120. package/src/stages/translation.ts +26 -0
  121. package/src/testing/conversation-parser.ts +101 -0
  122. package/src/testing/conversation.test.ts +53 -0
  123. package/src/testing/conversation.txt +23 -0
  124. package/src/testing/fixtures.ts +18 -0
  125. package/src/testing/index.ts +8 -0
  126. package/src/testing/scripted-source.ts +35 -0
  127. package/src/testing/stream-simulator.ts +125 -0
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@dxos/pipeline-transcription",
3
+ "version": "0.0.0",
4
+ "description": "Streaming stage pipeline for transcription post-processing (correction, entity extraction, summarization).",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "info@dxos.org",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "source": "./src/index.ts",
18
+ "types": "./dist/types/src/index.d.ts",
19
+ "default": "./dist/lib/neutral/index.mjs"
20
+ },
21
+ "./testing": {
22
+ "source": "./src/testing/index.ts",
23
+ "types": "./dist/types/src/testing/index.d.ts",
24
+ "default": "./dist/lib/neutral/testing/index.mjs"
25
+ }
26
+ },
27
+ "types": "dist/types/src/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "dependencies": {
33
+ "@dxos/ai": "workspace:*",
34
+ "@dxos/assistant": "workspace:*",
35
+ "@dxos/async": "workspace:*",
36
+ "@dxos/compute": "workspace:*",
37
+ "@dxos/config": "workspace:*",
38
+ "@dxos/context": "workspace:*",
39
+ "@dxos/echo": "workspace:*",
40
+ "@dxos/effect": "workspace:*",
41
+ "@dxos/invariant": "workspace:*",
42
+ "@dxos/keys": "workspace:*",
43
+ "@dxos/log": "workspace:*",
44
+ "@dxos/pipeline": "workspace:*",
45
+ "@dxos/tracing": "workspace:*",
46
+ "@dxos/types": "workspace:*",
47
+ "@effect/ai": "catalog:",
48
+ "wavefile": "catalog:"
49
+ },
50
+ "devDependencies": {
51
+ "@dxos/echo-client": "workspace:*",
52
+ "effect": "catalog:"
53
+ },
54
+ "peerDependencies": {
55
+ "effect": "catalog:"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "beast": {}
61
+ }
@@ -0,0 +1,39 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { DXN } from '@dxos/echo';
8
+
9
+ import { resolveModel } from './model-routing';
10
+ import { DEFAULT_STAGE_MODEL, findPreset } from './PipelineConfig';
11
+ import { makeCorrectionStage } from './stages';
12
+
13
+ describe('PipelineConfig', () => {
14
+ test('meeting preset enables the three real stages in order', ({ expect }) => {
15
+ const enabled = findPreset('Meeting')!
16
+ .stages.filter((stage) => stage.enabled)
17
+ .map((stage) => stage.id);
18
+ expect(enabled).toEqual(['correct', 'extract', 'summarize']);
19
+ });
20
+
21
+ test('notes preset runs correction only', ({ expect }) => {
22
+ const enabled = findPreset('Notes')!
23
+ .stages.filter((stage) => stage.enabled)
24
+ .map((stage) => stage.id);
25
+ expect(enabled).toEqual(['correct']);
26
+ });
27
+ });
28
+
29
+ describe('resolveModel', () => {
30
+ test('config override beats stage default beats preset default', ({ expect }) => {
31
+ const stage = makeCorrectionStage();
32
+ const override = DXN.make('com.meta.model.llama-3-2-1b.instruct');
33
+ expect(resolveModel({ id: 'correct', enabled: true, model: override }, stage)).toEqual(override);
34
+ expect(resolveModel(undefined, stage)).toEqual(stage.model);
35
+ expect(resolveModel({ id: 'correct', enabled: true }, { ...stage, model: undefined }, DEFAULT_STAGE_MODEL)).toEqual(
36
+ DEFAULT_STAGE_MODEL,
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,71 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { DXN, Obj, Type } from '@dxos/echo';
8
+
9
+ /** Cheap, fast default for all stages; same model used by `update-chat-name` / `qualifier`. */
10
+ export const DEFAULT_STAGE_MODEL: DXN.DXN = DXN.make('com.anthropic.model.claude-haiku-4-5.default');
11
+
12
+ /** Per-stage configuration entry within a {@link PipelineConfig}. */
13
+ export const StageConfig = Schema.Struct({
14
+ /** Stage id (matches a registered `Stage.id`). */
15
+ id: Schema.String,
16
+ enabled: Schema.Boolean,
17
+ /** Optional per-stage model override (an NSID name); resolved by the runtime. */
18
+ model: Schema.optional(DXN.NameSchema),
19
+ /** Optional sliding-window override in blocks. Positive integer; bad values fail validation. */
20
+ window: Schema.optional(Schema.Struct({ blocks: Schema.Number.pipe(Schema.int(), Schema.positive()) })),
21
+ });
22
+ export type StageConfig = Schema.Schema.Type<typeof StageConfig>;
23
+
24
+ /**
25
+ * Ordered, persisted configuration that drives a {@link PipelineRuntime}: which stages run, in what
26
+ * order, with which model and window. Presets ("Meeting", "Notes") are seeded instances.
27
+ */
28
+ export class PipelineConfig extends Type.makeObject<PipelineConfig>(
29
+ DXN.make('org.dxos.type.transcriptionPipelineConfig', '0.1.0'),
30
+ )(
31
+ Schema.Struct({
32
+ name: Schema.String,
33
+ stages: Schema.mutable(Schema.Array(StageConfig)),
34
+ }),
35
+ ) {}
36
+
37
+ /** A plain preset descriptor (used to seed a {@link PipelineConfig} object). */
38
+ export type PipelinePreset = {
39
+ name: string;
40
+ stages: StageConfig[];
41
+ };
42
+
43
+ /**
44
+ * Built-in presets. `Meeting` runs the three real stages; `Notes` runs correction (+ extraction)
45
+ * and streams into the editor without a cumulative summary.
46
+ */
47
+ export const PIPELINE_PRESETS: readonly PipelinePreset[] = Object.freeze([
48
+ {
49
+ name: 'Meeting',
50
+ stages: [
51
+ { id: 'correct', enabled: true },
52
+ { id: 'extract', enabled: true },
53
+ { id: 'summarize', enabled: true },
54
+ ],
55
+ },
56
+ {
57
+ name: 'Notes',
58
+ stages: [
59
+ { id: 'correct', enabled: true },
60
+ { id: 'extract', enabled: false },
61
+ { id: 'summarize', enabled: false },
62
+ ],
63
+ },
64
+ ]);
65
+
66
+ export const findPreset = (name: string): PipelinePreset | undefined =>
67
+ PIPELINE_PRESETS.find((preset) => preset.name === name);
68
+
69
+ /** Instantiate a `PipelineConfig` ECHO object from a preset descriptor. */
70
+ export const makeConfigFromPreset = (preset: PipelinePreset): PipelineConfig =>
71
+ Obj.make(PipelineConfig, { name: preset.name, stages: preset.stages.map((stage) => ({ ...stage })) });
@@ -0,0 +1,74 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Stream from 'effect/Stream';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { EffectEx } from '@dxos/effect';
10
+
11
+ import { captureCommit } from './dispatch';
12
+ import { PipelineRuntime, type TelemetryEvent } from './PipelineRuntime';
13
+ import { type Stage, StageWrite } from './Stage';
14
+ import { makeCorrectionStage, makeSummarizationStage } from './stages';
15
+ import { SAMPLE_MEETING, scriptedSource } from './testing';
16
+ import { TranscriptEvent } from './TranscriptEvent';
17
+
18
+ describe('PipelineRuntime', () => {
19
+ test('runs correction per block and summarizes on silence', async ({ expect }) => {
20
+ const { commit, writes } = captureCommit();
21
+ await EffectEx.runPromise(
22
+ PipelineRuntime.run({
23
+ source: scriptedSource(SAMPLE_MEETING),
24
+ stages: [makeCorrectionStage(), makeSummarizationStage()],
25
+ commit,
26
+ }),
27
+ );
28
+
29
+ const corrected = writes.flatMap((write) => write.blockUpdates ?? []).map((update) => update.corrected);
30
+ expect(corrected).toContain('So i think we should ship munich on friday.');
31
+
32
+ const summaries = writes.map((write) => write.transcriptUpdate?.summary).filter(Boolean);
33
+ expect(summaries.length).toBeGreaterThan(0);
34
+ });
35
+
36
+ test('honours disabled stage config', async ({ expect }) => {
37
+ const { commit, writes } = captureCommit();
38
+ await EffectEx.runPromise(
39
+ PipelineRuntime.run({
40
+ source: scriptedSource(SAMPLE_MEETING),
41
+ stages: [makeCorrectionStage()],
42
+ configs: [{ id: 'correct', enabled: false }],
43
+ commit,
44
+ }),
45
+ );
46
+ expect(writes).toHaveLength(0);
47
+ });
48
+
49
+ test('latest-wins coalesces stale work under load without interrupting', async ({ expect }) => {
50
+ const telemetry: TelemetryEvent[] = [];
51
+ // A slow stage over three synchronously-delivered blocks: sliding overflow replaces stale queued
52
+ // work with the latest (rather than interrupting the in-flight run), so fewer than three runs
53
+ // commit and every reported outcome is a clean commit (no interrupt/error signal).
54
+ const slow: Stage<{ window: any[] }> = {
55
+ id: 'slow',
56
+ trigger: 'per-block',
57
+ concurrency: 'latest-wins',
58
+ run: () => Effect.sleep('50 millis').pipe(Effect.as(StageWrite.empty())),
59
+ };
60
+ const { commit } = captureCommit();
61
+ const source = Stream.fromIterable([
62
+ TranscriptEvent.block({ _tag: 'transcript', started: 's', text: 'one' }),
63
+ TranscriptEvent.block({ _tag: 'transcript', started: 's', text: 'two' }),
64
+ TranscriptEvent.block({ _tag: 'transcript', started: 's', text: 'three' }),
65
+ ]);
66
+ await EffectEx.runPromise(
67
+ PipelineRuntime.run({ source, stages: [slow], commit, onTelemetry: (event) => telemetry.push(event) }),
68
+ );
69
+ const committed = telemetry.filter((event) => event.outcome === 'committed');
70
+ expect(committed.length).toBeGreaterThan(0);
71
+ expect(committed.length).toBeLessThan(3);
72
+ expect(telemetry.every((event) => event.outcome === 'committed')).toBe(true);
73
+ });
74
+ });
@@ -0,0 +1,192 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Cause from 'effect/Cause';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
8
+ import { pipe } from 'effect/Function';
9
+ import * as Stream from 'effect/Stream';
10
+
11
+ import { DXN } from '@dxos/echo';
12
+ import { log } from '@dxos/log';
13
+ import { Pipeline, Stage as PipelineStage } from '@dxos/pipeline';
14
+ import { type ContentBlock } from '@dxos/types';
15
+
16
+ import { type EntityLookup } from './lookup';
17
+ import { resolveModel } from './model-routing';
18
+ import { type StageConfig } from './PipelineConfig';
19
+ import { type Stage, type StageContext, type StageWrite } from './Stage';
20
+ import { type TranscriptEvent } from './TranscriptEvent';
21
+
22
+ /** Upper bound on retained blocks; bounds memory for long sessions (stages slice their own window). */
23
+ const MAX_WINDOW = 128;
24
+
25
+ /** Backpressure lag (in chunks) the broadcast driver may run ahead of the slowest branch. */
26
+ const BROADCAST_BUFFER = 128;
27
+
28
+ /**
29
+ * Commits a stage's output to storage. Receives the exact window slice the stage was invoked with,
30
+ * so `BlockUpdate.index` can be resolved against the originating blocks.
31
+ */
32
+ export type CommitFn = (write: StageWrite, window: readonly ContentBlock.Transcript[]) => Effect.Effect<void>;
33
+
34
+ /**
35
+ * Outcome reported per stage invocation. The `sliding`/`dropping` overflow model has no fiber-level
36
+ * signal for stale work, so only `committed` and `error` are reported (the previous fiber-based
37
+ * runtime also surfaced `interrupted`/`skipped`).
38
+ */
39
+ export type StageOutcome = 'committed' | 'error';
40
+
41
+ export type TelemetryEvent = {
42
+ readonly stageId: string;
43
+ readonly model: DXN.DXN;
44
+ readonly trigger: TranscriptEvent['kind'];
45
+ readonly durationMs: number;
46
+ readonly outcome: StageOutcome;
47
+ };
48
+
49
+ export type RunOptions = {
50
+ /** The continuous transcript event stream (ASR source or a scripted fixture). */
51
+ readonly source: Stream.Stream<TranscriptEvent>;
52
+ /** Ordered stages to run. */
53
+ readonly stages: readonly Stage<any, any>[];
54
+ /** Sink for stage outputs (in-memory capture in tests; ECHO dispatcher in production). */
55
+ readonly commit: CommitFn;
56
+ /** Entity-reference resolver injected into each stage context (backend-agnostic). */
57
+ readonly lookup?: EntityLookup;
58
+ /** Per-stage configuration (enabled / model / window). Absent → stage runs with its defaults. */
59
+ readonly configs?: readonly StageConfig[];
60
+ /** Default model when neither config nor stage specifies one. */
61
+ readonly presetDefault?: DXN.DXN;
62
+ /** Optional telemetry sink (drives the testbench). */
63
+ readonly onTelemetry?: (event: TelemetryEvent) => void;
64
+ };
65
+
66
+ /** A window slice a stage is invoked over; the commit resolves `BlockUpdate.index` against it. */
67
+ type Slice = ContentBlock.Transcript[];
68
+
69
+ /** A stage invocation's output paired with the slice it ran over (needed by {@link CommitFn}). */
70
+ type Enriched = { readonly write: StageWrite; readonly window: Slice };
71
+
72
+ /**
73
+ * Private bridge between this module's plain `StageContext` object and `@dxos/pipeline`'s
74
+ * Requirements-based `Stage`. Not part of any public contract: stage authors (see `Stage.ts`) keep
75
+ * receiving `ctx: StageContext` as an explicit parameter; only this module's internal wiring into
76
+ * `@dxos/pipeline` goes through Effect's Context/Layer mechanism.
77
+ */
78
+ class StageContextService extends Context.Tag('@dxos/pipeline-transcription/StageContextService')<
79
+ StageContextService,
80
+ StageContext
81
+ >() {}
82
+
83
+ const triggerMatches = (stage: Stage<any, any>, kind: TranscriptEvent['kind']): boolean =>
84
+ (stage.trigger === 'per-block' && kind === 'block') ||
85
+ (stage.trigger === 'on-silence' && kind === 'silence') ||
86
+ (stage.trigger === 'periodic' && kind === 'tick');
87
+
88
+ const triggerKind = (stage: Stage<any, any>): TranscriptEvent['kind'] =>
89
+ stage.trigger === 'per-block' ? 'block' : stage.trigger === 'on-silence' ? 'silence' : 'tick';
90
+
91
+ /**
92
+ * Per-stage window + trigger, as a `@dxos/pipeline` stage. Maintains this branch's own rolling block
93
+ * window (blocks appended, capped at {@link MAX_WINDOW}) and emits the window slice on events matching
94
+ * the stage's trigger; otherwise it filters the item out, so a non-triggering event never reaches
95
+ * {@link runStage}.
96
+ */
97
+ const windowTrigger = (
98
+ stage: Stage<any, any>,
99
+ config: StageConfig | undefined,
100
+ ): PipelineStage.Stage<TranscriptEvent, Slice> => {
101
+ const blocks = config?.window?.blocks ?? stage.window?.blocks;
102
+ return (input) =>
103
+ input.pipe(
104
+ Stream.mapAccum([] as Slice, (window, event) => {
105
+ const next = event.kind === 'block' ? [...window, event.block].slice(-MAX_WINDOW) : window;
106
+ const slice = triggerMatches(stage, event.kind) ? (blocks ? next.slice(-blocks) : next) : undefined;
107
+ return [next, slice];
108
+ }),
109
+ Stream.filter((slice): slice is Slice => slice !== undefined),
110
+ );
111
+ };
112
+
113
+ /**
114
+ * Runs the stage over a triggered slice, pairing its write with that slice. Per-stage `overflow`
115
+ * enforces concurrency: `latest-wins → sliding` (a newer slice replaces the queued one), `skip-if-busy
116
+ * → dropping` (new slices dropped while a run is in flight). Neither interrupts the in-flight run —
117
+ * errors are logged + reported and swallowed (emit `undefined` → no commit), as before.
118
+ */
119
+ const runStage = (
120
+ stage: Stage<any, any>,
121
+ config: StageConfig | undefined,
122
+ presetDefault: DXN.DXN | undefined,
123
+ onTelemetry?: (event: TelemetryEvent) => void,
124
+ ): PipelineStage.Stage<Slice, Enriched, never, StageContextService> => {
125
+ const model = resolveModel(config, stage, presetDefault);
126
+ const trigger = triggerKind(stage);
127
+ return PipelineStage.map(
128
+ stage.id,
129
+ (slice: Slice) =>
130
+ Effect.gen(function* () {
131
+ const context = yield* StageContextService;
132
+ const started = yield* Effect.sync(() => Date.now());
133
+ const input = stage.select ? stage.select(slice) : { window: slice };
134
+ const write = yield* stage.run(input, context);
135
+ const durationMs = (yield* Effect.sync(() => Date.now())) - started;
136
+ onTelemetry?.({ stageId: stage.id, model, trigger, durationMs, outcome: 'committed' });
137
+ return { write, window: slice };
138
+ }).pipe(
139
+ Effect.catchAllCause((cause) =>
140
+ Effect.sync(() => {
141
+ log.catch(Cause.squash(cause));
142
+ onTelemetry?.({ stageId: stage.id, model, trigger, durationMs: 0, outcome: 'error' });
143
+ return undefined;
144
+ }),
145
+ ),
146
+ ),
147
+ { overflow: stage.concurrency === 'latest-wins' ? 'sliding' : 'dropping', bufferSize: 1 },
148
+ );
149
+ };
150
+
151
+ /**
152
+ * Drive each stage's discrete invocations off the event stream. Stages are peers, not a chain: the
153
+ * event stream is broadcast to one `@dxos/pipeline` per enabled stage (each with its own window,
154
+ * trigger, and overflow), all committing to a shared sink. Resolves once the source ends and every
155
+ * branch has drained.
156
+ */
157
+ const run = (options: RunOptions): Effect.Effect<void> =>
158
+ Effect.scoped(
159
+ Effect.gen(function* () {
160
+ const { source, stages, commit, lookup, configs, presetDefault, onTelemetry } = options;
161
+ const configFor = (id: string): StageConfig | undefined => configs?.find((config) => config.id === id);
162
+ const enabled = stages.filter((stage) => configFor(stage.id)?.enabled ?? true);
163
+ if (enabled.length === 0) {
164
+ return yield* Stream.runDrain(source);
165
+ }
166
+
167
+ // Isolate commit failures per write: a failed commit must not fail its branch (which, under the
168
+ // unbounded `Effect.forEach`, would interrupt the sibling branches). Log and drop instead.
169
+ const sink: Pipeline.Sink<Enriched> = ({ write, window }) =>
170
+ commit(write, window).pipe(Effect.catchAllCause((cause) => Effect.sync(() => log.catch(Cause.squash(cause)))));
171
+ const branches = yield* source.pipe(Stream.broadcast(enabled.length, BROADCAST_BUFFER));
172
+
173
+ yield* Effect.forEach(
174
+ branches,
175
+ (branch, index) => {
176
+ const stage = enabled[index];
177
+ const config = configFor(stage.id);
178
+ const context: StageContext = { lookup, model: resolveModel(config, stage, presetDefault) };
179
+ return pipe(
180
+ branch,
181
+ windowTrigger(stage, config),
182
+ runStage(stage, config, presetDefault, onTelemetry),
183
+ Pipeline.run({ sink }),
184
+ Effect.provideService(StageContextService, context),
185
+ );
186
+ },
187
+ { concurrency: 'unbounded', discard: true },
188
+ );
189
+ }),
190
+ );
191
+
192
+ export const PipelineRuntime = Object.freeze({ run });
package/src/Stage.ts ADDED
@@ -0,0 +1,86 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as Effect from 'effect/Effect';
6
+
7
+ import { DXN, type Ref } from '@dxos/echo';
8
+ import { type ContentBlock } from '@dxos/types';
9
+
10
+ import { type EntityLookup } from './lookup';
11
+
12
+ /**
13
+ * When a stage's discrete computation fires, relative to the pipeline stream.
14
+ * - `per-block`: on every new transcript block.
15
+ * - `on-silence`: when the speaker pauses (silence edge).
16
+ * - `periodic`: on a timer tick.
17
+ */
18
+ export type StageTrigger = 'per-block' | 'on-silence' | 'periodic';
19
+
20
+ /**
21
+ * Policy applied when a stage is triggered while a previous invocation is still in flight.
22
+ * - `latest-wins`: interrupt the in-flight invocation and start a new one over the latest window.
23
+ * - `skip-if-busy`: drop the new trigger and let the in-flight invocation complete.
24
+ */
25
+ export type StageConcurrency = 'latest-wins' | 'skip-if-busy';
26
+
27
+ /** Fields of a transcript block that stages may patch. */
28
+ export type BlockPatch = Partial<{
29
+ corrected: string;
30
+ references: Ref.Ref<any>[];
31
+ candidates: ContentBlock.Candidate[];
32
+ translation: string;
33
+ }>;
34
+
35
+ /** A patch to a single block, keyed by its index within the window snapshot passed to the stage. */
36
+ export type BlockUpdate = { index: number } & BlockPatch;
37
+
38
+ /** A patch to the `Transcript` object itself. */
39
+ export type TranscriptUpdate = Partial<{
40
+ summary: string;
41
+ summaryUpdatedAt: string;
42
+ resolvedReferents: { surface: string; referent: string; ref?: Ref.Ref<any> }[];
43
+ }>;
44
+
45
+ /** The result of a stage invocation: a typed description of ECHO mutations. */
46
+ export type StageWrite = {
47
+ blockUpdates?: BlockUpdate[];
48
+ transcriptUpdate?: TranscriptUpdate;
49
+ };
50
+
51
+ export const StageWrite = Object.freeze({
52
+ blocks: (blockUpdates: BlockUpdate[]): StageWrite => ({ blockUpdates }),
53
+ transcript: (transcriptUpdate: TranscriptUpdate): StageWrite => ({ transcriptUpdate }),
54
+ empty: (): StageWrite => ({}),
55
+ });
56
+
57
+ /** Context supplied to a stage's `run` by the runtime. */
58
+ export type StageContext = {
59
+ /** Resolves entity references for extraction. Backend-agnostic (full-text, vector, …). */
60
+ readonly lookup?: EntityLookup;
61
+ /** The model resolved for this stage by the runtime (config → stage → preset default). */
62
+ readonly model: DXN.DXN;
63
+ };
64
+
65
+ /**
66
+ * A pipeline stage: a discrete, repeatedly-invoked transform over a window of the transcript stream.
67
+ *
68
+ * The runtime — not the stage — owns the continuous stream; it builds `input` from a window snapshot
69
+ * (`select`) and invokes `run` once per trigger, enforcing `concurrency` and injecting `model`.
70
+ *
71
+ * LLM-backed stages delegate `run` to an `Operation` (typed I/O, memoized-LLM tests, standalone
72
+ * re-invocation). Pure stages implement `run` directly.
73
+ */
74
+ export interface Stage<In = unknown, E = unknown> {
75
+ readonly id: string;
76
+ readonly trigger: StageTrigger;
77
+ /** Sliding context size in blocks. Absent → the runtime passes the full current window. */
78
+ readonly window?: { blocks: number };
79
+ readonly concurrency: StageConcurrency;
80
+ /** Default model for the stage; overridden by per-stage config in the runtime. */
81
+ readonly model?: DXN.DXN;
82
+ /** Build the stage input from the current window snapshot. Defaults to `{ window }`. */
83
+ readonly select?: (window: ContentBlock.Transcript[]) => In;
84
+ /** The discrete per-trigger computation. */
85
+ run(input: In, ctx: StageContext): Effect.Effect<StageWrite, E>;
86
+ }
@@ -0,0 +1,23 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type ContentBlock } from '@dxos/types';
6
+
7
+ /**
8
+ * Events flowing through the pipeline stream. The runtime owns this continuous stream and slices
9
+ * it into bounded windows that drive discrete stage invocations.
10
+ */
11
+ export type TranscriptEvent =
12
+ | { kind: 'block'; block: ContentBlock.Transcript }
13
+ | { kind: 'silence'; sinceMs: number }
14
+ | { kind: 'tick' };
15
+
16
+ export const TranscriptEvent = Object.freeze({
17
+ /** A new transcript block was appended by the ASR source. */
18
+ block: (block: ContentBlock.Transcript): TranscriptEvent => ({ kind: 'block', block }),
19
+ /** The speaker has been silent for `sinceMs` milliseconds. */
20
+ silence: (sinceMs: number): TranscriptEvent => ({ kind: 'silence', sinceMs }),
21
+ /** Periodic timer tick (drives periodic-trigger stages). */
22
+ tick: (): TranscriptEvent => ({ kind: 'tick' }),
23
+ });
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export type AudioChunk = {
6
+ /**
7
+ * Timestamp of the beginning of the chunk.
8
+ */
9
+ timestamp: number;
10
+
11
+ /**
12
+ * 16-bit PCM data.
13
+ */
14
+ data: Float64Array;
15
+ };
16
+
17
+ export type WavConfig = {
18
+ channels: number;
19
+ sampleRate: number;
20
+ bitDepthCode: string;
21
+ };
22
+
23
+ /**
24
+ * Records MediaStreamTrack to Uint8Array 16-bit PCM data.
25
+ * The data is passed to the onChunk callback with the timestamp of the chunk.
26
+ * All chunks are aligned and sequential.
27
+ * Time ─────────────────────────────────────────────────►
28
+ *
29
+ * Recording:
30
+ * t1 ┌──────┐
31
+ * t2 ┌──────┬──────┐
32
+ * t3 ┌──────┬──────┬──────┐
33
+ * t4 ┌──────┬──────┬──────┬──────┐
34
+ * └──────┴──────┴──────┴──────┘
35
+ * 1st 2nd 3rd 4th
36
+ * chunk chunk chunk chunk
37
+ *
38
+ */
39
+ export interface AudioRecorder {
40
+ wavConfig: WavConfig;
41
+
42
+ /**
43
+ * Sets callback for receiving audio chunks.
44
+ */
45
+ setOnChunk(onChunk: (chunk: AudioChunk) => void): void;
46
+
47
+ /**
48
+ * Starts the recorder.
49
+ */
50
+ start(): Promise<void>;
51
+
52
+ /**
53
+ * Stops the recorder.
54
+ */
55
+ stop(): Promise<void>;
56
+ }
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './audio-recorder';
6
+ export * from './sentence-buffer';
7
+ export * from './transcriber';
8
+ export * from './pipeline';