unrag 0.2.5 → 0.2.7

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 (42) hide show
  1. package/dist/cli/index.js +611 -174
  2. package/package.json +12 -6
  3. package/registry/config/unrag.config.ts +9 -8
  4. package/registry/connectors/google-drive/_api-types.ts +60 -0
  5. package/registry/connectors/google-drive/client.ts +99 -38
  6. package/registry/connectors/google-drive/sync.ts +97 -69
  7. package/registry/connectors/google-drive/types.ts +76 -37
  8. package/registry/connectors/notion/client.ts +12 -3
  9. package/registry/connectors/notion/render.ts +62 -23
  10. package/registry/connectors/notion/sync.ts +30 -23
  11. package/registry/core/assets.ts +11 -10
  12. package/registry/core/config.ts +10 -25
  13. package/registry/core/context-engine.ts +71 -2
  14. package/registry/core/deep-merge.ts +45 -0
  15. package/registry/core/ingest.ts +117 -44
  16. package/registry/core/types.ts +96 -2
  17. package/registry/docs/unrag.md +6 -1
  18. package/registry/embedding/_shared.ts +25 -0
  19. package/registry/embedding/ai.ts +8 -68
  20. package/registry/embedding/azure.ts +88 -0
  21. package/registry/embedding/bedrock.ts +88 -0
  22. package/registry/embedding/cohere.ts +88 -0
  23. package/registry/embedding/google.ts +102 -0
  24. package/registry/embedding/mistral.ts +71 -0
  25. package/registry/embedding/ollama.ts +90 -0
  26. package/registry/embedding/openai.ts +88 -0
  27. package/registry/embedding/openrouter.ts +127 -0
  28. package/registry/embedding/together.ts +77 -0
  29. package/registry/embedding/vertex.ts +111 -0
  30. package/registry/embedding/voyage.ts +169 -0
  31. package/registry/extractors/audio-transcribe/index.ts +39 -23
  32. package/registry/extractors/file-docx/index.ts +8 -1
  33. package/registry/extractors/file-pptx/index.ts +22 -1
  34. package/registry/extractors/file-xlsx/index.ts +24 -1
  35. package/registry/extractors/image-caption-llm/index.ts +8 -3
  36. package/registry/extractors/image-ocr/index.ts +9 -4
  37. package/registry/extractors/pdf-llm/index.ts +9 -4
  38. package/registry/extractors/pdf-text-layer/index.ts +23 -2
  39. package/registry/extractors/video-frames/index.ts +8 -3
  40. package/registry/extractors/video-transcribe/index.ts +40 -24
  41. package/registry/manifest.json +346 -0
  42. package/registry/store/drizzle-postgres-pgvector/store.ts +26 -6
@@ -15,6 +15,31 @@ export type NotionBlockNode = {
15
15
  children: NotionBlockNode[];
16
16
  };
17
17
 
