recallnest 3.0.0 → 3.0.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 (184) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +222 -254
  3. package/README_CN.md +206 -243
  4. package/package.json +3 -2
  5. package/src/capture-engine.ts +26 -4
  6. package/src/context-composer-stable.ts +5 -1
  7. package/src/embedder.ts +24 -0
  8. package/src/env-config.ts +18 -0
  9. package/src/ingest.ts +225 -17
  10. package/src/kg-extractor.ts +17 -0
  11. package/src/memory-output.ts +35 -0
  12. package/src/pivot-apply.ts +52 -1
  13. package/src/pivot-evidence.ts +128 -0
  14. package/src/ppr-traversal.ts +25 -1
  15. package/src/runtime-config.ts +9 -0
  16. package/src/session-engine.ts +11 -4
  17. package/src/session-output.ts +9 -1
  18. package/src/session-store.ts +2 -3
  19. package/src/skill-promotion.ts +67 -1
  20. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  21. package/src/__tests__/activity-counter.test.ts +0 -125
  22. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  23. package/src/__tests__/admission-control.test.ts +0 -206
  24. package/src/__tests__/age-label.test.ts +0 -33
  25. package/src/__tests__/anchor-generator.test.ts +0 -67
  26. package/src/__tests__/api-server-security.test.ts +0 -102
  27. package/src/__tests__/auto-consolidation.test.ts +0 -168
  28. package/src/__tests__/auto-recall.test.ts +0 -131
  29. package/src/__tests__/babel-integration.test.ts +0 -144
  30. package/src/__tests__/batch-dedup.test.ts +0 -65
  31. package/src/__tests__/belief-history.test.ts +0 -220
  32. package/src/__tests__/canary-eval.test.ts +0 -114
  33. package/src/__tests__/capture-engine.test.ts +0 -1328
  34. package/src/__tests__/capture-heuristic.test.ts +0 -171
  35. package/src/__tests__/cascade-forget.test.ts +0 -210
  36. package/src/__tests__/category-min-scores.test.ts +0 -116
  37. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  38. package/src/__tests__/circuit-breaker.test.ts +0 -99
  39. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  40. package/src/__tests__/confidence-metadata.test.ts +0 -125
  41. package/src/__tests__/confidence-tracker.test.ts +0 -261
  42. package/src/__tests__/conflict-advisor.test.ts +0 -206
  43. package/src/__tests__/conflict-escalation.test.ts +0 -149
  44. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  45. package/src/__tests__/conflict-output.test.ts +0 -85
  46. package/src/__tests__/conflict-store.test.ts +0 -418
  47. package/src/__tests__/connector-v1.test.ts +0 -181
  48. package/src/__tests__/consolidation-engine.test.ts +0 -520
  49. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  50. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  51. package/src/__tests__/context-composer-pins.test.ts +0 -64
  52. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  53. package/src/__tests__/context-reconstructor.test.ts +0 -65
  54. package/src/__tests__/context-renderer.test.ts +0 -91
  55. package/src/__tests__/continuity-eval.test.ts +0 -275
  56. package/src/__tests__/continuity-schema.test.ts +0 -97
  57. package/src/__tests__/contracts.test.ts +0 -262
  58. package/src/__tests__/conversation-importer.test.ts +0 -692
  59. package/src/__tests__/core-recall-eval.test.ts +0 -235
  60. package/src/__tests__/core-summary.test.ts +0 -109
  61. package/src/__tests__/data-checkup.test.ts +0 -304
  62. package/src/__tests__/decay-exemption.test.ts +0 -129
  63. package/src/__tests__/dedup-actions.test.ts +0 -58
  64. package/src/__tests__/dedup-guard.test.ts +0 -39
  65. package/src/__tests__/deterministic-id.test.ts +0 -39
  66. package/src/__tests__/distill-lock.test.ts +0 -305
  67. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  68. package/src/__tests__/doctor.test.ts +0 -319
  69. package/src/__tests__/dream-pipeline.test.ts +0 -820
  70. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  71. package/src/__tests__/dream-write-batching.test.ts +0 -211
  72. package/src/__tests__/drill-down.test.ts +0 -108
  73. package/src/__tests__/embedder.test.ts +0 -122
  74. package/src/__tests__/emotion-decay.test.ts +0 -46
  75. package/src/__tests__/emotion-detector.test.ts +0 -56
  76. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  77. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  78. package/src/__tests__/entity-resolver.test.ts +0 -58
  79. package/src/__tests__/env-config-invariant.test.ts +0 -80
  80. package/src/__tests__/env-config.test.ts +0 -211
  81. package/src/__tests__/error-signature-boost.test.ts +0 -87
  82. package/src/__tests__/error-signature.test.ts +0 -95
  83. package/src/__tests__/error-taxonomy.test.ts +0 -129
  84. package/src/__tests__/ethics-layer.test.ts +0 -476
  85. package/src/__tests__/event-segmenter.test.ts +0 -153
  86. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  87. package/src/__tests__/frequency-tracker.test.ts +0 -113
  88. package/src/__tests__/freshness.test.ts +0 -186
  89. package/src/__tests__/graph-export.test.ts +0 -489
  90. package/src/__tests__/hotness-blend.test.ts +0 -283
  91. package/src/__tests__/ingest-chunking.test.ts +0 -44
  92. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  93. package/src/__tests__/ingest.test.ts +0 -360
  94. package/src/__tests__/interference-detector.test.ts +0 -203
  95. package/src/__tests__/kg-extractor.test.ts +0 -192
  96. package/src/__tests__/kg-store.test.ts +0 -300
  97. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  98. package/src/__tests__/llm-consolidation.test.ts +0 -226
  99. package/src/__tests__/maintenance-locks.test.ts +0 -197
  100. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  101. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  102. package/src/__tests__/memory-boundaries.test.ts +0 -282
  103. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  104. package/src/__tests__/memory-evolution.test.ts +0 -271
  105. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  106. package/src/__tests__/memory-lint.test.ts +0 -421
  107. package/src/__tests__/memory-output.test.ts +0 -224
  108. package/src/__tests__/memory-promotion.test.ts +0 -315
  109. package/src/__tests__/memory-utility.test.ts +0 -256
  110. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  111. package/src/__tests__/multi-vector.test.ts +0 -242
  112. package/src/__tests__/narrative-architecture.test.ts +0 -456
  113. package/src/__tests__/noise-filter.test.ts +0 -205
  114. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  115. package/src/__tests__/obsidian-connector.test.ts +0 -363
  116. package/src/__tests__/omitted-reporting.test.ts +0 -98
  117. package/src/__tests__/openai-contract.test.ts +0 -513
  118. package/src/__tests__/pending-review.test.ts +0 -77
  119. package/src/__tests__/pii-redact.test.ts +0 -333
  120. package/src/__tests__/pivot-apply.test.ts +0 -373
  121. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  122. package/src/__tests__/pivot-distill.test.ts +0 -1178
  123. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  124. package/src/__tests__/ppr-traversal.test.ts +0 -256
  125. package/src/__tests__/prediction-engine.test.ts +0 -349
  126. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  127. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  128. package/src/__tests__/preference-matcher.test.ts +0 -282
  129. package/src/__tests__/preference-slots.test.ts +0 -205
  130. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  131. package/src/__tests__/prospective-memory.test.ts +0 -283
  132. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  133. package/src/__tests__/query-entity-detector.test.ts +0 -171
  134. package/src/__tests__/query-expander-alias.test.ts +0 -146
  135. package/src/__tests__/query-expander.test.ts +0 -34
  136. package/src/__tests__/recall-gate.test.ts +0 -266
  137. package/src/__tests__/recall-governor.test.ts +0 -227
  138. package/src/__tests__/recall-mode.test.ts +0 -64
  139. package/src/__tests__/resolve-db-path.test.ts +0 -130
  140. package/src/__tests__/resolve-tier.test.ts +0 -64
  141. package/src/__tests__/result-synthesizer.test.ts +0 -198
  142. package/src/__tests__/resume-context.test.ts +0 -4898
  143. package/src/__tests__/retrieval-eval.test.ts +0 -163
  144. package/src/__tests__/retrieval-trace.test.ts +0 -213
  145. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  146. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  147. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  148. package/src/__tests__/retroactive-boost.test.ts +0 -223
  149. package/src/__tests__/salience-scoring.test.ts +0 -106
  150. package/src/__tests__/scope-inventory.test.ts +0 -200
  151. package/src/__tests__/scope-policy.test.ts +0 -69
  152. package/src/__tests__/scope-suggester.test.ts +0 -80
  153. package/src/__tests__/session-checkpoint.test.ts +0 -291
  154. package/src/__tests__/session-distiller.test.ts +0 -640
  155. package/src/__tests__/session-gc.test.ts +0 -215
  156. package/src/__tests__/skill-engine.test.ts +0 -552
  157. package/src/__tests__/skill-promotion.test.ts +0 -654
  158. package/src/__tests__/skill-verifier.test.ts +0 -122
  159. package/src/__tests__/source-heartbeat.test.ts +0 -254
  160. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  161. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  162. package/src/__tests__/store-read-consistency.test.ts +0 -122
  163. package/src/__tests__/store-schema-migration.test.ts +0 -87
  164. package/src/__tests__/store-scope-audit.test.ts +0 -81
  165. package/src/__tests__/store-upsert.test.ts +0 -201
  166. package/src/__tests__/supersede-chain.test.ts +0 -154
  167. package/src/__tests__/synthesis-contract.test.ts +0 -252
  168. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  169. package/src/__tests__/temporal-parser.test.ts +0 -241
  170. package/src/__tests__/temporal-validity.test.ts +0 -160
  171. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  172. package/src/__tests__/term-registry.test.ts +0 -121
  173. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  174. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  175. package/src/__tests__/topic-tag.test.ts +0 -113
  176. package/src/__tests__/trusted-memory.test.ts +0 -867
  177. package/src/__tests__/ui-server-security.test.ts +0 -93
  178. package/src/__tests__/usage-tracker.test.ts +0 -287
  179. package/src/__tests__/utility-weight.test.ts +0 -57
  180. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  181. package/src/__tests__/version-manager.test.ts +0 -248
  182. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  183. package/src/__tests__/workflow-observation.test.ts +0 -433
  184. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -1,513 +0,0 @@
