akm-cli 0.9.15 → 0.9.16-alpha.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.
- package/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import { HEALTH_PROBE_TIMEOUT_MS } from "../client.js";
|
|
5
|
+
/**
|
|
6
|
+
* Window assumed for a provider that reports nothing about its own context
|
|
7
|
+
* size (an OpenAI-compatible server, a gateway such as Bifrost): the most
|
|
8
|
+
* common embedding window; the same-run adaptive shrink already in
|
|
9
|
+
* `src/llm/embedders/remote.ts` corrects an overestimate.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_WINDOW_TOKENS = 8_192;
|
|
12
|
+
/**
|
|
13
|
+
* Chars-per-token ratio assumed when the provider exposes no exact
|
|
14
|
+
* tokenizer to calibrate against: field-measured p99 on dense markdown
|
|
15
|
+
* (#954, the same field evidence `DEFAULT_TOKEN_BUDGET` in
|
|
16
|
+
* `src/llm/embedders/remote.ts` was tuned against).
|
|
17
|
+
*/
|
|
18
|
+
export const CHARS_PER_TOKEN_TAIL = 2.6;
|
|
19
|
+
/**
|
|
20
|
+
* Reserves room, inside the provider's own token window, for the one-line
|
|
21
|
+
* header every unit's text is prefixed with (entry name, or
|
|
22
|
+
* "entry name › section title" — see A1's `deriveUnits`), so a unit's
|
|
23
|
+
* header plus body never together exceed the real window. 64 tokens
|
|
24
|
+
* comfortably covers a realistic header without materially shrinking the
|
|
25
|
+
* usable window on a small-context provider.
|
|
26
|
+
*/
|
|
27
|
+
export const UNIT_HEADER_MARGIN_TOKENS = 64;
|
|
28
|
+
/**
|
|
29
|
+
* Percentile (of chars-per-token ratios, sorted ascending) calibration
|
|
30
|
+
* reports: the single densest sampled text, i.e. the smallest
|
|
31
|
+
* chars-per-token ratio — the same conservative, worst-case-density intent
|
|
32
|
+
* the {@link CHARS_PER_TOKEN_TAIL} fallback encodes as a fixed p99. Over
|
|
33
|
+
* {@link CALIBRATION_SHAPES}' eight samples this percentile always resolves
|
|
34
|
+
* to index 0 — it is simply the minimum ratio observed.
|
|
35
|
+
*/
|
|
36
|
+
const CALIBRATION_PERCENTILE = 0.01;
|
|
37
|
+
/** Content posted to `/tokenize` purely to check the route exists, before spending the full calibration corpus on it. */
|
|
38
|
+
const TOKENIZE_PRESENCE_PROBE_TEXT = "ping";
|
|
39
|
+
/**
|
|
40
|
+
* Representative text shapes to calibrate a provider's chars-per-token
|
|
41
|
+
* ratio against, tokenized once each (eight requests total): mirrors the
|
|
42
|
+
* mix real markdown fragments produce — prose, code, a table, a list, a
|
|
43
|
+
* link, non-Latin text (which tokenizes at a very different ratio than
|
|
44
|
+
* English prose), SQL, and dense technical prose.
|
|
45
|
+
*/
|
|
46
|
+
const CALIBRATION_SHAPES = [
|
|
47
|
+
"This is a short sentence describing typical prose content used to calibrate the tokenizer.",
|
|
48
|
+
"```ts\nfunction add(a: number, b: number): number {\n return a + b;\n}\n```",
|
|
49
|
+
"| Column A | Column B | Column C |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n| 4 | 5 | 6 |",
|
|
50
|
+
"- first item in a list\n- second item in a list\n- third item, a little longer than the rest",
|
|
51
|
+
"See [the reference documentation](https://example.com/docs/reference) for more detail on this API.",
|
|
52
|
+
"日本語のテキストは英語と比べてトークンあたりの文字数が大きく異なることがあります。",
|
|
53
|
+
"SELECT id, name, description FROM entries WHERE tags LIKE '%embedding%' ORDER BY updated_at DESC LIMIT 50;",
|
|
54
|
+
"A longer paragraph mixing punctuation, numbers (like 42 and 3.14), and technical terms such as `tokenizer`, `embedding`, and `context window`.",
|
|
55
|
+
];
|
|
56
|
+
/** Index into a `length`-element array, sorted ascending, at `percentile` (0-1). Clamped so a tiny array still yields a valid index. */
|
|
57
|
+
function percentileIndex(length, percentile) {
|
|
58
|
+
if (length <= 1)
|
|
59
|
+
return 0;
|
|
60
|
+
return Math.max(0, Math.min(length - 1, Math.floor((length - 1) * percentile)));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Calibrate `charsPerToken` against a real provider/model by tokenizing
|
|
64
|
+
* {@link CALIBRATION_SHAPES} once each and taking the
|
|
65
|
+
* {@link CALIBRATION_PERCENTILE} (densest-text) chars-per-token ratio across
|
|
66
|
+
* them — with eight samples that percentile is simply the minimum ratio
|
|
67
|
+
* observed. A sample whose tokenize call fails is skipped rather than
|
|
68
|
+
* aborting the whole calibration; only a total wipeout (every sample
|
|
69
|
+
* failed) falls back to {@link CHARS_PER_TOKEN_TAIL}.
|
|
70
|
+
*/
|
|
71
|
+
async function calibrateCharsPerToken(countTokens) {
|
|
72
|
+
const ratios = [];
|
|
73
|
+
for (const text of CALIBRATION_SHAPES) {
|
|
74
|
+
try {
|
|
75
|
+
const tokens = await countTokens(text);
|
|
76
|
+
if (tokens > 0)
|
|
77
|
+
ratios.push(text.length / tokens);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// One bad calibration sample must not abort the whole probe.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (ratios.length === 0)
|
|
84
|
+
return CHARS_PER_TOKEN_TAIL;
|
|
85
|
+
ratios.sort((a, b) => a - b);
|
|
86
|
+
return ratios[percentileIndex(ratios.length, CALIBRATION_PERCENTILE)];
|
|
87
|
+
}
|
|
88
|
+
/** GET/POST `url` bounded by `timeoutMs`, additionally aborting if `externalSignal` fires. Never throws on timeout/abort itself — that surfaces as a normal fetch rejection to the caller, which every call site here already catches. */
|
|
89
|
+
function timedFetch(fetchImpl, url, init, timeoutMs, externalSignal) {
|
|
90
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
91
|
+
const signal = externalSignal ? AbortSignal.any([externalSignal, timeoutSignal]) : timeoutSignal;
|
|
92
|
+
return fetchImpl(url, { ...init, signal });
|
|
93
|
+
}
|
|
94
|
+
/** Parse a response body as JSON, or `undefined` on anything that is not valid JSON — a malformed response is a failed probe, not a thrown error. */
|
|
95
|
+
async function readJson(res) {
|
|
96
|
+
try {
|
|
97
|
+
return await res.json();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Probe llama.cpp's `/tokenize` route: one presence check with a fixed
|
|
105
|
+
* short string, then (only if that succeeds) a reusable `countTokens`
|
|
106
|
+
* closure. The closure deliberately does NOT carry the probe's own
|
|
107
|
+
* `probeSignal` forward — that signal belongs to this one
|
|
108
|
+
* `probeProviderLimits` call's lifecycle, while the returned function is
|
|
109
|
+
* held onto and invoked much later (real indexing), so it is bound only to
|
|
110
|
+
* its own fresh per-call timeout.
|
|
111
|
+
*/
|
|
112
|
+
async function probeLlamaCppCountTokens(origin, fetchImpl, timeoutMs, probeSignal) {
|
|
113
|
+
const tokenizeOnce = (text, signal) => timedFetch(fetchImpl, `${origin}/tokenize`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: text }) }, timeoutMs, signal);
|
|
114
|
+
const presence = await tokenizeOnce(TOKENIZE_PRESENCE_PROBE_TEXT, probeSignal);
|
|
115
|
+
if (!presence.ok)
|
|
116
|
+
return undefined;
|
|
117
|
+
const presenceBody = (await readJson(presence));
|
|
118
|
+
if (!Array.isArray(presenceBody?.tokens))
|
|
119
|
+
return undefined;
|
|
120
|
+
return async (text) => {
|
|
121
|
+
const res = await tokenizeOnce(text, undefined);
|
|
122
|
+
if (!res.ok)
|
|
123
|
+
throw new Error(`llama.cpp /tokenize request failed (${res.status})`);
|
|
124
|
+
const json = (await readJson(res));
|
|
125
|
+
if (!Array.isArray(json?.tokens))
|
|
126
|
+
throw new Error("Unexpected /tokenize response: missing tokens array");
|
|
127
|
+
return json.tokens.length;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function probeLlamaCpp(origin, config, fetchImpl, timeoutMs, signal) {
|
|
131
|
+
const res = await timedFetch(fetchImpl, `${origin}/props`, { method: "GET" }, timeoutMs, signal);
|
|
132
|
+
if (!res.ok)
|
|
133
|
+
return undefined;
|
|
134
|
+
const body = (await readJson(res));
|
|
135
|
+
const windowTokens = body?.default_generation_settings?.n_ctx;
|
|
136
|
+
if (typeof windowTokens !== "number" || !Number.isFinite(windowTokens) || windowTokens <= 0)
|
|
137
|
+
return undefined;
|
|
138
|
+
const probedSlots = typeof body?.total_slots === "number" && body.total_slots > 0 ? body.total_slots : 1;
|
|
139
|
+
const slots = config.concurrency ?? probedSlots;
|
|
140
|
+
const countTokens = await probeLlamaCppCountTokens(origin, fetchImpl, timeoutMs, signal).catch(() => undefined);
|
|
141
|
+
const charsPerToken = countTokens ? await calibrateCharsPerToken(countTokens) : CHARS_PER_TOKEN_TAIL;
|
|
142
|
+
return { windowTokens, slots, source: "llama.cpp", charsPerToken };
|
|
143
|
+
}
|
|
144
|
+
/** Ollama does not expose `num_parallel` (its in-flight slot count) via any API route, so the probe always reports 1 slot unless `config.concurrency` overrides it. */
|
|
145
|
+
const OLLAMA_DEFAULT_SLOTS = 1;
|
|
146
|
+
/** Find `"<arch>.context_length"` in Ollama's `model_info` — the arch prefix varies per model family, so every key is checked rather than assuming one name. */
|
|
147
|
+
function findOllamaContextLength(modelInfo) {
|
|
148
|
+
for (const [key, value] of Object.entries(modelInfo)) {
|
|
149
|
+
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
async function probeOllama(origin, config, fetchImpl, timeoutMs, signal) {
|
|
156
|
+
if (!config.model)
|
|
157
|
+
return undefined;
|
|
158
|
+
const res = await timedFetch(fetchImpl, `${origin}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: config.model }) }, timeoutMs, signal);
|
|
159
|
+
if (!res.ok)
|
|
160
|
+
return undefined;
|
|
161
|
+
const body = (await readJson(res));
|
|
162
|
+
if (!body?.model_info)
|
|
163
|
+
return undefined;
|
|
164
|
+
const windowTokens = findOllamaContextLength(body.model_info);
|
|
165
|
+
if (windowTokens === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
return {
|
|
168
|
+
windowTokens,
|
|
169
|
+
slots: config.concurrency ?? OLLAMA_DEFAULT_SLOTS,
|
|
170
|
+
source: "ollama",
|
|
171
|
+
charsPerToken: CHARS_PER_TOKEN_TAIL,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** The origin (`scheme://host[:port]`) to probe against, or `undefined` when `endpoint` is absent, unparseable, or not http(s) — a local-only embedder (no remote `endpoint`) never touches the network. */
|
|
175
|
+
function resolveOrigin(endpoint) {
|
|
176
|
+
if (!endpoint)
|
|
177
|
+
return undefined;
|
|
178
|
+
try {
|
|
179
|
+
const parsed = new URL(endpoint);
|
|
180
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
181
|
+
return undefined;
|
|
182
|
+
return parsed.origin;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** The probe's own per-request timeout: `config.timeoutMs` when set, else the shared health-probe default (`src/llm/client.ts`'s `HEALTH_PROBE_TIMEOUT_MS`) reused rather than redefined. */
|
|
189
|
+
function resolveProbeTimeoutMs(config) {
|
|
190
|
+
return config.timeoutMs ?? HEALTH_PROBE_TIMEOUT_MS;
|
|
191
|
+
}
|
|
192
|
+
function defaultLimits(config) {
|
|
193
|
+
return {
|
|
194
|
+
windowTokens: DEFAULT_WINDOW_TOKENS,
|
|
195
|
+
slots: config.concurrency ?? 1,
|
|
196
|
+
source: "default",
|
|
197
|
+
charsPerToken: CHARS_PER_TOKEN_TAIL,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* An implausible probed window that cannot even fit its own header margin
|
|
202
|
+
* plus one real character of unit text — `unitMaxChars` would floor to 0,
|
|
203
|
+
* and `deriveUnits` (`src/indexer/units/unit.ts`) throws on a non-positive
|
|
204
|
+
* `maxChars`. Reusing `unitMaxChars` itself as the usability test, rather
|
|
205
|
+
* than a second hand-picked threshold, keeps the two in lockstep by
|
|
206
|
+
* construction: a window this function accepts is, by definition, one
|
|
207
|
+
* `deriveUnits` can never crash on.
|
|
208
|
+
*/
|
|
209
|
+
function isUsableWindow(limits) {
|
|
210
|
+
return unitMaxChars(limits) > 0;
|
|
211
|
+
}
|
|
212
|
+
async function probeProviderLimitsUncached(config, opts) {
|
|
213
|
+
const origin = resolveOrigin(config.endpoint);
|
|
214
|
+
if (!origin)
|
|
215
|
+
return defaultLimits(config);
|
|
216
|
+
const fetchImpl = opts?.fetch ?? fetch;
|
|
217
|
+
const timeoutMs = resolveProbeTimeoutMs(config);
|
|
218
|
+
const signal = opts?.signal;
|
|
219
|
+
// An endpoint that answers with a window too small to be usable (at or
|
|
220
|
+
// below UNIT_HEADER_MARGIN_TOKENS) is treated exactly like one that
|
|
221
|
+
// reported nothing recognisable: falling through here means EVERY window
|
|
222
|
+
// this module ever hands out is safe to feed straight into
|
|
223
|
+
// `unitMaxChars`/`deriveUnits`, so the crash guard lives in exactly one
|
|
224
|
+
// place instead of being re-defended at every downstream call site.
|
|
225
|
+
const llamaCpp = await probeLlamaCpp(origin, config, fetchImpl, timeoutMs, signal).catch(() => undefined);
|
|
226
|
+
if (llamaCpp && isUsableWindow(llamaCpp))
|
|
227
|
+
return llamaCpp;
|
|
228
|
+
const ollama = await probeOllama(origin, config, fetchImpl, timeoutMs, signal).catch(() => undefined);
|
|
229
|
+
if (ollama && isUsableWindow(ollama))
|
|
230
|
+
return ollama;
|
|
231
|
+
return defaultLimits(config);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Per-process memoisation of {@link probeProviderLimits}, keyed by the parts
|
|
235
|
+
* of `config` that change what gets probed (`endpoint`, `model`,
|
|
236
|
+
* `concurrency`, `timeoutMs`) — `reconcileRoots`, `reconcilePaths` and
|
|
237
|
+
* `drainEmbeddingQueue` each probe once per call, so a single `akm index` or
|
|
238
|
+
* `akm remember` otherwise repeated the same handful of HTTP requests two or
|
|
239
|
+
* three times over. The cached PROMISE is stored (not just its resolved
|
|
240
|
+
* value), so concurrent callers before the first probe settles share the one
|
|
241
|
+
* in-flight request set rather than each starting their own. A probe that
|
|
242
|
+
* falls back to `source: "default"` (network error, malformed response) is
|
|
243
|
+
* cached too: a process is one CLI run, and a flapping endpoint is the
|
|
244
|
+
* drain's own retry/back-off's problem, not this cache's.
|
|
245
|
+
*/
|
|
246
|
+
const providerLimitsCache = new Map();
|
|
247
|
+
/** TEST-ONLY: clear the per-process probe cache so each test starts from a clean slate. */
|
|
248
|
+
export function _resetProviderLimitsCacheForTests() {
|
|
249
|
+
providerLimitsCache.clear();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Probe the configured embedding endpoint for its OWN window/slot limits.
|
|
253
|
+
* Tries llama.cpp's `GET /props` first, then Ollama's `POST /api/show`; an
|
|
254
|
+
* endpoint that answers neither (an OpenAI-compatible server, a gateway),
|
|
255
|
+
* one that answers with an implausibly small window (see
|
|
256
|
+
* {@link isUsableWindow}) — or a config with no remote `endpoint` at all (a
|
|
257
|
+
* local-only embedder) — gets the conservative default. Never throws: any
|
|
258
|
+
* probe failure (a network error, a malformed response, an unparseable
|
|
259
|
+
* endpoint) resolves to the same default shape rather than rejecting.
|
|
260
|
+
*
|
|
261
|
+
* Memoised per process — see {@link providerLimitsCache} — so every caller
|
|
262
|
+
* with the same effective config (`endpoint`/`model`/`concurrency`/
|
|
263
|
+
* `timeoutMs`) shares one probe's HTTP requests instead of repeating them.
|
|
264
|
+
*/
|
|
265
|
+
export async function probeProviderLimits(config, opts) {
|
|
266
|
+
const cacheKey = JSON.stringify({
|
|
267
|
+
endpoint: config.endpoint,
|
|
268
|
+
model: config.model,
|
|
269
|
+
concurrency: config.concurrency,
|
|
270
|
+
timeoutMs: config.timeoutMs,
|
|
271
|
+
});
|
|
272
|
+
const cached = providerLimitsCache.get(cacheKey);
|
|
273
|
+
if (cached)
|
|
274
|
+
return cached;
|
|
275
|
+
const probe = probeProviderLimitsUncached(config, opts);
|
|
276
|
+
providerLimitsCache.set(cacheKey, probe);
|
|
277
|
+
return probe;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Character bound for one embedding unit's text (A1's `deriveUnits`
|
|
281
|
+
* `maxChars` parameter): `windowTokens` minus the header margin, converted
|
|
282
|
+
* to characters via `charsPerToken`. Never negative — a pathologically
|
|
283
|
+
* small window still yields a usable (if tiny) bound rather than a
|
|
284
|
+
* negative `maxChars` that would make every split trivially fail.
|
|
285
|
+
*/
|
|
286
|
+
export function unitMaxChars(limits) {
|
|
287
|
+
return Math.max(0, Math.floor((limits.windowTokens - UNIT_HEADER_MARGIN_TOKENS) * limits.charsPerToken));
|
|
288
|
+
}
|