Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Memory/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ The `MEMMY_MEMORY_HOST`, `MEMMY_MEMORY_PORT`, and `MEMMY_MEMORY_DB`
environment variables override the corresponding server settings. The
`MEMORY_SERVICE_*` aliases are also accepted.

Known OpenAI embedding model names are tokenized and split automatically with
a safe 7,500-token budget. When an OpenAI-compatible endpoint uses an opaque
deployment alias, set `memmyMemory.embedding.maxInputTokens` or
`MEMMY_EMBEDDING_MAX_INPUT_TOKENS` to a safe per-input budget so the same
token-aware splitting is enabled.
OpenAI-compatible embedding inputs are tokenized and split automatically with
a conservative 7,500-token per-input budget. Set
`memmyMemory.embedding.maxInputTokens` or
`MEMMY_EMBEDDING_MAX_INPUT_TOKENS` to use a smaller budget for a provider with
a shorter context window.

When `storage.token`, `MEMMY_MEMORY_TOKEN`, or `MEMORY_SERVICE_TOKEN` is set,
all HTTP routes except `GET /api/v1/health` require that token as a bearer token
Expand Down
2 changes: 1 addition & 1 deletion Memory/src/model/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ class HttpEmbedder implements Embedder {
const chunkVectors: number[][] = [];
for (const batch of plan.batches) {
chunkVectors.push(...await this.requestOpenAiShape(
batch.map((chunk) => chunk.tokens),
batch.map((chunk) => chunk.input),
provider,
url,
role
Expand Down
43 changes: 37 additions & 6 deletions Memory/src/model/openai-embedding-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET = 290_000;
export interface OpenAiEmbeddingChunk {
originalIndex: number;
tokens: number[];
input: string | number[];
}

export interface OpenAiEmbeddingPlan {
Expand All @@ -22,21 +23,37 @@ export function planOpenAiEmbeddingInputs(
configuredMaxInputTokens?: number
): OpenAiEmbeddingPlan | null {
const inputTokenBudget = resolveInputTokenBudget(model, configuredMaxInputTokens);
if (!inputTokenBudget) return null;
// Explicit budgets retain the historical token-id request shape for
// deployments that opt into it; opaque aliases use text chunks so their
// model-specific tokenizer is still applied by the provider.
const useTokenIds = isKnownOpenAiEmbeddingModel(model) || configuredMaxInputTokens !== undefined;
encoder ??= get_encoding("cl100k_base");
const encoded = texts.map((text) => Array.from(encoder!.encode(text, [], [])));
const totalTokens = encoded.reduce((sum, tokens) => sum + tokens.length, 0);
if (totalTokens <= OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET &&
encoded.every((tokens) => tokens.length <= inputTokenBudget)) return null;

const chunks = encoded.flatMap((tokens, originalIndex) => {
if (tokens.length === 0) return [{ originalIndex, tokens }];
if (tokens.length === 0) return [{ originalIndex, tokens, input: useTokenIds ? tokens : "" }];
const tokenBytes = useTokenIds
? undefined
: tokens.map((token) => encoder!.decode_single_token_bytes(token));
const items: OpenAiEmbeddingChunk[] = [];
for (let offset = 0; offset < tokens.length; offset += inputTokenBudget) {
for (let offset = 0; offset < tokens.length;) {
let end = Math.min(tokens.length, offset + inputTokenBudget);
if (!useTokenIds && end < tokens.length) {
while (end > offset && startsWithContinuationByte(tokenBytes?.[end])) {
end -= 1;
}
if (end === offset) end = Math.min(tokens.length, offset + inputTokenBudget);
}
const chunkTokens = tokens.slice(offset, end);
items.push({
originalIndex,
tokens: tokens.slice(offset, offset + inputTokenBudget)
tokens: chunkTokens,
input: useTokenIds ? chunkTokens : decodeTokenBytes(tokenBytes!.slice(offset, end))
});
offset = end;
}
return items;
});
Expand Down Expand Up @@ -74,14 +91,28 @@ function isKnownOpenAiEmbeddingModel(model?: string): boolean {
return /(?:^|[/.:])text-embedding-(?:3-(?:small|large)|ada-002)(?:$|[/.:])/i.test(model?.trim() ?? "");
}

function resolveInputTokenBudget(model?: string, configured?: number): number | null {
function resolveInputTokenBudget(_model?: string, configured?: number): number {
const explicit = typeof configured === "number" && Number.isFinite(configured) && configured > 0
? Math.floor(configured)
: undefined;
if (!isKnownOpenAiEmbeddingModel(model) && !explicit) return null;
// OpenAI-compatible deployments frequently expose an opaque deployment
// alias instead of the upstream model id. We cannot safely assume that
// alias has a larger context window, so apply the same conservative budget
// used for known OpenAI embedding models unless the caller opts into a
// smaller budget explicitly.
return Math.min(explicit ?? OPENAI_EMBEDDING_INPUT_TOKEN_BUDGET, OPENAI_EMBEDDING_INPUT_TOKEN_BUDGET);
}

function decodeTokenBytes(tokenBytes: Uint8Array[]): string {
const bytes = tokenBytes.flatMap((value) => Array.from(value));
return new TextDecoder().decode(Uint8Array.from(bytes));
}

function startsWithContinuationByte(bytes: Uint8Array | undefined): boolean {
const first = bytes?.[0];
return first !== undefined && (first & 0xc0) === 0x80;
}

function batchChunks(chunks: OpenAiEmbeddingChunk[]): OpenAiEmbeddingChunk[][] {
const batches: OpenAiEmbeddingChunk[][] = [];
let current: OpenAiEmbeddingChunk[] = [];
Expand Down
51 changes: 51 additions & 0 deletions Memory/tests/embedder.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir, rm } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { get_encoding } from "tiktoken";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_MEMMY_CONFIG } from "../src/config/index.js";
import { createEmbedder } from "../src/model/embedder.js";
Expand Down Expand Up @@ -138,6 +139,56 @@ describe("embedder", () => {
expect(sentInputs.every((input) => input.length <= 512)).toBe(true);
});

it("uses the conservative token budget for an opaque deployment alias by default", async () => {
const sentInputs: string[] = [];
vi.stubGlobal("fetch", vi.fn<typeof fetch>(async (_url, init) => {
const body = JSON.parse(String(init?.body)) as { input: string[] };
sentInputs.push(...body.input);
return new Response(JSON.stringify({
data: body.input.map(() => ({ embedding: [1, 0] }))
}), { status: 200, headers: { "content-type": "application/json" } });
}));
const embedder = createEmbedder({
...DEFAULT_MEMMY_CONFIG.embedding,
provider: "openai_compatible",
endpoint: "https://api.example.test/v1",
model: "production-embedding-deployment",
apiKey: "sk-test",
cache: false,
maxRetries: 0
});

await expect(embedder.embedOne(" memory".repeat(8_001))).resolves.toEqual([1, 0]);

expect(sentInputs.length).toBeGreaterThan(1);
const encoder = get_encoding("cl100k_base");
expect(sentInputs.every((input) => encoder.encode(input).length <= 7_500)).toBe(true);
expect(sentInputs.join("")).toBe(" memory".repeat(8_001));
});

it("keeps short opaque deployment inputs as text", async () => {
let requestInput: unknown;
vi.stubGlobal("fetch", vi.fn<typeof fetch>(async (_url, init) => {
requestInput = (JSON.parse(String(init?.body)) as { input: unknown }).input;
return new Response(JSON.stringify({ data: [{ embedding: [1, 0] }] }), {
status: 200,
headers: { "content-type": "application/json" }
});
}));
const embedder = createEmbedder({
...DEFAULT_MEMMY_CONFIG.embedding,
provider: "openai_compatible",
endpoint: "https://api.example.test/v1",
model: "production-embedding-deployment",
apiKey: "sk-test",
cache: false,
maxRetries: 0
});

await expect(embedder.embedOne("short memory")).resolves.toEqual([1, 0]);
expect(requestInput).toEqual(["short memory"]);
});

it("keeps chunked OpenAI embedding request batches below the aggregate token budget", async () => {
const requestTokenCounts: number[] = [];
vi.stubGlobal("fetch", vi.fn<typeof fetch>(async (_url, init) => {
Expand Down
19 changes: 19 additions & 0 deletions Memory/tests/viewer-memories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import type { TraceDTO } from "../agent-contract/dto.js";
import { pickSummary } from "../viewer/src/views/memory-summary.js";

describe("Memmy Viewer memories", () => {
it("shows the user query while the summary is pending and the summary once ready", () => {
const trace = {
summary: "摘要排队中",
userText: "修复记忆列表的摘要占位文案",
agentText: "已开始排查。",
} as TraceDTO;

expect(pickSummary(trace)).toBe("修复记忆列表的摘要占位文案");
expect(pickSummary({
...trace,
summary: "记忆列表在摘要生成前会展示用户请求",
})).toBe("记忆列表在摘要生成前会展示用户请求");
});
});
6 changes: 3 additions & 3 deletions Memory/viewer/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ export type {
ToolOutcomeDTO,
TurnInputDTO,
TurnResultDTO,
} from "../../../agent-contract/dto";
} from "../../../agent-contract/dto.js";

export type {
CoreEvent,
CoreEventType,
} from "../../../agent-contract/events";
} from "../../../agent-contract/events.js";

export type {
LogRecord,
LogLevel,
} from "../../../agent-contract/log-record";
} from "../../../agent-contract/log-record.js";
4 changes: 2 additions & 2 deletions Memory/viewer/src/stores/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,7 @@ const en = {
"Configure an embedding provider before repairing or rebuilding vectors.",
"settings.embedding.maxInputTokens.label": "Maximum input tokens",
"settings.embedding.maxInputTokens.hint":
"Known OpenAI embedding models use a safe 7,500-token budget automatically. Set a lower budget for custom deployment aliases; rebuild vectors after changing it.",
"OpenAI-compatible embedding models use a conservative 7,500-token budget automatically. Set a lower budget for providers with shorter context windows; rebuild vectors after changing it.",
"settings.embedding.providerBatchSize.label": "Embedding API batch size",
"settings.embedding.providerBatchSize.hint":
"Maximum texts per provider request. Rejected oversized batches are split automatically.",
Expand Down Expand Up @@ -1854,7 +1854,7 @@ const zh: Record<TranslationKey, string> = {
"可用 {ready}/{total};缺失 {missing};维度不匹配 {mismatch};当前维度 {dim}。",
"settings.embedding.maintenance.unavailable": "请先配置嵌入模型,再修复或重建向量。",
"settings.embedding.maxInputTokens.label": "单条输入最大 Token 数",
"settings.embedding.maxInputTokens.hint": "已知 OpenAI Embedding 模型自动使用安全的 7500 Token 预算;自定义部署别名可设置更低预算,修改后请重建向量。",
"settings.embedding.maxInputTokens.hint": "OpenAI 兼容 Embedding 模型会自动使用保守的 7500 Token 预算;上下文窗口更短时可设置更低预算,修改后请重建向量。",
"settings.embedding.providerBatchSize.label": "Embedding API 批量大小",
"settings.embedding.providerBatchSize.hint": "单次模型请求最多发送的文本数;超限失败时会自动拆批。",
"settings.embedding.repair": "修复缺失/错维",
Expand Down
22 changes: 1 addition & 21 deletions Memory/viewer/src/views/MemoriesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { TEAM_SHARING_UI_ENABLED } from "../features";
import type { TraceDTO } from "../api/types";
import { displayMemoryId } from "../utils/memory-id";
import { areAllIdsSelected, toggleIdsInSelection } from "../utils/selection";
import { pickSummary, usableSummary } from "./memory-summary";
import {
loadHubSharingEnabled,
normalizeShareScope,
Expand Down Expand Up @@ -644,16 +645,6 @@ function TraceMemoriesView() {

// ─── helpers ─────────────────────────────────────────────────────────────

function pickSummary(trace: TraceDTO): string {
const s = usableSummary(trace.summary);
if (s) return s;
const u = (trace.userText ?? "").replace(/\s+/g, " ").trim();
if (u) return u.length > 180 ? u.slice(0, 177) + "…" : u;
const a = (trace.agentText ?? "").replace(/\s+/g, " ").trim();
if (a) return a.length > 180 ? a.slice(0, 177) + "…" : a;
return "(empty trace)";
}

function pickGroupSummary(group: MemoryGroup): string {
const headSummary = usableSummary(group.head.summary);
if (headSummary) return headSummary;
Expand All @@ -667,17 +658,6 @@ function pickGroupSummary(group: MemoryGroup): string {
return pickSummary(group.head);
}

function usableSummary(summary: string | null | undefined): string {
const s = (summary ?? "").trim();
if (!s || isPlaceholderSummary(s)) return "";
return s;
}

function isPlaceholderSummary(summary: string): boolean {
const s = summary.trim().toLowerCase();
return s === "(empty turn)" || s === "(empty trace)" || s === "(empty)";
}

function detectRole(trace: TraceDTO): "user" | "assistant" | "tool" | "" {
if ((trace.toolCalls?.length ?? 0) > 0) return "tool";
if (trace.userText && trace.userText.length > (trace.agentText?.length ?? 0)) {
Expand Down
34 changes: 34 additions & 0 deletions Memory/viewer/src/views/memory-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { TraceDTO } from "../api/types.js";

const PLACEHOLDER_SUMMARIES = new Set([
"(empty turn)",
"(empty trace)",
"(empty)",
"摘要排队中",
"摘要整理中",
"摘要总结中",
]);

export function pickSummary(trace: TraceDTO): string {
const summary = usableSummary(trace.summary);
if (summary) return summary;

const userText = compact(trace.userText);
if (userText) return truncate(userText);

const agentText = compact(trace.agentText);
return agentText ? truncate(agentText) : "(empty trace)";
}

export function usableSummary(summary: string | null | undefined): string {
const value = (summary ?? "").trim();
return value && !PLACEHOLDER_SUMMARIES.has(value.toLowerCase()) ? value : "";
}

function compact(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}

function truncate(value: string): string {
return value.length > 180 ? `${value.slice(0, 177)}…` : value;
}
Loading