18
+ /**
19
+ * Notion block content payload that may contain rich_text and other properties.
20
+ */
21
+ interface BlockPayload {
22
+ rich_text?: RichText[];
23
+ checked?: boolean;
24
+ language?: string;
25
+ caption?: RichText[];
26
+ type?: string;
27
+ external?: { url?: string };
28
+ file?: { url?: string };
29
+ media_type?: string;
30
+ }
31
+
32
+ /**
33
+ * Get block-type-specific payload from a Notion block.
34
+ */
35
+ const getBlockPayload = (block: NotionBlock, type: string): BlockPayload | undefined => {
36
+ const payload = block[type];
37
+ if (typeof payload === "object" && payload !== null) {
38
+ return payload as BlockPayload;
39
+ }
40
+ return undefined;
41
+ };
42
+
18
43
  const rt = (value: unknown): string => {
19
44
  const items = Array.isArray(value) ? (value as RichText[]) : [];
20
45
  return items.map((t) => t?.plain_text ?? "").join("");
@@ -37,25 +62,30 @@ const toAssetKind = (notionType: string): AssetKind | null => {
37
62
  return supportedAssetKinds.has(t) ? t : null;
38
63
  };
39
64
 
40
- const pickUrl = (payload: any): string | undefined => {
41
- const type = String(payload?.type ?? "");
42
- if (type === "external") return asString(payload?.external?.url);
43
- if (type === "file") return asString(payload?.file?.url);
65
+ const pickUrl = (payload: BlockPayload | undefined): string | undefined => {
66
+ if (!payload) return undefined;
67
+ const type = String(payload.type ?? "");
68
+ if (type === "external") return asString(payload.external?.url);
69
+ if (type === "file") return asString(payload.file?.url);
44
70
  return undefined;
45
71
  };
46
72
 
47
- const pickCaption = (payload: any): string => {
73
+ const pickCaption = (payload: BlockPayload | undefined): string => {
48
74
  // Notion captions are typically an array of rich text items.
49
75
  return rt(payload?.caption);
50
76
  };
51
77
 
52
- const inferMediaType = (assetKind: AssetKind, payload: any): string | undefined => {
78
+ const inferMediaType = (assetKind: AssetKind, payload: BlockPayload | undefined): string | undefined => {
53
79
  if (assetKind === "pdf") return "application/pdf";
54
80
  // Notion does not consistently include media types; keep it optional.
55
81
  return asString(payload?.media_type) || undefined;
56
82
  };
57
83
 
58
- const asMetadata = (obj: Record<string, unknown>): Metadata => obj as any;
84
+ /**
85
+ * Convert a plain object to Metadata type.
86
+ * The Metadata type allows string, number, boolean, null values.
87
+ */
88
+ const toMetadata = (obj: Record<string, string>): Metadata => obj;
59
89
 
60
90
  export function extractNotionAssets(
61
91
  nodes: NotionBlockNode[],
@@ -66,10 +96,10 @@ export function extractNotionAssets(
66
96
 
67
97
  const walk = (node: NotionBlockNode, depth: number) => {
68
98
  if (depth > maxDepth) return;
69
- const b = node.block as any;
99
+ const b = node.block;
70
100
  const kind = toAssetKind(String(b.type ?? ""));
71
101
  if (kind) {
72
- const payload = b[kind];
102
+ const payload = getBlockPayload(b, kind);
73
103
  const url = pickUrl(payload);
74
104
  if (url) {
75
105
  const caption = pickCaption(payload).trim();
@@ -80,7 +110,7 @@ export function extractNotionAssets(
80
110
  data: { kind: "url", url, ...(mediaType ? { mediaType } : {}) },
81
111
  uri: url,
82
112
  ...(caption ? { text: caption } : {}),
83
- metadata: asMetadata({
113
+ metadata: toMetadata({
84
114
  connector: "notion",
85
115
  notionBlockId: String(b.id),
86
116
  notionBlockType: String(b.type),
@@ -108,40 +138,49 @@ export function renderNotionBlocksToText(
108
138
  const walk = (node: NotionBlockNode, depth: number, listDepth: number) => {
109
139
  if (depth > maxDepth) return;
110
140
  const b = node.block;
111
-
112
141
  const t = b.type;
113
142
 
114
143
  if (t === "paragraph") {
115
- const text = rt((b as any).paragraph?.rich_text);
144
+ const payload = getBlockPayload(b, "paragraph");
145
+ const text = rt(payload?.rich_text);
116
146
  if (text.trim()) lines.push(text);
117
147
  } else if (t === "heading_1") {
118
- const text = rt((b as any).heading_1?.rich_text);
148
+ const payload = getBlockPayload(b, "heading_1");
149
+ const text = rt(payload?.rich_text);
119
150
  if (text.trim()) lines.push(`# ${text}`);
120
151
  } else if (t === "heading_2") {
121
- const text = rt((b as any).heading_2?.rich_text);
152
+ const payload = getBlockPayload(b, "heading_2");
153
+ const text = rt(payload?.rich_text);
122
154
  if (text.trim()) lines.push(`## ${text}`);
123
155
  } else if (t === "heading_3") {
124
- const text = rt((b as any).heading_3?.rich_text);
156
+ const payload = getBlockPayload(b, "heading_3");
157
+ const text = rt(payload?.rich_text);
125
158
  if (text.trim()) lines.push(`### ${text}`);
126
159
  } else if (t === "bulleted_list_item") {
127
- const text = rt((b as any).bulleted_list_item?.rich_text);
160
+ const payload = getBlockPayload(b, "bulleted_list_item");
161
+ const text = rt(payload?.rich_text);
128
162
  if (text.trim()) lines.push(`${indent(listDepth)}- ${text}`);
129
163
  } else if (t === "numbered_list_item") {
130
- const text = rt((b as any).numbered_list_item?.rich_text);
164
+ const payload = getBlockPayload(b, "numbered_list_item");
165
+ const text = rt(payload?.rich_text);
131
166
  if (text.trim()) lines.push(`${indent(listDepth)}- ${text}`);
132
167
  } else if (t === "to_do") {
133
- const text = rt((b as any).to_do?.rich_text);
134
- const checked = Boolean((b as any).to_do?.checked);
168
+ const payload = getBlockPayload(b, "to_do");
169
+ const text = rt(payload?.rich_text);
170
+ const checked = Boolean(payload?.checked);
135
171
  if (text.trim()) lines.push(`${indent(listDepth)}- [${checked ? "x" : " "}] ${text}`);
136
172
  } else if (t === "quote") {
137
- const text = rt((b as any).quote?.rich_text);
173
+ const payload = getBlockPayload(b, "quote");
174
+ const text = rt(payload?.rich_text);
138
175
  if (text.trim()) lines.push(`> ${text}`);
139
176
  } else if (t === "callout") {
140
- const text = rt((b as any).callout?.rich_text);
177
+ const payload = getBlockPayload(b, "callout");
178
+ const text = rt(payload?.rich_text);
141
179
  if (text.trim()) lines.push(text);
142
180
  } else if (t === "code") {
143
- const text = rt((b as any).code?.rich_text);
144
- const lang = String((b as any).code?.language ?? "").trim();
181
+ const payload = getBlockPayload(b, "code");
182
+ const text = rt(payload?.rich_text);
183
+ const lang = String(payload?.language ?? "").trim();
145
184
  lines.push("```" + lang);
146
185
  if (text.trim()) lines.push(text);
147
186
  lines.push("```");
@@ -1,4 +1,10 @@
1
- import type { IngestResult } from "../../core";
1
+ import type { IngestResult, Metadata } from "../../core";
2
+ import { isFullPage } from "@notionhq/client";
3
+ import type {
4
+ GetPageResponse,
5
+ ListBlockChildrenResponse,
6
+ RichTextItemResponse,
7
+ } from "@notionhq/client/build/src/api-endpoints";
2
8
  import { createNotionClient, type NotionClient } from "./client";
3
9
  import { normalizeNotionPageId32, toUuidHyphenated } from "./ids";
4
10
  import {
@@ -37,17 +43,16 @@ export function buildNotionPageIngestInput(
37
43
  };
38
44
  }
39
45
 
40
- const richTextToText = (richText: any[] | undefined) =>
41
- (Array.isArray(richText) ? richText : [])
42
- .map((t) => String(t?.plain_text ?? ""))
43
- .join("");
46
+ const richTextToText = (richText: RichTextItemResponse[] | undefined): string =>
47
+ (richText ?? []).map((t) => t.plain_text).join("");
44
48
 
45
- const getNotionPageTitle = (page: any): string => {
46
- const props = page?.properties ?? {};
49
+ const getNotionPageTitle = (page: GetPageResponse): string => {
50
+ if (!isFullPage(page)) return "";
51
+ const props = page.properties;
47
52
  for (const key of Object.keys(props)) {
48
53
  const p = props[key];
49
- if (p?.type === "title") {
50
- return richTextToText(p?.title);
54
+ if (p.type === "title") {
55
+ return richTextToText(p.title);
51
56
  }
52
57
  }
53
58
  return "";
@@ -61,15 +66,15 @@ async function listAllBlockChildren(
61
66
  let cursor: string | undefined = undefined;
62
67
 
63
68
  while (true) {
64
- const res: any = await notion.blocks.children.list({
69
+ const res: ListBlockChildrenResponse = await notion.blocks.children.list({
65
70
  block_id: blockId,
66
71
  start_cursor: cursor,
67
72
  page_size: 100,
68
73
  });
69
74
 
70
- blocks.push(...((res?.results ?? []) as NotionBlock[]));
71
- if (!res?.has_more) break;
72
- cursor = res?.next_cursor ?? undefined;
75
+ blocks.push(...(res.results as NotionBlock[]));
76
+ if (!res.has_more) break;
77
+ cursor = res.next_cursor ?? undefined;
73
78
  if (!cursor) break;
74
79
  }
75
80
 
@@ -105,30 +110,30 @@ export async function loadNotionPageDocument(args: {
105
110
  const pageId = normalizeNotionPageId32(args.pageIdOrUrl);
106
111
  const apiId = toUuidHyphenated(pageId);
107
112
 
108
- const page: any = await args.notion.pages.retrieve({ page_id: apiId });
113
+ const page: GetPageResponse = await args.notion.pages.retrieve({ page_id: apiId });
109
114
  const title = getNotionPageTitle(page);
110
- const url = String(page?.url ?? "");
111
- const lastEditedTime = String(page?.last_edited_time ?? "");
115
+ const url = isFullPage(page) ? page.url : "";
116
+ const lastEditedTime = isFullPage(page) ? page.last_edited_time : "";
112
117
 
113
118
  const tree = await buildBlockTree(args.notion, apiId, 0, args.maxDepth ?? 4);
114
119
  const body = renderNotionBlocksToText(tree);
115
120
  const content = [title.trim(), body.trim()].filter(Boolean).join("\n\n");
116
121
  const assets = extractNotionAssets(tree);
117
122
 
118
- const metadata = {
123
+ const metadata: Metadata = {
119
124
  connector: "notion",
120
125
  kind: "page",
121
126
  pageId,
122
127
  url,
123
128
  title,
124
129
  lastEditedTime,
125
- } as const;
130
+ };
126
131
 
127
132
  const ingest = buildNotionPageIngestInput({
128
133
  pageId,
129
134
  content,
130
135
  assets,
131
- metadata: metadata as any,
136
+ metadata,
132
137
  sourceIdPrefix: args.sourceIdPrefix,
133
138
  });
134
139
 
@@ -140,10 +145,12 @@ export async function loadNotionPageDocument(args: {
140
145
  };
141
146
  }
142
147
 
143
- const isNotFound = (err: any) => {
144
- const status = Number(err?.status ?? err?.statusCode ?? err?.code);
148
+ const isNotFound = (err: unknown): boolean => {
149
+ if (typeof err !== "object" || err === null) return false;
150
+ const e = err as Record<string, unknown>;
151
+ const status = Number(e.status ?? e.statusCode ?? e.code ?? 0);
145
152
  if (status === 404) return true;
146
- const msg = String(err?.message ?? "");
153
+ const msg = String(e.message ?? "");
147
154
  return msg.toLowerCase().includes("could not find");
148
155
  };
149
156
 
@@ -187,7 +194,7 @@ export async function syncNotionPages(
187
194
  sourceId: doc.sourceId,
188
195
  content: doc.content,
189
196
  assets: doc.assets,
190
- metadata: doc.metadata as any,
197
+ metadata: doc.metadata,
191
198
  });
192
199
 
193
200
  succeeded += 1;
@@ -1,4 +1,5 @@
1
1
  import type { AssetKind, Chunk } from "./types";
2
+ import { hasAssetMetadata } from "./types";
2
3
 
3
4
  export type ChunkAssetRef = {
4
5
  assetId: string;
@@ -21,26 +22,26 @@ const assetKinds = new Set<AssetKind>(["image", "pdf", "audio", "video", "file"]
21
22
  export function getChunkAssetRef(
22
23
  chunk: Pick<Chunk, "metadata">
23
24
  ): ChunkAssetRef | null {
24
- const meta = chunk.metadata as any;
25
- const kind = meta?.assetKind;
26
- const id = meta?.assetId;
25
+ const meta = chunk.metadata;
27
26
 
28
- if (typeof kind !== "string" || !assetKinds.has(kind as AssetKind)) {
27
+ if (!hasAssetMetadata(meta)) {
29
28
  return null;
30
29
  }
31
- if (typeof id !== "string" || !id) {
30
+
31
+ const kind = meta.assetKind;
32
+ if (!assetKinds.has(kind)) {
32
33
  return null;
33
34
  }
34
35
 
35
- const assetUri = typeof meta?.assetUri === "string" ? meta.assetUri : undefined;
36
+ const assetUri = typeof meta.assetUri === "string" ? meta.assetUri : undefined;
36
37
  const assetMediaType =
37
- typeof meta?.assetMediaType === "string" ? meta.assetMediaType : undefined;
38
+ typeof meta.assetMediaType === "string" ? meta.assetMediaType : undefined;
38
39
  const extractor =
39
- typeof meta?.extractor === "string" ? meta.extractor : undefined;
40
+ typeof meta.extractor === "string" ? meta.extractor : undefined;
40
41
 
41
42
  return {
42
- assetId: id,
43
- assetKind: kind as AssetKind,
43
+ assetId: meta.assetId,
44
+ assetKind: kind,
44
45
  ...(assetUri ? { assetUri } : {}),
45
46
  ...(assetMediaType ? { assetMediaType } : {}),
46
47
  ...(extractor ? { extractor } : {}),
@@ -3,10 +3,11 @@ import type {
3
3
  ContextEngineConfig,
4
4
  ResolvedContextEngineConfig,
5
5
  AssetProcessingConfig,
6
- DeepPartial,
7
6
  ContentStorageConfig,
7
+ EmbeddingProcessingConfig,
8
8
  } from "./types";
9
9
  import { defaultChunker, resolveChunkingOptions } from "./chunking";
10
+ import { mergeDeep } from "./deep-merge";
10
11
 
11
12
  export const defineConfig = (config: ContextEngineConfig): ContextEngineConfig =>
12
13
  config;
@@ -123,30 +124,9 @@ export const defaultContentStorageConfig: ContentStorageConfig = {
123
124
  storeDocumentContent: true,
124
125
  };
125
126
 
126
- const mergeDeep = <T extends Record<string, any>>(
127
- base: T,
128
- overrides: DeepPartial<T> | undefined
129
- ): T => {
130
- if (!overrides) return base;
131
- const out: any = Array.isArray(base) ? [...base] : { ...base };
132
- for (const key of Object.keys(overrides) as Array<keyof T>) {
133
- const nextVal = overrides[key];
134
- if (nextVal === undefined) continue;
135
- const baseVal = base[key];
136
- if (
137
- baseVal &&
138
- typeof baseVal === "object" &&
139
- !Array.isArray(baseVal) &&
140
- nextVal &&
141
- typeof nextVal === "object" &&
142
- !Array.isArray(nextVal)
143
- ) {
144
- out[key] = mergeDeep(baseVal, nextVal as any);
145
- } else {
146
- out[key] = nextVal as any;
147
- }
148
- }
149
- return out as T;
127
+ export const defaultEmbeddingProcessingConfig: EmbeddingProcessingConfig = {
128
+ concurrency: 4,
129
+ batchSize: 32,
150
130
  };
151
131
 
152
132
  export const resolveAssetProcessingConfig = (
@@ -157,6 +137,10 @@ export const resolveContentStorageConfig = (
157
137
  overrides?: DeepPartial<ContentStorageConfig>
158
138
  ): ContentStorageConfig => mergeDeep(defaultContentStorageConfig, overrides);
159
139
 
140
+ export const resolveEmbeddingProcessingConfig = (
141
+ overrides?: DeepPartial<EmbeddingProcessingConfig>
142
+ ): EmbeddingProcessingConfig => mergeDeep(defaultEmbeddingProcessingConfig, overrides);
143
+
160
144
  export const resolveConfig = (
161
145
  config: ContextEngineConfig
162
146
  ): ResolvedContextEngineConfig => {
@@ -171,6 +155,7 @@ export const resolveConfig = (
171
155
  extractors: config.extractors ?? [],
172
156
  storage: resolveContentStorageConfig(config.storage),
173
157
  assetProcessing: resolveAssetProcessingConfig(config.assetProcessing),
158
+ embeddingProcessing: resolveEmbeddingProcessingConfig(config.embeddingProcessing),
174
159
  };
175
160
  };
176
161
 
@@ -3,6 +3,17 @@ import { ingest, planIngest } from "./ingest";
3
3
  import { retrieve } from "./retrieve";
4
4
  import { defineConfig, resolveConfig } from "./config";
5
5
  import { createAiEmbeddingProvider } from "../embedding/ai";
6
+ import { createOpenAiEmbeddingProvider } from "../embedding/openai";
7
+ import { createGoogleEmbeddingProvider } from "../embedding/google";
8
+ import { createOpenRouterEmbeddingProvider } from "../embedding/openrouter";
9
+ import { createAzureEmbeddingProvider } from "../embedding/azure";
10
+ import { createVertexEmbeddingProvider } from "../embedding/vertex";
11
+ import { createBedrockEmbeddingProvider } from "../embedding/bedrock";
12
+ import { createCohereEmbeddingProvider } from "../embedding/cohere";
13
+ import { createMistralEmbeddingProvider } from "../embedding/mistral";
14
+ import { createTogetherEmbeddingProvider } from "../embedding/together";
15
+ import { createOllamaEmbeddingProvider } from "../embedding/ollama";
16
+ import { createVoyageEmbeddingProvider } from "../embedding/voyage";
6
17
  import type {
7
18
  AssetExtractor,
8
19
  ContextEngineConfig,
@@ -70,12 +81,68 @@ export const defineUnragConfig = <T extends DefineUnragConfigInput>(config: T) =
70
81
  return embeddingProvider;
71
82
  }
72
83
 
84
+ if (config.embedding.provider === "openai") {
85
+ embeddingProvider = createOpenAiEmbeddingProvider(config.embedding.config);
86
+ return embeddingProvider;
87
+ }
88
+
89
+ if (config.embedding.provider === "google") {
90
+ embeddingProvider = createGoogleEmbeddingProvider(config.embedding.config);
91
+ return embeddingProvider;
92
+ }
93
+
94
+ if (config.embedding.provider === "openrouter") {
95
+ embeddingProvider = createOpenRouterEmbeddingProvider(config.embedding.config);
96
+ return embeddingProvider;
97
+ }
98
+
99
+ if (config.embedding.provider === "azure") {
100
+ embeddingProvider = createAzureEmbeddingProvider(config.embedding.config);
101
+ return embeddingProvider;
102
+ }
103
+
104
+ if (config.embedding.provider === "vertex") {
105
+ embeddingProvider = createVertexEmbeddingProvider(config.embedding.config);
106
+ return embeddingProvider;
107
+ }
108
+
109
+ if (config.embedding.provider === "bedrock") {
110
+ embeddingProvider = createBedrockEmbeddingProvider(config.embedding.config);
111
+ return embeddingProvider;
112
+ }
113
+
114
+ if (config.embedding.provider === "cohere") {
115
+ embeddingProvider = createCohereEmbeddingProvider(config.embedding.config);
116
+ return embeddingProvider;
117
+ }
118
+
119
+ if (config.embedding.provider === "mistral") {
120
+ embeddingProvider = createMistralEmbeddingProvider(config.embedding.config);
121
+ return embeddingProvider;
122
+ }
123
+
124
+ if (config.embedding.provider === "together") {
125
+ embeddingProvider = createTogetherEmbeddingProvider(config.embedding.config);
126
+ return embeddingProvider;
127
+ }
128
+
129
+ if (config.embedding.provider === "ollama") {
130
+ embeddingProvider = createOllamaEmbeddingProvider(config.embedding.config);
131
+ return embeddingProvider;
132
+ }
133
+
134
+ if (config.embedding.provider === "voyage") {
135
+ embeddingProvider = createVoyageEmbeddingProvider(config.embedding.config);
136
+ return embeddingProvider;
137
+ }
138
+
73
139
  embeddingProvider = config.embedding.create();
74
140
  return embeddingProvider;
75
141
  };
76
142
 
77
143
  const defaults = {
78
144
  chunking: config.defaults?.chunking ?? {},
145
+ embedding: config.defaults?.embedding ?? {},
79
146
  retrieval: {
80
147
  topK: config.defaults?.retrieval?.topK ?? 8,
81
148
  },
@@ -91,6 +158,10 @@ export const defineUnragConfig = <T extends DefineUnragConfigInput>(config: T) =
91
158
  return defineConfig({
92
159
  ...(config.engine ?? {}),
93
160
  defaults: defaults.chunking,
161
+ embeddingProcessing: {
162
+ ...(defaults.embedding ?? {}),
163
+ ...(config.engine?.embeddingProcessing ?? {}),
164
+ },
94
165
  embedding: getEmbeddingProvider(),
95
166
  store: runtime.store,
96
167
  extractors,
@@ -104,5 +175,3 @@ export const defineUnragConfig = <T extends DefineUnragConfigInput>(config: T) =
104
175
  new ContextEngine(createEngineConfig(runtime)),
105
176
  };
106
177
  };
107
-
108
-
@@ -0,0 +1,45 @@
1
+ import type { DeepPartial } from "./types";
2
+
3
+ /**
4
+ * Type guard to check if a value is a plain object (not array, not null).
5
+ */
6
+ export function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+
10
+ /**
11
+ * Deep merge utility that recursively merges overrides into base.
12
+ * Arrays are replaced, not merged. Undefined values in overrides are skipped.
13
+ */
14
+ export function mergeDeep<T extends Record<string, unknown>>(
15
+ base: T,
16
+ overrides: DeepPartial<T> | undefined
17
+ ): T {
18
+ if (!overrides) return base;
19
+
20
+ const out = (Array.isArray(base) ? [...base] : { ...base }) as T;
21
+
22
+ for (const key of Object.keys(overrides) as Array<keyof T>) {
23
+ const nextVal = overrides[key as keyof typeof overrides];
24
+ if (nextVal === undefined) continue;
25
+
26
+ const baseVal = base[key];
27
+
28
+ if (
29
+ isRecord(baseVal) &&
30
+ isRecord(nextVal) &&
31
+ !Array.isArray(baseVal) &&
32
+ !Array.isArray(nextVal)
33
+ ) {
34
+ out[key] = mergeDeep(
35
+ baseVal,
36
+ nextVal as DeepPartial<typeof baseVal>
37
+ ) as T[keyof T];
38
+ } else {
39
+ out[key] = nextVal as T[keyof T];
40
+ }
41
+ }
42
+
43
+ return out;
44
+ }
45
+