@loreai/gateway 0.14.0 → 0.15.0

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,433 +0,0 @@
1
- /**
2
- * Gateway recall interception — transparent memory search for any client.
3
- *
4
- * Uses a unified "Marker and Expand" strategy:
5
- *
6
- * 1. **On response (to client):** The recall `tool_use` block is replaced
7
- * with a human-readable marker text block
8
- * (`📚 Searching <scope> for "<query>"…`). The recall is executed
9
- * internally and the result is stored in session state.
10
- *
11
- * 2. **On request (from client):** Marker text blocks in the conversation
12
- * are expanded back into the original `tool_use` + `tool_result` pairs
13
- * before forwarding upstream.
14
- *
15
- * For recall-only responses, a follow-up call is still made internally
16
- * so the model can continue in the same HTTP response (seamless UX).
17
- *
18
- * All recall execution delegates to `runRecall()` from `@loreai/core`.
19
- */
20
- import {
21
- runRecall,
22
- RECALL_TOOL_DESCRIPTION,
23
- RECALL_PARAM_DESCRIPTIONS,
24
- log,
25
- config as loreConfig,
26
- type RecallScope,
27
- } from "@loreai/core";
28
-
29
- import type {
30
- GatewayTool,
31
- GatewayRequest,
32
- GatewayResponse,
33
- GatewayToolUseBlock,
34
- GatewayMessage,
35
- RecallStore,
36
- } from "./translate/types";
37
-
38
- // ---------------------------------------------------------------------------
39
- // Tool definition
40
- // ---------------------------------------------------------------------------
41
-
42
- /** Recall tool definition for injection into upstream requests. */
43
- export const RECALL_GATEWAY_TOOL: GatewayTool = {
44
- name: "recall",
45
- description: RECALL_TOOL_DESCRIPTION,
46
- inputSchema: {
47
- type: "object",
48
- properties: {
49
- query: {
50
- type: "string",
51
- description: RECALL_PARAM_DESCRIPTIONS.query,
52
- },
53
- scope: {
54
- type: "string",
55
- enum: ["all", "session", "project", "knowledge"],
56
- description: RECALL_PARAM_DESCRIPTIONS.scope,
57
- },
58
- },
59
- required: ["query"],
60
- },
61
- };
62
-
63
- export const RECALL_TOOL_NAME = "recall";
64
-
65
- // ---------------------------------------------------------------------------
66
- // Marker utilities — human-readable text ↔ recall tool round-trip
67
- // ---------------------------------------------------------------------------
68
-
69
- /** Scope → human-readable label for marker text. */
70
- const SCOPE_LABELS: Record<string, string> = {
71
- all: "all archives",
72
- session: "session history",
73
- project: "project archives",
74
- knowledge: "knowledge base",
75
- };
76
-
77
- /** Reverse: label → scope enum. */
78
- const LABEL_TO_SCOPE: Record<string, RecallScope> = Object.fromEntries(
79
- Object.entries(SCOPE_LABELS).map(([k, v]) => [v, k as RecallScope]),
80
- );
81
-
82
- /** Map a recall scope to a human-readable label. */
83
- export function scopeToLabel(scope: string = "all"): string {
84
- return SCOPE_LABELS[scope] ?? SCOPE_LABELS.all;
85
- }
86
-
87
- /** Map a human-readable label back to a scope enum value. */
88
- export function labelToScope(label: string): RecallScope {
89
- return LABEL_TO_SCOPE[label] ?? "all";
90
- }
91
-
92
- /**
93
- * Build a marker text string for a recall tool call.
94
- *
95
- * Format: `📚 Searching <scope-label> for "<query>"…`
96
- */
97
- export function buildRecallMarker(query: string, scope: string = "all"): string {
98
- return `📚 Searching ${scopeToLabel(scope)} for "${query}"…`;
99
- }
100
-
101
- /** Regex to parse a recall marker back into query + scope. */
102
- const MARKER_REGEX = /📚 Searching (.+?) for "(.+?)"…/;
103
-
104
- /**
105
- * Parse a recall marker text block, returning query and scope if valid.
106
- * Returns null if the text doesn't match the marker format.
107
- */
108
- export function parseRecallMarker(
109
- text: string,
110
- ): { query: string; scope: RecallScope } | null {
111
- const match = MARKER_REGEX.exec(text);
112
- if (!match) return null;
113
- return {
114
- query: match[2],
115
- scope: labelToScope(match[1]),
116
- };
117
- }
118
-
119
- /** Derive a store key from query + scope. */
120
- export function recallStoreKey(query: string, scope: string = "all"): string {
121
- return `${scope}:${query}`;
122
- }
123
-
124
- // ---------------------------------------------------------------------------
125
- // Marker expansion — restore tool_use + tool_result from markers on inbound
126
- // ---------------------------------------------------------------------------
127
-
128
- /**
129
- * Find recall marker text blocks in the conversation and expand them
130
- * back into tool_use + tool_result pairs for the upstream API.
131
- *
132
- * Scans ALL assistant messages (not just the last one) since markers
133
- * persist across turns until gradient evicts the message.
134
- *
135
- * Mutates the request in-place. Returns true if any expansion was performed.
136
- */
137
- export function expandRecallMarkers(
138
- req: GatewayRequest,
139
- store: RecallStore,
140
- ): boolean {
141
- let expanded = false;
142
-
143
- // Iterate forward; when we splice messages the index is adjusted.
144
- for (let i = 0; i < req.messages.length; i++) {
145
- const msg = req.messages[i];
146
- if (msg.role !== "assistant") continue;
147
-
148
- // Find the first (should be only) recall marker in this message.
149
- // We process one marker per assistant message per pass; the outer
150
- // loop will revisit if there's more than one (rare).
151
- let markerIdx = -1;
152
- let parsed: { query: string; scope: RecallScope } | null = null;
153
- for (let j = 0; j < msg.content.length; j++) {
154
- const block = msg.content[j];
155
- if (block.type !== "text") continue;
156
- parsed = parseRecallMarker(block.text);
157
- if (parsed) {
158
- markerIdx = j;
159
- break;
160
- }
161
- }
162
-
163
- if (markerIdx < 0 || !parsed) continue;
164
-
165
- const key = recallStoreKey(parsed.query, parsed.scope);
166
- const stored = store.get(key);
167
- if (!stored) continue; // No stored result — leave marker as-is
168
-
169
- // Check if there's non-tool content AFTER the marker in this message.
170
- // This happens when recall-only follow-up piped continuation content
171
- // (text blocks) into the same assistant message. Tool_use blocks after
172
- // the marker are from the same turn (mixed tools) and stay together.
173
- const afterMarker = msg.content.slice(markerIdx + 1);
174
- const hasContinuationAfter = afterMarker.length > 0 &&
175
- afterMarker.some((b) => b.type !== "tool_use");
176
-
177
- // Replace marker with tool_use
178
- msg.content[markerIdx] = {
179
- type: "tool_use",
180
- id: stored.toolUseId,
181
- name: RECALL_TOOL_NAME,
182
- input: stored.input,
183
- };
184
-
185
- // Truncate assistant message at the tool_use (remove continuation)
186
- if (hasContinuationAfter) {
187
- msg.content.length = markerIdx + 1;
188
- }
189
-
190
- // Build synthetic tool_result user message
191
- const toolResultMsg: GatewayMessage = {
192
- role: "user",
193
- content: [
194
- {
195
- type: "tool_result",
196
- toolUseId: stored.toolUseId,
197
- content: stored.result,
198
- },
199
- ],
200
- };
201
-
202
- if (hasContinuationAfter) {
203
- // Split: insert tool_result user message + continuation assistant
204
- // message after the current assistant message.
205
- const continuationMsg: GatewayMessage = {
206
- role: "assistant",
207
- content: afterMarker,
208
- };
209
- req.messages.splice(i + 1, 0, toolResultMsg, continuationMsg);
210
- // Skip past the two newly inserted messages
211
- i += 2;
212
- } else {
213
- // No split needed — insert tool_result into the following user message.
214
- // Prepend (unshift) so the recall result appears before existing
215
- // tool_results — matching the tool_use order in the assistant message.
216
- const nextMsg = req.messages[i + 1];
217
- if (nextMsg?.role === "user") {
218
- nextMsg.content.unshift({
219
- type: "tool_result",
220
- toolUseId: stored.toolUseId,
221
- content: stored.result,
222
- });
223
- } else {
224
- // No following user message — insert a synthetic one
225
- req.messages.splice(i + 1, 0, toolResultMsg);
226
- i += 1;
227
- }
228
- }
229
-
230
- expanded = true;
231
- }
232
-
233
- return expanded;
234
- }
235
-
236
- /**
237
- * Clean up orphaned recall store entries whose markers no longer
238
- * appear in the conversation (e.g. gradient evicted the turn).
239
- */
240
- export function cleanupRecallStore(
241
- req: GatewayRequest,
242
- store: RecallStore,
243
- ): void {
244
- if (store.size === 0) return;
245
-
246
- // Collect all marker keys still present in assistant messages
247
- const activeKeys = new Set<string>();
248
- for (const msg of req.messages) {
249
- if (msg.role !== "assistant") continue;
250
- for (const block of msg.content) {
251
- if (block.type !== "text") continue;
252
- const parsed = parseRecallMarker(block.text);
253
- if (parsed) {
254
- activeKeys.add(recallStoreKey(parsed.query, parsed.scope));
255
- }
256
- }
257
- }
258
-
259
- // Remove entries not referenced by any current marker
260
- for (const key of store.keys()) {
261
- if (!activeKeys.has(key)) {
262
- store.delete(key);
263
- }
264
- }
265
- }
266
-
267
- // ---------------------------------------------------------------------------
268
- // Detection helpers
269
- // ---------------------------------------------------------------------------
270
-
271
- /** Find the recall tool_use block in a GatewayResponse, if any. */
272
- export function findRecallToolUse(
273
- resp: GatewayResponse,
274
- ): GatewayToolUseBlock | undefined {
275
- return resp.content.find(
276
- (b): b is GatewayToolUseBlock =>
277
- b.type === "tool_use" && b.name === RECALL_TOOL_NAME,
278
- );
279
- }
280
-
281
- /** Check whether a response contains a recall tool_use. */
282
- export function hasRecallToolUse(resp: GatewayResponse): boolean {
283
- return findRecallToolUse(resp) !== undefined;
284
- }
285
-
286
- /** Check whether the response contains non-recall tool_use blocks. */
287
- export function hasOtherToolUse(resp: GatewayResponse): boolean {
288
- return resp.content.some(
289
- (b) => b.type === "tool_use" && b.name !== RECALL_TOOL_NAME,
290
- );
291
- }
292
-
293
- /** Check whether the client's tools list already includes a recall tool. */
294
- export function clientHasRecallTool(tools: GatewayTool[]): boolean {
295
- return tools.some((t) => t.name === RECALL_TOOL_NAME);
296
- }
297
-
298
- // ---------------------------------------------------------------------------
299
- // Recall execution
300
- // ---------------------------------------------------------------------------
301
-
302
- /** Parse recall input from the tool_use block. */
303
- function parseRecallInput(block: GatewayToolUseBlock): {
304
- query: string;
305
- scope: RecallScope;
306
- } {
307
- const input = block.input as Record<string, unknown>;
308
- return {
309
- query: typeof input.query === "string" ? input.query : "",
310
- scope: (input.scope as RecallScope) ?? "all",
311
- };
312
- }
313
-
314
- /**
315
- * Execute the recall tool and return formatted results.
316
- *
317
- * Wraps `runRecall()` with error handling — on failure returns a
318
- * user-friendly error string rather than throwing.
319
- */
320
- export async function executeRecall(
321
- block: GatewayToolUseBlock,
322
- projectPath: string,
323
- sessionID: string,
324
- ): Promise<{ result: string; input: { query: string; scope?: RecallScope } }> {
325
- const { query, scope } = parseRecallInput(block);
326
- const cfg = loreConfig();
327
-
328
- try {
329
- const result = await runRecall({
330
- query,
331
- scope,
332
- projectPath,
333
- sessionID,
334
- knowledgeEnabled: cfg.knowledge?.enabled ?? true,
335
- searchConfig: cfg.search,
336
- });
337
-
338
- return { result, input: { query, scope } };
339
- } catch (e) {
340
- log.error("gateway recall execution failed:", e);
341
- return {
342
- result: "Recall search failed. The memory system encountered an error.",
343
- input: { query, scope },
344
- };
345
- }
346
- }
347
-
348
- // ---------------------------------------------------------------------------
349
- // Follow-up request builder (Case 1: recall-only)
350
- // ---------------------------------------------------------------------------
351
-
352
- /**
353
- * Build a follow-up request after recall execution.
354
- *
355
- * The follow-up includes:
356
- * - All original messages
357
- * - The assistant's full response (including the recall tool_use)
358
- * - A user message with the recall tool_result
359
- * - Tools list WITHOUT recall (so the model won't call it again)
360
- *
361
- * The model continues from where it left off, now with recall results
362
- * in context. Its new response streams directly to the client.
363
- */
364
- export function buildRecallFollowUp(
365
- originalReq: GatewayRequest,
366
- resp: GatewayResponse,
367
- recallResult: string,
368
- recallToolUseBlock: GatewayToolUseBlock,
369
- ): GatewayRequest {
370
- // Build assistant message with ONLY the recall tool_use block.
371
- // Exclude any pre-recall text/thinking blocks — those were already streamed
372
- // to the client. By presenting only the tool_use, the model understands it
373
- // called recall and hasn't yet produced a substantive response, so it will
374
- // generate new content after receiving the tool_result.
375
- const assistantMessage: GatewayMessage = {
376
- role: "assistant",
377
- content: [recallToolUseBlock],
378
- };
379
-
380
- // Build user message with tool_result
381
- const toolResultMessage: GatewayMessage = {
382
- role: "user",
383
- content: [
384
- {
385
- type: "tool_result",
386
- toolUseId: recallToolUseBlock.id,
387
- content: recallResult || "[No results found.]",
388
- },
389
- ],
390
- };
391
-
392
- // Strip recall from tools list
393
- const toolsWithoutRecall = originalReq.tools.filter(
394
- (t) => t.name !== RECALL_TOOL_NAME,
395
- );
396
-
397
- return {
398
- ...originalReq,
399
- messages: [
400
- ...originalReq.messages,
401
- assistantMessage,
402
- toolResultMessage,
403
- ],
404
- tools: toolsWithoutRecall,
405
- };
406
- }
407
-
408
- // ---------------------------------------------------------------------------
409
- // Response content rewriting — replace recall tool_use with marker text
410
- // ---------------------------------------------------------------------------
411
-
412
- /**
413
- * Build a GatewayResponse with recall tool_use blocks replaced by marker text.
414
- *
415
- * Used for both recall-only and mixed-tools cases to produce a response
416
- * where the client sees human-readable markers instead of tool call mechanics.
417
- */
418
- export function replaceRecallWithMarker(
419
- resp: GatewayResponse,
420
- ): GatewayResponse {
421
- return {
422
- ...resp,
423
- content: resp.content.map((b) => {
424
- if (b.type === "tool_use" && b.name === RECALL_TOOL_NAME) {
425
- const input = b.input as Record<string, unknown>;
426
- const query = typeof input.query === "string" ? input.query : "";
427
- const scope = (input.scope as string) ?? "all";
428
- return { type: "text" as const, text: buildRecallMarker(query, scope) };
429
- }
430
- return b;
431
- }),
432
- };
433
- }
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
- }