@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
@@ -0,0 +1,37 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { afterEach, beforeEach, describe, test } from 'vitest';
6
+
7
+ import { Feed, Ref } from '@dxos/echo';
8
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
9
+ import { EffectEx } from '@dxos/effect';
10
+ import { type ContentBlock, Transcript } from '@dxos/types';
11
+
12
+ import { makeEchoCommit } from './dispatch';
13
+ import { StageWrite } from './Stage';
14
+
15
+ describe('makeEchoCommit', () => {
16
+ let builder: EchoTestBuilder;
17
+ beforeEach(async () => {
18
+ builder = await new EchoTestBuilder().open();
19
+ });
20
+ afterEach(async () => {
21
+ await builder.close();
22
+ });
23
+
24
+ test('applies block patches to the window and summary to the transcript', async ({ expect }) => {
25
+ const { db } = await builder.createDatabase({ types: [Transcript.Transcript, Feed.Feed] });
26
+ const feed = db.add(Feed.make());
27
+ const transcript = db.add(Transcript.make(Ref.make(feed)));
28
+ const commit = makeEchoCommit(transcript);
29
+
30
+ const block: ContentBlock.Transcript = { _tag: 'transcript', started: 's', text: 'hi there' };
31
+ await EffectEx.runPromise(commit(StageWrite.blocks([{ index: 0, corrected: 'Hi there.' }]), [block]));
32
+ expect(block.corrected).toEqual('Hi there.');
33
+
34
+ await EffectEx.runPromise(commit(StageWrite.transcript({ summary: 'A short summary.' }), []));
35
+ expect(transcript.summary).toEqual('A short summary.');
36
+ });
37
+ });
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { Obj } from '@dxos/echo';
8
+ import { type ContentBlock, type Transcript } from '@dxos/types';
9
+
10
+ import { type CommitFn } from './PipelineRuntime';
11
+ import { type StageWrite } from './Stage';
12
+
13
+ /**
14
+ * In-memory commit sink: records every {@link StageWrite} for assertions and testbench rendering.
15
+ */
16
+ export type CaptureCommit = {
17
+ readonly commit: CommitFn;
18
+ readonly writes: StageWrite[];
19
+ };
20
+
21
+ export const captureCommit = (): CaptureCommit => {
22
+ const writes: StageWrite[] = [];
23
+ const commit: CommitFn = (write) =>
24
+ Effect.sync(() => {
25
+ writes.push(write);
26
+ });
27
+ return { commit, writes };
28
+ };
29
+
30
+ /**
31
+ * ECHO commit: applies a stage's output to live objects. Block patches are applied to the block
32
+ * objects in the window slice the stage was invoked with (the runtime passes that slice); the
33
+ * transcript patch is applied to the `Transcript` object. Mutating a live ECHO object propagates
34
+ * via reactivity; a containing `Obj.update` transaction (when present) batches the writes.
35
+ */
36
+ export const makeEchoCommit =
37
+ (transcript: Transcript.Transcript): CommitFn =>
38
+ (write, window) =>
39
+ Effect.sync(() => {
40
+ for (const update of write.blockUpdates ?? []) {
41
+ const block: ContentBlock.Transcript | undefined = window[update.index];
42
+ if (!block) {
43
+ continue;
44
+ }
45
+ const { index: _index, ...patch } = update;
46
+ // Live ECHO objects must be mutated inside Obj.update; plain blocks (testbench) assign directly.
47
+ if (Obj.isObject(block)) {
48
+ Obj.update(block, (block) => Object.assign(block, patch));
49
+ } else {
50
+ Object.assign(block, patch);
51
+ }
52
+ }
53
+ if (write.transcriptUpdate) {
54
+ Obj.update(transcript, (transcript) => Object.assign(transcript, write.transcriptUpdate));
55
+ }
56
+ });
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './TranscriptEvent';
6
+ export * from './Stage';
7
+ export * from './lookup';
8
+ export * from './PipelineConfig';
9
+ export * from './model-routing';
10
+ export * from './PipelineRuntime';
11
+ export * from './live';
12
+ export * from './asr';
13
+ export * from './dispatch';
14
+ export * from './stages';
@@ -0,0 +1,63 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { captureCommit } from './dispatch';
8
+ import { runLivePipeline } from './live';
9
+ import { makeCorrectionStage } from './stages';
10
+
11
+ describe('runLivePipeline', () => {
12
+ test('drives per-block stages from pushed blocks and drains on end', async ({ expect }) => {
13
+ const { commit, writes } = captureCommit();
14
+ const live = runLivePipeline({ stages: [makeCorrectionStage()], commit });
15
+ live.block({ _tag: 'transcript', started: 's', text: 'so i think we should ship munich on friday' });
16
+ live.block({ _tag: 'transcript', started: 's', text: 'and review the docs next week' });
17
+ await live.end();
18
+
19
+ const corrected = writes.flatMap((write) => write.blockUpdates ?? []).map((update) => update.corrected);
20
+ expect(corrected.length).toBeGreaterThanOrEqual(2);
21
+ });
22
+
23
+ test('end resolves with no events pushed', async ({ expect }) => {
24
+ const { commit, writes } = captureCommit();
25
+ const live = runLivePipeline({ stages: [makeCorrectionStage()], commit });
26
+ await live.end();
27
+ expect(writes).toHaveLength(0);
28
+ });
29
+
30
+ // Headless equivalent of the file-transcription story: Whisper-style fragments (cut mid-sentence
31
+ // across chunk windows) streamed through correction. Guards the deterministic correction output —
32
+ // notably that a fragment ending in a comma does not become "Years,.".
33
+ test('corrects a fragmented transcript without punctuation artifacts', async ({ expect }) => {
34
+ const { commit, writes } = captureCommit();
35
+ const live = runLivePipeline({ stages: [makeCorrectionStage()], commit });
36
+ const fragments = [
37
+ "I've been living in London for about",
38
+ 'Years,',
39
+ 'maybe a bit longer',
40
+ "I've lived kind of mostly in",
41
+ 'and east London, so I moved to Camden originally',
42
+ 'and now I live in Hackney, which is probably',
43
+ 'the kind of trendiest area of London',
44
+ ];
45
+ for (const text of fragments) {
46
+ live.block({ _tag: 'transcript', started: 's', text });
47
+ }
48
+ await live.end();
49
+
50
+ const corrected = writes
51
+ .flatMap((write) => write.blockUpdates ?? [])
52
+ .map((update) => update.corrected)
53
+ .filter((text): text is string => typeof text === 'string');
54
+
55
+ expect(corrected.length).toBeGreaterThan(0);
56
+ for (const text of corrected) {
57
+ // Every corrected block ends with terminal punctuation and never a "comma + period" artifact.
58
+ expect(text).toMatch(/[.!?]$/);
59
+ expect(text).not.toMatch(/[,;:]\.$/);
60
+ }
61
+ expect(corrected).toContain('Years.');
62
+ });
63
+ });
package/src/live.ts ADDED
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Queue from 'effect/Queue';
7
+ import * as Stream from 'effect/Stream';
8
+
9
+ import { EffectEx } from '@dxos/effect';
10
+ import { type ContentBlock } from '@dxos/types';
11
+
12
+ import { PipelineRuntime, type RunOptions } from './PipelineRuntime';
13
+ import { TranscriptEvent } from './TranscriptEvent';
14
+
15
+ /**
16
+ * Imperative handle to a running pipeline fed by a live ASR source. The source pushes events as they
17
+ * arrive rather than yielding a pre-materialized stream.
18
+ */
19
+ export type LivePipeline = {
20
+ /** Append a transcript block produced by the ASR source. */
21
+ block: (block: ContentBlock.Transcript) => void;
22
+ /** Report that the speaker has been silent for `sinceMs` (triggers on-silence stages). */
23
+ silence: (sinceMs: number) => void;
24
+ /** Emit a periodic tick (triggers periodic stages). */
25
+ tick: () => void;
26
+ /** Close the source; resolves once the stream ends and all in-flight stages have drained. */
27
+ end: () => Promise<void>;
28
+ };
29
+
30
+ // A sentinel terminates the source after all queued events are consumed, so the run drains rather
31
+ // than dropping in-flight blocks (Queue.shutdown would discard anything not yet pulled).
32
+ const END = Symbol('end');
33
+ type Item = TranscriptEvent | typeof END;
34
+
35
+ /**
36
+ * Drives {@link PipelineRuntime} from a live ASR source: blocks pushed via the returned handle are
37
+ * enqueued onto the runtime's source stream; `end` closes the stream so the run drains and resolves.
38
+ * This is the single orchestration entry point for live capture — callers supply only the stages,
39
+ * commit sink, and (optional) lookup, mirroring a scripted `PipelineRuntime.run`.
40
+ */
41
+ export const runLivePipeline = (options: Omit<RunOptions, 'source'>): LivePipeline => {
42
+ const queue = Effect.runSync(Queue.unbounded<Item>());
43
+ const source = Stream.fromQueue(queue).pipe(Stream.takeWhile((item): item is TranscriptEvent => item !== END));
44
+ const done = EffectEx.runPromise(PipelineRuntime.run({ ...options, source }));
45
+ const offer = (item: Item) => Effect.runSync(Queue.offer(queue, item));
46
+ return {
47
+ block: (block) => offer(TranscriptEvent.block(block)),
48
+ silence: (sinceMs) => offer(TranscriptEvent.silence(sinceMs)),
49
+ tick: () => offer(TranscriptEvent.tick()),
50
+ end: async () => {
51
+ offer(END);
52
+ await done;
53
+ Effect.runSync(Queue.shutdown(queue));
54
+ },
55
+ };
56
+ };
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { afterEach, beforeEach, describe, test } from 'vitest';
6
+
7
+ import { Obj, Type } from '@dxos/echo';
8
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
9
+ import { Organization, Person } from '@dxos/types';
10
+
11
+ import { makeDatabaseLookup } from './lookup';
12
+
13
+ describe('makeDatabaseLookup', () => {
14
+ let builder: EchoTestBuilder;
15
+ beforeEach(async () => {
16
+ builder = await new EchoTestBuilder().open();
17
+ });
18
+ afterEach(async () => {
19
+ await builder.close();
20
+ });
21
+
22
+ test('restricts candidates to the requested types', async ({ expect }) => {
23
+ const { db } = await builder.createDatabase({ types: [Organization.Organization, Person.Person] });
24
+ // Both an organization and a person share the surface noun.
25
+ db.add(Obj.make(Organization.Organization, { name: 'Mercury' }));
26
+ db.add(Obj.make(Person.Person, { fullName: 'Mercury' }));
27
+ await db.flush({ indexes: true });
28
+
29
+ const lookup = makeDatabaseLookup(db);
30
+
31
+ // Unconstrained: both types match.
32
+ const all = await lookup('Mercury');
33
+ expect(all.length).toBeGreaterThanOrEqual(2);
34
+
35
+ // Constrained to organizations: only the organization matches.
36
+ const organizations = await lookup('Mercury', { types: [Type.getTypename(Organization.Organization)!] });
37
+ expect(organizations).toHaveLength(1);
38
+ const match = await organizations[0].ref.load();
39
+ expect(Obj.instanceOf(Organization.Organization, match)).toBe(true);
40
+ });
41
+ });
package/src/lookup.ts ADDED
@@ -0,0 +1,60 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Database, Filter, Obj, Query, Ref } from '@dxos/echo';
6
+
7
+ /**
8
+ * A candidate match for a surface noun: the matched object's id, a ref consumers can persist, and a
9
+ * relevance `score` (higher = better) from the search backend's match rank.
10
+ */
11
+ export type EntityMatch = {
12
+ id: string;
13
+ ref: Ref.Ref<any>;
14
+ score: number;
15
+ };
16
+
17
+ /**
18
+ * Additional context that narrows a lookup. Grows over time (e.g. the conversation window, an
19
+ * expected role); backends ignore fields they do not support.
20
+ */
21
+ export type LookupContext = {
22
+ /** Restrict matches to these typenames (e.g. the typename of `Person` / `Organization`). */
23
+ types?: string[];
24
+ };
25
+
26
+ /**
27
+ * Resolves a surface noun to its candidate matches, ranked best-first, backed by an index. The
28
+ * pipeline depends only on this function — not on ECHO/the database directly — so the backend
29
+ * (full-text, vector, a remote service, …) is an injected concern. Returning candidates (rather than
30
+ * a single match) lets a context-aware resolver disambiguate using the surrounding conversation.
31
+ * Empty when nothing matches.
32
+ */
33
+ export type EntityLookup = (noun: string, context?: LookupContext) => Promise<EntityMatch[]>;
34
+
35
+ export type DatabaseLookupOptions = {
36
+ /** Search backend. Full-text matches proper nouns precisely; vector matches semantically. */
37
+ searchKind?: 'full-text' | 'vector';
38
+ /** Maximum candidates returned per noun. */
39
+ limit?: number;
40
+ };
41
+
42
+ /** An {@link EntityLookup} backed by an ECHO database query (full-text by default). */
43
+ export const makeDatabaseLookup =
44
+ (db: Database.Database, { searchKind = 'full-text', limit = 5 }: DatabaseLookupOptions = {}): EntityLookup =>
45
+ async (noun, context) => {
46
+ // `runEntries` exposes each match's relevance rank; `run` would discard it.
47
+ const entries = await db.query(Query.select(Filter.text(noun, { type: searchKind }))).runEntries();
48
+ // Type narrowing is applied here rather than in the query: the executor rejects a text-search
49
+ // filter combined with a type filter ("query too complex").
50
+ const types = context?.types;
51
+ return entries
52
+ .flatMap((entry) => {
53
+ if (!entry.result || (types?.length && !types.includes(Obj.getTypename(entry.result) ?? ''))) {
54
+ return [];
55
+ }
56
+ return [{ id: entry.id, ref: Ref.make(entry.result), score: entry.match?.rank ?? 0 }];
57
+ })
58
+ .sort((a, b) => b.score - a.score)
59
+ .slice(0, limit);
60
+ };
@@ -0,0 +1,25 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { DXN } from '@dxos/echo';
6
+
7
+ import { DEFAULT_STAGE_MODEL, type StageConfig } from './PipelineConfig';
8
+ import { type Stage } from './Stage';
9
+
10
+ /**
11
+ * Resolve the model for a stage invocation. Precedence: per-stage config override → the stage's own
12
+ * default → the preset/pipeline default → the global {@link DEFAULT_STAGE_MODEL}.
13
+ *
14
+ * `StageConfig.model` is a validated NSID name in the schema (its format is checked, but it is not
15
+ * pinned to the current model catalog); it is parsed to a model DXN here for the `AiService` call.
16
+ */
17
+ export const resolveModel = (
18
+ stageConfig: StageConfig | undefined,
19
+ stage: Stage<any, any>,
20
+ presetDefault?: DXN.DXN,
21
+ ): DXN.DXN =>
22
+ (stageConfig?.model ? DXN.tryMake(stageConfig.model) : undefined) ??
23
+ stage.model ??
24
+ presetDefault ??
25
+ DEFAULT_STAGE_MODEL;
@@ -0,0 +1,70 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Stream from 'effect/Stream';
7
+ import { afterEach, beforeEach, describe, test } from 'vitest';
8
+
9
+ import { Obj } from '@dxos/echo';
10
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
11
+ import { EffectEx } from '@dxos/effect';
12
+ import { type ContentBlock, Organization, Person } from '@dxos/types';
13
+
14
+ import { makeDatabaseLookup } from './lookup';
15
+ import { PipelineRuntime } from './PipelineRuntime';
16
+ import { type CommitFn } from './PipelineRuntime';
17
+ import { makeCorrectionStage, makeExtractionStage } from './stages';
18
+ import { TranscriptEvent } from './TranscriptEvent';
19
+
20
+ // Reproduces the Pipeline testbench path: correction + extraction through the runtime, asserting that
21
+ // recognized entities are linked in the final corrected text (regardless of stage commit ordering).
22
+ describe('pipeline integration (correction + extraction)', () => {
23
+ let builder: EchoTestBuilder;
24
+ beforeEach(async () => {
25
+ builder = await new EchoTestBuilder().open();
26
+ });
27
+ afterEach(async () => {
28
+ await builder.close();
29
+ });
30
+
31
+ test('links seeded entities in corrected text', async ({ expect }) => {
32
+ const { db } = await builder.createDatabase({ types: [Organization.Organization, Person.Person] });
33
+ db.add(Obj.make(Organization.Organization, { name: 'DXOS' }));
34
+ db.add(Obj.make(Person.Person, { fullName: 'Sarah Johnson' }));
35
+ await db.flush({ indexes: true });
36
+
37
+ const blocks: ContentBlock.Transcript[] = [
38
+ { _tag: 'transcript', started: 's0', text: 'So I caught up with Sarah Johnson this morning' },
39
+ { _tag: 'transcript', started: 's1', text: 'We discussed the DXOS roadmap' },
40
+ ];
41
+ const source = Stream.fromIterable([
42
+ ...blocks.map((block) => TranscriptEvent.block(block)),
43
+ TranscriptEvent.silence(5_000),
44
+ ]);
45
+
46
+ // Mutate the window blocks in place (mirrors the story commit).
47
+ const commit: CommitFn = (write, window) =>
48
+ Effect.sync(() => {
49
+ for (const update of write.blockUpdates ?? []) {
50
+ const block = window[update.index];
51
+ if (block) {
52
+ const { index: _index, ...patch } = update;
53
+ Object.assign(block, patch);
54
+ }
55
+ }
56
+ });
57
+
58
+ await EffectEx.runPromise(
59
+ PipelineRuntime.run({
60
+ source,
61
+ stages: [makeCorrectionStage(), makeExtractionStage()],
62
+ lookup: makeDatabaseLookup(db),
63
+ commit,
64
+ }),
65
+ );
66
+
67
+ expect(blocks[0].corrected).toMatch(/\[Sarah Johnson\]\(echo:\/[^)]+\)/);
68
+ expect(blocks[1].corrected).toMatch(/\[DXOS\]\(echo:\/[^)]+\)/);
69
+ });
70
+ });
@@ -0,0 +1,46 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as LanguageModel from '@effect/ai/LanguageModel';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+ import * as Stream from 'effect/Stream';
9
+ import { describe, test } from 'vitest';
10
+
11
+ import { AiService } from '@dxos/ai';
12
+ import { EffectEx } from '@dxos/effect';
13
+
14
+ import { correctWithLanguageModel } from './correction-llm';
15
+
16
+ /** Minimal `AiService` whose `generateObject` returns a fixed payload (no network). */
17
+ const mockAiService = (object: unknown): Layer.Layer<AiService.AiService> =>
18
+ Layer.succeed(AiService.AiService, {
19
+ model: () =>
20
+ Layer.succeed(LanguageModel.LanguageModel, {
21
+ generateText: () => Effect.succeed({ text: '', content: [] }),
22
+ generateObject: () => Effect.succeed({ value: object, content: [] }),
23
+ streamText: () => Stream.empty,
24
+ } as any),
25
+ } as any);
26
+
27
+ describe('correctWithLanguageModel', () => {
28
+ test('maps the model output to per-block corrections', async ({ expect }) => {
29
+ const blocks = [
30
+ { _tag: 'transcript' as const, started: 's', text: 'years,' },
31
+ { _tag: 'transcript' as const, started: 's', text: 'maybe a bit longer' },
32
+ ];
33
+ const layer = mockAiService({
34
+ blocks: [
35
+ { index: 0, corrected: 'Years.' },
36
+ { index: 1, corrected: 'Maybe a bit longer.' },
37
+ ],
38
+ });
39
+
40
+ const result = await EffectEx.runPromise(correctWithLanguageModel(blocks).pipe(Effect.provide(layer)));
41
+ expect(result).toEqual([
42
+ { index: 0, corrected: 'Years.' },
43
+ { index: 1, corrected: 'Maybe a bit longer.' },
44
+ ]);
45
+ });
46
+ });
@@ -0,0 +1,40 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as LanguageModel from '@effect/ai/LanguageModel';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+ import * as Schema from 'effect/Schema';
9
+
10
+ import { AiService } from '@dxos/ai';
11
+ import { DXN } from '@dxos/echo';
12
+ import { type ContentBlock } from '@dxos/types';
13
+
14
+ import { DEFAULT_STAGE_MODEL } from '../PipelineConfig';
15
+
16
+ const CorrectionPayload = Schema.Struct({
17
+ blocks: Schema.Array(Schema.Struct({ index: Schema.Number, corrected: Schema.String })),
18
+ });
19
+
20
+ const PROMPT = `You repair raw speech-to-text fragments. For each numbered fragment, return a cleaned version: fix capitalization and terminal punctuation, join words split across fragment boundaries, and drop filler/garbage tokens — without changing the meaning. Return exactly one entry per input fragment, preserving its index.`;
21
+
22
+ /**
23
+ * LLM-backed correction over a window of transcript blocks (the {@link makeCorrectionStage} seam).
24
+ * Returns an Effect requiring the {@link AiService} — the consumer provides its model layer and adapts
25
+ * the Effect to the stage's Promise-shaped `CorrectFn` (e.g. `EffectEx.runPromise(... .pipe(Effect.provide(layer)))`).
26
+ */
27
+ export const correctWithLanguageModel = (
28
+ blocks: readonly ContentBlock.Transcript[],
29
+ model: DXN.DXN = DEFAULT_STAGE_MODEL,
30
+ ): Effect.Effect<ReadonlyArray<{ index: number; corrected: string }>, unknown, AiService.AiService> =>
31
+ Effect.gen(function* () {
32
+ const input = blocks.map((block, index) => `${index}: ${block.corrected ?? block.text}`).join('\n');
33
+ const response = yield* LanguageModel.generateObject({
34
+ schema: CorrectionPayload,
35
+ prompt: `${PROMPT}\n\n${input}`,
36
+ });
37
+ return response.value.blocks.map((block) => ({ index: block.index, corrected: block.corrected }));
38
+ // Model-layer construction failure is a fatal wiring fault (defect); transient LLM failures stay
39
+ // recoverable so callers can retry.
40
+ }).pipe(Effect.provide(AiService.model(DXN.getName(model)).pipe(Layer.orDie)));
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { correctText } from './correction';
8
+
9
+ describe('correctText', () => {
10
+ test('capitalizes and adds terminal punctuation', ({ expect }) => {
11
+ expect(correctText('so we should ship')).toEqual('So we should ship.');
12
+ });
13
+
14
+ test('preserves existing terminal punctuation', ({ expect }) => {
15
+ expect(correctText('done!')).toEqual('Done!');
16
+ });
17
+
18
+ test('replaces a trailing comma rather than appending (no "Years,.")', ({ expect }) => {
19
+ expect(correctText('Years,')).toEqual('Years.');
20
+ expect(correctText('well, anyway,')).toEqual('Well, anyway.');
21
+ });
22
+
23
+ test('empty input stays empty', ({ expect }) => {
24
+ expect(correctText(' ')).toEqual('');
25
+ });
26
+ });
@@ -0,0 +1,72 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { type ContentBlock } from '@dxos/types';
8
+
9
+ import { DEFAULT_STAGE_MODEL } from '../PipelineConfig';
10
+ import { type Stage, StageWrite } from '../Stage';
11
+
12
+ export type CorrectionInput = { window: ContentBlock.Transcript[] };
13
+
14
+ /**
15
+ * Corrects a window of transcript blocks, returning a `{ index, corrected }` patch per block it
16
+ * changes. The deterministic default is used unless an LLM-backed corrector is injected (see
17
+ * `correctWithLanguageModel`); the consumer binds the AI service and adapts it to this Promise shape.
18
+ */
19
+ export type CorrectFn = (
20
+ blocks: readonly ContentBlock.Transcript[],
21
+ ) => Promise<ReadonlyArray<{ index: number; corrected: string }>>;
22
+
23
+ export type CorrectionOptions = {
24
+ /** Inject an LLM-backed corrector; omit for the deterministic per-block correction. */
25
+ correct?: CorrectFn;
26
+ };
27
+
28
+ /**
29
+ * Deterministic correction: trim, capitalize the first letter, ensure terminal punctuation.
30
+ * The LLM seam (see `run`) replaces this with cross-batch repair over the window.
31
+ */
32
+ export const correctText = (text: string): string => {
33
+ const trimmed = text.trim();
34
+ if (trimmed.length === 0) {
35
+ return trimmed;
36
+ }
37
+ const capitalized = trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
38
+ if (/[.!?]$/.test(capitalized)) {
39
+ return capitalized;
40
+ }
41
+ // Replace a trailing non-terminal mark (comma/semicolon/colon) with the period rather than
42
+ // appending — a Whisper fragment like "Years," would otherwise become "Years,.".
43
+ return `${capitalized.replace(/[,;:]+$/, '')}.`;
44
+ };
45
+
46
+ /**
47
+ * Stage ①: clean up the most recent transcript block (punctuation, capitalization).
48
+ * Windowed for cross-batch context; emits a correction for the newest block.
49
+ */
50
+ export const makeCorrectionStage = ({ correct }: CorrectionOptions = {}): Stage<CorrectionInput> => ({
51
+ id: 'correct',
52
+ trigger: 'per-block',
53
+ window: { blocks: 8 },
54
+ concurrency: 'latest-wins',
55
+ model: DEFAULT_STAGE_MODEL,
56
+ run: ({ window }) =>
57
+ correct
58
+ ? // LLM seam: the injected corrector repairs the window (cross-batch word/punctuation fixes).
59
+ Effect.promise(() => correct(window)).pipe(
60
+ Effect.map((updates) => (updates.length > 0 ? StageWrite.blocks([...updates]) : StageWrite.empty())),
61
+ )
62
+ : Effect.sync(() => {
63
+ // Deterministic fallback: correct every not-yet-corrected block in the window. Robust under
64
+ // latest-wins — if earlier invocations were interrupted, the surviving run still covers their
65
+ // blocks; already-corrected blocks are skipped once the write is applied back (idempotent).
66
+ const updates = window
67
+ .map((block, index) => ({ block, index }))
68
+ .filter(({ block }) => block.corrected === undefined)
69
+ .map(({ block, index }) => ({ index, corrected: correctText(block.text) }));
70
+ return updates.length > 0 ? StageWrite.blocks(updates) : StageWrite.empty();
71
+ }),
72
+ });
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { type ContentBlock } from '@dxos/types';
8
+
9
+ import { type Stage, StageWrite } from '../Stage';
10
+
11
+ export type DiarizationInput = { window: ContentBlock.Transcript[] };
12
+
13
+ /**
14
+ * Stage ◆ (interface only / deferred): label the speaker of the newest block.
15
+ * Conforms to the `Stage` contract and is inert until a diarization backend is wired.
16
+ */
17
+ export const makeDiarizationStage = (): Stage<DiarizationInput> => ({
18
+ id: 'diarize',
19
+ trigger: 'per-block',
20
+ window: { blocks: 1 },
21
+ concurrency: 'latest-wins',
22
+ // TODO: implement diarization (assign speaker identity to the block).
23
+ run: () => Effect.succeed(StageWrite.empty()),
24
+ });