1
- /**
2
- * OpenAI-compatible HTTP contract tests.
3
- *
4
- * Why these exist (v3.0.0): the `openai` SDK moved from v4 to v7, which replaced the
5
- * bundled `node-fetch` transport with the platform's built-in `fetch`. Every existing
6
- * embedder/LLM test stubs `client.embeddings.create` or `client.chat.completions.create`
7
- * with a plain async function, so **none of them touch a socket** — a transport-level
8
- * regression (wrong URL, dropped field, mis-typed error) would pass all of them.
9
- *
10
- * These tests drive the real `Embedder` / `LLMClient` classes through the real SDK over
11
- * a real loopback HTTP server, and assert both directions of the contract:
12
- *
13
- * request — what recallnest actually puts on the wire for each provider profile
14
- * response — how it parses success, and how it fails on error and timeout
15
- *
16
- * No network egress, no vendor credentials, no paid calls: every endpoint is a
17
- * `node:http` server bound to 127.0.0.1 on an ephemeral port, and every API key is a
18
- * literal test string.
19
- */
20
-
21
- import { afterEach, describe, expect, it } from "bun:test";
22
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
23
- import { AddressInfo } from "node:net";
24
- import OpenAI from "openai";
25
-
26
- import { Embedder } from "../embedder.js";
27
- import { LLMClient } from "../llm-client.js";
28
-
29
- // ---------------------------------------------------------------------------
30
- // Mock endpoint
31
- // ---------------------------------------------------------------------------
32
-
33
- interface CapturedRequest {
34
- method: string;
35
- url: string;
36
- body: Record<string, unknown> | null;
37
- hasAuthorization: boolean;
38
- }
39
-
40
- interface MockEndpoint {
41
- /** Base URL including the `/v1` suffix, matching how providers are configured. */
42
- baseURL: string;
43
- requests: CapturedRequest[];
44
- close(): Promise<void>;
45
- }
46
-
47
- type Responder = (req: CapturedRequest, res: ServerResponse) => void;
48
-
49
- /** Never answers — used to exercise timeout paths without a real slow vendor. */
50
- const HANG: Responder = () => {};
51
-
52
- async function startMockEndpoint(respond: Responder): Promise<MockEndpoint> {
53
- const requests: CapturedRequest[] = [];
54
-
55
- const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
56
- let raw = "";
57
- req.on("data", (chunk) => {
58
- raw += chunk;
59
- });
60
- req.on("end", () => {
61
- let body: Record<string, unknown> | null = null;
62
- if (raw) {
63
- try {
64
- body = JSON.parse(raw) as Record<string, unknown>;
65
- } catch {
66
- body = null;
67
- }
68
- }
69
- const captured: CapturedRequest = {
70
- method: req.method ?? "",
71
- url: req.url ?? "",
72
- body,
73
- hasAuthorization: Boolean(req.headers.authorization),
74
- };
75
- requests.push(captured);
76
- respond(captured, res);
77
- });
78
- });
79
-
80
- await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
81
- const { port } = server.address() as AddressInfo;
82
-
83
- return {
84
- baseURL: `http://127.0.0.1:${port}/v1`,
85
- requests,
86
- async close() {
87
- // Hanging requests keep sockets open; closeAllConnections lets close() settle.
88
- server.closeAllConnections?.();
89
- await new Promise<void>((resolve) => server.close(() => resolve()));
90
- },
91
- };
92
- }
93
-
94
- /** Deterministic vector of the exact width the embedder was configured for. */
95
- function vec(dimensions: number, seed = 1): number[] {
96
- return Array.from({ length: dimensions }, (_, i) => Number(((seed + i) % 97) / 100));
97
- }
98
-
99
- function embeddingResponse(vectors: number[][]): string {
100
- return JSON.stringify({
101
- object: "list",
102
- data: vectors.map((embedding, index) => ({ object: "embedding", index, embedding })),
103
- model: "mock-embedding-model",
104
- usage: { prompt_tokens: 4, total_tokens: 4 },
105
- });
106
- }
107
-
108
- function chatResponse(content: string): string {
109
- return JSON.stringify({
110
- id: "chatcmpl-mock",
111
- object: "chat.completion",
112
- created: 1_700_000_000,
113
- model: "mock-chat-model",
114
- choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
115
- usage: {
116
- prompt_tokens: 11,
117
- completion_tokens: 7,
118
- total_tokens: 18,
119
- prompt_tokens_details: { cached_tokens: 3 },
120
- completion_tokens_details: { reasoning_tokens: 2 },
121
- },
122
- });
123
- }
124
-
125
- const okJson = (payload: string): Responder => (_req, res) => {
126
- res.writeHead(200, { "content-type": "application/json" });
127
- res.end(payload);
128
- };
129
-
130
- const errorJson = (status: number, message: string): Responder => (_req, res) => {
131
- res.writeHead(status, { "content-type": "application/json" });
132
- res.end(JSON.stringify({ error: { message, type: "invalid_request_error" } }));
133
- };
134
-
135
- /** Endpoints opened by the running test, closed in afterEach even when it fails. */
136
- let openEndpoints: MockEndpoint[] = [];
137
-
138
- async function endpoint(respond: Responder): Promise<MockEndpoint> {
139
- const created = await startMockEndpoint(respond);
140
- openEndpoints.push(created);
141
- return created;
142
- }
143
-
144
- afterEach(async () => {
145
- const pending = openEndpoints;
146
- openEndpoints = [];
147
- await Promise.all(pending.map((e) => e.close()));
148
- });
149
-
150
- /**
151
- * Swap in a client built exactly like the production one, plus a short timeout and no
152
- * SDK-level retries. Embedder has no timeout knob of its own, so this is the only way to
153
- * exercise the timeout branch in bounded time; `maxRetries: 0` isolates recallnest's own
154
- * retry policy from the SDK's so the request count is unambiguous.
155
- */
156
- function useBoundedClient(embedder: Embedder, baseURL: string, timeoutMs: number): void {
157
- (embedder as unknown as { client: OpenAI }).client = new OpenAI({
158
- apiKey: "test-key-not-a-real-credential",
159
- baseURL,
160
- timeout: timeoutMs,
161
- maxRetries: 0,
162
- });
163
- }
164
-
165
- // ---------------------------------------------------------------------------
166
- // Embeddings
167
- // ---------------------------------------------------------------------------
168
-
169
- describe("OpenAI-compatible embeddings contract", () => {
170
- it("sends the default OpenAI request shape and parses the vector back", async () => {
171
- const mock = await endpoint(okJson(embeddingResponse([[0.1, 0.2, 0.3]])));
172
-
173
- const embedder = new Embedder({
174
- provider: "openai-compatible",
175
- apiKey: "test-key-not-a-real-credential",
176
- model: "text-embedding-3-small",
177
- baseURL: mock.baseURL,
178
- dimensions: 3,
179
- });
180
-
181
- await expect(embedder.embedPassage("hello world")).resolves.toEqual([0.1, 0.2, 0.3]);
182
-
183
- expect(mock.requests).toHaveLength(1);
184
- const [request] = mock.requests;
185
- expect(request.method).toBe("POST");
186
- expect(request.url).toBe("/v1/embeddings");
187
- expect(request.hasAuthorization).toBe(true);
188
- expect(request.body).toMatchObject({
189
- model: "text-embedding-3-small",
190
- input: "hello world",
191
- // Forced by buildPayload so the SDK does not take its base64 decode path.
192
- encoding_format: "float",
193
- });
194
- // A plain OpenAI endpoint must not receive provider-specific extensions.
195
- expect(request.body).not.toHaveProperty("task");
196
- expect(request.body).not.toHaveProperty("normalized");
197
- });
198
-
199
- it("sends Jina's task/normalized/dimensions extensions and distinguishes query from passage", async () => {
200
- const jinaVector = vec(1024, 4);
201
- const mock = await endpoint(okJson(embeddingResponse([jinaVector])));
202
-
203
- // Mirrors config.json.example's shipped Jina profile.
204
- const embedder = new Embedder({
205
- provider: "openai-compatible",
206
- apiKey: "test-key-not-a-real-credential",
207
- model: "jina-embeddings-v5-text-small",
208
- baseURL: mock.baseURL,
209
- dimensions: 1024,
210
- taskQuery: "retrieval.query",
211
- taskPassage: "retrieval.passage",
212
- normalized: true,
213
- });
214
-
215
- await expect(embedder.embedQuery("who is on call")).resolves.toEqual(jinaVector);
216
- await expect(embedder.embedPassage("the on-call rota lives in ops")).resolves.toEqual(jinaVector);
217
-
218
- expect(mock.requests).toHaveLength(2);
219
- const [queryRequest, passageRequest] = mock.requests;
220
-
221
- expect(queryRequest.url).toBe("/v1/embeddings");
222
- expect(queryRequest.body).toMatchObject({
223
- model: "jina-embeddings-v5-text-small",
224
- task: "retrieval.query",
225
- normalized: true,
226
- dimensions: 1024,
227
- encoding_format: "float",
228
- });
229
-
230
- // Asymmetric embedding is the whole point of the task fields — pin the difference.
231
- expect(passageRequest.body).toMatchObject({ task: "retrieval.passage", normalized: true });
232
- expect(queryRequest.body?.task).not.toBe(passageRequest.body?.task);
233
- });
234
-
235
- it("omits dimensions for a Qwen-compatible endpoint that was not given an override", async () => {
236
- const qwenVector = vec(1024, 7);
237
- const mock = await endpoint(okJson(embeddingResponse([qwenVector])));
238
-
239
- // Qwen3 embedding via a local OpenAI-compatible runner: known model, no override,
240
- // no task fields. Providers that reject unknown fields must not receive any.
241
- const embedder = new Embedder({
242
- provider: "openai-compatible",
243
- apiKey: "test-key-not-a-real-credential",
244
- model: "ai/qwen3-embedding",
245
- baseURL: mock.baseURL,
246
- });
247
-
248
- await expect(embedder.embedPassage("qwen passage")).resolves.toEqual(qwenVector);
249
- expect(embedder.dimensions).toBe(1024);
250
-
251
- const [request] = mock.requests;
252
- expect(request.body).toMatchObject({ model: "ai/qwen3-embedding", input: "qwen passage" });
253
- expect(request.body).not.toHaveProperty("dimensions");
254
- expect(request.body).not.toHaveProperty("task");
255
- expect(request.body).not.toHaveProperty("normalized");
256
- });
257
-
258
- it("batches several passages into one request and preserves input order", async () => {
259
- const mock = await endpoint(okJson(embeddingResponse([[1, 0, 0], [0, 1, 0], [0, 0, 1]])));
260
-
261
- const embedder = new Embedder({
262
- provider: "openai-compatible",
263
- apiKey: "test-key-not-a-real-credential",
264
- model: "text-embedding-3-small",
265
- baseURL: mock.baseURL,
266
- dimensions: 3,
267
- chunking: false,
268
- });
269
-
270
- await expect(embedder.embedBatchPassage(["alpha", "beta", "gamma"])).resolves.toEqual([
271
- [1, 0, 0],
272
- [0, 1, 0],
273
- [0, 0, 1],
274
- ]);
275
-
276
- expect(mock.requests).toHaveLength(1);
277
- expect(mock.requests[0]?.body?.input).toEqual(["alpha", "beta", "gamma"]);
278
- });
279
-
280
- it("surfaces a provider error instead of returning a silent empty vector", async () => {
281
- const mock = await endpoint(errorJson(400, "input exceeds maximum context length"));
282
-
283
- const embedder = new Embedder({
284
- provider: "openai-compatible",
285
- apiKey: "test-key-not-a-real-credential",
286
- model: "text-embedding-3-small",
287
- baseURL: mock.baseURL,
288
- dimensions: 3,
289
- });
290
- // maxRetries: 0 — a 400 is not retryable, but pinning it makes the count meaningful.
291
- useBoundedClient(embedder, mock.baseURL, 5_000);
292
-
293
- await expect(embedder.embedPassage("too long")).rejects.toThrow(/context length/);
294
-
295
- // A non-transient error must not be retried by recallnest's own retry loop.
296
- expect(mock.requests).toHaveLength(1);
297
- });
298
-
299
- it("does not retry an authentication failure", async () => {
300
- const mock = await endpoint(errorJson(401, "invalid api key"));
301
-
302
- const embedder = new Embedder({
303
- provider: "openai-compatible",
304
- apiKey: "test-key-not-a-real-credential",
305
- model: "text-embedding-3-small",
306
- baseURL: mock.baseURL,
307
- dimensions: 3,
308
- });
309
- useBoundedClient(embedder, mock.baseURL, 5_000);
310
-
311
- await expect(embedder.embedPassage("hello")).rejects.toThrow(/invalid api key/);
312
- expect(mock.requests).toHaveLength(1);
313
- });
314
-
315
- it("does not turn a rate-limit reply into a chunking storm", async () => {
316
- const mock = await endpoint(errorJson(429, "Rate limit exceeded, please retry later"));
317
-
318
- // Regression guard. The context-error branch is gated on
319
- // /context|too long|exceed|length/i, which also matches rate-limit wording, and the
320
- // chunker returns short text unchanged — so re-embedding "the chunk" reproduced the
321
- // same 429 and chunked again. Measured before the fix: >61,000 requests in 5s against
322
- // an endpoint that was asking us to slow down. Bounded failure is the contract.
323
- const embedder = new Embedder({
324
- provider: "openai-compatible",
325
- apiKey: "test-key-not-a-real-credential",
326
- model: "text-embedding-3-small",
327
- baseURL: mock.baseURL,
328
- dimensions: 3,
329
- });
330
- useBoundedClient(embedder, mock.baseURL, 5_000);
331
-
332
- await expect(embedder.embedPassage("short text")).rejects.toThrow();
333
- expect(mock.requests.length).toBeLessThanOrEqual(3);
334
- }, 15_000);
335
-
336
- it("classifies a request timeout as transient and retries before giving up", async () => {
337
- const mock = await endpoint(HANG);
338
-
339
- const embedder = new Embedder({
340
- provider: "openai-compatible",
341
- apiKey: "test-key-not-a-real-credential",
342
- model: "text-embedding-3-small",
343
- baseURL: mock.baseURL,
344
- dimensions: 3,
345
- });
346
- useBoundedClient(embedder, mock.baseURL, 120);
347
-
348
- await expect(embedder.embedPassage("never answered")).rejects.toThrow();
349
-
350
- // EMBEDDING_RETRY_DELAYS_MS has two entries, so a transient failure is attempted
351
- // three times total. Seeing 3 requests is what proves the SDK's timeout error was
352
- // recognised as transient — a misclassification would show up here as 1.
353
- expect(mock.requests).toHaveLength(3);
354
- }, 15_000);
355
-
356
- it("raises the SDK's own timeout error type that the transient classifier keys on", async () => {
357
- const mock = await endpoint(HANG);
358
-
359
- // Guards the classifier's premise directly: embedder.ts tests for
360
- // OpenAI.APIConnectionTimeoutError by identity, so an SDK upgrade that renamed or
361
- // stopped exporting that class would silently turn timeouts into fatal errors.
362
- const client = new OpenAI({
363
- apiKey: "test-key-not-a-real-credential",
364
- baseURL: mock.baseURL,
365
- timeout: 120,
366
- maxRetries: 0,
367
- });
368
-
369
- let caught: unknown;
370
- try {
371
- await client.embeddings.create({ model: "text-embedding-3-small", input: "x" });
372
- } catch (error) {
373
- caught = error;
374
- }
375
-
376
- expect(caught).toBeInstanceOf(OpenAI.APIConnectionTimeoutError);
377
- expect(caught).toBeInstanceOf(OpenAI.APIConnectionError);
378
- }, 15_000);
379
- });
380
-
381
- // ---------------------------------------------------------------------------
382
- // Chat completions
383
- // ---------------------------------------------------------------------------
384
-
385
- describe("OpenAI-compatible chat completions contract", () => {
386
- function qwenClient(baseURL: string, timeoutMs = 5_000): LLMClient {
387
- // Mirrors DEFAULT_LLM_CONFIG (DashScope's OpenAI-compatible mode) with the base URL
388
- // pointed at the loopback mock.
389
- return new LLMClient({
390
- apiKey: "test-key-not-a-real-credential",
391
- model: "qwen-turbo",
392
- baseURL,
393
- timeoutMs,
394
- temperature: 0.1,
395
- });
396
- }
397
-
398
- it("sends the Qwen-compatible request shape and parses content plus usage details", async () => {
399
- const mock = await endpoint(okJson(chatResponse(" a reusable conclusion ")));
400
-
401
- const result = await qwenClient(mock.baseURL).chatLongDetailed("system rules", "user text", {
402
- tokenLimit: { parameter: "max_tokens", value: 256 },
403
- });
404
-
405
- expect(result).not.toBeNull();
406
- expect(result?.content).toBe("a reusable conclusion");
407
- expect(result?.finishReason).toBe("stop");
408
- expect(result?.responseModel).toBe("mock-chat-model");
409
- expect(result?.usage).toEqual({
410
- promptTokens: 11,
411
- completionTokens: 7,
412
- totalTokens: 18,
413
- cachedPromptTokens: 3,
414
- reasoningTokens: 2,
415
- });
416
-
417
- expect(mock.requests).toHaveLength(1);
418
- const [request] = mock.requests;
419
- expect(request.url).toBe("/v1/chat/completions");
420
- expect(request.hasAuthorization).toBe(true);
421
- expect(request.body).toMatchObject({
422
- model: "qwen-turbo",
423
- temperature: 0.1,
424
- max_tokens: 256,
425
- messages: [
426
- { role: "system", content: "system rules" },
427
- { role: "user", content: "user text" },
428
- ],
429
- });
430
- });
431
-
432
- it("passes JSON mode and max_completion_tokens through unchanged", async () => {
433
- const mock = await endpoint(okJson(chatResponse('{"has":true}')));
434
-
435
- await qwenClient(mock.baseURL).chatLongDetailed("system", "user", {
436
- tokenLimit: { parameter: "max_completion_tokens", value: 400 },
437
- responseFormat: { type: "json_object" },
438
- enableThinking: false,
439
- });
440
-
441
- const [request] = mock.requests;
442
- expect(request.body).toMatchObject({
443
- response_format: { type: "json_object" },
444
- max_completion_tokens: 400,
445
- enable_thinking: false,
446
- });
447
- // The two token-limit parameters are mutually exclusive on real endpoints.
448
- expect(request.body).not.toHaveProperty("max_tokens");
449
- });
450
-
451
- it("propagates a provider error and records it on the circuit breaker", async () => {
452
- const mock = await endpoint(errorJson(500, "upstream model overloaded"));
453
-
454
- // maxRetries: 0 keeps the SDK from retrying the 5xx, so the assertion below counts
455
- // recallnest's behaviour rather than the SDK's retry policy.
456
- const client = new LLMClient({
457
- apiKey: "test-key-not-a-real-credential",
458
- model: "qwen-turbo",
459
- baseURL: mock.baseURL,
460
- timeoutMs: 5_000,
461
- temperature: 0.1,
462
- maxRetries: 0,
463
- });
464
-
465
- await expect(client.chatLongDetailed("system", "user")).rejects.toThrow(/overloaded/);
466
- expect(mock.requests).toHaveLength(1);
467
- expect(client.breaker.canAttempt()).toBe(true); // one failure is below the trip threshold
468
- });
469
-
470
- it("aborts a hanging endpoint within its own timeout budget", async () => {
471
- const mock = await endpoint(HANG);
472
-
473
- // chatLongDetailed arms its AbortController at timeoutMs * 2.
474
- const client = new LLMClient({
475
- apiKey: "test-key-not-a-real-credential",
476
- model: "qwen-turbo",
477
- baseURL: mock.baseURL,
478
- timeoutMs: 100,
479
- temperature: 0.1,
480
- maxRetries: 0,
481
- });
482
-
483
- const startedAt = Date.now();
484
- await expect(client.chatLongDetailed("system", "user")).rejects.toThrow();
485
- const elapsed = Date.now() - startedAt;
486
-
487
- // Bounded failure is the contract: the request must not inherit the SDK's
488
- // ten-minute default timeout when recallnest asked for 100ms.
489
- expect(elapsed).toBeLessThan(10_000);
490
- expect(mock.requests).toHaveLength(1);
491
- }, 20_000);
492
-
493
- it("returns null instead of calling the endpoint once the breaker is open", async () => {
494
- const mock = await endpoint(okJson(chatResponse("should not be reached")));
495
-
496
- const client = new LLMClient(
497
- {
498
- apiKey: "test-key-not-a-real-credential",
499
- model: "qwen-turbo",
500
- baseURL: mock.baseURL,
501
- timeoutMs: 5_000,
502
- temperature: 0.1,
503
- },
504
- { failureThreshold: 1, resetTimeoutMs: 60_000 },
505
- );
506
-
507
- client.breaker.recordFailure();
508
- expect(client.breaker.canAttempt()).toBe(false);
509
-
510
- await expect(client.chatLongDetailed("system", "user")).resolves.toBeNull();
511
- expect(mock.requests).toHaveLength(0);
512
- });
513
- });
@@ -1,77 +0,0 @@
1
- import { describe, it, expect } from "bun:test";
2
- import {
3
- parseEvolution,
4
- patchEvolution,
5
- defaultEvolution,
6
- buildPendingReviewMetadata,
7
- isPendingReview,
8
- resolvePendingReview,
9
- isActiveMemory,
10
- } from "../memory-evolution.js";
11
-
12
- describe("HP-6: Pending review (delayed judgment)", () => {
13
- describe("buildPendingReviewMetadata", () => {
14
- it("marks entry as pending_review", () => {
15
- const meta = patchEvolution("{}", defaultEvolution());
16
- const result = buildPendingReviewMetadata(meta);
17
- const evo = parseEvolution(result);
18
- expect(evo.status).toBe("pending_review");
19
- });
20
-
21
- it("preserves other evolution fields", () => {
22
- const meta = patchEvolution("{}", { ...defaultEvolution(), accessCount: 5 });
23
- const result = buildPendingReviewMetadata(meta);
24
- const evo = parseEvolution(result);
25
- expect(evo.status).toBe("pending_review");
26
- expect(evo.accessCount).toBe(5);
27
- });
28
- });
29
-
30
- describe("isPendingReview", () => {
31
- it("returns true for pending_review status", () => {
32
- const meta = buildPendingReviewMetadata(patchEvolution("{}", defaultEvolution()));
33
- expect(isPendingReview(meta)).toBe(true);
34
- });
35
-
36
- it("returns false for active status", () => {
37
- const meta = patchEvolution("{}", defaultEvolution());
38
- expect(isPendingReview(meta)).toBe(false);
39
- });
40
-
41
- it("returns false for undefined", () => {
42
- expect(isPendingReview(undefined)).toBe(false);
43
- });
44
- });
45
-
46
- describe("resolvePendingReview", () => {
47
- it("transitions pending_review → active", () => {
48
- const meta = buildPendingReviewMetadata(patchEvolution("{}", defaultEvolution()));
49
- expect(parseEvolution(meta).status).toBe("pending_review");
50
-
51
- const resolved = resolvePendingReview(meta);
52
- expect(parseEvolution(resolved).status).toBe("active");
53
- });
54
- });
55
-
56
- describe("isActiveMemory includes pending_review", () => {
57
- it("active → true", () => {
58
- const meta = patchEvolution("{}", defaultEvolution());
59
- expect(isActiveMemory(meta)).toBe(true);
60
- });
61
-
62
- it("pending_review → true (participates in search)", () => {
63
- const meta = buildPendingReviewMetadata(patchEvolution("{}", defaultEvolution()));
64
- expect(isActiveMemory(meta)).toBe(true);
65
- });
66
-
67
- it("superseded → false", () => {
68
- const meta = patchEvolution("{}", { ...defaultEvolution(), status: "superseded" });
69
- expect(isActiveMemory(meta)).toBe(false);
70
- });
71
-
72
- it("archived → false", () => {
73
- const meta = patchEvolution("{}", { ...defaultEvolution(), status: "archived" });
74
- expect(isActiveMemory(meta)).toBe(false);
75
- });
76
- });
77
- });