Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
289 lines
9.3 KiB
TypeScript
289 lines
9.3 KiB
TypeScript
import { readBodyCapped } from "./web-ssrf.js";
|
|
|
|
const SUPERMEMORY_TIMEOUT_MS = 15_000;
|
|
|
|
/** Search responses contain at most five bounded memories plus small metadata. */
|
|
export const MAX_SUPERMEMORY_RESPONSE_BYTES = 1024 * 1024;
|
|
|
|
/** How many recalled memories a search asks for, and the most that are ever injected into a run. */
|
|
export const MAX_RECALLED_MEMORIES = 5;
|
|
|
|
/** Supermemory rejects memory content longer than this. */
|
|
export const MAX_MEMORY_CONTENT_CHARS = 10_000;
|
|
|
|
export interface SupermemoryResult {
|
|
memory: string;
|
|
similarity: number;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export type SupermemorySearchResponse =
|
|
| { ok: true; results: SupermemoryResult[] }
|
|
| { ok: false; error: string };
|
|
|
|
export type SupermemorySaveResponse = { ok: true } | { ok: false; error: string };
|
|
export type SupermemoryProbeResponse = { ok: true } | { ok: false; error: string };
|
|
|
|
export interface SupermemoryConnectionConfig {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
}
|
|
|
|
/** Base URLs are route prefixes, never credentials or request query/fragment state. */
|
|
export function parseSupermemoryBaseUrl(baseUrl: string): URL {
|
|
const url = new URL(baseUrl);
|
|
if (
|
|
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
url.username ||
|
|
url.password ||
|
|
// search/hash are empty for a bare ?/#, but those delimiters still swallow appended routes.
|
|
url.href.includes("?") ||
|
|
url.href.includes("#")
|
|
) {
|
|
throw new Error(
|
|
"Memory base URL must use HTTP(S) without credentials, a query, or a fragment.",
|
|
);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function requestUrl(baseUrl: string, path: string): string {
|
|
const url = parseSupermemoryBaseUrl(baseUrl);
|
|
url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`;
|
|
return url.href;
|
|
}
|
|
|
|
function requestSignal(signal?: AbortSignal): AbortSignal {
|
|
const timeout = AbortSignal.timeout(SUPERMEMORY_TIMEOUT_MS);
|
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
}
|
|
|
|
function boundedRecallLimit(limit: number): number {
|
|
return Math.min(Math.max(1, Math.floor(limit)), MAX_RECALLED_MEMORIES);
|
|
}
|
|
|
|
function unreachableError(error: unknown): string {
|
|
return `Supermemory is unreachable: ${error instanceof Error ? error.message : String(error)}`;
|
|
}
|
|
|
|
function authHeaders(config: SupermemoryConnectionConfig): Record<string, string> {
|
|
return { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" };
|
|
}
|
|
|
|
function parseSearchResults(data: unknown): SupermemoryResult[] {
|
|
if (!data || typeof data !== "object") return [];
|
|
const results = (data as { results?: unknown }).results;
|
|
if (!Array.isArray(results)) return [];
|
|
const parsed: SupermemoryResult[] = [];
|
|
for (const item of results) {
|
|
if (!item || typeof item !== "object") continue;
|
|
const row = item as {
|
|
memory?: unknown;
|
|
chunk?: unknown;
|
|
similarity?: unknown;
|
|
updatedAt?: unknown;
|
|
};
|
|
const text =
|
|
typeof row.memory === "string" ? row.memory : typeof row.chunk === "string" ? row.chunk : "";
|
|
const memory = text.trim().slice(0, MAX_MEMORY_CONTENT_CHARS);
|
|
if (!memory) continue;
|
|
parsed.push({
|
|
memory,
|
|
similarity: typeof row.similarity === "number" ? row.similarity : 0,
|
|
...(typeof row.updatedAt === "string" ? { updatedAt: row.updatedAt } : {}),
|
|
});
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export async function searchSupermemory(
|
|
query: string,
|
|
containerTag: string,
|
|
config: SupermemoryConnectionConfig,
|
|
limit = MAX_RECALLED_MEMORIES,
|
|
signal?: AbortSignal,
|
|
): Promise<SupermemorySearchResponse> {
|
|
try {
|
|
const requestAbort = requestSignal(signal);
|
|
const response = await fetch(requestUrl(config.baseUrl, "/v4/search"), {
|
|
method: "POST",
|
|
headers: authHeaders(config),
|
|
body: JSON.stringify({
|
|
q: query,
|
|
containerTag,
|
|
searchMode: "memories",
|
|
limit: boundedRecallLimit(limit),
|
|
}),
|
|
redirect: "error",
|
|
signal: requestAbort,
|
|
});
|
|
if (!response.ok) {
|
|
return { ok: false, error: `Supermemory search failed: ${response.status}` };
|
|
}
|
|
return {
|
|
ok: true,
|
|
results: parseSearchResults(await readSupermemoryJson(response, requestAbort)),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "Supermemory response is too large.") {
|
|
return { ok: false, error: error.message };
|
|
}
|
|
return { ok: false, error: unreachableError(error) };
|
|
}
|
|
}
|
|
|
|
async function readSupermemoryJson(response: Response, signal?: AbortSignal): Promise<unknown> {
|
|
const declared = Number(response.headers.get("content-length") ?? 0);
|
|
if (Number.isFinite(declared) && declared > MAX_SUPERMEMORY_RESPONSE_BYTES) {
|
|
await response.body?.cancel().catch(() => undefined);
|
|
throw new Error("Supermemory response is too large.");
|
|
}
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = await readBodyCapped(response, MAX_SUPERMEMORY_RESPONSE_BYTES, signal);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "Response is too large") {
|
|
throw new Error("Supermemory response is too large.");
|
|
}
|
|
throw error;
|
|
}
|
|
return JSON.parse(new TextDecoder().decode(bytes));
|
|
}
|
|
|
|
export async function searchSupermemoryContainers(
|
|
query: string,
|
|
containerTags: string[],
|
|
config: SupermemoryConnectionConfig,
|
|
limit = MAX_RECALLED_MEMORIES,
|
|
signal?: AbortSignal,
|
|
): Promise<SupermemorySearchResponse> {
|
|
const boundedLimit = boundedRecallLimit(limit);
|
|
const responses = await Promise.all(
|
|
containerTags.map((containerTag) =>
|
|
searchSupermemory(query, containerTag, config, boundedLimit, signal),
|
|
),
|
|
);
|
|
const successful = responses.filter(
|
|
(response): response is Extract<SupermemorySearchResponse, { ok: true }> => response.ok,
|
|
);
|
|
if (successful.length === 0) {
|
|
return {
|
|
ok: false,
|
|
error: responses
|
|
.filter(
|
|
(response): response is Extract<SupermemorySearchResponse, { ok: false }> => !response.ok,
|
|
)
|
|
.map((response) => response.error)
|
|
.join("; "),
|
|
};
|
|
}
|
|
const byMemory = new Map<string, SupermemoryResult>();
|
|
for (const result of successful.flatMap((response) => response.results)) {
|
|
const existing = byMemory.get(result.memory);
|
|
if (!existing || result.similarity > existing.similarity) byMemory.set(result.memory, result);
|
|
}
|
|
return {
|
|
ok: true,
|
|
results: [...byMemory.values()]
|
|
.sort((left, right) => right.similarity - left.similarity)
|
|
.slice(0, boundedLimit),
|
|
};
|
|
}
|
|
|
|
/** Deletes every memory in a container, e.g. after the conversation they summarize is cleared. */
|
|
export async function deleteSupermemoryContainer(
|
|
containerTag: string,
|
|
config: SupermemoryConnectionConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<SupermemorySaveResponse> {
|
|
try {
|
|
const response = await fetch(
|
|
requestUrl(config.baseUrl, `/v3/container-tags/${encodeURIComponent(containerTag)}`),
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
redirect: "error",
|
|
signal: requestSignal(signal),
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
return { ok: false, error: `Supermemory container delete failed: ${response.status}` };
|
|
}
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: unreachableError(error) };
|
|
}
|
|
}
|
|
|
|
export async function saveSupermemoryMemory(
|
|
content: string,
|
|
containerTag: string,
|
|
config: SupermemoryConnectionConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<SupermemorySaveResponse> {
|
|
const memory = content.trim().slice(0, MAX_MEMORY_CONTENT_CHARS);
|
|
if (!memory) {
|
|
return { ok: false, error: "Supermemory save skipped: memory content is empty." };
|
|
}
|
|
try {
|
|
const response = await fetch(requestUrl(config.baseUrl, "/v4/memories"), {
|
|
method: "POST",
|
|
headers: authHeaders(config),
|
|
body: JSON.stringify({ containerTag, memories: [{ content: memory, isStatic: false }] }),
|
|
redirect: "error",
|
|
signal: requestSignal(signal),
|
|
});
|
|
if (!response.ok) {
|
|
return { ok: false, error: `Supermemory save failed: ${response.status}` };
|
|
}
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: unreachableError(error) };
|
|
}
|
|
}
|
|
|
|
export async function saveSupermemoryMemoryToContainers(
|
|
content: string,
|
|
containerTags: string[],
|
|
config: SupermemoryConnectionConfig,
|
|
signal?: AbortSignal,
|
|
): Promise<SupermemorySaveResponse> {
|
|
const results = await Promise.all(
|
|
containerTags.map(async (containerTag) => ({
|
|
containerTag,
|
|
result: await saveSupermemoryMemory(content, containerTag, config, signal),
|
|
})),
|
|
);
|
|
const failures = results.filter(
|
|
(entry): entry is { containerTag: string; result: { ok: false; error: string } } =>
|
|
!entry.result.ok,
|
|
);
|
|
return failures.length === 0
|
|
? { ok: true }
|
|
: {
|
|
ok: false,
|
|
error: failures
|
|
.map(({ containerTag, result }) => `${containerTag}: ${result.error}`)
|
|
.join("; "),
|
|
};
|
|
}
|
|
|
|
export async function probeSupermemory(
|
|
config: SupermemoryConnectionConfig,
|
|
): Promise<SupermemoryProbeResponse> {
|
|
try {
|
|
const response = await fetch(requestUrl(config.baseUrl, "/v3/container-tags/list"), {
|
|
method: "GET",
|
|
headers: authHeaders(config),
|
|
redirect: "error",
|
|
signal: AbortSignal.timeout(SUPERMEMORY_TIMEOUT_MS),
|
|
});
|
|
if (!response.ok) {
|
|
return { ok: false, error: `Supermemory rejected the connection: ${response.status}` };
|
|
}
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, error: unreachableError(error) };
|
|
}
|
|
}
|