Compare commits

..
Author SHA1 Message Date
alvinfeiandClaude Opus 5.5 2fed7adf6b fix(memhub): set the SDK context recall lanes apart
POST /api/v2/context/search passed the caller's `limit` as the normal,
schema and proactive intention top-k alike, so limit=5 ran three full
recall lanes including the intention lane the SDK keeps off by default.
The v2 request now takes `limit` (default 10), `schema_limit` (default
3) and `intention_limit` (default 1) and passes each through. The host
broker's default item cap follows v2's default of 10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 15:48:48 +08:00
alvinfeiandClaude Opus 5.5 7308ed9df2 fix(memhub): search the memory list on submit, over 20 results
- The map's memory search no longer queries on every keystroke: typing
  edits the words and the search runs on the 搜索 button or Enter.
  Submitting the words already applied re-reads the page.
- Core asks SDK search for a 20-memory relevance window instead of 500;
  the larger window made a single search slow enough to freeze the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 15:27:20 +08:00
11 changed files with 214 additions and 26 deletions

No files matched your search

+3 -1
View File
@@ -3159,7 +3159,9 @@ def create_app(
# no score floor, then keep the rows that contain the words, so
# a word in the library is found and a word in none of it finds
# nothing. The whole library is never listed in Core.
fetch = 500
# A larger window made one search take long enough to freeze
# the page; 20 is the bounded window the library searches.
fetch = 20
search_raw = await asyncio.to_thread(
client.search,
search_query,
@@ -47,6 +47,19 @@ class ContextSearchRequest(BaseModel):
return self
class SdkContextSearchRequest(ContextSearchRequest):
"""`POST /api/v2/context/search`: the three SDK recall lanes are set apart.
`limit` is the normal lane's top-k and the cap on returned items;
`schema_limit` and `intention_limit` are the SDK's schema (L6) and
proactive intention (L7) lanes. A caller's values are passed through.
"""
limit: int = Field(default=10, ge=1, le=20, strict=True)
schema_limit: int = Field(default=3, ge=0, le=20, strict=True)
intention_limit: int = Field(default=1, ge=0, le=20, strict=True)
class ContextMessage(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -34,6 +34,7 @@ from one_memory.context_models import (
MemoryContextItem,
RawContextItem,
SdkContextItem,
SdkContextSearchRequest,
SdkContextSearchResponse,
SdkMemoryContextItem,
SdkRawContextItem,
@@ -439,7 +440,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
@router.post("/search", response_model=SdkContextSearchResponse)
async def search(
value: ContextSearchRequest,
value: SdkContextSearchRequest,
request: Request,
response: Response,
) -> SdkContextSearchResponse:
@@ -514,8 +515,8 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
limit=value.limit,
min_score=sdk_relevance_floor(),
schema_min_score=sdk_relevance_floor(),
schema_limit=value.limit,
intention_limit=value.limit,
schema_limit=value.schema_limit,
intention_limit=value.intention_limit,
scene="normal",
reader=HY_MEMORY_READER,
)
+52 -1
View File
@@ -409,7 +409,7 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
assert [item["memory_id"] for item in body["memories"]] == ["release"]
assert search_calls[0][0] == "release"
assert search_calls[0][1]["include_sources"] == ["cursor"]
assert search_calls[0][1]["limit"] == 500
assert search_calls[0][1]["limit"] == 20
assert search_calls[0][1]["intention_limit"] == 0
assert search_calls[0][1]["min_score"] == 0.0
assert list_calls == []
@@ -440,3 +440,54 @@ def test_sdk_memory_library_uses_sdk_search_and_skips_empty_rows_without_mutatio
assert "source:default" not in node_ids
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_sdk_context_search_sets_each_recall_lane_apart(tmp_path):
"""Normal, schema and intention lanes default to 10 / 3 / 1 and pass through."""
async def scenario():
app = application(tmp_path)
calls = []
class Client:
def search(self, query, **kwargs):
calls.append(kwargs)
return {"memories": {"schema": [], "proactive": [], "normal": []}}
def get(self, memory_id):
return None
sdk = Client()
class Handle:
async def client(self):
return sdk
async def close(self):
return None
async with app.router.lifespan_context(app):
app.state.sdk_client_handle = Handle()
async with client_for(app) as client:
defaulted = await client.post(
"/api/v2/context/search",
json={"query": "release", "kinds": ["memory"]},
)
chosen = await client.post(
"/api/v2/context/search",
json={
"query": "release",
"kinds": ["memory"],
"limit": 5,
"schema_limit": 0,
"intention_limit": 2,
},
)
assert defaulted.status_code == 200
assert chosen.status_code == 200
assert [
(call["limit"], call["schema_limit"], call["intention_limit"]) for call in calls
] == [(10, 3, 1), (5, 0, 2)]
asyncio.run(scenario())
+33 -4
View File
@@ -181,8 +181,9 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann
expect(screen.queryByText(/暂不能保存更改/)).toBeNull();
});
it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button", async () => {
it("OBS-19: Enter in the memory filter submits it; submitting it again re-reads", async () => {
const onRetry = vi.fn();
const onQuery = vi.fn();
const page = {
items: memories,
total: memories.length,
@@ -200,7 +201,7 @@ it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button
loading: false,
failed: false,
stale: false,
onQuery: vi.fn(),
onQuery,
onSource: vi.fn(),
resetPage: vi.fn(),
onPage: vi.fn(),
@@ -211,11 +212,39 @@ it("OBS-19: Enter in the memory filter re-reads, exactly like the refresh button
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "刷新地图记忆" }));
expect(onRetry).toHaveBeenCalledTimes(1);
// Typing alone does not search; Enter submits the words.
const input = screen.getByRole("textbox", { name: "筛选地图记忆" });
await userEvent.type(input, "第一");
expect(onQuery).not.toHaveBeenCalled();
await userEvent.type(input, "{Enter}");
expect(onQuery).toHaveBeenCalledWith("第一");
// Submitting the words already applied re-reads, like the refresh button.
await userEvent.clear(input);
await userEvent.type(input, "{Enter}");
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("searches only when 搜索 is pressed, not on every keystroke", async () => {
const onQuery = vi.fn();
const resetPage = vi.fn();
render(
<Host
simplified
libraryView={{
...pagedView({ items: memories, total: 2, offset: 0, limit: 100 }),
onQuery,
resetPage,
}}
/>,
);
await userEvent.type(
screen.getByRole("textbox", { name: "筛选地图记忆" }),
"第一{Enter}",
"版本号",
);
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onQuery).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "搜索" }));
expect(onQuery).toHaveBeenCalledWith("版本号");
expect(resetPage).toHaveBeenCalled();
});
it("OBS-15: switching memory shows a read in progress instead of the previous memory", async () => {
+44 -13
View File
@@ -197,6 +197,13 @@ export function MemoryMap(props: Props) {
const [axis, setAxis] = useState<MapAxis>("source");
const [groupKey, setGroupKey] = useState("");
const [search, setSearch] = useState("");
// On a server-paged list, typing only edits the words; the search runs when
// the reader submits it (the 搜索 button or Enter), not on every keystroke.
const [searchDraft, setSearchDraft] = useState(library?.query ?? "");
const submittedQuery = library?.query;
useEffect(() => {
if (submittedQuery !== undefined) setSearchDraft(submittedQuery);
}, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [question, setQuestion] = useState("");
@@ -827,7 +834,23 @@ export function MemoryMap(props: Props) {
))}
</fieldset>
)}
<div className="map-search">
<form
className="map-search"
role="search"
onSubmit={(e) => {
e.preventDefault();
if (!library || libraryPane === "dialogues") return;
const words = searchDraft.trim();
setFilterNotice("");
clear();
if (words === library.query.trim()) {
library.onRetry();
return;
}
library.onQuery(words);
library.resetPage();
}}
>
<Search size={16} aria-hidden="true" />
<input
aria-label={text("筛选地图记忆")}
@@ -843,31 +866,39 @@ export function MemoryMap(props: Props) {
value={
libraryPane === "dialogues"
? dialogueFilter
: (library?.query ?? search)
: library
? searchDraft
: search
}
maxLength={library ? 512 : undefined}
onKeyDown={(e) => {
// The refresh button next to this list re-reads the page;
// Enter has to do the same thing, not sit there doing nothing.
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
e.preventDefault();
library?.onRetry();
// Enter that confirms an IME candidate is not a search.
if (e.key === "Enter" && e.nativeEvent.isComposing)
e.preventDefault();
}}
onChange={(e) => {
if (libraryPane === "dialogues") {
setDialogueFilter(e.target.value);
return;
}
if (library) {
setSearchDraft(e.target.value);
return;
}
setFilterNotice("");
setSearch(e.target.value);
if (library) {
clear();
library.onQuery(e.target.value);
library.resetPage();
}
}}
/>
</div>
{library && libraryPane === "memories" ? (
<button
type="submit"
className="map-search-submit"
disabled={library.loading || hidden}
>
{text("搜索")}
</button>
) : null}
</form>
{library && libraryPane === "memories" && (
<div className="map-list-heading" aria-live="polite">
<span>
@@ -218,7 +218,7 @@ it("default map search finds an existing 501st memory rather than declaring no m
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.keyDown(screen.getByLabelText("筛选地图记忆"), { key: "Enter" });
fireEvent.submit(screen.getByRole("search"));
await waitFor(() =>
expect(
within(screen.getByRole("list", { name: "地图记忆列表" })).queryByText(
@@ -310,6 +310,7 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "第501条独有目标" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() =>
expect(
screen.getByText(
@@ -320,6 +321,7 @@ it("OBS-13: the settled-count headline names the filter it is counting", async (
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "" },
});
fireEvent.submit(screen.getByRole("search"));
await chooseSource("codex");
await waitFor(() =>
expect(
@@ -473,6 +475,7 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("记忆读取失败,请重试。");
expect(screen.queryByText("没有匹配的记忆")).toBeNull();
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
@@ -490,6 +493,7 @@ it("distinguishes failed search from empty, then retries the same query", async
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "确实没有这项" },
});
fireEvent.submit(screen.getByRole("search"));
await screen.findByText("没有匹配的记忆");
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
expect(
@@ -510,6 +514,7 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "旧搜索" },
});
fireEvent.submit(screen.getByRole("search"));
await waitFor(() => expect(resolve).toHaveLength(1));
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -521,6 +526,7 @@ it("aborts an older query after a source change and never replaces the newer res
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
await within(screen.getByRole("list", { name: "地图记忆列表" })).findByText(
"第501条独有目标",
);
@@ -553,6 +559,7 @@ it("hidden map aborts page reads and does not accept late body or poll until rea
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "迟到" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
const signals = vi
.mocked(api.getMemoryLibrary)
@@ -611,6 +618,7 @@ it("keeps the populated map while a search is in flight and does not treat it as
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "onememory" },
});
fireEvent.submit(screen.getByRole("search"));
await act(async () => {});
expect(screen.queryByText("正在读取…")).toBeNull();
expect(screen.getByText("已沉淀 501 条 · 当前展示 100 条")).toBeTruthy();
@@ -700,6 +708,7 @@ it("explicit refresh keeps the search scope and replaces the page with the lates
fireEvent.change(screen.getByLabelText("筛选地图记忆"), {
target: { value: "目标" },
});
fireEvent.submit(screen.getByRole("search"));
const list = screen.getByRole("list", { name: "地图记忆列表" });
await within(list).findByText("第501条独有目标");
vi.mocked(api.getMemoryLibrary).mockResolvedValueOnce({
+17
View File
@@ -689,6 +689,23 @@ button.rail-button.active::before {
background: transparent;
font-size: 12px;
}
.map-search input {
flex: 1;
}
.map-search-submit {
flex: none;
padding: 4px 10px;
border: 0;
border-radius: 6px;
color: #fff;
background: #3d5a70;
font-size: 12px;
cursor: pointer;
}
.map-search-submit:disabled {
opacity: 0.5;
cursor: default;
}
.map-search:focus-within,
.map-question:focus-within {
outline: 2px solid #7892a6;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "one-memory",
"version": "1.0.5",
"release": {
"build_number": 25,
"build_number": 22,
"macos_min_version": "13.0",
"app_update": "manual",
"runtime_manifest_schema": 1,
@@ -317,6 +317,39 @@ it("selects SDK Context v2 after cutover and passes native fields unchanged", as
);
});
it.each([
[10, 200],
[11, 502],
])(
"accepts up to Core's v2 default of 10 items when no limit is sent (%i items)",
async (count, status) => {
const f = await fixture();
const items = Array.from({ length: count }, (_, index) => ({
kind: "memory",
memory_id: `memory-${index}`,
content: "m",
layer: "l2_fact",
status: "active",
source: "codex",
updated_at: "2026-10-21T00:00:00Z",
score: 0.5,
truncated: false,
}));
f.setAuthority("sdk");
f.respond({
schema_version: 2,
request_id: "sdk-request",
state: "grounded",
items,
used_characters: count,
truncated: false,
total_ms: 1,
});
expect((await f.post({ query: "original" })).status).toBe(status);
},
);
it("does not fall back to legacy context when the selected SDK response is invalid", async () => {
const f = await fixture();
f.setAuthority("sdk");
@@ -74,7 +74,7 @@ export const CONTEXT_INPUT_SCHEMA = {
description:
"Filters raw only, within already granted sources; does not change memory scope.",
},
limit: { type: "integer", minimum: 1, maximum: 20, default: 8 },
limit: { type: "integer", minimum: 1, maximum: 20, default: 10 },
character_budget: {
type: "integer",
minimum: 200,
@@ -285,8 +285,10 @@ export function isContextResponseForRequest(
): value is HostContextSearchResponse {
if (!isHostContextSearchResponse(value)) return false;
const kinds = request.kinds ?? ["raw", "memory"];
// Core's default item cap: 8 on v1, 10 on SDK-native v2.
const limit = request.limit ?? (isSdkContextSearchResponse(value) ? 10 : 8);
return (
value.items.length <= (request.limit ?? 8) &&
value.items.length <= limit &&
value.used_characters <= (request.character_budget ?? 4000) &&
value.items.every(
(item) =>