@loreai/gateway 0.13.4 → 0.14.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/src/recall.ts DELETED
@@ -1,301 +0,0 @@
1
- /**
2
- * Gateway recall interception — transparent memory search for any client.
3
- *
4
- * Injects a `recall` tool into upstream requests and handles the response
5
- * transparently. Two strategies based on whether recall is the only tool:
6
- *
7
- * - **Case 1 (recall-only)**: "Pause and Continue" — pause client stream,
8
- * execute recall, send follow-up request, resume streaming in the same
9
- * HTTP response.
10
- * - **Case 2 (mixed tools)**: "Strip and Inject" — suppress recall blocks
11
- * from the client stream, execute recall in background, inject the result
12
- * into the next request from the client.
13
- *
14
- * All recall execution delegates to `runRecall()` from `@loreai/core`.
15
- */
16
- import {
17
- runRecall,
18
- RECALL_TOOL_DESCRIPTION,
19
- RECALL_PARAM_DESCRIPTIONS,
20
- log,
21
- config as loreConfig,
22
- type RecallScope,
23
- } from "@loreai/core";
24
-
25
- import type {
26
- GatewayTool,
27
- GatewayRequest,
28
- GatewayResponse,
29
- GatewayToolUseBlock,
30
- GatewayMessage,
31
- PendingRecall,
32
- } from "./translate/types";
33
-
34
- // ---------------------------------------------------------------------------
35
- // Tool definition
36
- // ---------------------------------------------------------------------------
37
-
38
- /** Recall tool definition for injection into upstream requests. */
39
- export const RECALL_GATEWAY_TOOL: GatewayTool = {
40
- name: "recall",
41
- description: RECALL_TOOL_DESCRIPTION,
42
- inputSchema: {
43
- type: "object",
44
- properties: {
45
- query: {
46
- type: "string",
47
- description: RECALL_PARAM_DESCRIPTIONS.query,
48
- },
49
- scope: {
50
- type: "string",
51
- enum: ["all", "session", "project", "knowledge"],
52
- description: RECALL_PARAM_DESCRIPTIONS.scope,
53
- },
54
- },
55
- required: ["query"],
56
- },
57
- };
58
-
59
- export const RECALL_TOOL_NAME = "recall";
60
-
61
- // ---------------------------------------------------------------------------
62
- // Pending recall state (cross-request, Case 2)
63
- // ---------------------------------------------------------------------------
64
-
65
- /** TTL for pending recall results — discard after 60 seconds. */
66
- const PENDING_RECALL_TTL_MS = 60_000;
67
-
68
- /** Check whether a pending recall is still valid (within TTL). */
69
- export function isPendingRecallValid(pending: PendingRecall): boolean {
70
- return Date.now() - pending.timestamp < PENDING_RECALL_TTL_MS;
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Detection helpers
75
- // ---------------------------------------------------------------------------
76
-
77
- /** Find the recall tool_use block in a GatewayResponse, if any. */
78
- export function findRecallToolUse(
79
- resp: GatewayResponse,
80
- ): GatewayToolUseBlock | undefined {
81
- return resp.content.find(
82
- (b): b is GatewayToolUseBlock =>
83
- b.type === "tool_use" && b.name === RECALL_TOOL_NAME,
84
- );
85
- }
86
-
87
- /** Check whether a response contains a recall tool_use. */
88
- export function hasRecallToolUse(resp: GatewayResponse): boolean {
89
- return findRecallToolUse(resp) !== undefined;
90
- }
91
-
92
- /** Check whether the response contains non-recall tool_use blocks. */
93
- export function hasOtherToolUse(resp: GatewayResponse): boolean {
94
- return resp.content.some(
95
- (b) => b.type === "tool_use" && b.name !== RECALL_TOOL_NAME,
96
- );
97
- }
98
-
99
- /** Check whether the client's tools list already includes a recall tool. */
100
- export function clientHasRecallTool(tools: GatewayTool[]): boolean {
101
- return tools.some((t) => t.name === RECALL_TOOL_NAME);
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Recall execution
106
- // ---------------------------------------------------------------------------
107
-
108
- /** Parse recall input from the tool_use block. */
109
- function parseRecallInput(block: GatewayToolUseBlock): {
110
- query: string;
111
- scope: RecallScope;
112
- } {
113
- const input = block.input as Record<string, unknown>;
114
- return {
115
- query: typeof input.query === "string" ? input.query : "",
116
- scope: (input.scope as RecallScope) ?? "all",
117
- };
118
- }
119
-
120
- /**
121
- * Execute the recall tool and return formatted results.
122
- *
123
- * Wraps `runRecall()` with error handling — on failure returns a
124
- * user-friendly error string rather than throwing.
125
- */
126
- export async function executeRecall(
127
- block: GatewayToolUseBlock,
128
- projectPath: string,
129
- sessionID: string,
130
- ): Promise<{ result: string; input: { query: string; scope?: RecallScope } }> {
131
- const { query, scope } = parseRecallInput(block);
132
- const cfg = loreConfig();
133
-
134
- try {
135
- const result = await runRecall({
136
- query,
137
- scope,
138
- projectPath,
139
- sessionID,
140
- knowledgeEnabled: cfg.knowledge?.enabled ?? true,
141
- searchConfig: cfg.search,
142
- });
143
-
144
- return { result, input: { query, scope } };
145
- } catch (e) {
146
- log.error("gateway recall execution failed:", e);
147
- return {
148
- result: "Recall search failed. The memory system encountered an error.",
149
- input: { query, scope },
150
- };
151
- }
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Follow-up request builder (Case 1: recall-only)
156
- // ---------------------------------------------------------------------------
157
-
158
- /**
159
- * Build a follow-up request after recall execution.
160
- *
161
- * The follow-up includes:
162
- * - All original messages
163
- * - The assistant's full response (including the recall tool_use)
164
- * - A user message with the recall tool_result
165
- * - Tools list WITHOUT recall (so the model won't call it again)
166
- *
167
- * The model continues from where it left off, now with recall results
168
- * in context. Its new response streams directly to the client.
169
- */
170
- export function buildRecallFollowUp(
171
- originalReq: GatewayRequest,
172
- resp: GatewayResponse,
173
- recallResult: string,
174
- recallToolUseBlock: GatewayToolUseBlock,
175
- ): GatewayRequest {
176
- // Build assistant message with ONLY the recall tool_use block.
177
- // Exclude any pre-recall text/thinking blocks — those were already streamed
178
- // to the client. By presenting only the tool_use, the model understands it
179
- // called recall and hasn't yet produced a substantive response, so it will
180
- // generate new content after receiving the tool_result.
181
- const assistantMessage: GatewayMessage = {
182
- role: "assistant",
183
- content: [recallToolUseBlock],
184
- };
185
-
186
- // Build user message with tool_result
187
- const toolResultMessage: GatewayMessage = {
188
- role: "user",
189
- content: [
190
- {
191
- type: "tool_result",
192
- toolUseId: recallToolUseBlock.id,
193
- content: recallResult || "[No results found.]",
194
- },
195
- ],
196
- };
197
-
198
- // Strip recall from tools list
199
- const toolsWithoutRecall = originalReq.tools.filter(
200
- (t) => t.name !== RECALL_TOOL_NAME,
201
- );
202
-
203
- return {
204
- ...originalReq,
205
- messages: [
206
- ...originalReq.messages,
207
- assistantMessage,
208
- toolResultMessage,
209
- ],
210
- tools: toolsWithoutRecall,
211
- };
212
- }
213
-
214
- // ---------------------------------------------------------------------------
215
- // Pending recall injection (Case 2: next request enrichment)
216
- // ---------------------------------------------------------------------------
217
-
218
- /**
219
- * Inject a pending recall result into the current request.
220
- *
221
- * Finds the last assistant message in `req.messages`, inserts the recall
222
- * tool_use block at the recorded position, and inserts a tool_result block
223
- * into the following user message.
224
- *
225
- * Mutates the request in-place for efficiency. Returns true if injection
226
- * was performed, false if the conversation structure didn't match
227
- * (e.g., no trailing assistant→user pair).
228
- */
229
- export function injectPendingRecall(
230
- req: GatewayRequest,
231
- pending: PendingRecall,
232
- ): boolean {
233
- const messages = req.messages;
234
- if (messages.length < 2) return false;
235
-
236
- // Find the last assistant message followed by a user message.
237
- // The pending recall was from the previous turn's assistant response.
238
- let assistantIdx = -1;
239
- for (let i = messages.length - 2; i >= 0; i--) {
240
- if (
241
- messages[i].role === "assistant" &&
242
- messages[i + 1]?.role === "user"
243
- ) {
244
- assistantIdx = i;
245
- break;
246
- }
247
- }
248
-
249
- if (assistantIdx < 0) {
250
- log.warn("injectPendingRecall: no assistant→user pair found");
251
- return false;
252
- }
253
-
254
- const assistantMsg = messages[assistantIdx];
255
- const userMsg = messages[assistantIdx + 1];
256
-
257
- // Insert recall tool_use into assistant message at the recorded position.
258
- // Clamp to content length in case the message was modified by gradient.
259
- const insertPos = Math.min(pending.position, assistantMsg.content.length);
260
- const recallToolUse: GatewayToolUseBlock = {
261
- type: "tool_use",
262
- id: pending.toolUseId,
263
- name: RECALL_TOOL_NAME,
264
- input: pending.input,
265
- };
266
- assistantMsg.content.splice(insertPos, 0, recallToolUse);
267
-
268
- // Insert recall tool_result into the user message.
269
- // Add it at the beginning alongside any other tool_results.
270
- userMsg.content.unshift({
271
- type: "tool_result",
272
- toolUseId: pending.toolUseId,
273
- content: pending.result,
274
- });
275
-
276
- // Strip recall from tools list for this request
277
- req.tools = req.tools.filter((t) => t.name !== RECALL_TOOL_NAME);
278
-
279
- return true;
280
- }
281
-
282
- // ---------------------------------------------------------------------------
283
- // Response content stripping (Case 2: remove recall from response)
284
- // ---------------------------------------------------------------------------
285
-
286
- /**
287
- * Build a GatewayResponse with recall tool_use blocks removed.
288
- *
289
- * Used for Case 2 to produce a clean response for `postResponse` storage
290
- * that excludes the gateway-internal recall blocks.
291
- */
292
- export function stripRecallFromResponse(
293
- resp: GatewayResponse,
294
- ): GatewayResponse {
295
- return {
296
- ...resp,
297
- content: resp.content.filter(
298
- (b) => !(b.type === "tool_use" && b.name === RECALL_TOOL_NAME),
299
- ),
300
- };
301
- }
package/src/recorder.ts DELETED
@@ -1,192 +0,0 @@
1
- /**
2
- * Fixture recorder and replayer for the Lore gateway.
3
- *
4
- * Recording mode: intercepts every upstream API call, writes the
5
- * (request, response) pair to an NDJSON fixture file, then returns
6
- * the real response to the caller unchanged.
7
- *
8
- * Replay mode: replays stored fixtures in sequence, never touching
9
- * the upstream API. Useful for deterministic integration tests.
10
- */
11
- import { appendFileSync } from "node:fs";
12
- import { log } from "@loreai/core";
13
-
14
- // ---------------------------------------------------------------------------
15
- // Public types
16
- // ---------------------------------------------------------------------------
17
-
18
- /** One entry per upstream API call, stored in the fixture file. */
19
- export interface FixtureEntry {
20
- /** Sequence number within the recording session (0-based). */
21
- seq: number;
22
- /** Wall-clock timestamp (ms since Unix epoch) when the call was made. */
23
- ts: number;
24
- /** The upstream request body as sent (Anthropic /v1/messages JSON). */
25
- request: unknown;
26
- /** The full upstream response body (non-streaming, even if original was streaming). */
27
- response: unknown;
28
- /** Whether the original request asked for a streaming response. */
29
- wasStreaming: boolean;
30
- /** Model that was used for the request. */
31
- model: string;
32
- }
33
-
34
- /**
35
- * Interceptor function injected into the upstream forwarding path.
36
- *
37
- * @param requestBody - The request body that will be sent upstream.
38
- * @param model - Model identifier from the request.
39
- * @param wasStreaming - Whether the original request was streaming.
40
- * @param makeRealRequest - Thunk that performs the actual HTTP request.
41
- * The interceptor decides whether to call it.
42
- */
43
- export type UpstreamInterceptor = (
44
- requestBody: unknown,
45
- model: string,
46
- wasStreaming: boolean,
47
- makeRealRequest: () => Promise<Response>,
48
- ) => Promise<Response>;
49
-
50
- // ---------------------------------------------------------------------------
51
- // Module-level state
52
- // ---------------------------------------------------------------------------
53
-
54
- /** Non-null when recording is active; holds the path of the fixture file. */
55
- let recordingPath: string | null = null;
56
-
57
- /** Monotonically increasing counter for fixture sequence numbers. */
58
- let seqCounter = 0;
59
-
60
- // ---------------------------------------------------------------------------
61
- // Recording control
62
- // ---------------------------------------------------------------------------
63
-
64
- /** Enable recording mode. All upstream calls will be appended to `fixturePath`. */
65
- export function startRecording(fixturePath: string): void {
66
- recordingPath = fixturePath;
67
- seqCounter = 0;
68
- log.info(`[recorder] recording to: ${fixturePath}`);
69
- }
70
-
71
- /** Disable recording mode. */
72
- export function stopRecording(): void {
73
- recordingPath = null;
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // Recording interceptor
78
- // ---------------------------------------------------------------------------
79
-
80
- /**
81
- * Returns an `UpstreamInterceptor` when recording mode is active, or
82
- * `null` when it is not.
83
- *
84
- * The returned interceptor:
85
- * 1. Calls `makeRealRequest()` to get the real upstream response.
86
- * 2. Reads the full response body text (works for both streaming and
87
- * non-streaming — the raw body is always valid JSON from Anthropic
88
- * even for streaming responses because we force `stream:false` when
89
- * we need the body for the fixture; for streaming the body is SSE
90
- * text which we store verbatim).
91
- * 3. Appends a `FixtureEntry` line to the fixture file.
92
- * 4. Returns a new `Response` with the same status, headers, and body
93
- * (the original body stream is already consumed, so we reconstitute it).
94
- */
95
- export function getRecordedInterceptor(): UpstreamInterceptor | null {
96
- if (!recordingPath) return null;
97
-
98
- // Capture the path at interceptor creation time so closure is stable
99
- const fixturePath = recordingPath;
100
-
101
- return async (
102
- requestBody: unknown,
103
- model: string,
104
- wasStreaming: boolean,
105
- makeRealRequest: () => Promise<Response>,
106
- ): Promise<Response> => {
107
- const ts = Date.now();
108
- const seq = seqCounter++;
109
-
110
- // Perform the real upstream request
111
- const realResponse = await makeRealRequest();
112
-
113
- // Collect all response headers before consuming the body
114
- const responseHeaders: Record<string, string> = {};
115
- realResponse.headers.forEach((value, key) => {
116
- responseHeaders[key] = value;
117
- });
118
-
119
- // Read the full body text — this consumes the stream
120
- const bodyText = await realResponse.text();
121
-
122
- // Parse body as JSON for structured storage; fall back to raw string
123
- let responseBody: unknown;
124
- try {
125
- responseBody = JSON.parse(bodyText);
126
- } catch {
127
- responseBody = bodyText;
128
- }
129
-
130
- // Write the fixture entry
131
- const entry: FixtureEntry = {
132
- seq,
133
- ts,
134
- request: requestBody,
135
- response: responseBody,
136
- wasStreaming,
137
- model,
138
- };
139
- appendFileSync(fixturePath, JSON.stringify(entry) + "\n", "utf8");
140
-
141
- log.info(`[recorder] captured turn seq=${seq} model=${model}`);
142
-
143
- // Return a new Response with the same status and headers but a fresh body
144
- return new Response(bodyText, {
145
- status: realResponse.status,
146
- headers: responseHeaders,
147
- });
148
- };
149
- }
150
-
151
- // ---------------------------------------------------------------------------
152
- // Replay interceptor
153
- // ---------------------------------------------------------------------------
154
-
155
- /**
156
- * Returns an interceptor that replays the given fixtures in sequence,
157
- * without ever calling `makeRealRequest()`.
158
- *
159
- * Each call advances an internal counter. When the counter exceeds
160
- * `fixtures.length`, an error is thrown.
161
- */
162
- export function getReplayInterceptor(fixtures: FixtureEntry[]): UpstreamInterceptor {
163
- let replayCounter = 0;
164
-
165
- return async (
166
- _requestBody: unknown,
167
- _model: string,
168
- _wasStreaming: boolean,
169
- _makeRealRequest: () => Promise<Response>,
170
- ): Promise<Response> => {
171
- if (replayCounter >= fixtures.length) {
172
- throw new Error(
173
- `Replay exhausted: no more fixtures (tried to replay entry ${replayCounter}, ` +
174
- `but only ${fixtures.length} fixture(s) are available)`,
175
- );
176
- }
177
-
178
- const fixture = fixtures[replayCounter++];
179
-
180
- log.info(
181
- `[recorder] replaying seq=${fixture.seq} model=${fixture.model} ` +
182
- `(${replayCounter}/${fixtures.length})`,
183
- );
184
-
185
- // Always return a non-streaming JSON response — the pipeline handles
186
- // re-streaming if the client originally requested SSE.
187
- return new Response(JSON.stringify(fixture.response), {
188
- status: 200,
189
- headers: { "content-type": "application/json" },
190
- });
191
- };
192
- }