@zenera/neo 1.1.10 → 1.1.12

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 (90) hide show
  1. package/README.md +3 -3
  2. package/dist/agent.d.ts +8 -7
  3. package/dist/agent.js +15 -8
  4. package/dist/architecture.d.ts +7 -5
  5. package/dist/architecture.js +20 -12
  6. package/dist/embedding.d.ts +20 -1
  7. package/dist/embedding.js +3 -2
  8. package/dist/embeddings/fanout.d.ts +51 -0
  9. package/dist/embeddings/fanout.js +76 -0
  10. package/dist/embeddings/gemini.d.ts +3 -10
  11. package/dist/embeddings/gemini.js +46 -30
  12. package/dist/embeddings/index.d.ts +3 -0
  13. package/dist/embeddings/index.js +3 -0
  14. package/dist/embeddings/limiter.d.ts +36 -0
  15. package/dist/embeddings/limiter.js +182 -0
  16. package/dist/embeddings/openai.d.ts +3 -2
  17. package/dist/embeddings/openai.js +54 -13
  18. package/dist/embeddings/openrouter.d.ts +2 -1
  19. package/dist/embeddings/openrouter.js +52 -23
  20. package/dist/embeddings/rate-limit.d.ts +16 -0
  21. package/dist/embeddings/rate-limit.js +131 -0
  22. package/dist/failure.d.ts +82 -0
  23. package/dist/failure.js +323 -0
  24. package/dist/index.d.ts +13 -2
  25. package/dist/index.js +13 -2
  26. package/dist/inspect/client.d.ts +1 -1
  27. package/dist/inspect/client.js +42 -26
  28. package/dist/kernel.d.ts +1 -1
  29. package/dist/kernel.js +40 -23
  30. package/dist/memory/files.d.ts +24 -0
  31. package/dist/memory/files.js +85 -0
  32. package/dist/memory/graph.d.ts +56 -0
  33. package/dist/memory/graph.js +159 -0
  34. package/dist/memory/index.d.ts +93 -0
  35. package/dist/memory/index.js +357 -0
  36. package/dist/memory/instructions.d.ts +15 -0
  37. package/dist/memory/instructions.js +103 -0
  38. package/dist/memory/page.d.ts +8 -0
  39. package/dist/memory/page.js +797 -0
  40. package/dist/memory/recall.d.ts +35 -0
  41. package/dist/memory/recall.js +161 -0
  42. package/dist/memory/render.d.ts +10 -0
  43. package/dist/memory/render.js +228 -0
  44. package/dist/memory/report.d.ts +57 -0
  45. package/dist/memory/report.js +113 -0
  46. package/dist/memory/store.d.ts +49 -0
  47. package/dist/memory/store.js +226 -0
  48. package/dist/memory/tools.d.ts +11 -0
  49. package/dist/memory/tools.js +310 -0
  50. package/dist/memory/types.d.ts +192 -0
  51. package/dist/memory/types.js +105 -0
  52. package/dist/memory/vectors.d.ts +29 -0
  53. package/dist/memory/vectors.js +152 -0
  54. package/dist/models/anthropic.d.ts +2 -1
  55. package/dist/models/anthropic.js +11 -4
  56. package/dist/models/factory.d.ts +15 -0
  57. package/dist/models/factory.js +49 -10
  58. package/dist/models/gemini.d.ts +2 -1
  59. package/dist/models/gemini.js +8 -3
  60. package/dist/models/openai-chat.d.ts +2 -1
  61. package/dist/models/openai-chat.js +10 -5
  62. package/dist/models/openai-responses.d.ts +2 -1
  63. package/dist/models/openai-responses.js +22 -7
  64. package/dist/models/openrouter.d.ts +2 -1
  65. package/dist/models/openrouter.js +14 -6
  66. package/dist/project/config.d.ts +55 -3
  67. package/dist/project/config.js +76 -1
  68. package/dist/project/index.d.ts +2 -2
  69. package/dist/project/index.js +1 -1
  70. package/dist/project/load.d.ts +16 -2
  71. package/dist/project/load.js +67 -1
  72. package/dist/runner.d.ts +4 -2
  73. package/dist/runner.js +29 -30
  74. package/dist/services.d.ts +23 -6
  75. package/dist/services.js +25 -15
  76. package/dist/tools/workspace.js +195 -17
  77. package/dist/trajectory.d.ts +14 -12
  78. package/dist/types.d.ts +5 -5
  79. package/dist/types.js +3 -3
  80. package/package.json +2 -5
  81. package/dist/memory-stores/factory.d.ts +0 -15
  82. package/dist/memory-stores/factory.js +0 -29
  83. package/dist/memory-stores/file.d.ts +0 -37
  84. package/dist/memory-stores/file.js +0 -174
  85. package/dist/memory-stores/in-memory.d.ts +0 -17
  86. package/dist/memory-stores/in-memory.js +0 -80
  87. package/dist/memory-stores/index.d.ts +0 -4
  88. package/dist/memory-stores/index.js +0 -5
  89. package/dist/memory.d.ts +0 -115
  90. package/dist/memory.js +0 -213
