@juspay/neurolink 10.3.0 → 10.4.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/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +430 -417
  3. package/dist/cli/loop/optionsSchema.js +4 -0
  4. package/dist/constants/contextWindows.d.ts +24 -0
  5. package/dist/constants/contextWindows.js +50 -0
  6. package/dist/context/errorDetection.d.ts +6 -0
  7. package/dist/context/errorDetection.js +18 -0
  8. package/dist/context/stepBudgetGuard.d.ts +17 -3
  9. package/dist/context/stepBudgetGuard.js +53 -14
  10. package/dist/core/baseProvider.d.ts +12 -0
  11. package/dist/core/baseProvider.js +18 -0
  12. package/dist/core/modules/GenerationHandler.js +208 -42
  13. package/dist/core/modules/ToolsManager.d.ts +17 -0
  14. package/dist/core/modules/ToolsManager.js +99 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +3 -0
  17. package/dist/knowledge/context.d.ts +18 -0
  18. package/dist/knowledge/context.js +91 -0
  19. package/dist/knowledge/defaults.d.ts +24 -0
  20. package/dist/knowledge/defaults.js +29 -0
  21. package/dist/knowledge/engine.d.ts +35 -0
  22. package/dist/knowledge/engine.js +179 -0
  23. package/dist/knowledge/index.d.ts +15 -0
  24. package/dist/knowledge/index.js +15 -0
  25. package/dist/knowledge/indexCache.d.ts +19 -0
  26. package/dist/knowledge/indexCache.js +109 -0
  27. package/dist/knowledge/knowledgeIndex.d.ts +41 -0
  28. package/dist/knowledge/knowledgeIndex.js +204 -0
  29. package/dist/knowledge/normalize.d.ts +32 -0
  30. package/dist/knowledge/normalize.js +74 -0
  31. package/dist/knowledge/resolve.d.ts +18 -0
  32. package/dist/knowledge/resolve.js +156 -0
  33. package/dist/knowledge/retrieval.d.ts +16 -0
  34. package/dist/knowledge/retrieval.js +221 -0
  35. package/dist/lib/constants/contextWindows.d.ts +24 -0
  36. package/dist/lib/constants/contextWindows.js +50 -0
  37. package/dist/lib/context/errorDetection.d.ts +6 -0
  38. package/dist/lib/context/errorDetection.js +18 -0
  39. package/dist/lib/context/stepBudgetGuard.d.ts +17 -3
  40. package/dist/lib/context/stepBudgetGuard.js +53 -14
  41. package/dist/lib/core/baseProvider.d.ts +12 -0
  42. package/dist/lib/core/baseProvider.js +18 -0
  43. package/dist/lib/core/modules/GenerationHandler.js +208 -42
  44. package/dist/lib/core/modules/ToolsManager.d.ts +17 -0
  45. package/dist/lib/core/modules/ToolsManager.js +99 -1
  46. package/dist/lib/files/fileTools.d.ts +1 -1
  47. package/dist/lib/index.d.ts +1 -0
  48. package/dist/lib/index.js +3 -0
  49. package/dist/lib/knowledge/context.d.ts +18 -0
  50. package/dist/lib/knowledge/context.js +92 -0
  51. package/dist/lib/knowledge/defaults.d.ts +24 -0
  52. package/dist/lib/knowledge/defaults.js +30 -0
  53. package/dist/lib/knowledge/engine.d.ts +35 -0
  54. package/dist/lib/knowledge/engine.js +180 -0
  55. package/dist/lib/knowledge/index.d.ts +15 -0
  56. package/dist/lib/knowledge/index.js +16 -0
  57. package/dist/lib/knowledge/indexCache.d.ts +19 -0
  58. package/dist/lib/knowledge/indexCache.js +110 -0
  59. package/dist/lib/knowledge/knowledgeIndex.d.ts +41 -0
  60. package/dist/lib/knowledge/knowledgeIndex.js +205 -0
  61. package/dist/lib/knowledge/normalize.d.ts +32 -0
  62. package/dist/lib/knowledge/normalize.js +75 -0
  63. package/dist/lib/knowledge/resolve.d.ts +18 -0
  64. package/dist/lib/knowledge/resolve.js +157 -0
  65. package/dist/lib/knowledge/retrieval.d.ts +16 -0
  66. package/dist/lib/knowledge/retrieval.js +222 -0
  67. package/dist/lib/mcp/toolDiscoveryService.js +59 -20
  68. package/dist/lib/neurolink.d.ts +14 -1
  69. package/dist/lib/neurolink.js +142 -12
  70. package/dist/lib/providers/litellm.d.ts +27 -19
  71. package/dist/lib/providers/litellm.js +171 -92
  72. package/dist/lib/providers/openaiChatCompletionsBase.d.ts +37 -1
  73. package/dist/lib/providers/openaiChatCompletionsBase.js +201 -33
  74. package/dist/lib/providers/openaiChatCompletionsClient.d.ts +23 -5
  75. package/dist/lib/providers/openaiChatCompletionsClient.js +94 -14
  76. package/dist/lib/proxy/proxyFetch.d.ts +17 -0
  77. package/dist/lib/proxy/proxyFetch.js +42 -4
  78. package/dist/lib/types/config.d.ts +12 -0
  79. package/dist/lib/types/conversation.d.ts +1 -1
  80. package/dist/lib/types/dynamic.d.ts +12 -0
  81. package/dist/lib/types/generate.d.ts +32 -2
  82. package/dist/lib/types/index.d.ts +1 -0
  83. package/dist/lib/types/index.js +1 -0
  84. package/dist/lib/types/knowledge.d.ts +342 -0
  85. package/dist/lib/types/knowledge.js +30 -0
  86. package/dist/lib/types/openaiCompatible.d.ts +2 -0
  87. package/dist/lib/types/providers.d.ts +9 -0
  88. package/dist/lib/types/stream.d.ts +14 -0
  89. package/dist/lib/utils/errorHandling.js +8 -2
  90. package/dist/lib/utils/schemaConversion.d.ts +16 -0
  91. package/dist/lib/utils/schemaConversion.js +165 -8
  92. package/dist/lib/utils/timeout.d.ts +22 -0
  93. package/dist/lib/utils/timeout.js +72 -12
  94. package/dist/lib/utils/tokenLimits.js +22 -0
  95. package/dist/lib/utils/toolCallRepair.d.ts +8 -0
  96. package/dist/lib/utils/toolCallRepair.js +4 -1
  97. package/dist/mcp/toolDiscoveryService.js +59 -20
  98. package/dist/neurolink.d.ts +14 -1
  99. package/dist/neurolink.js +142 -12
  100. package/dist/providers/litellm.d.ts +27 -19
  101. package/dist/providers/litellm.js +171 -92
  102. package/dist/providers/openaiChatCompletionsBase.d.ts +37 -1
  103. package/dist/providers/openaiChatCompletionsBase.js +201 -33
  104. package/dist/providers/openaiChatCompletionsClient.d.ts +23 -5
  105. package/dist/providers/openaiChatCompletionsClient.js +94 -14
  106. package/dist/proxy/proxyFetch.d.ts +17 -0
  107. package/dist/proxy/proxyFetch.js +42 -4
  108. package/dist/types/config.d.ts +12 -0
  109. package/dist/types/conversation.d.ts +1 -1
  110. package/dist/types/dynamic.d.ts +12 -0
  111. package/dist/types/generate.d.ts +32 -2
  112. package/dist/types/index.d.ts +1 -0
  113. package/dist/types/index.js +1 -0
  114. package/dist/types/knowledge.d.ts +342 -0
  115. package/dist/types/knowledge.js +29 -0
  116. package/dist/types/openaiCompatible.d.ts +2 -0
  117. package/dist/types/providers.d.ts +9 -0
  118. package/dist/types/stream.d.ts +14 -0
  119. package/dist/utils/errorHandling.js +8 -2
  120. package/dist/utils/schemaConversion.d.ts +16 -0
  121. package/dist/utils/schemaConversion.js +165 -8
  122. package/dist/utils/timeout.d.ts +22 -0
  123. package/dist/utils/timeout.js +72 -12
  124. package/dist/utils/tokenLimits.js +22 -0
  125. package/dist/utils/toolCallRepair.d.ts +8 -0
  126. package/dist/utils/toolCallRepair.js +4 -1
  127. package/package.json +4 -1
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Central default constants for the knowledge-grounding engine. Kept as
3
+ * runtime values (not type aliases) so they live outside src/lib/types/.
4
+ * Every number here is a starting value meant to be tuned through evaluation.
5
+ */
6
+ /** Field weights for the lexical scorer: title/alias matches outrank body. */
7
+ export const DEFAULT_FIELD_WEIGHTS = {
8
+ title: 5,
9
+ aliases: 5,
10
+ keywords: 3,
11
+ summary: 3,
12
+ body: 1,
13
+ };
14
+ /** How many scored candidates enter relationship expansion / assembly. */
15
+ export const DEFAULT_CANDIDATE_LIMIT = 24;
16
+ /** How many primary entries survive into the assembled context. */
17
+ export const DEFAULT_RESULT_LIMIT = 8;
18
+ /** Cap on relationship-expanded entries added after primary retrieval. */
19
+ export const DEFAULT_RELATION_LIMIT = 4;
20
+ /** Additive boost for an exact entry-id / configuration-key match (dominant). */
21
+ export const DEFAULT_EXACT_BOOST = 100;
22
+ /** Additive boost for an exact reviewed-alias phrase match (very high). */
23
+ export const DEFAULT_ALIAS_BOOST = 60;
24
+ /** Grounding-context token budget. */
25
+ export const DEFAULT_MAX_CONTEXT_TOKENS = 4000;
26
+ /** Hard ceiling for one grounding operation before it fails open. */
27
+ export const DEFAULT_TIMEOUT_MS = 800;
28
+ /** Bounded recent-turn window used to contextualize the query. */
29
+ export const DEFAULT_RECENT_TURNS = 4;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * KnowledgeGroundingEngine — the provider-neutral orchestrator.
3
+ *
4
+ * Loads the source set once at construction, reuses the process-level
5
+ * in-memory index cache when possible, and runs one retrieval + context
6
+ * assembly per eligible turn. The engine's snapshot is never reset after
7
+ * construction — sources are supplied only via the constructor config.
8
+ *
9
+ * The engine never throws from `ground()` — any failure fails open to "no
10
+ * grounding" with a `failureReason`, so an informational turn is never broken.
11
+ * It performs no logging and no telemetry itself; the client wiring reads
12
+ * `getStatus()`/the outcome metadata and emits spans.
13
+ */
14
+ import type { KnowledgeEngineStatus, KnowledgeGroundingConfig, KnowledgeGroundingInput, KnowledgeGroundingOutcome } from "../types/index.js";
15
+ export declare class KnowledgeGroundingEngine {
16
+ private readonly config;
17
+ private readonly resolved;
18
+ private snapshot;
19
+ private buildPromise;
20
+ private lastError;
21
+ private validationIssues;
22
+ private readonly now;
23
+ constructor(config: KnowledgeGroundingConfig, now?: () => number);
24
+ isEnabled(): boolean;
25
+ /** Resolve once the one-time build settles. Safe to call before every turn. */
26
+ ready(): Promise<void>;
27
+ getStatus(): KnowledgeEngineStatus;
28
+ private build;
29
+ private buildRequest;
30
+ /**
31
+ * Retrieve + assemble for one turn. Returns the ephemeral context to inject
32
+ * (or null), aggregate metadata, and the full retrieval. Never throws.
33
+ */
34
+ ground(input: KnowledgeGroundingInput): Promise<KnowledgeGroundingOutcome>;
35
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * KnowledgeGroundingEngine — the provider-neutral orchestrator.
3
+ *
4
+ * Loads the source set once at construction, reuses the process-level
5
+ * in-memory index cache when possible, and runs one retrieval + context
6
+ * assembly per eligible turn. The engine's snapshot is never reset after
7
+ * construction — sources are supplied only via the constructor config.
8
+ *
9
+ * The engine never throws from `ground()` — any failure fails open to "no
10
+ * grounding" with a `failureReason`, so an informational turn is never broken.
11
+ * It performs no logging and no telemetry itself; the client wiring reads
12
+ * `getStatus()`/the outcome metadata and emits spans.
13
+ */
14
+ import { assembleKnowledgeContext } from "./context.js";
15
+ import { DEFAULT_ALIAS_BOOST, DEFAULT_CANDIDATE_LIMIT, DEFAULT_EXACT_BOOST, DEFAULT_FIELD_WEIGHTS, DEFAULT_RECENT_TURNS, DEFAULT_RELATION_LIMIT, DEFAULT_RESULT_LIMIT, DEFAULT_TIMEOUT_MS, } from "./defaults.js";
16
+ import { getOrBuildKnowledgeIndexSnapshot } from "./indexCache.js";
17
+ import { retrieve } from "./retrieval.js";
18
+ import { withTimeout } from "../utils/async/withTimeout.js";
19
+ /** Fallback source version when a source (or manifest) declares none. */
20
+ const FALLBACK_VERSION = "0";
21
+ const resolveRetrieval = (retrieval) => ({
22
+ candidateLimit: retrieval?.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT,
23
+ resultLimit: retrieval?.resultLimit ?? DEFAULT_RESULT_LIMIT,
24
+ relationLimit: retrieval?.relationLimit ?? DEFAULT_RELATION_LIMIT,
25
+ fieldWeights: retrieval?.fieldWeights ?? DEFAULT_FIELD_WEIGHTS,
26
+ exactBoost: retrieval?.exactBoost ?? DEFAULT_EXACT_BOOST,
27
+ aliasBoost: retrieval?.aliasBoost ?? DEFAULT_ALIAS_BOOST,
28
+ });
29
+ const emptyMetadata = (durationMs, failureReason) => ({
30
+ retrievalMode: "lexical",
31
+ selectedIds: [],
32
+ expandedIds: [],
33
+ candidateCount: 0,
34
+ contextTokens: 0,
35
+ truncated: false,
36
+ durationMs,
37
+ failureReason,
38
+ });
39
+ export class KnowledgeGroundingEngine {
40
+ config;
41
+ resolved;
42
+ snapshot = null;
43
+ buildPromise = null;
44
+ lastError = null;
45
+ validationIssues = [];
46
+ now;
47
+ constructor(config, now = () => Date.now()) {
48
+ this.config = config;
49
+ this.resolved = resolveRetrieval(config.retrieval);
50
+ this.now = now;
51
+ if (config.enabled && config.sources && config.sources.length > 0) {
52
+ this.buildPromise = this.build(config.sources).catch((error) => {
53
+ this.lastError = String(error);
54
+ });
55
+ }
56
+ }
57
+ isEnabled() {
58
+ return this.config.enabled;
59
+ }
60
+ /** Resolve once the one-time build settles. Safe to call before every turn. */
61
+ async ready() {
62
+ if (this.buildPromise) {
63
+ await this.buildPromise;
64
+ }
65
+ }
66
+ getStatus() {
67
+ return {
68
+ enabled: this.config.enabled,
69
+ ready: this.snapshot !== null,
70
+ entryCount: this.snapshot?.entryCount ?? 0,
71
+ lastError: this.lastError,
72
+ validationIssues: this.validationIssues,
73
+ };
74
+ }
75
+ async build(sources) {
76
+ const { snapshot, validation } = await getOrBuildKnowledgeIndexSnapshot(sources, FALLBACK_VERSION, this.resolved.fieldWeights);
77
+ this.validationIssues = validation.issues;
78
+ if (!validation.ok) {
79
+ // Never load a partially valid registry — leave the index unbuilt.
80
+ const errorCount = validation.issues.filter((issue) => issue.level === "error").length;
81
+ this.lastError = `knowledge validation failed with ${errorCount} error(s)`;
82
+ return;
83
+ }
84
+ // The snapshot may be newly built or reused from another NeuroLink instance
85
+ // in this process. It is immutable and safe to share across conversations.
86
+ this.snapshot = snapshot;
87
+ this.lastError = null;
88
+ }
89
+ buildRequest(input) {
90
+ const scope = input.scope ?? {};
91
+ return {
92
+ query: input.query,
93
+ recentTurns: (input.recentTurns ?? []).slice(-DEFAULT_RECENT_TURNS),
94
+ enabledIntegrations: scope.enabledIntegrations ?? [],
95
+ };
96
+ }
97
+ /**
98
+ * Retrieve + assemble for one turn. Returns the ephemeral context to inject
99
+ * (or null), aggregate metadata, and the full retrieval. Never throws.
100
+ */
101
+ async ground(input) {
102
+ const started = this.now();
103
+ const wallClockStarted = Date.now();
104
+ if (!this.config.enabled) {
105
+ return {
106
+ ephemeralContext: null,
107
+ metadata: emptyMetadata(0),
108
+ retrieval: null,
109
+ };
110
+ }
111
+ try {
112
+ const timeoutMs = this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
113
+ const outcome = await withTimeout((async () => {
114
+ await this.ready();
115
+ const snapshot = this.snapshot;
116
+ if (!snapshot) {
117
+ return {
118
+ ephemeralContext: null,
119
+ metadata: emptyMetadata(this.now() - started, "index-not-ready"),
120
+ retrieval: null,
121
+ };
122
+ }
123
+ const request = this.buildRequest(input);
124
+ const selection = retrieve(snapshot, request, this.resolved, this.config.blockedDomains);
125
+ const assembled = assembleKnowledgeContext(selection, this.config.context);
126
+ const durationMs = this.now() - started;
127
+ const retrieval = {
128
+ entries: [...selection.primary, ...selection.expanded],
129
+ assembledContext: assembled.assembledContext,
130
+ confidence: selection.confidence,
131
+ citations: assembled.citations,
132
+ selectedEntryIds: selection.primary.map((entry) => entry.id),
133
+ expandedEntryIds: selection.expanded.map((entry) => entry.id),
134
+ candidateCount: selection.candidateCount,
135
+ contextTokens: assembled.contextTokens,
136
+ truncated: assembled.truncated,
137
+ durationMs,
138
+ };
139
+ const metadata = {
140
+ retrievalMode: "lexical",
141
+ selectedIds: retrieval.selectedEntryIds,
142
+ expandedIds: retrieval.expandedEntryIds,
143
+ candidateCount: selection.candidateCount,
144
+ contextTokens: assembled.contextTokens,
145
+ truncated: assembled.truncated,
146
+ durationMs,
147
+ confidence: selection.confidence,
148
+ };
149
+ // Nothing to inject when retrieval found nothing (or all matches were filtered out).
150
+ if (!assembled.assembledContext) {
151
+ return { ephemeralContext: null, metadata, retrieval };
152
+ }
153
+ const ephemeralContext = {
154
+ content: assembled.assembledContext,
155
+ kind: "knowledge",
156
+ trusted: true,
157
+ citations: assembled.citations,
158
+ metadata: { confidence: selection.confidence },
159
+ };
160
+ return { ephemeralContext, metadata, retrieval };
161
+ })(), timeoutMs, `Knowledge grounding timed out after ${timeoutMs}ms`);
162
+ // Promise timers cannot pre-empt synchronous indexing/scoring work. Check
163
+ // elapsed wall time as well so an over-budget synchronous pass still
164
+ // fails open instead of injecting a late result.
165
+ if (Date.now() - wallClockStarted > timeoutMs) {
166
+ throw new Error(`Knowledge grounding timed out after ${timeoutMs}ms`);
167
+ }
168
+ return outcome;
169
+ }
170
+ catch (error) {
171
+ // Fail open: no grounding rather than a broken turn.
172
+ return {
173
+ ephemeralContext: null,
174
+ metadata: emptyMetadata(this.now() - started, String(error)),
175
+ retrieval: null,
176
+ };
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public runtime exports for the knowledge-grounding engine.
3
+ *
4
+ * Per the repo convention, this module barrel re-exports only runtime values
5
+ * (classes and functions). All knowledge types flow through the canonical
6
+ * types barrel (`../types/index.js`) and are re-exported from the package root
7
+ * alongside every other public type.
8
+ */
9
+ export { KnowledgeGroundingEngine } from "./engine.js";
10
+ export { manifestToSources, normalizeAndValidate, resolveEntry, } from "./resolve.js";
11
+ export { buildDocument, buildIndexSnapshot, KnowledgeLexicalIndex, } from "./knowledgeIndex.js";
12
+ export { assembleKnowledgeContext } from "./context.js";
13
+ export { retrieve } from "./retrieval.js";
14
+ export { normalizePhrases, normalizeText, tokenize } from "./normalize.js";
15
+ export { DEFAULT_ALIAS_BOOST, DEFAULT_CANDIDATE_LIMIT, DEFAULT_EXACT_BOOST, DEFAULT_FIELD_WEIGHTS, DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_TIMEOUT_MS, DEFAULT_RECENT_TURNS, DEFAULT_RELATION_LIMIT, DEFAULT_RESULT_LIMIT, } from "./defaults.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public runtime exports for the knowledge-grounding engine.
3
+ *
4
+ * Per the repo convention, this module barrel re-exports only runtime values
5
+ * (classes and functions). All knowledge types flow through the canonical
6
+ * types barrel (`../types/index.js`) and are re-exported from the package root
7
+ * alongside every other public type.
8
+ */
9
+ export { KnowledgeGroundingEngine } from "./engine.js";
10
+ export { manifestToSources, normalizeAndValidate, resolveEntry, } from "./resolve.js";
11
+ export { buildDocument, buildIndexSnapshot, KnowledgeLexicalIndex, } from "./knowledgeIndex.js";
12
+ export { assembleKnowledgeContext } from "./context.js";
13
+ export { retrieve } from "./retrieval.js";
14
+ export { normalizePhrases, normalizeText, tokenize } from "./normalize.js";
15
+ export { DEFAULT_ALIAS_BOOST, DEFAULT_CANDIDATE_LIMIT, DEFAULT_EXACT_BOOST, DEFAULT_FIELD_WEIGHTS, DEFAULT_MAX_CONTEXT_TOKENS, DEFAULT_TIMEOUT_MS, DEFAULT_RECENT_TURNS, DEFAULT_RELATION_LIMIT, DEFAULT_RESULT_LIMIT, } from "./defaults.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Process-level cache for knowledge index snapshots.
3
+ *
4
+ * Knowledge sources are constructor-level static content. Multiple NeuroLink
5
+ * instances in the same server process can therefore share the same immutable
6
+ * lexical index instead of rebuilding it for every conversation/session.
7
+ */
8
+ import type { KnowledgeFieldWeights, KnowledgeIndexSnapshot, KnowledgeSource, KnowledgeValidationResult } from "../types/index.js";
9
+ export declare const KNOWLEDGE_INDEX_CACHE_LIMIT = 32;
10
+ /**
11
+ * Cache key intentionally uses source/version metadata, not conversation scope.
12
+ * The source content hash prevents stale reuse even when a host forgets to bump
13
+ * a source version after changing entries.
14
+ */
15
+ export declare const buildKnowledgeIndexCacheKey: (sources: KnowledgeSource[], fallbackVersion: string, weights: KnowledgeFieldWeights) => string;
16
+ export declare const getOrBuildKnowledgeIndexSnapshot: (sources: KnowledgeSource[], fallbackVersion: string, weights: KnowledgeFieldWeights) => Promise<{
17
+ snapshot: KnowledgeIndexSnapshot | null;
18
+ validation: KnowledgeValidationResult;
19
+ }>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Process-level cache for knowledge index snapshots.
3
+ *
4
+ * Knowledge sources are constructor-level static content. Multiple NeuroLink
5
+ * instances in the same server process can therefore share the same immutable
6
+ * lexical index instead of rebuilding it for every conversation/session.
7
+ */
8
+ import { createHash } from "crypto";
9
+ import { buildIndexSnapshot } from "./knowledgeIndex.js";
10
+ import { normalizeAndValidate, resolveEntry } from "./resolve.js";
11
+ const CACHE_SCHEMA_VERSION = "knowledge-index-cache:v1";
12
+ export const KNOWLEDGE_INDEX_CACHE_LIMIT = 32;
13
+ const indexBuildCache = new Map();
14
+ const rememberCacheEntry = (cacheKey, buildPromise) => {
15
+ if (indexBuildCache.has(cacheKey)) {
16
+ indexBuildCache.delete(cacheKey);
17
+ }
18
+ indexBuildCache.set(cacheKey, buildPromise);
19
+ while (indexBuildCache.size > KNOWLEDGE_INDEX_CACHE_LIMIT) {
20
+ const oldestKey = indexBuildCache.keys().next().value;
21
+ if (oldestKey === undefined) {
22
+ return;
23
+ }
24
+ indexBuildCache.delete(oldestKey);
25
+ }
26
+ };
27
+ const escapePart = (value) => encodeURIComponent(value);
28
+ const stableStringify = (value) => {
29
+ if (value === null || typeof value !== "object") {
30
+ return JSON.stringify(value);
31
+ }
32
+ if (Array.isArray(value)) {
33
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
34
+ }
35
+ const record = value;
36
+ const entries = Object.keys(record)
37
+ .sort()
38
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
39
+ return `{${entries.join(",")}}`;
40
+ };
41
+ const compareEntries = (left, right) => left.id.localeCompare(right.id) ||
42
+ stableStringify(left).localeCompare(stableStringify(right));
43
+ const hashSourceContent = (source, fallbackVersion) => {
44
+ const version = source.version ?? fallbackVersion;
45
+ const effectiveContent = {
46
+ id: source.id,
47
+ version,
48
+ entries: source.entries
49
+ .map((entry) => resolveEntry(entry, version))
50
+ .sort(compareEntries),
51
+ };
52
+ return createHash("sha256")
53
+ .update(stableStringify(effectiveContent))
54
+ .digest("hex");
55
+ };
56
+ const formatFieldWeights = (weights) => [
57
+ `title=${weights.title}`,
58
+ `aliases=${weights.aliases}`,
59
+ `keywords=${weights.keywords}`,
60
+ `summary=${weights.summary}`,
61
+ `body=${weights.body}`,
62
+ ].join(",");
63
+ const formatSource = (source, fallbackVersion) => [
64
+ escapePart(source.id),
65
+ escapePart(source.version ?? fallbackVersion),
66
+ source.entries.length,
67
+ source.entries
68
+ .map((entry) => escapePart(entry.id))
69
+ .sort()
70
+ .join(","),
71
+ hashSourceContent(source, fallbackVersion),
72
+ ].join(":");
73
+ /**
74
+ * Cache key intentionally uses source/version metadata, not conversation scope.
75
+ * The source content hash prevents stale reuse even when a host forgets to bump
76
+ * a source version after changing entries.
77
+ */
78
+ export const buildKnowledgeIndexCacheKey = (sources, fallbackVersion, weights) => [
79
+ CACHE_SCHEMA_VERSION,
80
+ `weights=${formatFieldWeights(weights)}`,
81
+ ...sources.map((source) => formatSource(source, fallbackVersion)),
82
+ ].join("|");
83
+ export const getOrBuildKnowledgeIndexSnapshot = async (sources, fallbackVersion, weights) => {
84
+ const cacheKey = buildKnowledgeIndexCacheKey(sources, fallbackVersion, weights);
85
+ const cached = indexBuildCache.get(cacheKey);
86
+ if (cached) {
87
+ rememberCacheEntry(cacheKey, cached);
88
+ return cached;
89
+ }
90
+ const buildPromise = (async () => {
91
+ const { entries, validation } = await normalizeAndValidate(sources, {
92
+ manifestVersion: fallbackVersion,
93
+ });
94
+ if (!validation.ok) {
95
+ return { snapshot: null, validation };
96
+ }
97
+ return {
98
+ snapshot: buildIndexSnapshot(entries, weights),
99
+ validation,
100
+ };
101
+ })();
102
+ rememberCacheEntry(cacheKey, buildPromise);
103
+ void buildPromise.catch(() => {
104
+ if (indexBuildCache.get(cacheKey) === buildPromise) {
105
+ indexBuildCache.delete(cacheKey);
106
+ }
107
+ });
108
+ return buildPromise;
109
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * In-memory knowledge index construction.
3
+ *
4
+ * Index construction is an application-process lifecycle operation, not a
5
+ * per-session one. A complete `KnowledgeIndexSnapshot` is built once from the
6
+ * normalized entries and then queried by every turn.
7
+ *
8
+ * The lexical scorer is a compact field-aware BM25 (BM25F-style): each of the
9
+ * five text fields is scored independently and combined with per-field
10
+ * weights, retaining the per-field breakdown so a retrieval decision is
11
+ * explainable in traces. No embedding model or vector store is involved.
12
+ */
13
+ import type { IndexedKnowledgeDocument, KnowledgeFieldWeights, KnowledgeIndexSnapshot, KnowledgeLexicalMatch, NormalizedKnowledgeEntry } from "../types/index.js";
14
+ /**
15
+ * Field-aware BM25 over the document set. Query text is scored per field and
16
+ * the weighted per-field scores are summed. Satisfies the structural
17
+ * `KnowledgeLexicalSearcher` type held by a snapshot.
18
+ */
19
+ export declare class KnowledgeLexicalIndex {
20
+ private readonly weights;
21
+ private readonly postings;
22
+ private readonly fieldLengths;
23
+ private readonly fieldTokenTotals;
24
+ private readonly avgFieldLength;
25
+ private docCount;
26
+ constructor(weights: KnowledgeFieldWeights);
27
+ add(document: IndexedKnowledgeDocument): void;
28
+ finalize(): void;
29
+ search(queryTokens: string[], topK: number, eligibleEntryIds?: ReadonlySet<string>): KnowledgeLexicalMatch[];
30
+ }
31
+ /**
32
+ * Build the internal search document from a normalized entry. `exactKeys`
33
+ * carry whole-phrase identifiers (entry id and title); each
34
+ * text field is tokenized for the field-aware scorer.
35
+ */
36
+ export declare const buildDocument: (entry: NormalizedKnowledgeEntry) => IndexedKnowledgeDocument;
37
+ /**
38
+ * Build the complete immutable snapshot: entry map, exact/alias/relation
39
+ * indexes, and the field-aware lexical index.
40
+ */
41
+ export declare const buildIndexSnapshot: (entries: NormalizedKnowledgeEntry[], weights: KnowledgeFieldWeights) => KnowledgeIndexSnapshot;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * In-memory knowledge index construction.
3
+ *
4
+ * Index construction is an application-process lifecycle operation, not a
5
+ * per-session one. A complete `KnowledgeIndexSnapshot` is built once from the
6
+ * normalized entries and then queried by every turn.
7
+ *
8
+ * The lexical scorer is a compact field-aware BM25 (BM25F-style): each of the
9
+ * five text fields is scored independently and combined with per-field
10
+ * weights, retaining the per-field breakdown so a retrieval decision is
11
+ * explainable in traces. No embedding model or vector store is involved.
12
+ */
13
+ import { normalizePhrases, normalizeText, tokenize } from "./normalize.js";
14
+ const FIELD_NAMES = [
15
+ "title",
16
+ "aliases",
17
+ "keywords",
18
+ "summary",
19
+ "body",
20
+ ];
21
+ /** Okapi BM25 saturation and length-normalization parameters. */
22
+ const BM25_K1 = 1.5;
23
+ const BM25_B = 0.75;
24
+ /**
25
+ * Field-aware BM25 over the document set. Query text is scored per field and
26
+ * the weighted per-field scores are summed. Satisfies the structural
27
+ * `KnowledgeLexicalSearcher` type held by a snapshot.
28
+ */
29
+ export class KnowledgeLexicalIndex {
30
+ weights;
31
+ // field -> (term -> (docId -> term frequency))
32
+ postings = new Map();
33
+ // field -> (docId -> field length in tokens)
34
+ fieldLengths = new Map();
35
+ // field -> total tokens across all docs (for average length)
36
+ fieldTokenTotals = new Map();
37
+ // field -> average field length (computed in finalize)
38
+ avgFieldLength = new Map();
39
+ docCount = 0;
40
+ constructor(weights) {
41
+ this.weights = weights;
42
+ for (const field of FIELD_NAMES) {
43
+ this.postings.set(field, new Map());
44
+ this.fieldLengths.set(field, new Map());
45
+ this.fieldTokenTotals.set(field, 0);
46
+ }
47
+ }
48
+ add(document) {
49
+ this.docCount += 1;
50
+ for (const field of FIELD_NAMES) {
51
+ const tokens = document.fields[field];
52
+ const postingsForField = this.postings.get(field);
53
+ const lengthsForField = this.fieldLengths.get(field);
54
+ if (!postingsForField || !lengthsForField) {
55
+ continue;
56
+ }
57
+ lengthsForField.set(document.id, tokens.length);
58
+ this.fieldTokenTotals.set(field, (this.fieldTokenTotals.get(field) ?? 0) + tokens.length);
59
+ const termFrequency = new Map();
60
+ for (const token of tokens) {
61
+ termFrequency.set(token, (termFrequency.get(token) ?? 0) + 1);
62
+ }
63
+ for (const [term, frequency] of termFrequency) {
64
+ const docMap = postingsForField.get(term);
65
+ if (docMap) {
66
+ docMap.set(document.id, frequency);
67
+ }
68
+ else {
69
+ postingsForField.set(term, new Map([[document.id, frequency]]));
70
+ }
71
+ }
72
+ }
73
+ }
74
+ finalize() {
75
+ for (const field of FIELD_NAMES) {
76
+ const total = this.fieldTokenTotals.get(field) ?? 0;
77
+ this.avgFieldLength.set(field, this.docCount > 0 ? total / this.docCount : 0);
78
+ }
79
+ }
80
+ search(queryTokens, topK, eligibleEntryIds) {
81
+ if (this.docCount === 0 || queryTokens.length === 0) {
82
+ return [];
83
+ }
84
+ const uniqueTerms = Array.from(new Set(queryTokens));
85
+ const totals = new Map();
86
+ const breakdowns = new Map();
87
+ for (const field of FIELD_NAMES) {
88
+ const weight = this.weights[field];
89
+ const avgdl = this.avgFieldLength.get(field) ?? 0;
90
+ const postingsForField = this.postings.get(field);
91
+ const lengthsForField = this.fieldLengths.get(field);
92
+ if (weight <= 0 || avgdl <= 0 || !postingsForField || !lengthsForField) {
93
+ continue;
94
+ }
95
+ for (const term of uniqueTerms) {
96
+ const docMap = postingsForField.get(term);
97
+ if (!docMap) {
98
+ continue;
99
+ }
100
+ const documentFrequency = docMap.size;
101
+ const idf = Math.log(1 +
102
+ (this.docCount - documentFrequency + 0.5) /
103
+ (documentFrequency + 0.5));
104
+ for (const [docId, frequency] of docMap) {
105
+ if (eligibleEntryIds && !eligibleEntryIds.has(docId)) {
106
+ continue;
107
+ }
108
+ const length = lengthsForField.get(docId) ?? 0;
109
+ const denominator = frequency + BM25_K1 * (1 - BM25_B + BM25_B * (length / avgdl));
110
+ const contribution = ((idf * (frequency * (BM25_K1 + 1))) / denominator) * weight;
111
+ totals.set(docId, (totals.get(docId) ?? 0) + contribution);
112
+ const breakdown = breakdowns.get(docId) ?? {};
113
+ breakdown[field] = (breakdown[field] ?? 0) + contribution;
114
+ breakdowns.set(docId, breakdown);
115
+ }
116
+ }
117
+ }
118
+ const matches = [];
119
+ for (const [id, score] of totals) {
120
+ matches.push({ id, score, fieldScores: breakdowns.get(id) ?? {} });
121
+ }
122
+ matches.sort((left, right) => right.score - left.score || compareIds(left.id, right.id));
123
+ return matches.slice(0, topK);
124
+ }
125
+ }
126
+ /** Deterministic tiebreak so equal scores produce a stable order. */
127
+ const compareIds = (left, right) => {
128
+ if (left < right) {
129
+ return -1;
130
+ }
131
+ if (left > right) {
132
+ return 1;
133
+ }
134
+ return 0;
135
+ };
136
+ const addToIndex = (index, key, id) => {
137
+ const bucket = index.get(key);
138
+ if (bucket) {
139
+ bucket.add(id);
140
+ }
141
+ else {
142
+ index.set(key, new Set([id]));
143
+ }
144
+ };
145
+ /**
146
+ * Build the internal search document from a normalized entry. `exactKeys`
147
+ * carry whole-phrase identifiers (entry id and title); each
148
+ * text field is tokenized for the field-aware scorer.
149
+ */
150
+ export const buildDocument = (entry) => {
151
+ const exactKeys = normalizePhrases([entry.id, entry.title]);
152
+ return {
153
+ id: entry.id,
154
+ exactKeys,
155
+ fields: {
156
+ title: tokenize(entry.title),
157
+ aliases: entry.aliases.flatMap((alias) => tokenize(alias)),
158
+ keywords: entry.keywords.flatMap((keyword) => tokenize(keyword)),
159
+ summary: tokenize(entry.summary),
160
+ body: tokenize(entry.body),
161
+ },
162
+ };
163
+ };
164
+ /**
165
+ * Build the complete immutable snapshot: entry map, exact/alias/relation
166
+ * indexes, and the field-aware lexical index.
167
+ */
168
+ export const buildIndexSnapshot = (entries, weights) => {
169
+ const entriesById = new Map();
170
+ const exactIndex = new Map();
171
+ const aliasIndex = new Map();
172
+ const relationIndex = new Map();
173
+ const lexical = new KnowledgeLexicalIndex(weights);
174
+ for (const entry of entries) {
175
+ entriesById.set(entry.id, entry);
176
+ const document = buildDocument(entry);
177
+ for (const key of document.exactKeys) {
178
+ addToIndex(exactIndex, key, entry.id);
179
+ }
180
+ for (const alias of entry.aliases) {
181
+ const key = normalizeText(alias);
182
+ if (key) {
183
+ addToIndex(aliasIndex, key, entry.id);
184
+ }
185
+ }
186
+ const relations = [...entry.relatedEntryIds];
187
+ if (entry.parentEntryId) {
188
+ relations.push(entry.parentEntryId);
189
+ }
190
+ if (relations.length > 0) {
191
+ relationIndex.set(entry.id, relations);
192
+ }
193
+ lexical.add(document);
194
+ }
195
+ lexical.finalize();
196
+ return {
197
+ entriesById,
198
+ exactIndex,
199
+ aliasIndex,
200
+ relationIndex,
201
+ lexical,
202
+ entryCount: entries.length,
203
+ };
204
+ };