Merge pull request #280 from Tencent-Hunyuan/fix/multi-user-hardening

fix(memhub): multi-user responses echo user_id, cross-user delete answers 404, Hermes profiles isolated for any root
This commit is contained in:
feitianxiang authored and GitHub committed 2026-11-02 16:44:04 +08:00
commit 850c728c9d
26 files changed
+626 -60

No files matched your search

+27
View File
@@ -4,6 +4,33 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased
- MemHub multi-user hardening (follow-up to #250):
- Read responses name the user they were read for: `user_id` (the one
asked for, `local` by default) is now on `GET /api/v2/memory-library`,
`/memory-library/layers`, `/user-profile`, `/home-summary`,
`/memories/graph`, `/raw`, and `POST /api/v2/context/search`,
`/api/v1/recall`, `/api/v2/memories/ask`. `ask/stream` carries it on its
first (`recall`) event. The Host Bridge context validator accepts the new
field and still accepts a Core without it.
- `DELETE /api/v2/memories/{id}` answers 404 `memory_not_found` for another
user's memory and for an id that does not exist (the same response, so
existence is still not revealed), and also when the SDK finds nothing to
delete; 204 only when the caller's own memory was deleted. The App treats
that 404 as already gone (a double-click delete no longer shows an
error); `memhub memories delete` prints "未找到记忆 <id>(可能已删除)",
exits 0, and its JSON is `state: "committed"` with `already_gone: true`.
- Hermes never reads a `profiles/` directory at any depth (scan, detect and
live sync), so a root granted above `~/.hermes` cannot pick up another
user's profile. Connecting refuses a root that is the `profiles`
container itself (422 `hermes_root_is_profiles_container`) and a root
equal to, containing or inside another user's active Hermes grant (409
`hermes_root_overlaps_other_user`, symlinks resolved); `local` on
`~/.hermes` with another user on `~/.hermes/profiles/<name>` stays
allowed. The CLI explains both codes.
- `memhub sources connect --user-id` with an invalid id now says the user
id need not equal the Hermes profile name (e.g. `--user-id wife-work
--root ~/.hermes/profiles/wife:work`).
- MemHub's DeepSeek Harness app page can grant recall again (#230). The
"一键插入应用" switch used to be greyed out with `unsupported_host`, and a
profile installed from the CLI stayed `install-only` with no way to approve
+9
View File
@@ -137,6 +137,15 @@ memhub profile
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
用户长期画像,不通过普通 Search 拼装。
读接口都回显 `user_id`(实际读的用户,未指定时为 `local`):
`GET /api/v2/memory-library`、`/memory-library/layers`、`/user-profile`、
`/user-profile/versions`、`/home-summary`、`/memories/graph`、`/raw`,
`POST /api/v2/context/search`、`/api/v1/recall`、`/api/v2/memories/ask`;
`/memories/ask/stream` 写在第一个 `recall` 事件上。
`DELETE /api/v2/memories/{id}` 只在删掉调用者自己的记忆时回 204;另一个用户的
记忆和不存在的 ID 一律回 404 `memory_not_found`(不暴露是否存在),调用方按
「已不存在」处理。
返回值:
```python
+25 -13
View File
@@ -1938,7 +1938,7 @@ async function runCommand(
return reject(
descriptor,
asJson,
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。",
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。用户 ID 不必等于 Hermes profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。",
);
}
if (!parsed) return usage();
@@ -2328,29 +2328,41 @@ async function runCommand(
...userIdParameters(userId),
}),
);
// A delete answered 404 `memory_not_found` is already gone (deleted
// before, or never this user's): what was asked for holds, so it exits 0
// as committed and says so, instead of failing a retry or a double run.
const alreadyGone =
parsed.kind === "delete" &&
outcome.state === "rejected" &&
outcome.code === "memory_not_found";
const state = alreadyGone ? "committed" : outcome.state;
const document = {
schema_version: 2,
product: PRODUCT,
command: `memories ${parsed.kind}`,
state: outcome.state,
state,
operation: parsed.kind,
memory_id: parsed.memoryId,
...(outcome.state === "committed"
? parsed.kind === "update"
? { result: outcome.data }
: {}
: { code: outcome.code }),
...(alreadyGone
? { already_gone: true }
: outcome.state === "committed"
? parsed.kind === "update"
? { result: outcome.data }
: {}
: { code: outcome.code }),
};
emit(
asJson
? `${JSON.stringify(document)}\n`
: outcome.state === "committed"
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
: outcome.code === "memory_id_ambiguous"
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
: `操作失败:${outcome.code}\n`,
: alreadyGone
? `未找到记忆 ${parsed.memoryId}(可能已删除),无需再删。\n`
: outcome.state === "committed"
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
: outcome.code === "memory_id_ambiguous"
? "这个 ID 同时存在于多个场景;请加 --kind(以及 --variant)指明是哪一条。\n"
: `操作失败:${outcome.code}\n`,
);
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
return state === "committed" ? EXIT_OK : EXIT_FAILED;
}
if (
named === "settings" &&
+3 -3
View File
@@ -258,7 +258,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "sources",
summary:
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(会自动排除 ~/.hermes/profiles/**,各用户互不读取)。",
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(任何层级的 profiles/ 目录都会被排除,各用户互不读取)。--root 不能是 profiles 目录本身,也不能与另一个用户的 Hermes 目录相同或互相包含(~/.hermes 与 ~/.hermes/profiles/<name> 除外)。",
usage:
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
options: [
@@ -270,7 +270,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
flag: "--user-id <id>",
summary:
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;其他来源只能是 local。",
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;用户 ID 不必等于 profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。其他来源只能是 local。",
},
{
flag: "--root <path>",
@@ -397,7 +397,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
{
name: "memories",
summary:
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes;要删的记忆已不存在(或不属于该用户)时提示未找到,按已删除处理并退出 0。",
usage:
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
options: [
+13
View File
@@ -348,12 +348,25 @@ export function parseApiKey(bytes: Buffer | null): string | undefined {
return value;
}
/** What to do about a refusal whose code alone does not say. */
const FLOW_CODE_HINTS: Record<string, string> = {
hermes_root_is_profiles_container:
"--root 指向的是 profiles 目录本身;请指向其中一个 profile,例如 ~/.hermes/profiles/wife。",
hermes_root_overlaps_other_user:
"--root 与另一个用户已接入的 Hermes 目录相同或互相包含,会读到对方的记录;请换一个目录,或先撤销对方的接入。~/.hermes 与 ~/.hermes/profiles/<name> 可以分属两个用户。",
};
export function renderFlowText(document: FlowDocument): string {
const lines = [
`操作状态:${document.state}`,
`操作 ID:${document.operation_id ?? "无"}`,
"code" in document ? `代码:${document.code}` : "已返回有界回执。",
];
const hint =
"code" in document && typeof document.code === "string"
? FLOW_CODE_HINTS[document.code]
: undefined;
if (hint) lines.push(hint);
// A partial success or an all-rejected batch must name each source that was
// not accepted; a bare "partial" leaves the user with nothing to act on.
if (
+27
View File
@@ -366,6 +366,33 @@ describe("memories update / delete pass the namespace", () => {
);
});
it("reports a delete answered 404 as already gone and exits 0", async () => {
transport.delete.mockResolvedValue({
status: 404,
data: { detail: "memory_not_found" },
});
const json = await capturedRun([
"memories",
"delete",
"gone-1",
"--yes",
"--json",
]);
expect(json.code).toBe(0);
expect(JSON.parse(json.stdout)).toEqual({
schema_version: 2,
product: expect.any(String),
command: "memories delete",
state: "committed",
operation: "delete",
memory_id: "gone-1",
already_gone: true,
});
const text = await capturedRun(["memories", "delete", "gone-1", "--yes"]);
expect(text.code).toBe(0);
expect(text.stdout).toContain("未找到记忆 gone-1");
});
it("explains an ambiguous id instead of a bare code", async () => {
transport.delete.mockResolvedValue({
status: 409,
+42
View File
@@ -373,6 +373,48 @@ describe("sources connect for a Hermes profile", () => {
}
expect(transport.post).not.toHaveBeenCalled();
});
it("says the user id need not equal the Hermes profile name", async () => {
const result = await capturedRun([
"sources",
"connect",
"hermes",
"--user-id",
"wife:work",
"--root",
"~/.hermes/profiles/wife:work",
]);
expect(result.code).toBe(2);
expect(result.stderr).toContain("用户 ID 不必等于 Hermes profile 名");
expect(result.stderr).toContain(
"--user-id wife-work --root ~/.hermes/profiles/wife:work",
);
expect(transport.post).not.toHaveBeenCalled();
});
it("explains a refused overlapping or profiles-container root", async () => {
for (const code of [
"hermes_root_is_profiles_container",
"hermes_root_overlaps_other_user",
]) {
transport.post.mockResolvedValueOnce({
status: code.endsWith("container") ? 422 : 409,
data: { detail: code },
});
const result = await capturedRun([
"sources",
"connect",
"hermes",
"--user-id",
"kid",
"--root",
"/profiles",
]);
expect(result.code).toBe(1);
expect(result.stdout).toContain(code);
expect(result.stdout).toContain("--root");
}
});
});
async function capturedRun(arguments_: string[]) {
+105 -11
View File
@@ -130,6 +130,7 @@ from one_memory.hy_sdk_types import (
from one_memory.imports.access import (
AuthorizedSourceRoot,
SourceAccessError,
resolved_source_root,
root_identity_matches,
source_root_identity,
)
@@ -169,6 +170,11 @@ from one_memory.imports.locations import SourceLocation, inspect_source_location
from one_memory.imports.models import EvidenceRecord
from one_memory.imports.runner import live_sync_arrival
from one_memory.imports.sources.common import SourceInstallState
from one_memory.imports.sources.hermes import (
HERMES_ROOT_IS_PROFILES_CONTAINER,
hermes_root_conflict,
hermes_root_is_profiles_container,
)
from one_memory.imports.sources.registry import (
RootCandidateKind,
SourceRegistration,
@@ -531,11 +537,25 @@ class SdkMemoryUpdateRequest(BaseModel):
class SdkUserProfileResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
#: The user whose profile this is: the `user_id` asked for, else `local`.
user_id: str
request_id: str
profile: dict[str, object] | None
elapsed_ms: float = Field(ge=0)
class SdkMemoryLibraryPage(HySdkMemoryPage):
"""`GET /api/v2/memory-library`: one SDK page and the user it was read for."""
model_config = ConfigDict(frozen=True, extra="forbid")
user_id: str
@classmethod
def of(cls, page: HySdkMemoryPage, user_id: str) -> "SdkMemoryLibraryPage":
return cls(**dict(page), user_id=user_id)
class SdkProfileVersionsResponse(HySdkProfileVersions):
model_config = ConfigDict(frozen=True, extra="forbid")
@@ -590,6 +610,8 @@ class SdkRawItem(HySdkRawRecord):
class SdkRawPage(BaseModel):
model_config = ConfigDict(extra="forbid")
#: The user whose raw turns these are.
user_id: str
items: tuple[SdkRawItem, ...] = ()
total: int = Field(ge=0)
offset: int = Field(ge=0)
@@ -603,6 +625,8 @@ class SdkRawPage(BaseModel):
class SdkLayerCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
#: The user whose memories were counted.
user_id: str
#: Every SDK layer, zero when empty.
layers: dict[str, int]
total: int = Field(ge=0)
@@ -998,6 +1022,8 @@ class AskMemoryRequest(SdkSearchFilters):
class SdkHomeSummary(BaseModel):
schema_version: Literal[2] = 2
#: The user the card was read for.
user_id: str
memory_count: int
latest: HySdkMemoryRecord | None
observed_at: datetime
@@ -1024,6 +1050,8 @@ class MemoryGraphEdge(BaseModel):
class MemoryGraphResponse(BaseModel):
#: The user whose memories were mapped.
user_id: str
total_memories: int
projected_memories: int
nodes: tuple[MemoryGraphNode, ...]
@@ -2698,6 +2726,7 @@ def create_app(
)
if not root_identity_matches(registration.candidate.path, request.expected_root_id):
raise HTTPException(status_code=409, detail="source_identity_changed")
await _refuse_overlapping_root(source_id, registration.candidate.path, DEFAULT_USER_ID)
root_changed = prior is not None and prior.root_id != request.expected_root_id
if root_changed:
source_jobs = [job for job in _job_store(app).list_jobs() if job.source_id == source_id]
@@ -3098,6 +3127,20 @@ def create_app(
registrations = [_require_registration(app, source_id) for source_id in source_ids]
if request.request_id is None:
raise HTTPException(status_code=409, detail="source_confirmation_required")
for registration in registrations:
if registration.descriptor.source_id in MULTI_USER_SOURCES:
try:
resolved = await asyncio.to_thread(
app.state.source_candidate_store.resolve, registration
)
except SourceCandidateError:
# The connect below reports the missing candidate itself.
continue
await _refuse_overlapping_root(
registration.descriptor.source_id,
resolved.candidate.path,
DEFAULT_USER_ID,
)
history_after = await _onboarding_history_after(
request,
registrations,
@@ -3197,6 +3240,42 @@ def create_app(
raise
return ConnectSourcesResponse(**result.model_dump())
async def _refuse_overlapping_root(source_id: str, root: Path, user_id: str) -> None:
"""Refuse a Hermes root that would read another user's files.
422 `hermes_root_is_profiles_container` for `.../profiles` itself;
409 `hermes_root_overlaps_other_user` for a root equal to, holding or
inside another user's active grant, unless one is the other's
`profiles/<name>` (the scan never enters `profiles/`).
"""
if source_id not in MULTI_USER_SOURCES:
return
if hermes_root_is_profiles_container(root):
raise HTTPException(status_code=422, detail=HERMES_ROOT_IS_PROFILES_CONTAINER)
def conflict_with_others() -> str | None:
try:
mine = resolved_source_root(root)
except SourceAccessError:
# Unreadable or missing: the grant that follows refuses it
# with its own code.
return None
others = [
Path(path)
for receipt, path in _grant_store(app).grants()
if receipt.source_id == source_id
and receipt.user_id != user_id
and receipt.revoked_at is None
]
return hermes_root_conflict(mine, others)
conflict = await asyncio.to_thread(conflict_with_others)
if conflict is not None:
raise HTTPException(
status_code=422 if conflict == HERMES_ROOT_IS_PROFILES_CONTAINER else 409,
detail=conflict,
)
async def _connect_other_root(request: ConnectSourcesRequest) -> ConnectSourcesResponse:
"""Grant one Hermes root to a user: `~/.hermes/profiles/<name>` for its own user.
@@ -3226,6 +3305,7 @@ def create_app(
root = Path(request.root)
if not root.is_absolute():
raise HTTPException(status_code=422, detail="source_root_must_be_absolute")
await _refuse_overlapping_root(source_id, root, request.user_id)
# Named outright by the caller, like an environment override:
# no saved candidate may replace it, and naming it confirms it.
registration = replace(
@@ -3761,6 +3841,7 @@ def create_app(
raise _sdk_http_error(error, "hy_memory_list_failed") from None
total = stats.get("total") if isinstance(stats, dict) else None
return SdkHomeSummary(
user_id=user_id,
memory_count=total if isinstance(total, int) and total >= 0 else page.total,
latest=page.memories[0] if page.memories else None,
observed_at=datetime.now(UTC),
@@ -3768,7 +3849,7 @@ def create_app(
@app.get(
"/api/v2/memory-library",
response_model=HySdkMemoryPage,
response_model=SdkMemoryLibraryPage,
dependencies=strict,
)
async def sdk_memory_library(
@@ -3785,7 +3866,7 @@ def create_app(
exclude_sources: list[str] | None = Query(default=None), # noqa: B008
session_ids: list[str] | None = Query(default=None), # noqa: B008
agent_ids: list[str] | None = Query(default=None), # noqa: B008
) -> HySdkMemoryPage:
) -> SdkMemoryLibraryPage:
"""One page of memories across chat, working and coding.
Without `query`: `list_memories(scene="all")`, newest update first;
@@ -3831,7 +3912,7 @@ def create_app(
status_code=422,
detail="sdk_search_supports_active_status_only",
)
return await _library_search_page(
searched = await _library_search_page(
app,
client,
query=search_query,
@@ -3844,6 +3925,7 @@ def create_app(
user_id=user_id,
filters=filters,
)
return SdkMemoryLibraryPage.of(searched, user_id)
raw = await asyncio.to_thread(
client.list_memories,
user_id=user_id,
@@ -3861,7 +3943,7 @@ def create_app(
raw, limit=limit, offset=offset
)
_log_skipped_sdk_memories(skipped)
return page
return SdkMemoryLibraryPage.of(page, user_id)
except HTTPException:
raise
except Exception as error:
@@ -3907,6 +3989,7 @@ def create_app(
except Exception as error:
raise _sdk_http_error(error, "hy_memory_list_failed") from None
return SdkLayerCounts(
user_id=user_id,
layers=counts,
total=sum(counts.values()),
statuses=tuple(chosen) if chosen else None,
@@ -4067,6 +4150,7 @@ def create_app(
)
)
return MemoryGraphResponse(
user_id=user_id,
total_memories=total if isinstance(total, int) else len(memories),
projected_memories=len(memories),
nodes=tuple(nodes.values()),
@@ -4088,7 +4172,7 @@ def create_app(
profile = HySdkUserProfile.from_sdk(value)
except Exception as error:
raise _sdk_http_error(error, "hy_memory_profile_read_failed") from None
return SdkUserProfileResponse.model_validate(profile.model_dump())
return SdkUserProfileResponse(user_id=user_id, **profile.model_dump())
@app.get(
"/api/v2/user-profile/versions",
@@ -4212,15 +4296,16 @@ def create_app(
try:
async with app.state.sdk_write_coordinator.use_client() as client:
# `delete` takes no user. Another user's memory is answered
# exactly like an id that does not exist: 204, nothing
# touched, so the status cannot tell that it exists.
# exactly like an id that does not exist: 404
# `memory_not_found`, nothing touched, so the status cannot
# tell that it exists. Callers read 404 as "already gone".
current = await asyncio.to_thread(
client.get, memory_id, **_namespace_kwargs(kind, variant)
)
if current is not None and not owns(
if current is None or not owns(
HySdkMemoryRecord.from_sdk(current).user_id, user_id
):
return Response(status_code=204)
raise HTTPException(status_code=404, detail="memory_not_found")
result = await asyncio.to_thread(
client.delete, memory_id, **_namespace_kwargs(kind, variant)
)
@@ -4229,7 +4314,11 @@ def create_app(
error_code = result.get("error_code")
if error_code or result.get("partial") is True:
raise HySdkError(str(error_code or "hy_memory_delete_incomplete"))
if result.get("success") is not True and result.get("deleted_count") != 0:
if result.get("success") is not True:
# Read a moment ago, found by nobody now (a second delete won
# the race): the same answer as an id that never existed.
if result.get("deleted_count") == 0:
raise HTTPException(status_code=404, detail="memory_not_found")
raise HySdkError("hy_memory_delete_rejected")
except HTTPException:
raise
@@ -4302,6 +4391,7 @@ def create_app(
and (before is None or at < before)
)
return SdkRawPage(
user_id=user_id,
items=tuple(SdkRawItem.of(record) for record in records),
total=page.total,
offset=offset,
@@ -4334,6 +4424,7 @@ def create_app(
)
except MemoryAnswerError as error:
raise HTTPException(status_code=503, detail=str(error)) from None
answer = answer.model_copy(update={"user_id": request.user_id})
if request.question != "__om_probe__" and access is not None:
from one_memory.call_event_recorder import ask_call_event, try_record
@@ -4375,6 +4466,9 @@ def create_app(
):
if event.get("type") == "final":
final = event
elif event.get("type") == "recall":
# The first event names whose memories answer.
event = {**event, "user_id": request.user_id}
yield _sse(_serialize_ask_event(event))
except MemoryAnswerError:
yield _sse({"type": "error", "code": "memory_answer_failed"})
@@ -4453,7 +4547,7 @@ def create_app(
)
except Exception:
app.state.call_event_recording_error = True
return receipt
return receipt.model_copy(update={"user_id": request.user_id})
@app.get(
"/api/v1/memory-processing",
@@ -155,6 +155,8 @@ class SdkContextSearchResponse(BaseModel):
used_characters: int = Field(ge=0, le=20000)
truncated: bool
total_ms: float = Field(ge=0)
#: Whose memories were searched: the request's `user_id`, else `local`.
user_id: str = Field(min_length=1, max_length=64)
class ContextLaneResult(BaseModel):
@@ -261,6 +261,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
used_characters=0,
truncated=True,
total_ms=(time.monotonic() - started) * 1000,
user_id=value.user_id,
)
await record(final, timed_out=True)
return final
@@ -306,6 +307,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
used_characters=used,
truncated=truncated,
total_ms=(time.monotonic() - started) * 1000,
user_id=value.user_id,
)
await record(final, timed_out=False)
return final
@@ -436,6 +436,11 @@ def root_identity_matches(root: Path, expected: str) -> bool:
return _root_identity_matches_details(resolved, details, expected)
def resolved_source_root(root: Path) -> Path:
"""The real directory a root names (symlinks followed), as a grant records it."""
return _root_details(root)[0]
def _check_root_identity(root: Path, expected: str | None) -> None:
if expected is not None and not root_identity_matches(root, expected):
raise SourceAccessError(SourceAccessErrorCode.SOURCE_IDENTITY_CHANGED)
@@ -27,7 +27,7 @@ def discovered_off_loop(
*,
suffixes: frozenset[str],
max_targets: int,
excluded_top_directories: frozenset[str] = frozenset(),
excluded_directory_names: frozenset[str] = frozenset(),
) -> AsyncIterator[SourceFileDiscoveryEvent]:
"""The same walk, for callers that have an event loop to keep free.
@@ -42,7 +42,7 @@ def discovered_off_loop(
grant,
suffixes=suffixes,
max_targets=max_targets,
excluded_top_directories=excluded_top_directories,
excluded_directory_names=excluded_directory_names,
),
scope_key=grant.source_id,
)
@@ -53,15 +53,17 @@ def discover_source_files(
*,
suffixes: frozenset[str],
max_targets: int,
excluded_top_directories: frozenset[str] = frozenset(),
excluded_directory_names: frozenset[str] = frozenset(),
) -> Iterator[SourceFileDiscoveryEvent]:
"""Metadata only: one lstat per candidate, no open and no per-file grant lookup.
The grant and root identity are checked once before the walk. Each listed
file keeps the grant's check, which runs again when that file is opened;
the open itself refuses symlinks and any inode other than the one listed.
`excluded_top_directories` are directories directly under the root that
are never entered, such as another user's Hermes profiles.
`excluded_directory_names` are directory names never entered, at any
depth under the root, such as the `profiles` container that holds other
users' Hermes profiles. Any depth, because the granted root may sit above
the Hermes home (`~/.hermes` inside it is then `.hermes/profiles`).
"""
grant.check_root()
inspected = 0
@@ -69,12 +71,10 @@ def discover_source_files(
grant.root,
followlinks=False,
):
at_root = Path(directory) == grant.root
child_directories[:] = sorted(
child
for child in child_directories
if not (Path(directory) / child).is_symlink()
and not (at_root and child in excluded_top_directories)
if not (Path(directory) / child).is_symlink() and child not in excluded_directory_names
)
for filename in sorted(filenames):
if Path(filename).suffix.lower() not in suffixes:
@@ -1,9 +1,9 @@
import json
import re
import sqlite3
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Iterable, Iterator
from functools import partial
from pathlib import PurePosixPath
from pathlib import Path, PurePosixPath
from pydantic import JsonValue
@@ -88,7 +88,7 @@ class HermesSourceAdapter:
self.grant,
suffixes=frozenset({".jsonl", ".db"}),
max_targets=10_000,
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
):
if isinstance(item, AuthorizedSourceFile) and _is_hermes_target(
item.relative_path.as_posix()
@@ -102,7 +102,7 @@ class HermesSourceAdapter:
self.grant,
suffixes=frozenset({".jsonl", ".db"}),
max_targets=options.max_scan_targets or 100_000,
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
excluded_directory_names=frozenset({HERMES_PROFILES_DIRECTORY}),
):
yield ScanProgressEvent(
phase=ScanPhase.DISCOVER,
@@ -253,16 +253,62 @@ def parse_hermes_rollout_record(
#: own `state.db` and sessions. A profile is another person's history: it is
#: read only through its own grant (its own MemHub user), never as part of the
#: root above it. Hermes' own export skips this directory for the same reason.
#:
#: Skipped at any depth, not only directly under the root: a root granted
#: above the Hermes home holds `.hermes/profiles/`. Hermes keeps nothing of
#: its own under a directory of that name (its history is `state.db` and
#: `sessions/`), so nothing real is lost.
HERMES_PROFILES_DIRECTORY = "profiles"
def _is_hermes_target(relative_path: str) -> bool:
path = PurePosixPath(relative_path)
if path.parts and path.parts[0] == HERMES_PROFILES_DIRECTORY:
if HERMES_PROFILES_DIRECTORY in path.parts[:-1]:
return False
return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts)
def hermes_root_is_profiles_container(root: Path) -> bool:
"""`profiles` itself holds everyone's profiles; it is never one person's history."""
return root.name == HERMES_PROFILES_DIRECTORY
def hermes_root_conflict(mine: Path, other_users_roots: Iterable[Path]) -> str | None:
"""Why the resolved root `mine` may not be granted beside other users' Hermes roots.
Paths are compared resolved (symlinks followed; the other users' roots are
resolved here). Two roots that are equal or nest would read the same files
for two users, except when one is exactly the other's `profiles/<name>`:
the scan above never enters `profiles/`, so the two stay apart (`local` on
`~/.hermes`, a user on `~/.hermes/profiles/<name>`). None when it may.
"""
if hermes_root_is_profiles_container(mine):
return HERMES_ROOT_IS_PROFILES_CONTAINER
for other in other_users_roots:
theirs = _real(other)
if mine == theirs:
return HERMES_ROOT_OVERLAPS_OTHER_USER
for parent, child in ((mine, theirs), (theirs, mine)):
if parent in child.parents:
relative = child.relative_to(parent).parts
if len(relative) != 2 or relative[0] != HERMES_PROFILES_DIRECTORY:
return HERMES_ROOT_OVERLAPS_OTHER_USER
return None
#: 422: the root is the `profiles` container, not a profile.
HERMES_ROOT_IS_PROFILES_CONTAINER = "hermes_root_is_profiles_container"
#: 409: the root is, holds or sits inside another user's Hermes root.
HERMES_ROOT_OVERLAPS_OTHER_USER = "hermes_root_overlaps_other_user"
def _real(path: Path) -> Path:
try:
return path.expanduser().resolve(strict=False)
except (OSError, RuntimeError):
return path.expanduser().absolute()
def _read_rollout(
source_file: AuthorizedSourceFile,
options: ScanOptions,
@@ -135,6 +135,8 @@ class RecallReceipt(BaseModel):
#: exists yet (`fallback_reason`). Raw hits are excerpts, not memories.
mode: Literal["memory", "raw"] = "memory"
fallback_reason: Literal["model_unavailable", "no_memories"] | None = None
#: Whose memories were searched: the request's `user_id`, else `local`.
user_id: str = DEFAULT_USER_ID
class RecallTarget(Protocol):
@@ -33,7 +33,7 @@ from one_memory.model_profiles import (
)
from one_memory.sdk_search_options import SdkSearchFilters, SearchLane
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
from one_memory.user_scope import owns
from one_memory.user_scope import DEFAULT_USER_ID, owns
_NO_GROUNDING = "现有记忆不足以回答这个问题。"
_GROUNDING_LIMIT = 6
@@ -62,6 +62,8 @@ class SdkMemoryAnswer(BaseModel):
answer: str
memories: tuple[SdkAskMemory, ...]
mode: Literal["generated", "extractive", "empty"]
#: Whose memories answered: the request's `user_id`, else `local`.
user_id: str = DEFAULT_USER_ID
class _PromptMemory:
@@ -293,6 +293,7 @@ def test_sdk_user_profile_http_returns_native_assembled_profile(tmp_path):
response = await client.get("/api/v2/user-profile")
assert response.status_code == 200, response.text
assert response.json() == {
"user_id": "local",
"request_id": "profile-request",
"profile": {
"memory_id": "l0-head",
@@ -334,7 +334,9 @@ def test_another_users_memory_id_answers_404_for_read_update_and_delete(
assert (await client.get(path)).status_code == 404
assert (await client.put(path, json={"content": "stolen"})).status_code == 404
assert (await client.delete(path)).status_code == 204
refused = await client.delete(path)
assert refused.status_code == 404
assert refused.json()["detail"] == "memory_not_found"
assert sdk.memories[wife_memory]["content"] == "wife drinks coffee"
own = await client.get(path, params={"user_id": "wife"})
@@ -343,6 +345,36 @@ def test_another_users_memory_id_answers_404_for_read_update_and_delete(
assert updated.status_code == 200
assert (await client.delete(path, params={"user_id": "wife"})).status_code == 204
assert wife_memory not in sdk.memories
# A second delete (a double click) finds nothing: 404, already gone.
again = await client.delete(path, params={"user_id": "wife"})
assert again.status_code == 404
assert again.json()["detail"] == "memory_not_found"
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
def test_a_delete_that_loses_the_race_answers_404_like_a_missing_id(tmp_path, monkeypatch) -> None:
"""Read as the caller's own, then gone before the SDK deletes it."""
async def scenario(app, sdk, client):
await _write_both_users(app, sdk, client)
[mine] = [
memory_id
for memory_id, row in sdk.memories.items()
if row["content"] == "local drinks tea"
]
original = sdk.delete
def raced(memory_id, kind=None, variant=None):
sdk.memories.pop(memory_id, None)
return original(memory_id, kind=kind, variant=variant)
sdk.delete = raced # type: ignore[method-assign]
response = await client.delete(f"/api/v2/memories/{mine}")
missing = await client.delete("/api/v2/memories/no-such-memory")
assert _observed(response) == _observed(missing)
assert response.status_code == 404
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -426,6 +458,52 @@ async def _true() -> bool:
return True
@pytest.mark.fresh_sdk
@pytest.mark.parametrize("user", [None, "wife"])
def test_every_read_echoes_the_user_it_was_read_for(tmp_path, monkeypatch, user) -> None:
"""Each read names the user it answered for: the one asked for, else `local`."""
async def scenario(app, sdk, client):
await _write_both_users(app, sdk, client)
monkeypatch.setattr(
"one_memory.api._recall_model_ready", lambda _app: _true(), raising=True
)
app.state.recall_target = _sdk_recall_target(app, "local")
expected = user or "local"
params = {} if user is None else {"user_id": user}
body = {} if user is None else {"user_id": user}
reads = [
await client.get("/api/v2/memory-library", params=params),
await client.get("/api/v2/memory-library", params={**params, "query": "route"}),
await client.get("/api/v2/memory-library/layers", params=params),
await client.get("/api/v2/user-profile", params=params),
await client.get("/api/v2/home-summary", params=params),
await client.get("/api/v2/memories/graph", params=params),
await client.get("/api/v2/raw", params=params),
await client.post(
"/api/v2/context/search", json={"query": "drinks", "kinds": ["memory"], **body}
),
await client.post("/api/v1/recall", json={"query": "drinks", **body}),
await client.post("/api/v2/memories/ask", json={"question": "what drink", **body}),
]
for response in reads:
assert response.status_code == 200, (response.request.url, response.text)
assert response.json()["user_id"] == expected, response.request.url
async with client.stream(
"POST", "/api/v2/memories/ask/stream", json={"question": "what drink", **body}
) as streamed:
events = [
json.loads(line[5:])
async for line in streamed.aiter_lines()
if line.startswith("data:")
]
assert events[0]["type"] == "recall"
assert events[0]["user_id"] == expected
run_with_sdk(tmp_path, monkeypatch, scenario)
@pytest.mark.fresh_sdk
@pytest.mark.parametrize(
("method", "path", "kwargs"),
@@ -569,6 +647,26 @@ def test_a_hermes_root_scan_never_reads_its_profiles(tmp_path) -> None:
assert _scan_texts(wife, tmp_path) == ["I am the wife", "hello", "wife's transcript"]
def test_a_profiles_directory_is_skipped_at_any_depth(tmp_path) -> None:
"""A root above the Hermes home still never reads `.hermes/profiles/**`."""
home = tmp_path / "home"
hermes, _wife = _hermes_homes(home)
(hermes / "sessions").mkdir()
(hermes / "sessions" / "own.jsonl").write_text(
json.dumps({"type": "message", "role": "user", "content": "own transcript"}) + "\n"
)
nested = hermes / "sessions" / "profiles" / "kid" / "sessions"
nested.mkdir(parents=True)
(nested / "k.jsonl").write_text(
json.dumps({"type": "message", "role": "user", "content": "kid's transcript"}) + "\n"
)
above = _scan_texts(home, tmp_path)
assert "I am the wife" not in above and "kid's transcript" not in above
assert set(above) == {"I am the husband", "hi", "own transcript"}
assert "kid's transcript" not in _scan_texts(hermes, tmp_path)
def _jobs(database: Path) -> list[sqlite3.Row]:
with sqlite3.connect(database) as db:
db.row_factory = sqlite3.Row
@@ -719,6 +817,87 @@ def test_a_migrated_home_with_the_old_grant_triggers_can_connect_and_revoke(tmp_
asyncio.run(scenario())
async def _connect(client, user_id: str, root: Path | None = None, **extra):
body: dict[str, object] = {"source_ids": ["hermes"], "request_id": str(uuid4()), **extra}
if user_id != "local":
body["user_id"] = user_id
if root is not None:
body["root"] = str(root)
return await client.post("/api/v1/sources/connect", json=body)
@pytest.mark.fresh_sdk
def test_a_hermes_root_that_is_the_profiles_container_is_refused(tmp_path) -> None:
async def scenario():
home = tmp_path / "home"
hermes, _wife = _hermes_homes(home)
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
for user in ("wife", "local"):
refused = await _connect(client, user, hermes / "profiles")
assert refused.status_code == 422, refused.text
assert refused.json()["detail"] == "hermes_root_is_profiles_container"
assert app.state.source_grant_store.grants() == ()
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_two_users_on_one_hermes_root_or_nested_roots_are_refused(tmp_path) -> None:
async def scenario():
home = tmp_path / "home"
hermes, wife = _hermes_homes(home)
(wife / "sessions").mkdir()
link = home / "hermes-link"
link.symlink_to(hermes, target_is_directory=True)
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
location = (await client.post("/api/v1/sources/hermes/location")).json()
local = await _connect(client, "local", source_roots={"hermes": location["root_id"]})
assert local.status_code == 200, local.text
# Same root, the same root through a symlink, a root inside it
# that is not `profiles/<name>`, and a root above it.
for root in (hermes, link, wife / "sessions", home):
refused = await _connect(client, "kid", root)
assert refused.status_code == 409, (root, refused.text)
assert refused.json()["detail"] == "hermes_root_overlaps_other_user"
# The standard layout: `local` on ~/.hermes, wife on its profile.
accepted = await _connect(client, "wife", wife)
assert accepted.status_code == 200, accepted.text
# Another user on wife's own root is refused too.
twin = await _connect(client, "wife2", wife)
assert twin.status_code == 409
# Once wife's grant is revoked her root is free again.
await client.post("/api/v1/sources/hermes/revoke", params={"user_id": "wife"})
assert (await _connect(client, "wife2", wife)).status_code == 200
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_local_cannot_take_another_users_hermes_root(tmp_path) -> None:
"""The check runs for `local`'s own connect too, not only for other users."""
async def scenario():
home = tmp_path / "home"
hermes, _wife = _hermes_homes(home)
other = home / "elsewhere" / ".hermes"
_hermes_state_db(other, [(1, "s", "user", "elsewhere")])
app = application(tmp_path, home=home)
async with app.router.lifespan_context(app), client_for(app) as client:
# wife took ~/.hermes itself, which is `local`'s default root.
taken = await _connect(client, "wife", hermes)
assert taken.status_code == 200, taken.text
location = (await client.post("/api/v1/sources/hermes/location")).json()
refused = await _connect(client, "local", source_roots={"hermes": location["root_id"]})
assert refused.status_code == 409, refused.text
assert refused.json()["detail"] == "hermes_root_overlaps_other_user"
# `local` on a root of its own that does not overlap is fine.
assert (await _connect(client, "local", other)).status_code == 200
asyncio.run(scenario())
@pytest.mark.fresh_sdk
def test_only_hermes_takes_another_user_or_a_custom_root(tmp_path) -> None:
async def scenario():
@@ -852,7 +1031,7 @@ def test_another_users_id_answers_exactly_like_an_id_that_does_not_exist(
other = await client.request(method, theirs, **kwargs)
absent = await client.request(method, missing, **kwargs)
assert _observed(other) == _observed(absent), (method, theirs)
assert other.status_code == (204 if method == "DELETE" else 404)
assert other.status_code == 404
assert sdk.memories == before
run_with_sdk(tmp_path, monkeypatch, scenario)
@@ -13,9 +13,18 @@ vi.mock("axios", () => ({
delete: del,
interceptors: { request: { use: vi.fn() } },
}),
isAxiosError: (value: unknown) =>
typeof value === "object" && value !== null && "isAxiosError" in value,
},
}));
function httpError(status: number, detail: string) {
return Object.assign(new Error(`http_${status}`), {
isAxiosError: true,
response: { status, data: { detail } },
});
}
import {
deleteSdkMemory,
getHomeSummary,
@@ -100,6 +109,18 @@ describe("paged memory library client", () => {
expect(del).toHaveBeenLastCalledWith("/api/v2/memories/c1", undefined);
});
it("treats a delete answered 404 memory_not_found as already gone", async () => {
del.mockRejectedValueOnce(httpError(404, "memory_not_found"));
await expect(deleteSdkMemory("gone")).resolves.toBeUndefined();
const other = httpError(404, "source_not_found");
del.mockRejectedValueOnce(other);
await expect(deleteSdkMemory("x")).rejects.toBe(other);
const failed = httpError(503, "hy_memory_delete_failed");
del.mockRejectedValueOnce(failed);
await expect(deleteSdkMemory("x")).rejects.toBe(failed);
});
it("forwards cancellation for inactive or superseded views", async () => {
const controller = new AbortController();
get.mockResolvedValueOnce({
+19 -3
View File
@@ -763,9 +763,25 @@ export async function deleteSdkMemory(
...(traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : {}),
...(params ? { params } : {}),
};
await client.delete(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
Object.keys(config).length ? config : undefined,
try {
await client.delete(
`/api/v2/memories/${encodeURIComponent(memoryId)}`,
Object.keys(config).length ? config : undefined,
);
} catch (reason) {
// 404 `memory_not_found`: already gone (a second click, or another
// window deleted it first). The memory is not there, which is what the
// person asked for, so it is not an error.
if (isMemoryNotFound(reason)) return;
throw reason;
}
}
function isMemoryNotFound(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
apiErrorCode(reason) === "memory_not_found"
);
}
+12 -2
View File
@@ -69,8 +69,14 @@ node apps/cli/dist/one-memory.cjs sources list
```
- 只有 `hermes` 能带 `--user-id`(非 `local`)或 `--root`;其他来源带了会退 2,什么都不发。
- 用户 ID 不必等于 profile 名:profile 名里有 `:` 等字符时另起一个,例如
`--user-id wife-work --root ~/.hermes/profiles/wife:work`。
- `--root` 里的 `~` 由 CLI 展开为主目录,相对路径按当前目录解析成绝对路径。
- 扫描 `~/.hermes` 时会排除 `profiles/**`,所以 `local` 不会读到 wife 的 `state.db`。
- 扫描时任何层级名为 `profiles` 的目录都会被排除,所以 `local` 不会读到 wife 的 `state.db`,
即使授权的根在 `~/.hermes` 之上。
- `--root` 不能是 `profiles` 目录本身(`hermes_root_is_profiles_container`),也不能与另一个
用户已接入的 Hermes 目录相同、包含它或在它之内(`hermes_root_overlaps_other_user`,解析
symlink 后比较);`~/.hermes` 给 `local`、`~/.hermes/profiles/<name>` 给另一个用户是允许的。
- App 不变:它只读写 `local`,其他来源也都固定是 `local`。
## 3. 看导入进度
@@ -140,7 +146,11 @@ node apps/cli/dist/one-memory.cjs write status <request_id> --user-id wife
- 用户 ID 只能是 1–64 位字母、数字、`.`、`_`、`-`;其他写法退 2,不连 Runtime。
- 不带 `--user-id`(或写 `--user-id local`)时请求里不出现 `user_id`,和以前完全一样。
- 用户之间互相隔离:读、改、删另一个用户的记忆 ID 返回「未找到」;write 的 request id 也只属于提交它的用户。
- 用户之间互相隔离:读、改、删另一个用户的记忆 ID 返回「未找到」(HTTP 404 `memory_not_found`,
与不存在的 ID 完全一样);write 的 request id 也只属于提交它的用户。
- `memories delete <id> --yes` 遇到「未找到」时按已删除处理:提示「未找到记忆 <id>(可能已删除)」,
退出 0;`--json` 为 `state: "committed"` 加 `already_gone: true`。App 里重复点删除也不再报错。
- 读接口(`memories list`、`profile`、`recall` 等背后的 HTTP 响应)都带 `user_id`,即实际读的是哪个用户。
### 画像版本(`profile --versions`)
@@ -5,6 +5,11 @@ Post-cutover hosts use SDK-native Context v2 at
`POST /api/v2/context/search`. A v2 response is never converted into v1 by
inventing revisions, Evidence IDs, paths, or adoption state.
Since MemHub 1.0.6 the v2 response also carries `user_id`, the user whose
memories were searched (`local` unless the request named one). The Host
Bridge validator accepts it as an optional field, so a response from an older
Core without it still validates; any other unknown field still fails closed.
## Transport
- The Broker `/context` route (used by host hooks and `recall-cli --context`;
@@ -20,6 +20,20 @@ cursor。旧文件后续追加也必须能被再次发现,不能只有一个
用户授权是读取前置条件。环境变量只用于发现候选根,不能代替授权。每个候选
文件必须在打开时再次验证 realpath 归属、symlink、普通文件类型和大小预算。
多用户(仅 Hermes):一个授权根属于一个 `user_id`。Hermes 的扫描、检测和
持续同步共用同一次遍历,**任何层级**名为 `profiles` 的目录都不进入(发现
helper 的 `excluded_directory_names`),所以授权根即使在 `~/.hermes` 之上,
也不会读到 `.hermes/profiles/<name>`。接入时 Core 拒绝:
- 根的最后一段是 `profiles`(profiles 容器本身):422
`hermes_root_is_profiles_container`;
- 根(解析 symlink 后)与另一个用户未撤销的 Hermes 授权根相同、包含它或在
它之内:409 `hermes_root_overlaps_other_user`。唯一例外是一方正好是另一方
的 `profiles/<name>`(`local` 在 `~/.hermes`、另一个用户在
`~/.hermes/profiles/<name>`),因为上面的排除已把两者隔开。
本机 `local` 的接入(`/sources/connect`、`/sources/{id}/authorize`)也走同一检查。
## Imported source record
Adapter 输出的 `ScannedEvidence` 是兼容命名的导入中间表示。原文持久化与 raw
+3
View File
@@ -118,6 +118,9 @@ export interface SdkContextSearchResponse {
used_characters: number;
truncated: boolean;
total_ms: number;
/** Whose memories were searched; `local` unless the request named a user.
* Absent from a Core older than 1.0.6. */
user_id?: string;
}
/** Native Host Bridge wire response. Schemas stay distinct and are never adapted. */
+16 -1
View File
@@ -425,6 +425,8 @@ export interface HomeSummary {
export interface SdkHomeSummary {
schema_version: 2;
/** The user the card was read for (`local` unless one was named). */
user_id?: string;
memory_count: number;
latest: SdkMemoryRecord | null;
observed_at: string;
@@ -605,6 +607,8 @@ export interface SdkMemoryRecord {
}
export interface SdkMemoryPage {
/** `GET /api/v2/memory-library`: the user the page was read for. */
user_id?: string;
memories: SdkMemoryRecord[];
total: number;
limit: number;
@@ -621,6 +625,8 @@ export interface SdkMemoryPage {
}
export interface SdkUserProfileResponse {
/** The user whose profile this is (`local` unless one was named). */
user_id?: string;
request_id: string;
profile: Record<string, unknown> | null;
elapsed_ms: number;
@@ -649,6 +655,8 @@ export interface SdkRawRecord {
}
export interface SdkRawPage {
/** The user whose raw turns these are. */
user_id?: string;
items: SdkRawRecord[];
total: number;
limit: number;
@@ -659,6 +667,8 @@ export interface SdkRawPage {
/** `GET /api/v2/memory-library/layers`: memories per SDK layer. */
export interface SdkLayerCounts {
/** The user whose memories were counted. */
user_id?: string;
layers: Record<string, number>;
total: number;
statuses: string[] | null;
@@ -763,7 +773,8 @@ export type AskMemoryEvent =
| { type: "error"; code: string };
export type SdkAskMemoryEvent =
| { type: "recall"; memories: SdkAskMemory[] }
/** `user_id`: whose memories answer (Core 1.0.6+). */
| { type: "recall"; memories: SdkAskMemory[]; user_id?: string }
| { type: "delta"; text: string }
| {
type: "final";
@@ -833,6 +844,8 @@ export interface RecallReceipt {
fallback_reason?: "model_unavailable" | "no_memories" | null;
/** Sources the caller was not allowed to read, left out of this result. */
excluded_sources?: string[];
/** Whose memories were searched (`local` unless the request named a user). */
user_id?: string;
}
export interface LiveSyncHealth {
@@ -874,6 +887,8 @@ export interface MemoryGraphEdge {
}
export interface MemoryGraphResponse {
/** The user whose memories were mapped. */
user_id?: string;
total_memories: number;
projected_memories: number;
nodes: MemoryGraphNode[];
@@ -90,6 +90,11 @@ it("accepts only the SDK context shape", () => {
).toBe(false);
expect(contextResponseInvalidField(mixed)).toBeNull();
expect(contextResponseInvalidField(legacyShape)).toBe("response");
// Core 1.0.6 echoes whose memories were searched; an older Core does not.
expect(isSdkContextSearchResponse({ ...mixed, user_id: "local" })).toBe(true);
expect(isSdkContextSearchResponse({ ...mixed, user_id: "" })).toBe(false);
expect(isSdkContextSearchResponse({ ...mixed, user_id: 7 })).toBe(false);
expect(isSdkContextSearchResponse({ ...mixed, owner: "local" })).toBe(false);
});
async function fixture(
@@ -140,15 +140,22 @@ export function isSdkContextSearchResponse(
): value is SdkContextSearchResponse {
if (
!record(value) ||
!exact(value, [
"schema_version",
"request_id",
"state",
"items",
"used_characters",
"truncated",
"total_ms",
]) ||
// `user_id` (whose memories were searched) is optional so a Core from
// before it was echoed still validates.
!exact(
value,
[
"schema_version",
"request_id",
"state",
"items",
"used_characters",
"truncated",
"total_ms",
],
["user_id"],
) ||
(value.user_id !== undefined && !text(value.user_id, 1, 64)) ||
value.schema_version !== 2 ||
!text(value.request_id, 1, 64) ||
!member(value.state, ["grounded", "empty", "partial", "unavailable"]) ||
@@ -223,8 +230,15 @@ function record(value: unknown): value is Record<string, unknown> {
function only(value: Record<string, unknown>, keys: string[]): boolean {
return Object.keys(value).every((key) => keys.includes(key));
}
function exact(value: Record<string, unknown>, keys: string[]): boolean {
return only(value, keys) && keys.every((key) => Object.hasOwn(value, key));
function exact(
value: Record<string, unknown>,
keys: string[],
optional: string[] = [],
): boolean {
return (
only(value, [...keys, ...optional]) &&
keys.every((key) => Object.hasOwn(value, key))
);
}
function text(value: unknown, min: number, max: number): value is string {
return (