package/README.md CHANGED
@@ -134,8 +134,9 @@ of surfacing three turns into a run as a confused model.
134
134
  wildcard. Workspace file tools and a Podman sandbox ship with the library.
135
135
  - **Skills** — instruction bundles discovered and loaded on demand instead of
136
136
  permanently occupying the prompt, optionally owning their own tools.
137
- - **Memory** — pluggable `MemoryStore`s, with scopes deciding what is private to
138
- an agent and what is shared.
137
+ - **Memory** — a timed, masked knowledge graph that outlives a run: recall
138
+ returns a stitched subgraph rather than a ranked list, remembered files are
139
+ mounted at `/memory`, and standing preferences go into the system prompt.
139
140
  - **Trajectory** — an append-only log of everything that happened. What is sent
140
141
  to a provider is derived from it, and compaction _covers_ nodes rather than
141
142
  deleting them, so the full record survives even when older turns have to be
@@ -150,7 +151,6 @@ of surfacing three turns into a run as a confused model.
150
151
  | `@zenera/neo` | agents, models, tools, kernel, inspection |
151
152
  | `@zenera/neo/project` | loading a project folder |
152
153
  | `@zenera/neo/skill-providers` | skill discovery and loading |
153
- | `@zenera/neo/memory-stores` | `MemoryStore` implementations |
154
154
  | `@zenera/neo/payload-stores` | payload persistence |
155
155
 
156
156
  ## Documentation
package/dist/agent.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MemoryBinding, ResolvedBinding } from './memory.ts';
1
+ import { type MemoryBinding, type ResolvedMemoryBinding } from './memory/types.ts';
2
2
  import type { Model } from './model.ts';
3
3
  import type { Instructions } from './prompt.ts';
4
4
  import type { SkillBinding } from './skills.ts';
