@danielblomma/cortex-mcp 2.2.5 → 2.4.1

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 (196) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +73 -3
  3. package/bin/cortex.mjs +339 -242
  4. package/bin/daemon-control.mjs +162 -0
  5. package/package.json +7 -3
  6. package/scaffold/mcp/dist/.cortex-build-hash +1 -0
  7. package/scaffold/mcp/dist/cli/enterprise-setup.js +134 -0
  8. package/scaffold/mcp/dist/cli/govern.js +937 -0
  9. package/scaffold/mcp/dist/cli/query.js +409 -0
  10. package/scaffold/mcp/dist/cli/run.js +295 -0
  11. package/scaffold/mcp/dist/cli/stage.js +308 -0
  12. package/scaffold/mcp/dist/cli/telemetry-test.js +141 -0
  13. package/scaffold/mcp/dist/cli/ungoverned-detector.js +133 -0
  14. package/scaffold/mcp/dist/contextEntities.js +282 -0
  15. package/scaffold/mcp/dist/core/audit/query.js +72 -0
  16. package/scaffold/mcp/dist/core/audit/writer.js +28 -0
  17. package/scaffold/mcp/dist/core/config.js +235 -0
  18. package/scaffold/mcp/dist/core/enterprise-host-identity.js +193 -0
  19. package/scaffold/mcp/dist/core/enterprise-identity.js +18 -0
  20. package/scaffold/mcp/dist/core/enterprise-rotation.js +158 -0
  21. package/scaffold/mcp/dist/core/govern-paths.js +21 -0
  22. package/scaffold/mcp/dist/core/index.js +14 -0
  23. package/scaffold/mcp/dist/core/license.js +278 -0
  24. package/scaffold/mcp/dist/core/policy/enforce.js +66 -0
  25. package/scaffold/mcp/dist/core/policy/injection.js +172 -0
  26. package/scaffold/mcp/dist/core/policy/store.js +179 -0
  27. package/scaffold/mcp/dist/core/rbac/check.js +30 -0
  28. package/scaffold/mcp/dist/core/secure-endpoint.js +25 -0
  29. package/scaffold/mcp/dist/core/telemetry/collector.js +285 -0
  30. package/scaffold/mcp/dist/core/telemetry/state-dir.js +31 -0
  31. package/scaffold/mcp/dist/core/validators/builtins.js +632 -0
  32. package/scaffold/mcp/dist/core/validators/config.js +44 -0
  33. package/scaffold/mcp/dist/core/validators/engine.js +120 -0
  34. package/scaffold/mcp/dist/core/validators/evaluators/code_comments.js +236 -0
  35. package/scaffold/mcp/dist/core/validators/evaluators/regex.js +116 -0
  36. package/scaffold/mcp/dist/core/workflow/artifact-io.js +103 -0
  37. package/scaffold/mcp/dist/core/workflow/capabilities.js +88 -0
  38. package/scaffold/mcp/dist/core/workflow/default-workflows.js +102 -0
  39. package/scaffold/mcp/dist/core/workflow/enforcement.js +164 -0
  40. package/scaffold/mcp/dist/core/workflow/envelope.js +132 -0
  41. package/scaffold/mcp/dist/core/workflow/index.js +11 -0
  42. package/scaffold/mcp/dist/core/workflow/mcp-tools.js +175 -0
  43. package/scaffold/mcp/dist/core/workflow/resolution.js +69 -0
  44. package/scaffold/mcp/dist/core/workflow/run-lifecycle.js +123 -0
  45. package/scaffold/mcp/dist/core/workflow/schemas.js +141 -0
  46. package/scaffold/mcp/dist/core/workflow/synced-capability-registry.js +60 -0
  47. package/scaffold/mcp/dist/core/workflow/synced-registry.js +60 -0
  48. package/scaffold/mcp/dist/daemon/capability-sync-checker.js +242 -0
  49. package/scaffold/mcp/dist/daemon/client.js +130 -0
  50. package/scaffold/mcp/dist/daemon/egress-proxy.js +288 -0
  51. package/scaffold/mcp/dist/daemon/global-host-events.js +222 -0
  52. package/scaffold/mcp/dist/daemon/heartbeat-pusher.js +116 -0
  53. package/scaffold/mcp/dist/daemon/heartbeat-tracker.js +165 -0
  54. package/scaffold/mcp/dist/daemon/host-events-pusher.js +249 -0
  55. package/scaffold/mcp/dist/daemon/main.js +449 -0
  56. package/scaffold/mcp/dist/daemon/paths.js +36 -0
  57. package/scaffold/mcp/dist/daemon/project-service-registry.js +78 -0
  58. package/scaffold/mcp/dist/daemon/protocol.js +8 -0
  59. package/scaffold/mcp/dist/daemon/server.js +180 -0
  60. package/scaffold/mcp/dist/daemon/skill-sync-checker.js +557 -0
  61. package/scaffold/mcp/dist/daemon/sync-checker.js +165 -0
  62. package/scaffold/mcp/dist/daemon/ungoverned-scanner.js +136 -0
  63. package/scaffold/mcp/dist/daemon/workflow-sync-checker.js +249 -0
  64. package/scaffold/mcp/dist/defaults.js +6 -0
  65. package/scaffold/mcp/dist/embed.js +627 -0
  66. package/scaffold/mcp/dist/embedScheduler.js +479 -0
  67. package/scaffold/mcp/dist/embeddings.js +167 -0
  68. package/scaffold/mcp/dist/enterprise/audit/push.js +66 -0
  69. package/scaffold/mcp/dist/enterprise/index.js +327 -0
  70. package/scaffold/mcp/dist/enterprise/model/deploy.js +27 -0
  71. package/scaffold/mcp/dist/enterprise/policy/sync.js +129 -0
  72. package/scaffold/mcp/dist/enterprise/privacy/boundary.js +184 -0
  73. package/scaffold/mcp/dist/enterprise/reviews/changed-files.js +33 -0
  74. package/scaffold/mcp/dist/enterprise/reviews/pattern-context.js +202 -0
  75. package/scaffold/mcp/dist/enterprise/reviews/policy-selection.js +46 -0
  76. package/scaffold/mcp/dist/enterprise/reviews/push.js +102 -0
  77. package/scaffold/mcp/dist/enterprise/reviews/trust-state.js +186 -0
  78. package/scaffold/mcp/dist/enterprise/telemetry/sync.js +57 -0
  79. package/scaffold/mcp/dist/enterprise/tools/enterprise.js +826 -0
  80. package/scaffold/mcp/dist/enterprise/tools/harness.js +40 -0
  81. package/scaffold/mcp/dist/enterprise/tools/walk.js +73 -0
  82. package/scaffold/mcp/dist/enterprise/violations/push.js +77 -0
  83. package/scaffold/mcp/dist/enterprise/workflow/push.js +44 -0
  84. package/scaffold/mcp/dist/enterprise/workflow/state.js +329 -0
  85. package/scaffold/mcp/dist/frontmatter.js +33 -0
  86. package/scaffold/mcp/dist/graph.js +769 -0
  87. package/scaffold/mcp/dist/graphCsv.js +55 -0
  88. package/scaffold/mcp/dist/graphMetrics.js +8 -0
  89. package/scaffold/mcp/dist/hooks/permission-request.js +89 -0
  90. package/scaffold/mcp/dist/hooks/post-tool-use.js +105 -0
  91. package/scaffold/mcp/dist/hooks/pre-compact.js +29 -0
  92. package/scaffold/mcp/dist/hooks/pre-tool-use.js +78 -0
  93. package/scaffold/mcp/dist/hooks/session-end.js +43 -0
  94. package/scaffold/mcp/dist/hooks/session-start.js +41 -0
  95. package/scaffold/mcp/dist/hooks/shared.js +194 -0
  96. package/scaffold/mcp/dist/hooks/stop.js +28 -0
  97. package/scaffold/mcp/dist/hooks/user-prompt-submit.js +33 -0
  98. package/scaffold/mcp/dist/impactPresentation.js +137 -0
  99. package/scaffold/mcp/dist/impactRanking.js +191 -0
  100. package/scaffold/mcp/dist/impactResponse.js +30 -0
  101. package/scaffold/mcp/dist/impactResults.js +105 -0
  102. package/scaffold/mcp/dist/impactSeed.js +20 -0
  103. package/scaffold/mcp/dist/impactTraversal.js +64 -0
  104. package/scaffold/mcp/dist/jsonl.js +77 -0
  105. package/scaffold/mcp/dist/loadGraph.js +759 -0
  106. package/scaffold/mcp/dist/lruCache.js +38 -0
  107. package/scaffold/mcp/dist/paths.js +97 -0
  108. package/scaffold/mcp/dist/patternEvidence.js +272 -0
  109. package/scaffold/mcp/dist/plugin.js +81 -0
  110. package/scaffold/mcp/dist/presets.js +78 -0
  111. package/scaffold/mcp/dist/relatedResponse.js +18 -0
  112. package/scaffold/mcp/dist/relatedTraversal.js +78 -0
  113. package/scaffold/mcp/dist/rules.js +23 -0
  114. package/scaffold/mcp/dist/search.js +212 -0
  115. package/scaffold/mcp/dist/searchCore.js +457 -0
  116. package/scaffold/mcp/dist/searchResults.js +230 -0
  117. package/scaffold/mcp/dist/server.js +317 -0
  118. package/scaffold/mcp/dist/types.js +1 -0
  119. package/scaffold/mcp/package-lock.json +336 -212
  120. package/scaffold/mcp/package.json +15 -6
  121. package/scaffold/mcp/src/cli/enterprise-setup.ts +82 -8
  122. package/scaffold/mcp/src/cli/govern.ts +137 -52
  123. package/scaffold/mcp/src/cli/query.ts +36 -2
  124. package/scaffold/mcp/src/cli/run.ts +53 -19
  125. package/scaffold/mcp/src/cli/stage.ts +8 -3
  126. package/scaffold/mcp/src/core/config.ts +9 -2
  127. package/scaffold/mcp/src/core/enterprise-host-identity.ts +259 -0
  128. package/scaffold/mcp/src/core/enterprise-identity.ts +20 -0
  129. package/scaffold/mcp/src/core/enterprise-rotation.ts +185 -0
  130. package/scaffold/mcp/src/core/govern-paths.ts +30 -0
  131. package/scaffold/mcp/src/core/license.ts +186 -17
  132. package/scaffold/mcp/src/core/secure-endpoint.ts +23 -0
  133. package/scaffold/mcp/src/core/workflow/enforcement.ts +8 -1
  134. package/scaffold/mcp/src/core/workflow/mcp-tools.ts +3 -0
  135. package/scaffold/mcp/src/core/workflow/resolution.ts +9 -1
  136. package/scaffold/mcp/src/core/workflow/synced-capability-registry.ts +15 -0
  137. package/scaffold/mcp/src/core/workflow/synced-registry.ts +15 -0
  138. package/scaffold/mcp/src/daemon/capability-sync-checker.ts +38 -2
  139. package/scaffold/mcp/src/daemon/egress-proxy.ts +14 -8
  140. package/scaffold/mcp/src/daemon/global-host-events.ts +288 -0
  141. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +4 -0
  142. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +2 -0
  143. package/scaffold/mcp/src/daemon/host-events-pusher.ts +5 -0
  144. package/scaffold/mcp/src/daemon/main.ts +99 -25
  145. package/scaffold/mcp/src/daemon/project-service-registry.ts +107 -0
  146. package/scaffold/mcp/src/daemon/skill-sync-checker.ts +368 -31
  147. package/scaffold/mcp/src/daemon/sync-checker.ts +4 -0
  148. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +57 -25
  149. package/scaffold/mcp/src/daemon/workflow-sync-checker.ts +41 -2
  150. package/scaffold/mcp/src/embed.ts +211 -10
  151. package/scaffold/mcp/src/enterprise/audit/push.ts +8 -0
  152. package/scaffold/mcp/src/enterprise/index.ts +1 -1
  153. package/scaffold/mcp/src/enterprise/policy/sync.ts +12 -0
  154. package/scaffold/mcp/src/enterprise/reviews/changed-files.ts +34 -0
  155. package/scaffold/mcp/src/enterprise/reviews/pattern-context.ts +231 -0
  156. package/scaffold/mcp/src/enterprise/reviews/push.ts +9 -0
  157. package/scaffold/mcp/src/enterprise/reviews/trust-state.ts +3 -1
  158. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +9 -0
  159. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +49 -36
  160. package/scaffold/mcp/src/enterprise/violations/push.ts +9 -0
  161. package/scaffold/mcp/src/enterprise/workflow/push.ts +7 -0
  162. package/scaffold/mcp/src/paths.ts +3 -5
  163. package/scaffold/mcp/src/patternEvidence.ts +347 -0
  164. package/scaffold/mcp/src/plugin.ts +20 -0
  165. package/scaffold/mcp/src/search.ts +35 -9
  166. package/scaffold/mcp/src/searchCore.ts +254 -12
  167. package/scaffold/mcp/src/searchResults.ts +96 -9
  168. package/scaffold/mcp/src/types.ts +7 -0
  169. package/scaffold/mcp/tests/changed-files.test.mjs +41 -0
  170. package/scaffold/mcp/tests/copilot-shim.test.mjs +19 -1
  171. package/scaffold/mcp/tests/egress-proxy.test.mjs +37 -0
  172. package/scaffold/mcp/tests/embed-entities.test.mjs +109 -1
  173. package/scaffold/mcp/tests/enterprise-identity-sync.test.mjs +401 -0
  174. package/scaffold/mcp/tests/enterprise-pattern-context.test.mjs +322 -0
  175. package/scaffold/mcp/tests/enterprise-setup.test.mjs +189 -0
  176. package/scaffold/mcp/tests/global-host-events.test.mjs +81 -0
  177. package/scaffold/mcp/tests/govern-install.test.mjs +95 -2
  178. package/scaffold/mcp/tests/govern-repair.test.mjs +20 -3
  179. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +26 -0
  180. package/scaffold/mcp/tests/license.test.mjs +367 -0
  181. package/scaffold/mcp/tests/paths.test.mjs +11 -3
  182. package/scaffold/mcp/tests/pattern-evidence.test.mjs +321 -0
  183. package/scaffold/mcp/tests/project-service-registry.test.mjs +172 -0
  184. package/scaffold/mcp/tests/query-cli.test.mjs +73 -1
  185. package/scaffold/mcp/tests/review-trust-contract.test.mjs +18 -0
  186. package/scaffold/mcp/tests/search-graph-score.test.mjs +167 -0
  187. package/scaffold/mcp/tests/secure-enterprise-endpoint.test.mjs +46 -0
  188. package/scaffold/mcp/tests/skill-sync-checker.test.mjs +446 -2
  189. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +104 -22
  190. package/scaffold/mcp/tests/workflow-cli.test.mjs +14 -1
  191. package/scaffold/mcp/tests/workflow-synced-capabilities.test.mjs +37 -0
  192. package/scaffold/mcp/tests/workflow-synced-registry.test.mjs +40 -1
  193. package/scaffold/scripts/dashboard.mjs +29 -18
  194. package/scaffold/scripts/doctor.sh +26 -8
  195. package/scaffold/scripts/ingest.mjs +37 -4
  196. package/scaffold/scripts/status.sh +13 -6
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Embedding scheduler: maximizes embedding throughput with zero change to the
3
+ * produced vectors (same model, same per-text inference semantics).
4
+ *
5
+ * Measured motivation (onnxruntime-node CPU):
6
+ * - One session cannot saturate many cores: 3 sessions x 4 threads beat
7
+ * 1 session x 12 threads by ~2x for BOTH short and long texts.
8
+ * - Batching pays only for short, similar-length texts; mixed-length batches
9
+ * lose badly to padding (attention cost is quadratic in padded length).
10
+ *
11
+ * Strategy: deduplicate identical texts, measure token lengths, micro-batch
12
+ * only short texts under a strict pad-waste bound, run everything through a
13
+ * pool of small-thread sessions, longest work first.
14
+ */
15
+ export const DEFAULT_SCHEDULER_OPTIONS = {
16
+ shortMaxTokens: 128,
17
+ batchTokenBudget: 2048,
18
+ // Micro-batching is OFF by default: batched GEMM uses different tiling than
19
+ // batch-of-1, which flips ~1e-6 rounding boundaries in the stored vectors
20
+ // (measured: 76/120 rounded vectors differ; cosine impact ~1e-9). The pool
21
+ // and dedup deliver the throughput win with byte-identical output; set
22
+ // CORTEX_EMBED_BATCH_SIZE>1 to opt in where byte identity does not matter.
23
+ batchMaxItems: 1,
24
+ maxPadWaste: 0.1
25
+ };
26
+ /** Groups identical texts so each unique string is embedded exactly once. */
27
+ export function groupDuplicates(pending) {
28
+ const byText = new Map();
29
+ for (const item of pending) {
30
+ const indexes = byText.get(item.text);
31
+ if (indexes) {
32
+ indexes.push(item.index);
33
+ }
34
+ else {
35
+ byText.set(item.text, [item.index]);
36
+ }
37
+ }
38
+ return [...byText.entries()].map(([text, indexes]) => ({ text, indexes }));
39
+ }
40
+ function singleUnit(item) {
41
+ return {
42
+ kind: "single",
43
+ texts: [item.text],
44
+ members: [item.indexes],
45
+ maxTokens: item.tokens,
46
+ cost: item.tokens * item.tokens
47
+ };
48
+ }
49
+ function batchUnit(items) {
50
+ const maxTokens = items[items.length - 1].tokens; // items arrive sorted asc
51
+ return {
52
+ kind: items.length === 1 ? "single" : "batch",
53
+ texts: items.map((item) => item.text),
54
+ members: items.map((item) => item.indexes),
55
+ maxTokens,
56
+ cost: items.length * maxTokens * maxTokens
57
+ };
58
+ }
59
+ /**
60
+ * Packs measured texts into work units. Long texts run alone (padding-free);
61
+ * short texts are greedily packed, ascending by length, into batches bounded
62
+ * by token budget, item count, and a pad-waste ceiling. Units are returned
63
+ * longest-cost-first so the pool does not end on a single long straggler.
64
+ */
65
+ export function packWorkUnits(unique, options) {
66
+ if (options.batchMaxItems < 1) {
67
+ throw new Error(`Invalid batchMaxItems: ${options.batchMaxItems}`);
68
+ }
69
+ const units = [];
70
+ const shorts = [];
71
+ for (const item of unique) {
72
+ if (options.batchMaxItems > 1 && item.tokens <= options.shortMaxTokens) {
73
+ shorts.push(item);
74
+ }
75
+ else {
76
+ units.push(singleUnit(item));
77
+ }
78
+ }
79
+ shorts.sort((a, b) => a.tokens - b.tokens);
80
+ let current = [];
81
+ let currentSum = 0;
82
+ const flush = () => {
83
+ if (current.length > 0) {
84
+ units.push(batchUnit(current));
85
+ current = [];
86
+ currentSum = 0;
87
+ }
88
+ };
89
+ for (const item of shorts) {
90
+ if (current.length > 0) {
91
+ const nextCount = current.length + 1;
92
+ const nextMax = item.tokens; // ascending order: the newcomer is the max
93
+ const paddedCompute = nextCount * nextMax;
94
+ const padWaste = (paddedCompute - (currentSum + item.tokens)) / paddedCompute;
95
+ if (nextCount > options.batchMaxItems ||
96
+ paddedCompute > options.batchTokenBudget ||
97
+ padWaste > options.maxPadWaste) {
98
+ flush();
99
+ }
100
+ }
101
+ current.push(item);
102
+ currentSum += item.tokens;
103
+ }
104
+ flush();
105
+ return units.sort((a, b) => b.cost - a.cost);
106
+ }
107
+ /**
108
+ * Usable memory for embedding work, from raw OS/process signals:
109
+ * - container limits (cgroups) cap everything: os.totalmem()/freemem() see
110
+ * the HOST, which would size pools/gates dangerously large inside a
111
+ * limited container;
112
+ * - "free" memory understates reality on platforms that count reclaimable
113
+ * cache as used (macOS reports ~0.5GB free on an idle 24GB laptop), so it
114
+ * is floored at a fraction of the effective total;
115
+ * - never plan beyond half of the effective total.
116
+ */
117
+ export function resolveMemoryHeadroom({ freeMemory, totalMemory, constrainedMemory, availableMemory }) {
118
+ const total = Number.isFinite(totalMemory) && totalMemory > 0 ? totalMemory : 8e9;
119
+ const limit = Number.isFinite(constrainedMemory ?? NaN) && constrainedMemory > 0
120
+ ? constrainedMemory
121
+ : Infinity;
122
+ const effectiveTotal = Math.min(total, limit);
123
+ const free = Number.isFinite(freeMemory) && freeMemory >= 0 ? freeMemory : 0;
124
+ const available = Number.isFinite(availableMemory ?? NaN) && availableMemory > 0
125
+ ? availableMemory
126
+ : free;
127
+ return Math.min(effectiveTotal * 0.5, Math.max(available, effectiveTotal * 0.375));
128
+ }
129
+ /** Sanitizes a tokenizer-reported maximum sequence length. Sentinel and
130
+ * absurd values (some configs report 1e30) fall back to 8192; anything
131
+ * beyond 128k tokens is treated as absurd. */
132
+ export function resolveModelMaxTokens(raw, overrideRaw) {
133
+ const value = Number(raw);
134
+ const modelMax = Number.isFinite(value) && value >= 1 && value <= 131072 ? Math.floor(value) : 8192;
135
+ const override = Number(overrideRaw);
136
+ if (Number.isFinite(override) && override >= 16 && override <= 131072) {
137
+ return Math.min(modelMax, Math.floor(override));
138
+ }
139
+ return modelMax;
140
+ }
141
+ /**
142
+ * Chooses the requested embedding token budget before model load, so cache
143
+ * signatures can include the same cap that inference will later enforce.
144
+ * Auto mode is quality-preserving: it uses the model's own maximum by default
145
+ * and only truncates when the user explicitly requests a numeric cap.
146
+ */
147
+ export function resolveTokenBudgetChoice(overrideRaw, uniqueCount) {
148
+ const raw = String(overrideRaw ?? "").trim().toLowerCase();
149
+ const override = Number(raw);
150
+ if (Number.isFinite(override) && override >= 16 && override <= 131072) {
151
+ const cap = Math.floor(override);
152
+ return { cap, mode: "explicit", reason: "env_override" };
153
+ }
154
+ if (raw === "model" || raw === "none" || raw === "off" || raw === "full") {
155
+ return { cap: null, mode: "model", reason: "env_full_model" };
156
+ }
157
+ const count = Number.isFinite(uniqueCount) && uniqueCount > 0 ? Math.floor(uniqueCount) : 0;
158
+ return {
159
+ cap: null,
160
+ mode: "auto",
161
+ reason: `quality_preserving_model_max:unique=${count}`
162
+ };
163
+ }
164
+ const AUTO_TOKEN_BUDGET_CANDIDATES = [8192, 4096, 2048];
165
+ const AUTO_TOKEN_BUDGET_MIN = 2048;
166
+ const AUTO_TOKEN_BUDGET_SESSION_BYTES = 2.4e9;
167
+ const AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 = 3e9;
168
+ const AUTO_TOKEN_BUDGET_MEMORY_MARGIN = 0.95;
169
+ function estimateTokenBudgetMemoryBytes(maxTokens, sessions) {
170
+ const sessionCount = Number.isFinite(sessions) && sessions >= 1 ? Math.floor(sessions) : 1;
171
+ const tokenCount = Number.isFinite(maxTokens) && maxTokens >= 1 ? Math.floor(maxTokens) : 8192;
172
+ const activationBytes = AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 * Math.pow(tokenCount / 4096, 2);
173
+ return sessionCount * AUTO_TOKEN_BUDGET_SESSION_BYTES + activationBytes;
174
+ }
175
+ /**
176
+ * Keeps auto quality-first but avoids choosing a token budget that is likely
177
+ * to OOM this process. Explicit numeric caps and explicit full-model modes are
178
+ * operator choices and are never degraded here.
179
+ */
180
+ export function resolveEffectiveTokenBudget({ choice, modelMaxTokens, memoryBytes, sessions }) {
181
+ if (choice.mode !== "auto" || choice.cap !== null) {
182
+ return choice;
183
+ }
184
+ const modelMax = Number.isFinite(modelMaxTokens) && modelMaxTokens >= 1 ? Math.floor(modelMaxTokens) : 8192;
185
+ if (modelMax <= AUTO_TOKEN_BUDGET_MIN) {
186
+ return choice;
187
+ }
188
+ if (!Number.isFinite(memoryBytes) || memoryBytes <= 0) {
189
+ return choice;
190
+ }
191
+ const available = memoryBytes * AUTO_TOKEN_BUDGET_MEMORY_MARGIN;
192
+ const candidates = AUTO_TOKEN_BUDGET_CANDIDATES
193
+ .filter((candidate) => candidate <= modelMax)
194
+ .sort((a, b) => b - a);
195
+ if (!candidates.includes(AUTO_TOKEN_BUDGET_MIN)) {
196
+ candidates.push(AUTO_TOKEN_BUDGET_MIN);
197
+ }
198
+ const selected = candidates.find((candidate) => estimateTokenBudgetMemoryBytes(candidate, sessions ?? 1) <= available) ??
199
+ AUTO_TOKEN_BUDGET_MIN;
200
+ if (selected >= modelMax) {
201
+ return choice;
202
+ }
203
+ return {
204
+ cap: selected,
205
+ mode: "auto_degraded",
206
+ reason: `memory_headroom cap=${selected} model_max=${modelMax} sessions=${Math.max(1, Math.floor(sessions ?? 1))} headroom_mb=${Math.round(memoryBytes / 1024 / 1024)}`
207
+ };
208
+ }
209
+ /**
210
+ * Builds a token counter from a tokenizer callable. Falls back to a
211
+ * chars/4 estimate when the tokenizer is unavailable, misbehaves, or
212
+ * returns an unexpected shape; always clamps at the model maximum, since
213
+ * inference truncates there anyway.
214
+ */
215
+ export function createTokenCounter(tokenizer, modelMaxTokens) {
216
+ return (text) => {
217
+ let tokens = Math.max(1, Math.ceil(text.length / 4));
218
+ try {
219
+ if (typeof tokenizer === "function") {
220
+ const encoded = tokenizer(text);
221
+ const dims = encoded?.input_ids?.dims;
222
+ const last = Array.isArray(dims) ? Number(dims[dims.length - 1]) : NaN;
223
+ if (Number.isFinite(last) && last > 0) {
224
+ tokens = last;
225
+ }
226
+ }
227
+ }
228
+ catch {
229
+ // keep the character estimate
230
+ }
231
+ return Math.min(tokens, modelMaxTokens);
232
+ };
233
+ }
234
+ export function truncateTextToTokenBudget(text, countTokens, maxTokens) {
235
+ if (!Number.isFinite(maxTokens) || maxTokens < 1 || countTokens(text) <= maxTokens) {
236
+ return text;
237
+ }
238
+ let low = 0;
239
+ let high = text.length;
240
+ while (low < high) {
241
+ const mid = Math.ceil((low + high) / 2);
242
+ if (countTokens(text.slice(0, mid)) <= maxTokens) {
243
+ low = mid;
244
+ }
245
+ else {
246
+ high = mid - 1;
247
+ }
248
+ }
249
+ return text.slice(0, low).trimEnd();
250
+ }
251
+ /** Estimated working memory one full pool session needs (model copy plus
252
+ * inference activations for a base-size model). Used only as a derating
253
+ * heuristic; deliberately conservative. */
254
+ const BYTES_PER_SESSION_ESTIMATE = 3e9;
255
+ /**
256
+ * Chooses the session-pool shape from a total thread budget and available
257
+ * memory. Four threads per session is the measured sweet spot; the session
258
+ * count derates on low-memory machines (each session holds its own model
259
+ * copy); tiny workloads skip the pool to avoid paying several model loads
260
+ * for seconds of inference. Everything is derived from the machine — no
261
+ * user configuration expected.
262
+ */
263
+ export function resolvePoolConfig({ threadBudget, poolOverride, uniqueCount, memoryBytes }) {
264
+ const budget = Number.isFinite(threadBudget) && threadBudget >= 1 ? Math.floor(threadBudget) : 1;
265
+ const memorySessionCap = Number.isFinite(memoryBytes) && memoryBytes > 0
266
+ ? Math.max(1, Math.floor(memoryBytes / BYTES_PER_SESSION_ESTIMATE))
267
+ : 4;
268
+ if (poolOverride && Number.isFinite(poolOverride) && poolOverride >= 1) {
269
+ // The override never exceeds the thread budget: CORTEX_EMBED_THREADS is a
270
+ // contract for co-located instances. Hard upper clamp keeps model-copy
271
+ // memory bounded even on huge machines. Deliberately NOT memory-derated:
272
+ // the explicit override is the operator's escape hatch when the
273
+ // heuristics misjudge a machine.
274
+ const sessions = Math.max(1, Math.min(Math.floor(poolOverride), budget, 8));
275
+ return { sessions, threadsPerSession: Math.max(1, Math.floor(budget / sessions)) };
276
+ }
277
+ if (uniqueCount < 64) {
278
+ return { sessions: 1, threadsPerSession: budget };
279
+ }
280
+ const threadsPerSession = Math.min(4, budget);
281
+ const sessions = Math.min(4, memorySessionCap, Math.max(1, Math.floor(budget / threadsPerSession)));
282
+ return { sessions, threadsPerSession };
283
+ }
284
+ /** Estimated activation memory per padded token for a base-size model at
285
+ * full context (linearized from a measured ~3GB per 8k-token inference;
286
+ * overcharges short texts, which is the safe direction). */
287
+ const BYTES_PER_IN_FLIGHT_TOKEN = 366_000;
288
+ /**
289
+ * Adapts the concurrent-token gate to the machine: enough budget to overlap
290
+ * several long texts when memory allows, never less than one model-max unit
291
+ * (so the largest possible text can always run), never more than eight.
292
+ */
293
+ export function resolveInFlightTokens({ memoryBytes, modelMaxTokens }) {
294
+ const maxUnit = Number.isFinite(modelMaxTokens) && modelMaxTokens >= 1 ? Math.floor(modelMaxTokens) : 8192;
295
+ if (!Number.isFinite(memoryBytes) || memoryBytes <= 0) {
296
+ return Math.max(DEFAULT_MAX_IN_FLIGHT_TOKENS, maxUnit);
297
+ }
298
+ const affordable = Math.floor(memoryBytes / BYTES_PER_IN_FLIGHT_TOKEN);
299
+ return Math.min(Math.max(affordable, maxUnit), 8 * maxUnit);
300
+ }
301
+ export function toEmbeddingVector(output) {
302
+ if (!output || typeof output !== "object") {
303
+ throw new Error("Invalid embedding output type");
304
+ }
305
+ const data = output.data;
306
+ if (!data || typeof data.length !== "number") {
307
+ throw new Error("Missing embedding data");
308
+ }
309
+ return Array.from(data).map((value) => Number(value));
310
+ }
311
+ /** Splits a batched [batch, dim] feature-extraction output into row vectors. */
312
+ export function sliceBatchVectors(output, batchLength) {
313
+ const flat = toEmbeddingVector(output);
314
+ if (batchLength < 1) {
315
+ throw new Error(`Invalid batch length: ${batchLength}`);
316
+ }
317
+ const dims = output.dims;
318
+ const lastDim = Array.isArray(dims) && dims.length > 0 && typeof dims[dims.length - 1] === "number"
319
+ ? Number(dims[dims.length - 1])
320
+ : null;
321
+ const dimensions = lastDim && lastDim > 0 ? lastDim : flat.length / batchLength;
322
+ if (!Number.isInteger(dimensions) || dimensions < 1 || flat.length !== dimensions * batchLength) {
323
+ throw new Error(`Batched embedding output shape mismatch: ${flat.length} values for ${batchLength} inputs`);
324
+ }
325
+ const vectors = [];
326
+ for (let row = 0; row < batchLength; row += 1) {
327
+ vectors.push(flat.slice(row * dimensions, (row + 1) * dimensions));
328
+ }
329
+ return vectors;
330
+ }
331
+ export const DEFAULT_MAX_IN_FLIGHT_TOKENS = 12288;
332
+ /**
333
+ * Executes work units across a pool of extractors via promise lanes (ORT
334
+ * inference releases the JS thread, so sessions genuinely run concurrently).
335
+ * A failed batch is retried per item; a failed text records a failure for
336
+ * every slot index it owns. Vectors for deduplicated texts are shared
337
+ * (callers must not mutate them in place).
338
+ */
339
+ export async function runWorkUnits(units, extractors, options = {}) {
340
+ if (extractors.length === 0) {
341
+ throw new Error("runWorkUnits requires at least one extractor");
342
+ }
343
+ for (const unit of units) {
344
+ if (!Number.isFinite(unit.maxTokens) || unit.maxTokens < 1) {
345
+ throw new Error(`Work unit has invalid maxTokens: ${unit.maxTokens}`);
346
+ }
347
+ }
348
+ const maxInFlight = options.maxInFlightTokens ?? DEFAULT_MAX_IN_FLIGHT_TOKENS;
349
+ const vectors = new Map();
350
+ const failures = [];
351
+ const assign = (members, vector) => {
352
+ for (const index of members) {
353
+ if (options.onVector) {
354
+ options.onVector(index, vector);
355
+ }
356
+ else {
357
+ vectors.set(index, vector);
358
+ }
359
+ }
360
+ };
361
+ const recordFailure = (members, error) => {
362
+ const message = error instanceof Error ? error.message : "embedding generation failed";
363
+ for (const index of members) {
364
+ failures.push({ index, message });
365
+ }
366
+ };
367
+ const embedSingle = async (extractor, text, members) => {
368
+ try {
369
+ const output = await extractor(text, { pooling: "mean", normalize: true });
370
+ const vector = toEmbeddingVector(output);
371
+ if (vector.length === 0) {
372
+ throw new Error("Empty embedding vector");
373
+ }
374
+ assign(members, vector);
375
+ }
376
+ catch (error) {
377
+ recordFailure(members, error);
378
+ }
379
+ };
380
+ const runUnit = async (extractor, unit) => {
381
+ if (unit.texts.length === 1) {
382
+ await embedSingle(extractor, unit.texts[0], unit.members[0]);
383
+ return;
384
+ }
385
+ try {
386
+ const output = await extractor(unit.texts, { pooling: "mean", normalize: true });
387
+ const rows = sliceBatchVectors(output, unit.texts.length);
388
+ unit.texts.forEach((_, row) => {
389
+ const vector = rows[row];
390
+ if (!vector || vector.length === 0) {
391
+ recordFailure(unit.members[row], new Error("Empty embedding vector"));
392
+ return;
393
+ }
394
+ assign(unit.members[row], vector);
395
+ });
396
+ }
397
+ catch {
398
+ // Batch-level failure: isolate the poison input by retrying singly.
399
+ for (let row = 0; row < unit.texts.length; row += 1) {
400
+ await embedSingle(extractor, unit.texts[row], unit.members[row]);
401
+ }
402
+ }
403
+ };
404
+ // Work distribution with a memory gate: a lane takes the next unit only
405
+ // when its padded token mass fits the in-flight budget; otherwise it scans
406
+ // for the first smaller unit that does (longest-first order means smaller
407
+ // work is always further down the list). Single-threaded JS makes the
408
+ // take-then-increment bookkeeping race-free.
409
+ const taken = new Array(units.length).fill(false);
410
+ let untakenCount = units.length;
411
+ let scanHead = 0; // first possibly-untaken index; avoids O(U^2) rescans
412
+ let inFlight = 0;
413
+ let waiters = [];
414
+ const unitTokens = (unit) => unit.texts.length * unit.maxTokens;
415
+ const takeNext = () => {
416
+ while (scanHead < units.length && taken[scanHead]) {
417
+ scanHead += 1;
418
+ }
419
+ for (let i = scanHead; i < units.length; i += 1) {
420
+ if (taken[i]) {
421
+ continue;
422
+ }
423
+ // Normal admission: the unit fits the remaining budget. Oversized
424
+ // units (larger than the whole budget) run exclusively: they may only
425
+ // start when nothing is in flight, and their token mass then blocks
426
+ // every other unit until they release. This keeps one giant from
427
+ // silently disabling the gate for the rest of the run.
428
+ const tokens = unitTokens(units[i]);
429
+ if (inFlight + tokens <= maxInFlight || inFlight === 0) {
430
+ taken[i] = true;
431
+ untakenCount -= 1;
432
+ inFlight += tokens;
433
+ return i;
434
+ }
435
+ }
436
+ return null;
437
+ };
438
+ const remaining = () => untakenCount > 0;
439
+ const release = (unit) => {
440
+ inFlight -= unitTokens(unit);
441
+ const wake = waiters;
442
+ waiters = [];
443
+ for (const resolve of wake) {
444
+ resolve();
445
+ }
446
+ };
447
+ const lanes = extractors.map(async (extractor) => {
448
+ while (remaining()) {
449
+ const index = takeNext();
450
+ if (index === null) {
451
+ // Budget exhausted by other lanes' in-flight work: wait for a release.
452
+ if (inFlight === 0) {
453
+ return; // nothing in flight and nothing fits: defensive exit
454
+ }
455
+ await new Promise((resolve) => waiters.push(resolve));
456
+ continue;
457
+ }
458
+ const unit = units[index];
459
+ try {
460
+ await runUnit(extractor, unit);
461
+ }
462
+ finally {
463
+ release(unit);
464
+ }
465
+ }
466
+ });
467
+ await Promise.all(lanes);
468
+ // Defensive: a unit that no lane ever took (cannot happen with the
469
+ // exclusive-admission rule, but silent loss would be worse than noise).
470
+ units.forEach((unit, index) => {
471
+ if (!taken[index]) {
472
+ for (const members of unit.members) {
473
+ recordFailure(members, new Error("work unit was never scheduled"));
474
+ }
475
+ }
476
+ });
477
+ failures.sort((a, b) => a.index - b.index);
478
+ return { vectors, failures };
479
+ }
@@ -0,0 +1,167 @@
1
+ import fs from "node:fs";
2
+ import { env, pipeline } from "@huggingface/transformers";
3
+ import { readJsonlRecords } from "./jsonl.js";
4
+ import { LruCache } from "./lruCache.js";
5
+ import { PATHS } from "./paths.js";
6
+ const EMBEDDING_INIT_RETRY_INTERVAL_MS = 5000;
7
+ let embeddingsCacheKey = "";
8
+ let embeddingsCache = { model: null, vectors: new Map() };
9
+ let embeddingExtractorModel = null;
10
+ let embeddingExtractorPromise = null;
11
+ let embeddingLastInitAttemptAt = 0;
12
+ let embeddingRuntimeWarning = null;
13
+ function asString(value, fallback = "") {
14
+ return typeof value === "string" ? value : fallback;
15
+ }
16
+ function toVector(output) {
17
+ if (!output || typeof output !== "object") {
18
+ return null;
19
+ }
20
+ const data = output.data;
21
+ if (!data || typeof data.length !== "number") {
22
+ return null;
23
+ }
24
+ const finite = Array.from(data)
25
+ .map((value) => Number(value))
26
+ .filter((value) => Number.isFinite(value));
27
+ return Float32Array.from(finite);
28
+ }
29
+ function readFileVersion(filePath) {
30
+ if (!fs.existsSync(filePath)) {
31
+ return "none";
32
+ }
33
+ try {
34
+ const stats = fs.statSync(filePath);
35
+ return `${Math.round(stats.mtimeMs)}:${stats.size}`;
36
+ }
37
+ catch {
38
+ return "none";
39
+ }
40
+ }
41
+ function parseEmbeddingIndex(raw) {
42
+ const vectors = new Map();
43
+ let model = null;
44
+ for (const item of raw) {
45
+ const id = asString(item.id);
46
+ if (!id)
47
+ continue;
48
+ const vectorRaw = item.vector;
49
+ if (!Array.isArray(vectorRaw))
50
+ continue;
51
+ const vector = [];
52
+ for (const value of vectorRaw) {
53
+ if (typeof value === "number" && Number.isFinite(value)) {
54
+ vector.push(value);
55
+ }
56
+ }
57
+ if (vector.length === 0)
58
+ continue;
59
+ // The boxed number[] is transient — only the Float32Array is retained,
60
+ // so peak memory is one line's vector, not the whole index in float64.
61
+ vectors.set(id, Float32Array.from(vector));
62
+ const nextModel = asString(item.model);
63
+ if (nextModel && !model) {
64
+ model = nextModel;
65
+ }
66
+ }
67
+ return { model, vectors };
68
+ }
69
+ export function loadEmbeddingIndex() {
70
+ const key = `${readFileVersion(PATHS.embeddingsManifest)}|${readFileVersion(PATHS.embeddingsEntities)}`;
71
+ if (embeddingsCacheKey === key) {
72
+ return embeddingsCache;
73
+ }
74
+ if (!fs.existsSync(PATHS.embeddingsEntities)) {
75
+ embeddingsCacheKey = key;
76
+ embeddingsCache = {
77
+ model: null,
78
+ vectors: new Map(),
79
+ warning: "Embedding index missing (run: cortex embed)"
80
+ };
81
+ return embeddingsCache;
82
+ }
83
+ const parsed = parseEmbeddingIndex(readJsonlRecords(PATHS.embeddingsEntities));
84
+ embeddingsCacheKey = key;
85
+ embeddingsCache =
86
+ parsed.vectors.size === 0
87
+ ? { ...parsed, warning: "Embedding index is empty; using lexical fallback." }
88
+ : parsed;
89
+ return embeddingsCache;
90
+ }
91
+ async function getEmbeddingExtractor(modelId) {
92
+ if (!modelId) {
93
+ return null;
94
+ }
95
+ if (embeddingExtractorModel !== modelId) {
96
+ embeddingExtractorModel = modelId;
97
+ embeddingExtractorPromise = null;
98
+ embeddingLastInitAttemptAt = 0;
99
+ }
100
+ if (embeddingExtractorPromise) {
101
+ const existing = await embeddingExtractorPromise;
102
+ if (existing) {
103
+ return existing;
104
+ }
105
+ if (Date.now() - embeddingLastInitAttemptAt < EMBEDDING_INIT_RETRY_INTERVAL_MS) {
106
+ return null;
107
+ }
108
+ // Previous init failed; allow a fresh retry after cooldown.
109
+ embeddingExtractorPromise = null;
110
+ }
111
+ if (Date.now() - embeddingLastInitAttemptAt < EMBEDDING_INIT_RETRY_INTERVAL_MS) {
112
+ return null;
113
+ }
114
+ embeddingLastInitAttemptAt = Date.now();
115
+ embeddingExtractorPromise = (async () => {
116
+ try {
117
+ fs.mkdirSync(PATHS.embeddingsModelCache, { recursive: true });
118
+ env.cacheDir = PATHS.embeddingsModelCache;
119
+ const extractor = await pipeline("feature-extraction", modelId);
120
+ embeddingRuntimeWarning = null;
121
+ return extractor;
122
+ }
123
+ catch (error) {
124
+ embeddingRuntimeWarning =
125
+ error instanceof Error ? error.message : "Failed to load embedding model";
126
+ return null;
127
+ }
128
+ })();
129
+ return embeddingExtractorPromise;
130
+ }
131
+ // Agents repeat queries verbatim within a session; the model pipeline is
132
+ // cached but each call still pays full inference. A small LRU keyed on
133
+ // (model, query) makes repeats free. Cached vectors are treated as
134
+ // immutable by all callers.
135
+ const queryEmbeddingCache = new LruCache(256);
136
+ export async function embedQuery(query, modelId) {
137
+ const cacheKey = `${modelId}\u0000${query}`;
138
+ const cached = queryEmbeddingCache.get(cacheKey);
139
+ if (cached) {
140
+ return cached;
141
+ }
142
+ const extractor = await getEmbeddingExtractor(modelId);
143
+ if (!extractor) {
144
+ return null;
145
+ }
146
+ try {
147
+ const output = await extractor(query, {
148
+ pooling: "mean",
149
+ normalize: true
150
+ });
151
+ const vector = toVector(output);
152
+ if (!vector || vector.length === 0) {
153
+ embeddingRuntimeWarning = "Failed to embed query text";
154
+ return null;
155
+ }
156
+ embeddingRuntimeWarning = null;
157
+ queryEmbeddingCache.set(cacheKey, vector);
158
+ return vector;
159
+ }
160
+ catch (error) {
161
+ embeddingRuntimeWarning = error instanceof Error ? error.message : "Failed to embed query text";
162
+ return null;
163
+ }
164
+ }
165
+ export function getEmbeddingRuntimeWarning() {
166
+ return embeddingRuntimeWarning;
167
+ }