@@ -22,8 +22,8 @@ export interface AgentOptions<TCtx = unknown> {
22
22
  tools?: AnyTool<TCtx>[];
23
23
  /** agents this one may hand the conversation over to (by name or instance) */
24
24
  handoffs?: (string | Agent<TCtx>)[];
25
- /** long-lived memory spaces this agent may read and/or write */
26
- memory?: MemoryBinding<TCtx>[];
25
+ /** how this agent sees the shared memory graph */
26
+ memory?: MemoryBinding<TCtx>;
27
27
  /** on-demand instruction bundles */
28
28
  skills?: SkillBinding;
29
29
  /** opt-in to parallel sub-agents */
@@ -40,16 +40,17 @@ export declare class Agent<TCtx = unknown> {
40
40
  readonly model?: Model;
41
41
  readonly tools: AnyTool<TCtx>[];
42
42
  readonly handoffs: string[];
43
- readonly memory: MemoryBinding<TCtx>[];
43
+ readonly memory?: MemoryBinding<TCtx>;
44
44
  readonly skills?: SkillBinding;
45
45
  readonly fork?: ForkOptions;
46
46
  constructor(opts: AgentOptions<TCtx>);
47
47
  /**
48
- * Memory bindings with their scope resolved against the run context. The
48
+ * The memory mask with `sees` resolved against the run context. The
49
49
  * resolved value is what gets recorded in the trajectory, so a resumed run
50
- * cannot drift into another user's space.
50
+ * cannot drift into another agent's slice. Everyone always sees the public
51
+ * label, so a binding can only ever widen from there.
51
52
  */
52
- memoryBindings(ctx: TCtx): ResolvedBinding[];
53
+ memoryBinding(ctx: TCtx): ResolvedMemoryBinding | undefined;
53
54
  }
54
55
  export declare class AgentRegistry<TCtx = unknown> {
55
56
  readonly agents: Map<string, Agent<TCtx>>;
package/dist/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ALL_AGENTS } from "./memory/types.js";
1
2
  import { HANDOFF_PREFIX } from "./types.js";
2
3
  /**
3
4
  * Note what is *not* here any more: `maxTurns`. A run ends on a final answer,
@@ -20,22 +21,28 @@ export class Agent {
20
21
  this.model = opts.model;
21
22
  this.tools = opts.tools ?? [];
22
23
  this.handoffs = (opts.handoffs ?? []).map((h) => (typeof h === 'string' ? h : h.name));
23
- this.memory = opts.memory ?? [];
24
+ this.memory = opts.memory;
24
25
  this.skills = opts.skills;
25
26
  this.fork = opts.fork;
26
27
  }
27
28
  /**
28
- * Memory bindings with their scope resolved against the run context. The
29
+ * The memory mask with `sees` resolved against the run context. The
29
30
  * resolved value is what gets recorded in the trajectory, so a resumed run
30
- * cannot drift into another user's space.
31
+ * cannot drift into another agent's slice. Everyone always sees the public
32
+ * label, so a binding can only ever widen from there.
31
33
  */
32
- memoryBindings(ctx) {
33
- return this.memory.map((b) => ({
34
- store: b.store,
35
- scope: typeof b.scope === 'function' ? b.scope(ctx) : (b.scope ?? `agent:${this.name}`),
34
+ memoryBinding(ctx) {
35
+ const b = this.memory;
36
+ if (!b) {
37
+ return undefined;
38
+ }
39
+ const sees = typeof b.sees === 'function' ? b.sees(ctx) : (b.sees ?? []);
40
+ return {
36
41
  access: b.access,
42
+ sees: [...new Set([ALL_AGENTS, ...sees])],
43
+ writes: b.writes ?? [ALL_AGENTS],
37
44
  autoRecall: b.autoRecall,
38
- }));
45
+ };
39
46
  }
40
47
  }
41
48
  // ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
1
1
  import type { AgentRegistry } from './agent.ts';
2
+ import { type MemoryAccess } from './memory/types.ts';
2
3
  import type { Model } from './model.ts';
3
4
  import type { Services } from './services.ts';
4
5
  import type { SkillBinding } from './skills.ts';
@@ -18,10 +19,11 @@ export interface ArchSkill {
18
19
  toolNames?: string[];
19
20
  }
20
21
  export interface ArchMemory {
21
- store: string;
22
- /** `(per run)` when the binding resolves its scope from the run context */
23
- scope: string;
24
- access: 'read' | 'read-write';
22
+ access: MemoryAccess;
23
+ /** audience labels this agent may read; `(per run)` when resolved from context */
24
+ sees: string[] | '(per run)';
25
+ /** labels it may write */
26
+ writes: string[];
25
27
  autoRecall?: boolean;
26
28
  }
27
29
  export interface ArchAgent {
@@ -42,7 +44,7 @@ export interface ArchAgent {
42
44
  /** the binding names a provider the services do not know */
43
45
  unresolved?: boolean;
44
46
  };
45
- memory: ArchMemory[];
47
+ memory?: ArchMemory;
46
48
  fork?: {
47
49
  agents?: string[];
48
50
  maxBranches?: number;
@@ -1,3 +1,4 @@
1
+ import { ALL_AGENTS } from "./memory/types.js";
1
2
  import { HANDOFF_PREFIX } from "./types.js";
2
3
  /**
3
4
  * Projects a registry into a snapshot. Async only because a skill catalog is
@@ -83,23 +84,30 @@ function allowFilter(binding) {
83
84
  }
84
85
  /**
85
86
  * With a context in hand this is exactly what the run will record. Without
86
- * one, a scope function is left symbolic rather than called: it would be
87
- * handed `undefined` and either throw or invent a space no run ever uses.
87
+ * one, a `sees` function is left symbolic rather than called: it would be
88
+ * handed `undefined` and either throw or invent a mask no run ever uses.
88
89
  */
89
90
  function describeMemory(agent, context) {
91
+ const b = agent.memory;
92
+ if (!b) {
93
+ return undefined;
94
+ }
90
95
  if (context !== undefined) {
91
- return agent.memoryBindings(context).map((b) => ({
92
- store: b.store,
93
- scope: b.scope,
94
- access: b.access,
95
- autoRecall: b.autoRecall ? true : undefined,
96
- }));
96
+ const r = agent.memoryBinding(context);
97
+ return {
98
+ access: r.access,
99
+ sees: r.sees,
100
+ writes: r.writes,
101
+ autoRecall: r.autoRecall ? true : undefined,
102
+ };
97
103
  }
98
- return agent.memory.map((b) => ({
99
- store: b.store,
100
- scope: typeof b.scope === 'function' ? '(per run)' : (b.scope ?? `agent:${agent.name}`),
104
+ return {
101
105
  access: b.access,
106
+ sees: typeof b.sees === 'function'
107
+ ? '(per run)'
108
+ : [...new Set([ALL_AGENTS, ...(b.sees ?? [])])],
109
+ writes: b.writes ?? [ALL_AGENTS],
102
110
  autoRecall: b.autoRecall ? true : undefined,
103
- }));
111
+ };
104
112
  }
105
113
  //# sourceMappingURL=architecture.js.map
@@ -11,7 +11,11 @@ import type { TokenUsage } from './types.ts';
11
11
  */
12
12
  export type EmbeddingTaskType = 'query' | 'document';
13
13
  export interface EmbeddingRequest {
14
- /** the texts to encode, in the order the vectors come back */
14
+ /**
15
+ * The texts to encode, in the order the vectors come back. Any length: the
16
+ * adapter splits it to whatever the model accepts per request and issues
17
+ * those in parallel, so a caller never has to know the cap or invent one.
18
+ */
15
19
  input: string[];
16
20
  taskType?: EmbeddingTaskType;
17
21
  /** truncate to this width, where the model supports it */
@@ -21,6 +25,19 @@ export interface EmbeddingRequest {
21
25
  * Turn it off to see what the model actually returned.
22
26
  */
23
27
  normalize?: boolean;
28
+ /**
29
+ * Texts finished so far, out of the whole input. A large batch is minutes
30
+ * of one `await`, and a caller with nothing to say for that long is
31
+ * indistinguishable from a hung one.
32
+ */
33
+ onProgress?: (done: number, total: number) => void;
34
+ /**
35
+ * Each slice of the input as it comes back, `at` being where it started in
36
+ * `input`. A large request is many round trips, and a caller that persists
37
+ * them can survive a crash without paying for the same vectors twice.
38
+ * Vectors arrive normalised, exactly as the response will carry them.
39
+ */
40
+ onSlice?: (at: number, vectors: number[][]) => void;
24
41
  signal?: AbortSignal;
25
42
  }
26
43
  export interface EmbeddingResponse {
@@ -57,4 +74,6 @@ export interface Embedder {
57
74
  * provider without three copies of the same loop.
58
75
  */
59
76
  export declare function embeddingResponse(vectors: number[][], req: EmbeddingRequest, usage?: TokenUsage): EmbeddingResponse;
77
+ /** Idempotent, so settling a vector that is already unit length changes nothing. */
78
+ export declare function unitVector(vector: number[]): number[];
60
79
  //# sourceMappingURL=embedding.d.ts.map
package/dist/embedding.js CHANGED
@@ -4,12 +4,13 @@
4
4
  */
5
5
  export function embeddingResponse(vectors, req, usage) {
6
6
  return {
7
- vectors: req.normalize === false ? vectors : vectors.map(unit),
7
+ vectors: req.normalize === false ? vectors : vectors.map(unitVector),
8
8
  dimensions: vectors[0]?.length ?? 0,
9
9
  usage,
10
10
  };
11
11
  }
12
- function unit(vector) {
12
+ /** Idempotent, so settling a vector that is already unit length changes nothing. */
13
+ export function unitVector(vector) {
13
14
  let sum = 0;
14
15
  for (const x of vector) {
15
16
  sum += x * x;
@@ -0,0 +1,51 @@
1
+ import { type EmbeddingRequest, type EmbeddingResponse } from '../embedding.ts';
2
+ import type { ProviderNamed } from '../failure.ts';
3
+ import { RateLimiter } from './limiter.ts';
4
+ /** What one request came back with, before it is joined to the others. */
5
+ export interface EmbeddingSlice {
6
+ vectors: number[][];
7
+ /** absent where the vendor reports characters, or nothing */
8
+ inputTokens?: number;
9
+ }
10
+ /** The per-model knobs every adapter shares, and the connection they share it with. */
11
+ export interface BatchOptions extends ProviderNamed {
12
+ /** texts per request; the model's own cap applies when unset */
13
+ maxBatch?: number;
14
+ /** estimated tokens per request; the model's own cap applies when unset */
15
+ maxBatchTokens?: number;
16
+ /**
17
+ * Paces every embedding request on one connection. Supplied by the registry
18
+ * so two embedders on one account cannot each ramp up to the full limit;
19
+ * an adapter built by hand gets one of its own.
20
+ */
21
+ limiter?: RateLimiter;
22
+ }
23
+ export interface FanoutPlan {
24
+ /** the embedder's id, for the one error message that needs it */
25
+ id: string;
26
+ limiter: RateLimiter;
27
+ maxBatch: number;
28
+ maxBatchTokens: number;
29
+ send(input: string[]): Promise<EmbeddingSlice>;
30
+ }
31
+ /**
32
+ * Four characters to a token: within about 15% for English prose, wrong for CJK
33
+ * and for long identifiers. It only has to be right enough to stay under a cap
34
+ * that is already generous, and being wrong costs an extra request, not a
35
+ * failed one.
36
+ */
37
+ export declare const estimateTokens: (text: string) => number;
38
+ export declare function fanout(req: EmbeddingRequest, plan: FanoutPlan): Promise<EmbeddingResponse>;
39
+ interface Part {
40
+ /** where these texts started in the caller's array */
41
+ at: number;
42
+ texts: string[];
43
+ }
44
+ /**
45
+ * Greedy, on both caps at once. A single text over the token budget goes alone
46
+ * and is sent as it is: truncating it would answer with a vector for something
47
+ * the caller did not ask about, which is worse than the error it will get.
48
+ */
49
+ export declare function sliceBatch(input: readonly string[], maxBatch: number, maxBatchTokens: number): Part[];
50
+ export {};
51
+ //# sourceMappingURL=fanout.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { embeddingResponse, unitVector, } from "../embedding.js";
2
+ import { zeroUsage } from "../types.js";
3
+ import { RateLimiter } from "./limiter.js";
4
+ /**
5
+ * Four characters to a token: within about 15% for English prose, wrong for CJK
6
+ * and for long identifiers. It only has to be right enough to stay under a cap
7
+ * that is already generous, and being wrong costs an extra request, not a
8
+ * failed one.
9
+ */
10
+ export const estimateTokens = (text) => Math.ceil(text.length / 4);
11
+ export async function fanout(req, plan) {
12
+ if (req.input.length === 0) {
13
+ return embeddingResponse([], req);
14
+ }
15
+ const parts = sliceBatch(req.input, plan.maxBatch, plan.maxBatchTokens);
16
+ const settle = req.normalize !== false;
17
+ let done = 0;
18
+ const slices = await Promise.all(parts.map((part) => plan.limiter.run(async () => {
19
+ const slice = await plan.send(part.texts);
20
+ if (slice.vectors.length !== part.texts.length) {
21
+ throw new Error(`embedder "${plan.id}": answered ${slice.vectors.length} vectors ` +
22
+ `for ${part.texts.length} texts`);
23
+ }
24
+ const unit = settle ? slice.vectors.map(unitVector) : slice.vectors;
25
+ done += part.texts.length;
26
+ req.onSlice?.(part.at, unit);
27
+ req.onProgress?.(done, req.input.length);
28
+ return { ...slice, vectors: unit };
29
+ }, req.signal)));
30
+ const vectors = new Array(req.input.length);
31
+ for (const [at, part] of parts.entries()) {
32
+ for (const [i, vector] of slices[at].vectors.entries()) {
33
+ vectors[part.at + i] = vector;
34
+ }
35
+ }
36
+ // Settled slice by slice above. Doing it again over the join would hold a
37
+ // second copy of every vector at once, which for a large corpus is GBs.
38
+ return embeddingResponse(vectors, { ...req, normalize: false }, usageOf(slices));
39
+ }
40
+ /**
41
+ * Greedy, on both caps at once. A single text over the token budget goes alone
42
+ * and is sent as it is: truncating it would answer with a vector for something
43
+ * the caller did not ask about, which is worse than the error it will get.
44
+ */
45
+ export function sliceBatch(input, maxBatch, maxBatchTokens) {
46
+ const parts = [];
47
+ let texts = [];
48
+ let at = 0;
49
+ let tokens = 0;
50
+ for (const [i, text] of input.entries()) {
51
+ const cost = estimateTokens(text);
52
+ if (texts.length > 0 && (texts.length >= maxBatch || tokens + cost > maxBatchTokens)) {
53
+ parts.push({ at, texts });
54
+ texts = [];
55
+ at = i;
56
+ tokens = 0;
57
+ }
58
+ texts.push(text);
59
+ tokens += cost;
60
+ }
61
+ if (texts.length > 0) {
62
+ parts.push({ at, texts });
63
+ }
64
+ return parts;
65
+ }
66
+ /** Undefined unless something reported a number: a zero would read as free. */
67
+ function usageOf(slices) {
68
+ let total;
69
+ for (const slice of slices) {
70
+ if (slice.inputTokens !== undefined) {
71
+ total = (total ?? 0) + slice.inputTokens;
72
+ }
73
+ }
74
+ return total === undefined ? undefined : { ...zeroUsage(), inputTokens: total };
75
+ }
76
+ //# sourceMappingURL=fanout.js.map
@@ -1,19 +1,12 @@
1
1
  import type { GoogleGenAI } from '@google/genai';
2
- import { type Embedder, type EmbeddingRequest, type EmbeddingResponse } from '../embedding.ts';
2
+ import type { Embedder, EmbeddingRequest, EmbeddingResponse } from '../embedding.ts';
3
+ import { type BatchOptions } from './fanout.ts';
3
4
  /** Provider-specific knobs. */
4
- export interface GeminiEmbedderOptions {
5
+ export interface GeminiEmbedderOptions extends BatchOptions {
5
6
  /** default width; a request may override it */
6
7
  dimensions?: number;
7
8
  /** a document's title, which the retrieval task type takes into account */
8
9
  title?: string;
9
- /**
10
- * Texts this model accepts per request. One is the default because that is
11
- * what every `gemini-embedding-*` model takes — `embedContent` is a
12
- * single-document endpoint there, and the batch path is a separate,
13
- * job-based API. The older `text-embedding-*` models accept far more and
14
- * can say so.
15
- */
16
- maxBatch?: number;
17
10
  }
18
11
  export declare class GeminiEmbedder implements Embedder {
19
12
  #private;
@@ -1,4 +1,6 @@
1
- import { embeddingResponse, } from "../embedding.js";
1
+ import { callSites, called } from "../failure.js";
2
+ import { fanout } from "./fanout.js";
3
+ import { RateLimiter } from "./limiter.js";
2
4
  // ---------------------------------------------------------------------------
3
5
  // Google Gemini embeddings adapter
4
6
  //
@@ -15,16 +17,36 @@ const TASK_TYPES = {
15
17
  query: 'RETRIEVAL_QUERY',
16
18
  document: 'RETRIEVAL_DOCUMENT',
17
19
  };
18
- /** Requests in flight at once when a batch has to be split across several. */
19
- const CONCURRENCY = 8;
20
+ /**
21
+ * One, because `embedContent` is a single-document endpoint for every
22
+ * `gemini-embedding-*` model — it answers more than one content with *"The
23
+ * embedContent API for this model only supports one content at a time"* — and
24
+ * the batch path is a separate, job-based API. The older `text-embedding-*`
25
+ * models take far more.
26
+ *
27
+ * This is why the fan-out matters most here: a corpus is one request per chunk,
28
+ * so how many may be in flight is the entire runtime of a build.
29
+ */
30
+ const MAX_BATCH = 1;
31
+ const LEGACY_MAX_BATCH = 250;
32
+ const MAX_BATCH_TOKENS = 20_000;
33
+ const LEGACY_MODEL = /^text-embedding-/;
20
34
  export class GeminiEmbedder {
21
35
  id;
22
36
  #client;
23
37
  #options;
38
+ #limiter;
39
+ #maxBatch;
40
+ #maxBatchTokens;
41
+ #site;
24
42
  constructor(id, client, options = {}) {
25
43
  this.id = id;
26
44
  this.#client = client;
27
45
  this.#options = options;
46
+ this.#limiter = options.limiter ?? new RateLimiter();
47
+ this.#site = callSites(id, options.provider);
48
+ this.#maxBatch = options.maxBatch ?? (LEGACY_MODEL.test(id) ? LEGACY_MAX_BATCH : MAX_BATCH);
49
+ this.#maxBatchTokens = options.maxBatchTokens ?? MAX_BATCH_TOKENS;
28
50
  }
29
51
  async embed(req) {
30
52
  const config = {
@@ -34,38 +56,32 @@ export class GeminiEmbedder {
34
56
  title: this.#options.title,
35
57
  outputDimensionality: req.dimensions ?? this.#options.dimensions,
36
58
  abortSignal: req.signal,
59
+ // The SDK retries nothing unless `retryOptions` is present, and the
60
+ // client was built with some; one attempt hands a refusal back out
61
+ // to the limiter, which is the only thing that can slow the run down.
62
+ httpOptions: { retryOptions: { attempts: 1 } },
37
63
  };
38
- // The contract is batch-first and this endpoint usually is not, so the
39
- // splitting happens here rather than in every caller. Chunks are issued
40
- // in waves and their results concatenated in order, so a caller cannot
41
- // tell how many requests it took.
42
- const size = Math.max(1, this.#options.maxBatch ?? 1);
43
- const chunks = [];
44
- for (let i = 0; i < req.input.length; i += size) {
45
- chunks.push(req.input.slice(i, i + size));
46
- }
47
- const vectors = [];
48
- for (let i = 0; i < chunks.length; i += CONCURRENCY) {
49
- const wave = await Promise.all(chunks.slice(i, i + CONCURRENCY).map((chunk) => this.#send(chunk, config)));
50
- for (const part of wave) {
51
- vectors.push(...part);
52
- }
53
- }
54
64
  // Usage stays undefined on purpose: this API reports billable
55
65
  // *characters*, on Vertex only, which is not a token count and would be
56
66
  // a lie in `TokenUsage`.
57
- return embeddingResponse(vectors, req);
58
- }
59
- async #send(input, config) {
60
- const res = await this.#client.models.embedContent({
61
- model: this.id,
62
- // Spelled out as one `Content` per text. `ContentListUnion` also
63
- // accepts a `string[]`, and reads it as the *parts of a single
64
- // document* — six sentences in, one vector back, and no error.
65
- contents: input.map((text) => ({ parts: [{ text }] })),
66
- config,
67
+ return await fanout(req, {
68
+ id: this.id,
69
+ limiter: this.#limiter,
70
+ maxBatch: this.#maxBatch,
71
+ maxBatchTokens: this.#maxBatchTokens,
72
+ send: async (input) => {
73
+ const res = await called(this.#site('embedding', 'models.embedContent'), () => this.#client.models.embedContent({
74
+ model: this.id,
75
+ // Spelled out as one `Content` per text.
76
+ // `ContentListUnion` also accepts a `string[]`, and
77
+ // reads it as the *parts of a single document* — six
78
+ // sentences in, one vector back, and no error.
79
+ contents: input.map((text) => ({ parts: [{ text }] })),
80
+ config,
81
+ }));
82
+ return { vectors: (res.embeddings ?? []).map((e) => e.values ?? []) };
83
+ },
67
84
  });
68
- return (res.embeddings ?? []).map((e) => e.values ?? []);
69
85
  }
70
86
  }
71
87
  //# sourceMappingURL=gemini.js.map
@@ -1,4 +1,7 @@
1
+ export * from './fanout.ts';
1
2
  export * from './gemini.ts';
3
+ export * from './limiter.ts';
2
4
  export * from './openai.ts';
3
5
  export * from './openrouter.ts';
6
+ export * from './rate-limit.ts';
4
7
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,7 @@
1
+ export * from "./fanout.js";
1
2
  export * from "./gemini.js";
3
+ export * from "./limiter.js";
2
4
  export * from "./openai.js";
3
5
  export * from "./openrouter.js";
6
+ export * from "./rate-limit.js";
4
7
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,36 @@
1
+ export interface LimiterOptions {
2
+ /** never go below this many in flight */
3
+ min?: number;
4
+ /** where to start, before anything is known */
5
+ start?: number;
6
+ /** never go above this many, whatever the provider tolerates */
7
+ max?: number;
8
+ /** consecutive successes, while saturated, that earn one more slot */
9
+ stride?: number;
10
+ /** the first wait after a refusal, doubling while they continue */
11
+ initialBackoffMs?: number;
12
+ maxBackoffMs?: number;
13
+ /** attempts after the first, per task */
14
+ maxRetries?: number;
15
+ }
16
+ export declare const DEFAULT_MIN = 1;
17
+ export declare const DEFAULT_START = 4;
18
+ export declare const DEFAULT_MAX = 16;
19
+ export declare const DEFAULT_STRIDE = 8;
20
+ export declare const DEFAULT_INITIAL_BACKOFF_MS = 1000;
21
+ export declare const DEFAULT_MAX_BACKOFF_MS = 60000;
22
+ export declare const DEFAULT_MAX_RETRIES = 4;
23
+ export declare class RateLimiter {
24
+ #private;
25
+ constructor(options?: LimiterOptions);
26
+ /** How many may be in flight as things stand. Moves; read it for reporting only. */
27
+ get limit(): number;
28
+ get inFlight(): number;
29
+ /**
30
+ * Runs the task when there is room, retries it when the failure allows, and
31
+ * gives up when it does not. The slot is released before any wait, so a
32
+ * task serving its backoff is not also holding the concurrency down.
33
+ */
34
+ run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
35
+ }
36
+ //# sourceMappingURL=limiter.d.ts.map