Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c2f48579f | ||
|
|
d29615abeb | ||
|
|
0965519e16 | ||
|
|
7e9f0d98d8 | ||
|
|
0ff4538f36 | ||
|
|
a9709788d8 | ||
|
|
c1e5916b51 | ||
|
|
97de5480bd | ||
|
|
97d9309ca8 | ||
|
|
b1b3e0a291 | ||
|
|
53507c7abe | ||
|
|
5a1d252787 | ||
|
|
8895fea775 | ||
|
|
78882f3300 | ||
|
|
988667cd87 | ||
|
|
72968f831f | ||
|
|
af25f51f9e | ||
|
|
b3f13e130f | ||
|
|
a621262fac | ||
|
|
e027635d3f | ||
|
|
4cb33b7108 | ||
|
|
b50c9f33eb | ||
|
|
545b7d38bf | ||
|
|
5f78d67363 | ||
|
|
33d7713d4e | ||
|
|
68a4b546f9 | ||
|
|
cbcc50f35e | ||
|
|
0eb9a2844e | ||
|
|
5103492762 | ||
|
|
082aeee1f2 | ||
|
|
201901d35e |
No files matched your search
@@ -4,6 +4,12 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## MemHub 1.0.5 — 2026-10-28
|
||||
|
||||
Build 36, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime. Upgrading from 1.0.4 migrates the existing library to SDK
|
||||
authority on first launch.
|
||||
|
||||
- MemHub turns off the HY pipeline log and trace at every diagnostic level
|
||||
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
|
||||
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
|
||||
@@ -88,6 +94,29 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||
profiles, SDK stores, and source/host grants are preserved.
|
||||
|
||||
## Hy Memory 2.0.9 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.9`](https://pypi.org/project/hy-memory/2.0.9/).
|
||||
- Placeholder values no longer overwrite the user's basic profile (L0). A
|
||||
small extraction model filled `basic_info` with `{"name": "用户"}`,
|
||||
`The user` or `N/A` when the user had said nothing about themselves, and
|
||||
each placeholder superseded the real value, so the L0 chain read
|
||||
费天翔 → Fei → 用户 → The user. Two defences:
|
||||
- `upsert_basic_profile` drops placeholder values: empty, `用户`,
|
||||
`the user`, `user`, `n/a`, `na`, `none`, `null`, `unknown`, `未提供`,
|
||||
`未知`, `不详`, `-`, `?` and the field's own name, compared
|
||||
case-insensitively with whitespace and punctuation stripped. When nothing
|
||||
is left it writes no new version and no new L0 head. Placeholders already
|
||||
stored in the chain no longer override earlier real values when the
|
||||
profile is assembled, so `get_user_profile` of an affected user shows the
|
||||
last real value again without any migration. This also stops an
|
||||
assistant quoting an old memory ("The user's name is 用户.") from being
|
||||
written back.
|
||||
- The extraction prompts (zh and en) state the rule: only what the user
|
||||
said in a `user` turn, never the field name or a placeholder, `{}`
|
||||
otherwise, with a small-talk counter-example.
|
||||
|
||||
## Hy Memory 2.0.8 — 2026-10-28
|
||||
|
||||
- Published
|
||||
|
||||
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
|
||||
|
||||
@@ -682,6 +682,23 @@ Output:
|
||||
```
|
||||
John's occupation and location are HIS attributes, so they go into `memory` with "John" as the subject. They must NEVER land in `basic_info.occupation` / `basic_info.location` — `basic_info` is exclusively the user's own profile. Only `name` (Tim, from the user message's `name` field) belongs there.
|
||||
|
||||
## Example 11 — Counter-example: small talk has no basic_info
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello! The user's name is The user. How can I help?"}]
|
||||
Memory Date: 2025-09-03
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The user stated nothing about themselves. The assistant's line is not a user turn, and "The user" is a placeholder, not a name. Output `{}` — never `{"name": "The user"}` or `{"name": "user"}`.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
@@ -749,6 +766,14 @@ Rules:
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
@@ -817,6 +842,14 @@ Rules:
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
|
||||
@@ -365,6 +365,13 @@ EXTRACT_PROMPT_ZH = """## 最近 k 条消息
|
||||
* basic_info:**只装用户本人的画像**。
|
||||
仅当 NEW user messages 中**明确**陈述稳定画像字段(如 姓名、年龄、所在地、职业、雇主)时才填写。
|
||||
绝不推断、绝不编造。没有任何字段被明确陈述时输出 `{{}}`。
|
||||
* **硬性规则**:只有用户在 `user` 回合里**明确说出**的字段才能填写。只在
|
||||
assistant 回合出现的内容(包括 assistant 引用旧记忆,如"The user's name is X"
|
||||
/ "用户名是X")一律不算。没有符合条件的字段时,必须输出 `"basic_info": {{}}`。
|
||||
* **绝不**把字段名本身或占位符当值写入("用户"、"The user"、"user"、"name"、
|
||||
"姓名"、"N/A"、"unknown"、"未知"、"未提供"、"-"),宁可省略该字段。
|
||||
* 负例:寒暄如 `[user]: 你好` / `[assistant]: 我可以帮你。`,或
|
||||
`[user]: Reply with exactly: PONG` → `"basic_info": {{}}`。
|
||||
* `name` 的例外:用户自己那条消息上的 `name` 字段,或文首形如
|
||||
"用户名叫X" / "The user's name is X" 的那一行,都是 `name` 的权威来源
|
||||
——即使用户本人从未开口自报姓名。
|
||||
@@ -440,6 +447,13 @@ EXTRACT_PROMPT_ZH_NO_INTENTION = """## 最近 k 条消息
|
||||
* basic_info:**只装用户本人的画像**。
|
||||
仅当 NEW user messages 中**明确**陈述稳定画像字段(如 姓名、年龄、所在地、职业、雇主)时才填写。
|
||||
绝不推断、绝不编造。没有任何字段被明确陈述时输出 `{{}}`。
|
||||
* **硬性规则**:只有用户在 `user` 回合里**明确说出**的字段才能填写。只在
|
||||
assistant 回合出现的内容(包括 assistant 引用旧记忆,如"The user's name is X"
|
||||
/ "用户名是X")一律不算。没有符合条件的字段时,必须输出 `"basic_info": {{}}`。
|
||||
* **绝不**把字段名本身或占位符当值写入("用户"、"The user"、"user"、"name"、
|
||||
"姓名"、"N/A"、"unknown"、"未知"、"未提供"、"-"),宁可省略该字段。
|
||||
* 负例:寒暄如 `[user]: 你好` / `[assistant]: 我可以帮你。`,或
|
||||
`[user]: Reply with exactly: PONG` → `"basic_info": {{}}`。
|
||||
* `name` 的例外:用户自己那条消息上的 `name` 字段,或文首形如
|
||||
"用户名叫X" / "The user's name is X" 的那一行,都是 `name` 的权威来源
|
||||
——即使用户本人从未开口自报姓名。
|
||||
@@ -669,6 +683,23 @@ Memory Date:2025-09-02
|
||||
```
|
||||
职业和所在地是 **John 的**属性,所以进 `memory` 并以"John"作主体,**绝不能**落到 `basic_info` 的 `occupation` / `location` —— `basic_info` 专属于用户本人。只有 `name`(Tim,来自用户消息的 `name` 字段)才该放进去。注意输出语言随输入语言(此例为英文)。
|
||||
|
||||
## 示例 11 —— 反例:寒暄没有 basic_info
|
||||
|
||||
新消息:
|
||||
[{"role": "user", "content": "你好"},
|
||||
{"role": "assistant", "content": "你好!我可以帮你。之前记得 The user's name is 用户."}]
|
||||
Memory Date:2025-09-03
|
||||
|
||||
输出:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
用户没有说任何关于自己的信息。assistant 那句不是 user 回合,"用户" 也是占位符而不是名字。必须输出 `{}`——**绝不能**输出 `{"name": "用户"}`。
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
@@ -208,6 +210,76 @@ def _sanitize_kv(
|
||||
return result
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 占位符守卫(P0-1)
|
||||
# ================================================================
|
||||
# 弱模型(如 qwen3.5-2b)在用户什么都没说时,会把 schema 字段说明里的词当值
|
||||
# 填进 basic_info:{"name": "用户"} / "The user" / "N/A"。每个占位符都会
|
||||
# supersede 掉真实值,演化链变成 费天翔 → Fei → 用户 → The user。
|
||||
# 规则:占位符永远不入库;读侧组装 KV 时占位符也不覆盖已有真实值。
|
||||
|
||||
_PLACEHOLDER_VALUES = frozenset({
|
||||
"用户", "该用户", "本用户", "用户本人",
|
||||
"the user", "the user's", "user", "a user", "this user",
|
||||
"n/a", "na", "none", "null", "nil", "unknown", "unspecified",
|
||||
"not provided", "not specified", "not mentioned",
|
||||
"未提供", "未知", "不详", "未说明", "未提及", "无",
|
||||
"-", "?",
|
||||
})
|
||||
|
||||
# 内置字段的中文标签:弱模型也会把字段说明的中文词当值。
|
||||
_FIELD_LABELS: Dict[str, tuple] = {
|
||||
"name": ("姓名", "名字", "用户名", "用户姓名", "用户名字"),
|
||||
"age": ("年龄", "用户年龄"),
|
||||
"location": ("所在地", "地点", "城市", "用户所在地"),
|
||||
"occupation": ("职业", "工作", "用户职业"),
|
||||
"employer": ("雇主", "公司", "用户雇主"),
|
||||
}
|
||||
|
||||
# 去掉标点/符号后再比较,让 "The user." / "用户。" / "N/A" 都命中。
|
||||
# "-" / "?" 本身就是纯标点,剥完为空串,同样判为占位符。
|
||||
_PUNCT_RE = re.compile(r"[\s\.,;:!?'\"`~()\[\]{}<>/\\|_\-\u3000-\u303f\uff00-\uff0f\uff1a-\uff20]+")
|
||||
|
||||
|
||||
def _placeholder_key(value: str) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(value or "")).strip().lower()
|
||||
s = s.replace("\u2019", "'")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
_PLACEHOLDER_KEYS = frozenset(_placeholder_key(v) for v in _PLACEHOLDER_VALUES)
|
||||
|
||||
|
||||
def is_placeholder_value(field_name: str, value: Any) -> bool:
|
||||
"""value 是否为占位符(空 / 用户 / The user / N/A / 字段名本身 等)。
|
||||
|
||||
大小写不敏感、首尾空白与标点不敏感。数字(如 age=30)永远不是占位符。
|
||||
"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return False
|
||||
key = _placeholder_key(value)
|
||||
if not key:
|
||||
return True
|
||||
if key in _PLACEHOLDER_KEYS:
|
||||
return True
|
||||
field_key = _placeholder_key(field_name)
|
||||
if field_key and key in (field_key, field_key.replace("_", " ")):
|
||||
return True
|
||||
for label in _FIELD_LABELS.get(field_name, ()):
|
||||
if key == _placeholder_key(label):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _drop_placeholders(kv: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {k: v for k, v in kv.items() if not is_placeholder_value(k, v)}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 公共入口:upsert_basic_profile
|
||||
# ================================================================
|
||||
@@ -287,6 +359,8 @@ async def _upsert_basic_profile_unlocked(
|
||||
# L0 Basic Info 从输入、唯一性到落盘都只属于 user_id。
|
||||
|
||||
new_kv = _sanitize_kv(kv or {}, allowed_fields=allowed_fields)
|
||||
# 单调性守卫:占位符永远不覆盖已有值,过滤后为空就不写新版本、不产生新链头。
|
||||
new_kv = _drop_placeholders(new_kv)
|
||||
if not new_kv:
|
||||
return BasicProfileUpsertResult(
|
||||
success=True, changed=False, reason="no valid fields after sanitize"
|
||||
@@ -513,5 +587,8 @@ def _assemble_full_kv_from_nodes(
|
||||
for k, v in kv.items():
|
||||
if allowed_set is not None and k not in allowed_set:
|
||||
continue
|
||||
# 历史上已落盘的占位符版本(如 name=用户)不覆盖更早的真实值。
|
||||
if is_placeholder_value(k, v):
|
||||
continue
|
||||
full_kv[k] = v
|
||||
return full_kv
|
||||
+4
-4
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.8`。MemHub 源码版本为 `1.0.5`
|
||||
(build 14),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.9`。MemHub 源码版本为 `1.0.5`
|
||||
(build 36),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -118,7 +118,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.8`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.9`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
@@ -2352,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.8",
|
||||
"versionInfo": "2.0.9",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
|
||||
@@ -9,7 +9,7 @@ Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.8
|
||||
## hy-memory 2.0.9
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
|
||||
@@ -162,10 +162,54 @@ it("never emits a secret from a malformed manually written event", async () => {
|
||||
command: "logs",
|
||||
state: "empty",
|
||||
events: 0,
|
||||
invalid_lines: 1,
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// After an upgrade the files still hold an older build's records. They are
|
||||
// whole JSON lines the reader leaves out, not damage, so the text output must
|
||||
// not warn about them; only a line that is not JSON is called damaged.
|
||||
it("warns only about lines that are not JSON, not about older records", async () => {
|
||||
const dataDir = await fixture();
|
||||
const older = {
|
||||
schema_version: 0,
|
||||
service: "desktop",
|
||||
component: "desktop.lifecycle",
|
||||
event: "desktop.started",
|
||||
};
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-desktop.jsonl"),
|
||||
`${JSON.stringify(older)}\n${JSON.stringify({ ...older, schema_version: 1 })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((value) => {
|
||||
stdout += String(value);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((value) => {
|
||||
stderr += String(value);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await run(["logs"], { ONE_MEMORY_DATA_DIR: dataDir })).toBe(0);
|
||||
expect(stdout).toContain("core.start_failed");
|
||||
expect(stdout).not.toContain("损坏");
|
||||
expect(stderr).toBe("");
|
||||
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-cli.jsonl"),
|
||||
'{"schema_version": 1, "event": \n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
stdout = "";
|
||||
expect(await run(["logs"], { ONE_MEMORY_DATA_DIR: dataDir })).toBe(0);
|
||||
expect(stdout).toContain("跳过 1 行损坏的诊断记录。");
|
||||
expect(stderr).toBe("memhub: 跳过 1 行损坏的诊断记录。\n");
|
||||
});
|
||||
|
||||
it("rejects invalid filters without touching the log directory", async () => {
|
||||
const dataDir = path.join(
|
||||
await mkdtemp(path.join(os.tmpdir(), "om-cli-no-log-")),
|
||||
|
||||
+143
-11
@@ -190,8 +190,10 @@ import {
|
||||
} from "./managed-cli-link.js";
|
||||
import {
|
||||
addMemory,
|
||||
DEFAULT_USER_ID,
|
||||
isMemoryId,
|
||||
isRequestId,
|
||||
isUserId,
|
||||
MEMORY_KINDS,
|
||||
MEMORY_SCENES,
|
||||
MEMORY_VARIANTS,
|
||||
@@ -209,6 +211,7 @@ import {
|
||||
renderMemoryAddText,
|
||||
renderMemoryDetailText,
|
||||
renderMemoryPageText,
|
||||
userIdParameters,
|
||||
validMemoryText,
|
||||
} from "./memory-report.js";
|
||||
import {
|
||||
@@ -538,6 +541,23 @@ async function runCommand(
|
||||
environment = { ...environment, ONE_MEMORY_DATA_DIR: lifted.dataDir };
|
||||
}
|
||||
}
|
||||
// `--user-id <id>` is global to the memory commands: it names whose
|
||||
// memories are read or written. It is lifted before dispatch so each
|
||||
// command's own parser keeps seeing exactly the arguments it always did.
|
||||
// `sources connect` gives the flag its own meaning and parses it itself.
|
||||
let userId: string | undefined;
|
||||
if (USER_ID_COMMANDS.has(arguments_[0] ?? "")) {
|
||||
const lifted = liftUserIdOption(arguments_);
|
||||
if (lifted === null) {
|
||||
return reject(
|
||||
findCommand(arguments_[0] ?? ""),
|
||||
asJson,
|
||||
"--user-id 无效:只能是 1–64 位字母、数字、点、下划线或连字符。",
|
||||
);
|
||||
}
|
||||
arguments_ = lifted.arguments_;
|
||||
userId = lifted.userId;
|
||||
}
|
||||
environment = await environmentWithManagedDataDir(environment);
|
||||
const command = arguments_[0];
|
||||
if (command === undefined) return reject(undefined, asJson, "缺少命令。");
|
||||
@@ -776,9 +796,11 @@ async function runCommand(
|
||||
const attachment = await attachToRuntimeOrNull(environment);
|
||||
if (attachment === null) return runtimeUnavailable(descriptor, asJson);
|
||||
if (parsed.kind === "status") {
|
||||
const receipt = await readMemoryWrite(attachment, parsed.id).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const receipt = await readMemoryWrite(
|
||||
attachment,
|
||||
parsed.id,
|
||||
userId,
|
||||
).catch(() => undefined);
|
||||
if (receipt === undefined) {
|
||||
return emitFailure(asJson, "write status", "memory_write_read_failed");
|
||||
}
|
||||
@@ -812,6 +834,7 @@ async function runCommand(
|
||||
? {}
|
||||
: { memory_at: parsed.memory_at }),
|
||||
...(parsed.priority === undefined ? {} : { priority: parsed.priority }),
|
||||
...(userId === undefined ? {} : { user_id: userId }),
|
||||
});
|
||||
if (outcome.state !== "accepted") {
|
||||
emit(
|
||||
@@ -957,9 +980,12 @@ async function runCommand(
|
||||
state: "empty",
|
||||
events: 0,
|
||||
invalid_lines: report.invalid_lines,
|
||||
unrecognized_lines: report.unrecognized_lines,
|
||||
})}\n`
|
||||
: renderLogText(report.events, report.invalid_lines),
|
||||
);
|
||||
// Only lines that are not JSON are damage. Records an older build wrote
|
||||
// are left out without a warning: after an upgrade they are expected.
|
||||
if (report.invalid_lines) {
|
||||
process.stderr.write(
|
||||
`${PRODUCT}: 跳过 ${report.invalid_lines} 行损坏的诊断记录。\n`,
|
||||
@@ -1582,6 +1608,7 @@ async function runCommand(
|
||||
limit: parsed.limit,
|
||||
character_budget: parsed.characterBudget,
|
||||
timeout_ms: parsed.timeoutMs,
|
||||
...userIdParameters(userId),
|
||||
})
|
||||
: ({
|
||||
schema_version: 2,
|
||||
@@ -1780,6 +1807,16 @@ async function runCommand(
|
||||
}
|
||||
if (named === "sources" && arguments_[1] !== "list") {
|
||||
const parsed = parseSourcesCommand(arguments_.slice(1));
|
||||
if (
|
||||
!parsed &&
|
||||
arguments_.some((item) => item === "--user-id" || item === "--root")
|
||||
) {
|
||||
return reject(
|
||||
descriptor,
|
||||
asJson,
|
||||
"--user-id 和 --root 只能用于接入单个 hermes 来源(Hermes profile),用户 ID 只能是 1–64 位字母、数字、点、下划线或连字符。",
|
||||
);
|
||||
}
|
||||
if (!parsed) return usage();
|
||||
if (parsed.kind === "receipt") {
|
||||
const connected = await connectRuntime(environment);
|
||||
@@ -2004,7 +2041,11 @@ async function runCommand(
|
||||
);
|
||||
return connected.exitCode;
|
||||
}
|
||||
const outcome = await connected.reader<unknown>("/api/v2/user-profile");
|
||||
const profileUser = userIdParameters(userId);
|
||||
const outcome =
|
||||
profileUser.user_id === undefined
|
||||
? await connected.reader<unknown>("/api/v2/user-profile")
|
||||
: await connected.reader<unknown>("/api/v2/user-profile", profileUser);
|
||||
const value =
|
||||
outcome.state === "ok"
|
||||
? (outcome.data as {
|
||||
@@ -2109,7 +2150,7 @@ async function runCommand(
|
||||
}
|
||||
const { reader, operations } = connected;
|
||||
if (parsed.kind === "list") {
|
||||
const document = await readMemoryPage(reader, parsed.query);
|
||||
const document = await readMemoryPage(reader, parsed.query, userId);
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
@@ -2122,6 +2163,7 @@ async function runCommand(
|
||||
reader,
|
||||
parsed.memoryId,
|
||||
parsed.namespace,
|
||||
userId,
|
||||
);
|
||||
emit(
|
||||
asJson
|
||||
@@ -2133,8 +2175,8 @@ async function runCommand(
|
||||
if (parsed.kind === "add" || parsed.kind === "add-status") {
|
||||
const document =
|
||||
parsed.kind === "add"
|
||||
? await addMemory(operations, text ?? "", parsed.requestId)
|
||||
: await readMemoryAddStatus(reader, parsed.requestId);
|
||||
? await addMemory(operations, text ?? "", parsed.requestId, userId)
|
||||
: await readMemoryAddStatus(reader, parsed.requestId, userId);
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
@@ -2149,9 +2191,13 @@ async function runCommand(
|
||||
content: text,
|
||||
...namespaceParameters(parsed.namespace),
|
||||
...(parsed.task === undefined ? {} : { task: parsed.task }),
|
||||
...userIdParameters(userId),
|
||||
})
|
||||
: await operations.delete<unknown>(
|
||||
withQuery(memoryPath, namespaceParameters(parsed.namespace)),
|
||||
withQuery(memoryPath, {
|
||||
...namespaceParameters(parsed.namespace),
|
||||
...userIdParameters(userId),
|
||||
}),
|
||||
);
|
||||
const document = {
|
||||
schema_version: 2,
|
||||
@@ -3675,13 +3721,21 @@ async function connectSources(
|
||||
asJson,
|
||||
);
|
||||
}
|
||||
if (parsed.importHistory) {
|
||||
// Another user's Hermes profile is granted and imported in one connect
|
||||
// request: only that request can carry a user and a root.
|
||||
if (
|
||||
parsed.importHistory ||
|
||||
parsed.userId !== undefined ||
|
||||
parsed.root !== undefined
|
||||
) {
|
||||
const document = await connectSourcesWithImport(reader, operations, {
|
||||
sourceIds: parsed.sourceIds,
|
||||
confirmOverrides: parsed.confirmOverrides,
|
||||
...(parsed.requestId === undefined
|
||||
? {}
|
||||
: { requestId: parsed.requestId }),
|
||||
...(parsed.userId === undefined ? {} : { userId: parsed.userId }),
|
||||
...(parsed.root === undefined ? {} : { root: parsed.root }),
|
||||
});
|
||||
emitFlowDocument(document, asJson);
|
||||
return flowExitCode(document);
|
||||
@@ -5131,14 +5185,23 @@ type SourcesCommand =
|
||||
importHistory: boolean;
|
||||
confirmOverrides: boolean;
|
||||
requestId?: string;
|
||||
/** `--user-id`: whose grant (Hermes only). Absent for `local`. */
|
||||
userId?: string;
|
||||
/** `--root`: an absolute root other than the candidate (Hermes only). */
|
||||
root?: string;
|
||||
}
|
||||
| { kind: "all"; importHistory: boolean; confirmOverrides: boolean }
|
||||
| { kind: "receipt"; requestId: string };
|
||||
|
||||
const SOURCE_ID_SHAPE = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
|
||||
/** Only Hermes keeps one history per profile, so only Hermes may be
|
||||
* connected for another user or from another root. */
|
||||
const MULTI_USER_SOURCES = new Set(["hermes"]);
|
||||
|
||||
/** `sources connect <id> [<id> ...] | --all-detected [--import]
|
||||
* [--confirm-overrides] [--request-id <uuid>]` and `sources receipt <id>`.
|
||||
* [--confirm-overrides] [--request-id <uuid>]
|
||||
* [--user-id <id>] [--root <path>]` and `sources receipt <id>`.
|
||||
*
|
||||
* Sources may be named positionally or with repeated `--source` (the old
|
||||
* `init --source` spelling is rewritten to this command). */
|
||||
@@ -5154,6 +5217,8 @@ function parseSourcesCommand(rest: readonly string[]): SourcesCommand | null {
|
||||
const sourceIds: string[] = [];
|
||||
const switches = new Set<string>();
|
||||
let requestId: string | undefined;
|
||||
let userId: string | undefined;
|
||||
let root: string | undefined;
|
||||
for (let index = 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (["--import", "--confirm-overrides", "--all-detected"].includes(token)) {
|
||||
@@ -5161,6 +5226,21 @@ function parseSourcesCommand(rest: readonly string[]): SourcesCommand | null {
|
||||
switches.add(token);
|
||||
continue;
|
||||
}
|
||||
if (token === "--user-id" || token === "--root") {
|
||||
const value = tokens[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
if (token === "--user-id") {
|
||||
if (userId !== undefined || !isUserId(value)) return null;
|
||||
userId = value;
|
||||
} else {
|
||||
if (root !== undefined) return null;
|
||||
const expanded = expandRootPath(value);
|
||||
if (expanded === null) return null;
|
||||
root = expanded;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === "--source" || token === "--request-id") {
|
||||
const value = tokens[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
@@ -5184,10 +5264,23 @@ function parseSourcesCommand(rest: readonly string[]): SourcesCommand | null {
|
||||
const confirmOverrides = switches.has("--confirm-overrides");
|
||||
if (
|
||||
requestId !== undefined &&
|
||||
(!OPERATION_ID.test(requestId) || !importHistory)
|
||||
(!OPERATION_ID.test(requestId) ||
|
||||
!(importHistory || userId !== undefined || root !== undefined))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// `--user-id local` is the default and changes nothing.
|
||||
if (userId === DEFAULT_USER_ID) userId = undefined;
|
||||
if (userId !== undefined || root !== undefined) {
|
||||
// One Hermes profile per connect: the user and root name one grant.
|
||||
if (
|
||||
switches.has("--all-detected") ||
|
||||
sourceIds.length !== 1 ||
|
||||
!MULTI_USER_SOURCES.has(sourceIds[0])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (switches.has("--all-detected")) {
|
||||
// One names exact sources, the other means "whatever is detected".
|
||||
if (sourceIds.length > 0 || requestId !== undefined) return null;
|
||||
@@ -5200,9 +5293,24 @@ function parseSourcesCommand(rest: readonly string[]): SourcesCommand | null {
|
||||
importHistory,
|
||||
confirmOverrides,
|
||||
...(requestId === undefined ? {} : { requestId }),
|
||||
...(userId === undefined ? {} : { userId }),
|
||||
...(root === undefined ? {} : { root }),
|
||||
};
|
||||
}
|
||||
|
||||
/** `--root` as an absolute path: a leading `~` is the home directory, a
|
||||
* relative path is taken from the working directory. Core expands nothing. */
|
||||
function expandRootPath(value: string): string | null {
|
||||
if (value.length === 0 || /[\0\r\n]/.test(value)) return null;
|
||||
const expanded =
|
||||
value === "~"
|
||||
? os.homedir()
|
||||
: value.startsWith("~/")
|
||||
? path.join(os.homedir(), value.slice(2))
|
||||
: value;
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
function parseGraphWidthCommand(
|
||||
rest: readonly string[],
|
||||
): { decision: GraphWidthDecision; confirmed: boolean } | null {
|
||||
@@ -6596,6 +6704,30 @@ const OWN_DATA_DIR_COMMANDS = new Set([
|
||||
|
||||
/** Remove one `--data-dir <absolute path>` from argv; null when it is
|
||||
* repeated, missing its value, or not an absolute path. */
|
||||
/** Memory commands that honour the global `--user-id`. */
|
||||
const USER_ID_COMMANDS = new Set(["memories", "recall", "profile", "write"]);
|
||||
|
||||
/**
|
||||
* Take `--user-id <id>` out of the arguments. The default user (`local`) is
|
||||
* returned as undefined, so a default request names no user at all and stays
|
||||
* byte-identical to one sent before multi-user support.
|
||||
*/
|
||||
function liftUserIdOption(
|
||||
arguments_: readonly string[],
|
||||
): { arguments_: string[]; userId?: string } | null {
|
||||
const index = arguments_.indexOf("--user-id");
|
||||
if (index === -1) return { arguments_: [...arguments_] };
|
||||
if (arguments_.indexOf("--user-id", index + 1) !== -1) return null;
|
||||
const value = arguments_[index + 1];
|
||||
if (value === undefined || value.startsWith("--") || !isUserId(value)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
arguments_: [...arguments_.slice(0, index), ...arguments_.slice(index + 2)],
|
||||
...(value === DEFAULT_USER_ID ? {} : { userId: value }),
|
||||
};
|
||||
}
|
||||
|
||||
function liftDataDirOption(
|
||||
arguments_: readonly string[],
|
||||
): { arguments_: string[]; dataDir?: string } | null {
|
||||
|
||||
@@ -68,6 +68,12 @@ const JSON_OPTION: CommandOption = {
|
||||
flag: "--json",
|
||||
summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。",
|
||||
};
|
||||
/** Global to the memory commands: whose memories are read or written. */
|
||||
const USER_ID_OPTION: CommandOption = {
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"读写哪个用户的记忆,默认 local(App 用的就是 local)。1–64 位字母、数字、点、下划线或连字符;其他用户的记忆来自用 sources connect hermes --user-id 接入的 Hermes profile。",
|
||||
};
|
||||
const WATCH_OPTION: CommandOption = {
|
||||
flag: "--watch",
|
||||
summary: "串行持续刷新;Ctrl-C 只停止观察,不停止 Runtime。",
|
||||
@@ -252,15 +258,25 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "sources",
|
||||
summary:
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。",
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(会自动排除 ~/.hermes/profiles/**,各用户互不读取)。",
|
||||
usage:
|
||||
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
"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: [
|
||||
{
|
||||
flag: "--import",
|
||||
summary:
|
||||
"接入后立即导入历史会话并开启持续同步;不加时只授权,处理保持冻结。",
|
||||
},
|
||||
{
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;其他来源只能是 local。",
|
||||
},
|
||||
{
|
||||
flag: "--root <path>",
|
||||
summary:
|
||||
"只用于 hermes:读取的根目录(~ 会展开为主目录)。默认是 Hermes 自己的目录 ~/.hermes。",
|
||||
},
|
||||
{
|
||||
flag: "--all-detected",
|
||||
summary:
|
||||
@@ -373,7 +389,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
summary:
|
||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes。",
|
||||
usage:
|
||||
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--json]\n memhub memories add-status <request-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--json]",
|
||||
"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: [
|
||||
{
|
||||
flag: "--scene <all|chat|working|coding>",
|
||||
@@ -411,6 +427,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"add 的幂等键;用同一个 id 重试只会读回第一次的结果,不会重复添加。不给时自动生成并输出。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
@@ -418,8 +435,8 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "profile",
|
||||
summary: "读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充)。",
|
||||
usage: "memhub profile [--json]",
|
||||
options: [JSON_OPTION],
|
||||
usage: "memhub profile [--user-id <id>] [--json]",
|
||||
options: [USER_ID_OPTION, JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
@@ -475,7 +492,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
summary:
|
||||
"从原始对话与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。文本输出标明每条是「原始对话摘录」还是「记忆」(JSON 里是 kind: raw|memory)。",
|
||||
usage:
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--user-id <id>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
options: [
|
||||
{
|
||||
flag: "--query-file <path>",
|
||||
@@ -502,6 +519,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
flag: "--timeout-ms <n>",
|
||||
summary: "Core 查询时限 50..30000 毫秒,默认 2000。",
|
||||
},
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
@@ -650,7 +668,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
summary:
|
||||
"提交一段 OpenAI 格式的对话(messages)到写入队列,异步加工成记忆;或按 request id 查询状态。request id 是幂等键:不给时自动生成并输出,重试请带上同一个。新增一条结论式记忆请用 memories add。",
|
||||
usage:
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status <request-id> [--json]",
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--user-id <id>] [--json]\n memhub write status <request-id> [--user-id <id>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--messages-file <path>",
|
||||
@@ -666,6 +684,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{ flag: "--memory-at <RFC3339>", summary: "对话发生时间,必须带时区。" },
|
||||
{ flag: "--live", summary: "优先处理。" },
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface ContextSearchInput {
|
||||
limit: number;
|
||||
character_budget: number;
|
||||
timeout_ms: number;
|
||||
/** Whose memories to search. Omitted for the default user (`local`). */
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/** The CLI's JSON document mirrors Core's SDK-native context schema v2. */
|
||||
|
||||
@@ -12,6 +12,7 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v1/live-sync",
|
||||
"/api/v1/memory-processing",
|
||||
"/api/v1/model-profiles",
|
||||
"/api/v1/source-grants",
|
||||
"/api/v1/sources",
|
||||
"/api/v2/home-summary",
|
||||
"/api/v2/memory-library",
|
||||
@@ -310,6 +311,8 @@ export async function submitMemoryWrite(
|
||||
messages: readonly OpenAIMessage[];
|
||||
memory_at?: string;
|
||||
priority?: "live" | "normal";
|
||||
/** Whose memory this is. Omitted for the default user (`local`). */
|
||||
user_id?: string;
|
||||
},
|
||||
): Promise<MemoryWriteOutcome> {
|
||||
let response: { status: number; data: unknown };
|
||||
@@ -327,6 +330,9 @@ export async function submitMemoryWrite(
|
||||
...(request.priority === undefined
|
||||
? {}
|
||||
: { priority: request.priority }),
|
||||
...(request.user_id === undefined || request.user_id === "local"
|
||||
? {}
|
||||
: { user_id: request.user_id }),
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
@@ -469,11 +475,16 @@ export async function deleteModelProfile(
|
||||
export async function readMemoryWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
requestId: string,
|
||||
/** A receipt belongs to the user that submitted it; omit for `local`. */
|
||||
userId?: string,
|
||||
): Promise<MemoryWriteReceipt | null> {
|
||||
try {
|
||||
const response = await axios.get<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes/${encodeURIComponent(requestId)}`,
|
||||
{
|
||||
...(userId === undefined || userId === "local"
|
||||
? {}
|
||||
: { params: { user_id: userId } }),
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
|
||||
@@ -6,9 +6,10 @@ const WRITE_PATHS = [
|
||||
] as const;
|
||||
/** `POST /api/v2/memories` adds one memory (the app's 添加记忆). */
|
||||
const MEMORY_ADD_PATH = "/api/v2/memories";
|
||||
/** One SDK memory; delete names its namespace in the query string. */
|
||||
/** One SDK memory; delete names its namespace, and a non-default user, in
|
||||
* the query string. */
|
||||
const SDK_MEMORY_PATH =
|
||||
/^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}(?:\?kind=(?:chat|experience|fact)(?:&variant=(?:working|coding))?)?$/;
|
||||
/^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}(?:\?(?:kind=(?:chat|experience|fact)(?:&variant=(?:working|coding))?(?:&user_id=[A-Za-z0-9._-]{1,64})?|user_id=[A-Za-z0-9._-]{1,64}))?$/;
|
||||
const MODEL_PROBE_PATH =
|
||||
/^\/api\/v1\/model-profiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/probe$/;
|
||||
const MODEL_PROFILE_PATH =
|
||||
|
||||
@@ -110,9 +110,18 @@ export async function connectSourcesWithImport(
|
||||
sourceIds: readonly string[];
|
||||
confirmOverrides: boolean;
|
||||
requestId?: string;
|
||||
/** Whose grant this is. Omitted for the default user (`local`). */
|
||||
userId?: string;
|
||||
/** An absolute root other than the source's candidate (a Hermes
|
||||
* profile). Core reads its identity itself, so the source's candidate
|
||||
* root id and revision are not sent for it. */
|
||||
root?: string;
|
||||
},
|
||||
): Promise<FlowDocument> {
|
||||
const sourceIds = input.sourceIds;
|
||||
// A second user's grant is not a first run, and a custom root has no
|
||||
// candidate the app showed: both are a plain connect.
|
||||
const profile = input.root !== undefined || input.userId !== undefined;
|
||||
if (
|
||||
sourceIds.length < 1 ||
|
||||
sourceIds.length > 32 ||
|
||||
@@ -134,9 +143,14 @@ export async function connectSourcesWithImport(
|
||||
return invalid("source_unavailable");
|
||||
}
|
||||
const items = selected.map(([candidate]) => candidate);
|
||||
const needsOverride = items
|
||||
.filter((item) => item.requires_override_confirmation)
|
||||
.map((item) => item.source_id);
|
||||
// A custom root is the caller's own choice, not the environment override
|
||||
// the confirmation exists for.
|
||||
const needsOverride =
|
||||
input.root !== undefined
|
||||
? []
|
||||
: items
|
||||
.filter((item) => item.requires_override_confirmation)
|
||||
.map((item) => item.source_id);
|
||||
if (needsOverride.length > 0 && !input.confirmOverrides) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -154,18 +168,22 @@ export async function connectSourcesWithImport(
|
||||
request = buildSourceImportRequest({
|
||||
sourceIds: [...sourceIds],
|
||||
sourceRoots: Object.fromEntries(
|
||||
items.map((item) => [item.source_id, item.candidate_root_id]),
|
||||
items.flatMap((item) =>
|
||||
input.root === undefined
|
||||
? [[item.source_id, item.candidate_root_id]]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
sourceCandidateRevisions: Object.fromEntries(
|
||||
items.flatMap((item) =>
|
||||
item.candidate_revision === undefined
|
||||
item.candidate_revision === undefined || input.root !== undefined
|
||||
? []
|
||||
: [[item.source_id, item.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirmEnvironmentOverrides: needsOverride.length > 0,
|
||||
fullRescan: false,
|
||||
onboarding: true,
|
||||
onboarding: !profile,
|
||||
enableSync: true,
|
||||
requestId,
|
||||
});
|
||||
@@ -175,7 +193,11 @@ export async function connectSourcesWithImport(
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
const result = await operations.post<unknown>(request.path, {
|
||||
...request.body,
|
||||
...(input.userId === undefined ? {} : { user_id: input.userId }),
|
||||
...(input.root === undefined ? {} : { root: input.root }),
|
||||
});
|
||||
if (result.state === "rejected") {
|
||||
return rejected(requestId, result.code);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,11 @@ export async function readLogReport(
|
||||
query: LogQuery,
|
||||
): Promise<{
|
||||
events: DiagnosticEventV1[];
|
||||
/** Lines that are not JSON: the only ones `memhub logs` calls damaged. */
|
||||
invalid_lines: number;
|
||||
/** Whole JSON lines the reader leaves out, such as an older build's
|
||||
* records. Nothing is wrong with them, so the text output says nothing. */
|
||||
unrecognized_lines: number;
|
||||
failure?: "log_directory_unreadable";
|
||||
}> {
|
||||
// `readDiagnosticEvents` treats an unreadable logs path as "no events". That
|
||||
@@ -119,6 +123,7 @@ export async function readLogReport(
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
@@ -129,6 +134,7 @@ export async function readLogReport(
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,25 @@ import { type CoreReader, RESERVED_MEMORY_SEGMENTS } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
/** A MemHub user id, the same shape Core accepts. `local` is the default. */
|
||||
const USER_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
export const DEFAULT_USER_ID = "local";
|
||||
|
||||
export function isUserId(value: string): boolean {
|
||||
return USER_ID.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `user_id` to send, or nothing. The default user is never named on the
|
||||
* wire, so a request without `--user-id` stays exactly what it was before
|
||||
* multi-user support and still works against an older Core.
|
||||
*/
|
||||
export function userIdParameters(userId?: string): { user_id?: string } {
|
||||
return userId === undefined || userId === DEFAULT_USER_ID
|
||||
? {}
|
||||
: { user_id: userId };
|
||||
}
|
||||
|
||||
/** Output budget: a long memory is shortened visibly, never dropped silently. */
|
||||
const CONTENT_BUDGET = 240;
|
||||
|
||||
@@ -101,10 +120,12 @@ export interface MemoryDetailDocument {
|
||||
export async function readMemoryPage(
|
||||
reader: CoreReader,
|
||||
query: MemoryQuery,
|
||||
userId?: string,
|
||||
): Promise<MemoryPageDocument> {
|
||||
const parameters: Record<string, string | number> = {
|
||||
offset: query.offset,
|
||||
limit: query.limit,
|
||||
...userIdParameters(userId),
|
||||
};
|
||||
if (query.status !== undefined) parameters.statuses = query.status;
|
||||
if (query.source !== undefined) parameters.source = query.source;
|
||||
@@ -156,8 +177,12 @@ export async function readMemoryDetail(
|
||||
reader: CoreReader,
|
||||
memoryId: string,
|
||||
namespace: MemoryNamespace = {},
|
||||
userId?: string,
|
||||
): Promise<MemoryDetailDocument> {
|
||||
const parameters = namespaceParameters(namespace);
|
||||
const parameters = {
|
||||
...namespaceParameters(namespace),
|
||||
...userIdParameters(userId),
|
||||
};
|
||||
const outcome = await reader<unknown>(
|
||||
`/api/v2/memories/${memoryId}`,
|
||||
Object.keys(parameters).length > 0 ? parameters : undefined,
|
||||
@@ -236,6 +261,7 @@ export async function addMemory(
|
||||
operations: CoreOperations,
|
||||
text: string,
|
||||
requestId: string = randomUUID(),
|
||||
userId?: string,
|
||||
): Promise<MemoryAddDocument> {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
@@ -259,7 +285,10 @@ export async function addMemory(
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const result = await operations.post<unknown>(request.path, request.body);
|
||||
const result = await operations.post<unknown>(request.path, {
|
||||
...request.body,
|
||||
...userIdParameters(userId),
|
||||
});
|
||||
if (result.state !== "committed") {
|
||||
return { ...base, state: result.state, code: result.code };
|
||||
}
|
||||
@@ -273,6 +302,7 @@ export async function addMemory(
|
||||
export async function readMemoryAddStatus(
|
||||
reader: CoreReader,
|
||||
requestId: string,
|
||||
userId?: string,
|
||||
): Promise<MemoryAddDocument> {
|
||||
const base = {
|
||||
schema_version: 2 as const,
|
||||
@@ -283,7 +313,11 @@ export async function readMemoryAddStatus(
|
||||
if (!UUID.test(requestId)) {
|
||||
return { ...base, state: "invalid", code: "invalid_request_id" };
|
||||
}
|
||||
const outcome = await reader<unknown>(memoryAddStatusPath(requestId));
|
||||
const user = userIdParameters(userId);
|
||||
const outcome =
|
||||
user.user_id === undefined
|
||||
? await reader<unknown>(memoryAddStatusPath(requestId))
|
||||
: await reader<unknown>(memoryAddStatusPath(requestId), user);
|
||||
if (outcome.state === "not_found") {
|
||||
return { ...base, state: "not_found", code: "memory_add_not_found" };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,21 @@ export interface SourcesDocument {
|
||||
state: ReadState["state"];
|
||||
code?: string;
|
||||
sources: Array<Record<string, unknown>>;
|
||||
/** Every grant of every user (`GET /api/v1/source-grants`). Absent when
|
||||
* Core is too old to list them; `sources` is the default user's view. */
|
||||
grants?: SourceGrant[];
|
||||
}
|
||||
|
||||
/** One source grant. Only Hermes has grants for users other than `local`,
|
||||
* one per Hermes profile root. */
|
||||
export interface SourceGrant {
|
||||
user_id: string;
|
||||
source_id: string;
|
||||
root_label: string | null;
|
||||
root_path: string | null;
|
||||
authorized: boolean;
|
||||
revoked_at: string | null;
|
||||
sync_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SyncDocument {
|
||||
@@ -86,14 +101,66 @@ export async function readSources(
|
||||
sources: [],
|
||||
};
|
||||
}
|
||||
const grants = await readSourceGrants(reader);
|
||||
return {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
product: PRODUCT,
|
||||
state: "ok",
|
||||
sources: result.data.map(projectSource),
|
||||
// `/api/v1/sources` is the default user's view of every source.
|
||||
sources: result.data.map((source) => ({
|
||||
...projectSource(source),
|
||||
user_id: "local",
|
||||
})),
|
||||
...(grants === null ? {} : { grants }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The grants of every user, or null when Core cannot list them (an older
|
||||
* Core answers 404): the source list is then shown as before. */
|
||||
async function readSourceGrants(
|
||||
reader: CoreReader,
|
||||
): Promise<SourceGrant[] | null> {
|
||||
let outcome: ReadOutcome<unknown>;
|
||||
try {
|
||||
outcome = await reader<unknown>("/api/v1/source-grants");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (outcome.state !== "ok") return null;
|
||||
const value = outcome.data;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const items = (value as { grants?: unknown }).grants;
|
||||
if (!Array.isArray(items)) return null;
|
||||
const grants: SourceGrant[] = [];
|
||||
const text = (candidate: unknown): string | null =>
|
||||
typeof candidate === "string" && candidate.length <= 4096
|
||||
? candidate
|
||||
: null;
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||
const grant = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof grant.user_id !== "string" ||
|
||||
!/^[A-Za-z0-9._-]{1,64}$/.test(grant.user_id) ||
|
||||
typeof grant.source_id !== "string" ||
|
||||
!/^[a-z0-9][a-z0-9-]{0,127}$/.test(grant.source_id) ||
|
||||
typeof grant.authorized !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
grants.push({
|
||||
user_id: grant.user_id,
|
||||
source_id: grant.source_id,
|
||||
root_label: text(grant.root_label),
|
||||
root_path: text(grant.root_path),
|
||||
authorized: grant.authorized,
|
||||
revoked_at: text(grant.revoked_at),
|
||||
sync_enabled: grant.sync_enabled === true,
|
||||
});
|
||||
}
|
||||
return grants;
|
||||
}
|
||||
|
||||
export async function readSync(reader: CoreReader): Promise<SyncDocument> {
|
||||
const result = validated(
|
||||
await reader<unknown>("/api/v1/live-sync"),
|
||||
@@ -231,6 +298,22 @@ export function renderSourcesText(document: SourcesDocument): string {
|
||||
);
|
||||
}
|
||||
if (document.sources.length === 0) lines.push("(没有来源)");
|
||||
// The list above is the default user's (`local`). Another user's grant,
|
||||
// a Hermes profile, is listed with its user so it is not mistaken for it.
|
||||
const others = (document.grants ?? []).filter(
|
||||
(grant) => grant.user_id !== "local",
|
||||
);
|
||||
if (others.length > 0) {
|
||||
lines.push("其他用户的连接:");
|
||||
for (const grant of others) {
|
||||
const name =
|
||||
document.sources.find((source) => source.source_id === grant.source_id)
|
||||
?.display_name ?? grant.source_id;
|
||||
lines.push(
|
||||
`- ${name}(用户 ${grant.user_id}):${grant.authorized ? "已连接" : grant.revoked_at ? "已撤销" : "未连接"};处理${grant.sync_enabled ? "已开启" : "已冻结"}${grant.root_path ? `;目录 ${grant.root_path}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -179,12 +179,72 @@ describe("CLI-07/10 source and synchronization reads", () => {
|
||||
sync_enabled: true,
|
||||
sync_completeness: "additions_only",
|
||||
});
|
||||
expect(requestedPaths()).toEqual(["/api/v1/sources"]);
|
||||
// The grants list answers 404 here, as an older Core does: the source
|
||||
// list is shown as before, every row the default user's.
|
||||
expect(report.sources[0].user_id).toBe("local");
|
||||
expect(report.grants).toBeUndefined();
|
||||
expect(requestedPaths()).toEqual([
|
||||
"/api/v1/sources",
|
||||
"/api/v1/source-grants",
|
||||
]);
|
||||
expect(requestedPaths().some((path) => path.includes("detect"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows every user's grant, a Hermes profile with its user", async () => {
|
||||
const grants = {
|
||||
schema_version: 1,
|
||||
grants: [
|
||||
{
|
||||
user_id: "local",
|
||||
source_id: "codex",
|
||||
root_label: ".codex",
|
||||
root_path: "/home/synthetic/.codex",
|
||||
authorized: true,
|
||||
revoked_at: null,
|
||||
sync_enabled: true,
|
||||
},
|
||||
{
|
||||
user_id: "wife",
|
||||
source_id: "hermes",
|
||||
root_label: "wife",
|
||||
root_path: "/home/synthetic/.hermes/profiles/wife",
|
||||
authorized: true,
|
||||
revoked_at: null,
|
||||
sync_enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/source-grants"
|
||||
? { status: 200, data: grants }
|
||||
: (RESPONSES.get(url) ?? { status: 404, data: {} }),
|
||||
);
|
||||
let streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["sources", "list", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const report = JSON.parse(streams.stdout());
|
||||
expect(report.grants).toEqual(grants.grants);
|
||||
expect(report.sources[0]).toMatchObject({
|
||||
source_id: "codex",
|
||||
user_id: "local",
|
||||
});
|
||||
streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["sources", "list"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain("其他用户的连接:");
|
||||
expect(streams.stdout()).toContain(
|
||||
"hermes(用户 wife):已连接;处理已开启;目录 /home/synthetic/.hermes/profiles/wife",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps degraded synchronization distinct and prints its error code", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
@@ -552,6 +612,7 @@ describe("CLI-19 progress and issue reads", () => {
|
||||
requestedPaths().every((path) =>
|
||||
[
|
||||
"/api/v1/sources",
|
||||
"/api/v1/source-grants",
|
||||
"/api/v1/live-sync",
|
||||
"/api/v1/import-jobs",
|
||||
"/api/v1/memory-processing",
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import os, { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
const direct = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: direct.get,
|
||||
post: direct.post,
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const REQUEST = "33333333-3333-4333-8333-333333333333";
|
||||
const HERMES = {
|
||||
source_id: "hermes",
|
||||
display_name: "Hermes",
|
||||
storage_kinds: ["sqlite"],
|
||||
available: true,
|
||||
authorized: true,
|
||||
detected: null,
|
||||
root_label: ".hermes",
|
||||
candidate_path: "~/.hermes",
|
||||
candidate_kind: "default",
|
||||
candidate_revision: 0,
|
||||
candidate_root_id: "hermes-root-identity",
|
||||
imported_messages: 0,
|
||||
imported_conversations: 0,
|
||||
capabilities: {
|
||||
historical_import: "implemented",
|
||||
incremental_import: "implemented",
|
||||
live_sync: "implemented",
|
||||
recall: "implemented",
|
||||
installer: "not_implemented",
|
||||
verification: "fixture_only",
|
||||
},
|
||||
sync_completeness: "additions_only",
|
||||
location_state: "found",
|
||||
};
|
||||
const CODEX = {
|
||||
...HERMES,
|
||||
source_id: "codex",
|
||||
display_name: "Codex",
|
||||
candidate_root_id: "codex-root-identity",
|
||||
};
|
||||
|
||||
function addReceipt() {
|
||||
return {
|
||||
evidence_id: "44444444-4444-4444-8444-444444444444",
|
||||
text: "Prefers tea",
|
||||
scope: "all-apps",
|
||||
created_at: "2026-10-27T00:00:00Z",
|
||||
state: "pending",
|
||||
task_id: null,
|
||||
memory_ids: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of [
|
||||
...Object.values(transport),
|
||||
...Object.values(direct),
|
||||
]) {
|
||||
method.mockReset();
|
||||
}
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
transport.get.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/sources") {
|
||||
return { status: 200, data: [HERMES, CODEX] };
|
||||
}
|
||||
if (url === "/api/v2/user-profile") {
|
||||
return {
|
||||
status: 200,
|
||||
data: { request_id: "r-1", profile: null, elapsed_ms: 1 },
|
||||
};
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
transport.post.mockImplementation(
|
||||
async (url: string, body: Record<string, unknown>) =>
|
||||
url === "/api/v1/sources/connect"
|
||||
? {
|
||||
status: 200,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
accepted_source_ids: ["hermes"],
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
},
|
||||
}
|
||||
: url === "/api/v2/memories"
|
||||
? { status: 201, data: addReceipt() }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
transport.put.mockResolvedValue({ status: 200, data: {} });
|
||||
transport.delete.mockResolvedValue({ status: 204, data: "" });
|
||||
direct.post.mockResolvedValue({
|
||||
status: 202,
|
||||
data: {
|
||||
request_id: REQUEST,
|
||||
state: "accepted",
|
||||
created_at: "T1",
|
||||
updated_at: "T1",
|
||||
},
|
||||
});
|
||||
direct.get.mockResolvedValue({
|
||||
data: {
|
||||
request_id: REQUEST,
|
||||
state: "accepted",
|
||||
created_at: "T1",
|
||||
updated_at: "T1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("--user-id on the memory commands", () => {
|
||||
it("passes the user as a query parameter to memory reads and deletes", async () => {
|
||||
await capturedRun(["memories", "list", "--user-id", "wife", "--json"]);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memory-library", {
|
||||
params: { offset: 0, limit: 100, user_id: "wife" },
|
||||
});
|
||||
// Global: the flag may come before the subcommand too.
|
||||
await capturedRun(["memories", "--user-id", "wife", "show", "m-1"]);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memories/m-1", {
|
||||
params: { user_id: "wife" },
|
||||
});
|
||||
await capturedRun(["memories", "add-status", REQUEST, "--user-id", "wife"]);
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
`/api/v2/memories/additions/${REQUEST}`,
|
||||
{ params: { user_id: "wife" } },
|
||||
);
|
||||
await capturedRun(["profile", "--user-id", "wife", "--json"]);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/user-profile", {
|
||||
params: { user_id: "wife" },
|
||||
});
|
||||
await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"fact-1",
|
||||
"--kind",
|
||||
"fact",
|
||||
"--yes",
|
||||
"--user-id",
|
||||
"wife",
|
||||
]);
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
"/api/v2/memories/fact-1?kind=fact&user_id=wife",
|
||||
undefined,
|
||||
);
|
||||
await capturedRun([
|
||||
"memories",
|
||||
"delete",
|
||||
"m-1",
|
||||
"--yes",
|
||||
"--user-id",
|
||||
"wife",
|
||||
]);
|
||||
expect(transport.delete).toHaveBeenCalledWith(
|
||||
"/api/v2/memories/m-1?user_id=wife",
|
||||
undefined,
|
||||
);
|
||||
await capturedRun(["write", "status", REQUEST, "--user-id", "wife"]);
|
||||
expect(direct.get.mock.calls[0][0]).toBe(
|
||||
`http://127.0.0.1:8765/api/v2/memory-writes/${REQUEST}`,
|
||||
);
|
||||
expect(direct.get.mock.calls[0][1].params).toEqual({ user_id: "wife" });
|
||||
});
|
||||
|
||||
it("passes the user in the body of memory writes and searches", async () => {
|
||||
await capturedRun(["memories", "add", "Prefers tea", "--user-id", "wife"]);
|
||||
expect(transport.post.mock.calls[0][0]).toBe("/api/v2/memories");
|
||||
expect(transport.post.mock.calls[0][1]).toMatchObject({
|
||||
text: "Prefers tea",
|
||||
scope: "all-apps",
|
||||
user_id: "wife",
|
||||
});
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-user-id-"));
|
||||
const content = path.join(directory, "content.txt");
|
||||
await writeFile(content, "Prefers green tea\n");
|
||||
await capturedRun([
|
||||
"memories",
|
||||
"update",
|
||||
"m-1",
|
||||
"--content-file",
|
||||
content,
|
||||
"--user-id",
|
||||
"wife",
|
||||
]);
|
||||
expect(transport.put.mock.calls[0][0]).toBe("/api/v2/memories/m-1");
|
||||
expect(transport.put.mock.calls[0][1]).toMatchObject({ user_id: "wife" });
|
||||
await capturedRun(["recall", "tea", "--user-id", "wife", "--json"]);
|
||||
const search = transport.post.mock.calls.find(
|
||||
([url]) => url === "/api/v2/context/search",
|
||||
);
|
||||
expect(search?.[1]).toMatchObject({ query: "tea", user_id: "wife" });
|
||||
const messages = path.join(directory, "messages.json");
|
||||
await writeFile(
|
||||
messages,
|
||||
JSON.stringify([{ role: "user", content: "I like tea" }]),
|
||||
);
|
||||
await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--messages-file",
|
||||
messages,
|
||||
"--session",
|
||||
"s-1",
|
||||
"--user-id",
|
||||
"wife",
|
||||
]);
|
||||
expect(direct.post.mock.calls[0][0]).toBe(
|
||||
"http://127.0.0.1:8765/api/v2/memory-writes",
|
||||
);
|
||||
expect(direct.post.mock.calls[0][1]).toMatchObject({
|
||||
session_id: "s-1",
|
||||
user_id: "wife",
|
||||
});
|
||||
});
|
||||
|
||||
it("names no user by default or for --user-id local", async () => {
|
||||
for (const extra of [[], ["--user-id", "local"]]) {
|
||||
transport.get.mockClear();
|
||||
transport.post.mockClear();
|
||||
await capturedRun(["memories", "list", ...extra, "--json"]);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memory-library", {
|
||||
params: { offset: 0, limit: 100 },
|
||||
});
|
||||
await capturedRun(["profile", ...extra]);
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
"/api/v2/user-profile",
|
||||
undefined,
|
||||
);
|
||||
await capturedRun(["memories", "add", "Prefers tea", ...extra]);
|
||||
expect(transport.post.mock.calls[0][1]).not.toHaveProperty("user_id");
|
||||
await capturedRun(["recall", "tea", ...extra, "--json"]);
|
||||
const search = transport.post.mock.calls.find(
|
||||
([url]) => url === "/api/v2/context/search",
|
||||
);
|
||||
expect(search?.[1]).not.toHaveProperty("user_id");
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a malformed user id before contacting Core", async () => {
|
||||
for (const value of ["bad:id", "", "x".repeat(65), "a b", "--json"]) {
|
||||
const result = await capturedRun([
|
||||
"memories",
|
||||
"list",
|
||||
"--user-id",
|
||||
value,
|
||||
]);
|
||||
expect(result.code).toBe(2);
|
||||
}
|
||||
expect(
|
||||
(await capturedRun(["profile", "--user-id", "a", "--user-id", "b"])).code,
|
||||
).toBe(2);
|
||||
expect((await capturedRun(["recall", "tea", "--user-id"])).code).toBe(2);
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources connect for a Hermes profile", () => {
|
||||
it("connects hermes for another user from its own root", async () => {
|
||||
const result = await capturedRun([
|
||||
"sources",
|
||||
"connect",
|
||||
"hermes",
|
||||
"--user-id",
|
||||
"wife",
|
||||
"--root",
|
||||
"~/.hermes/profiles/wife",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post).toHaveBeenCalledTimes(1);
|
||||
const [url, body] = transport.post.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/sources/connect");
|
||||
expect(body).toMatchObject({
|
||||
source_ids: ["hermes"],
|
||||
// Core reads the custom root's identity itself.
|
||||
source_roots: {},
|
||||
source_candidate_revisions: {},
|
||||
onboarding: false,
|
||||
user_id: "wife",
|
||||
root: path.join(os.homedir(), ".hermes/profiles/wife"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a plain connect unchanged and accepts --import with a profile", async () => {
|
||||
await capturedRun(["sources", "connect", "hermes", "--import", "--json"]);
|
||||
const plain = transport.post.mock.calls[0][1];
|
||||
expect(plain).not.toHaveProperty("user_id");
|
||||
expect(plain).not.toHaveProperty("root");
|
||||
expect(plain).toMatchObject({
|
||||
source_roots: { hermes: "hermes-root-identity" },
|
||||
onboarding: true,
|
||||
});
|
||||
transport.post.mockClear();
|
||||
const result = await capturedRun([
|
||||
"sources",
|
||||
"connect",
|
||||
"hermes",
|
||||
"--import",
|
||||
"--user-id",
|
||||
"wife",
|
||||
"--root",
|
||||
"/profiles/wife",
|
||||
"--request-id",
|
||||
REQUEST,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.post.mock.calls[0][1]).toMatchObject({
|
||||
request_id: REQUEST,
|
||||
user_id: "wife",
|
||||
root: "/profiles/wife",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses --user-id and --root for any source but hermes", async () => {
|
||||
for (const argv of [
|
||||
["sources", "connect", "codex", "--user-id", "wife"],
|
||||
["sources", "connect", "codex", "--root", "/somewhere", "--import"],
|
||||
["sources", "connect", "hermes", "codex", "--user-id", "wife"],
|
||||
["sources", "connect", "--all-detected", "--user-id", "wife"],
|
||||
["sources", "connect", "hermes", "--user-id", "bad:id"],
|
||||
]) {
|
||||
const result = await capturedRun(argv);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stderr).toContain("hermes");
|
||||
}
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(arguments_, {
|
||||
ONE_MEMORY_DATA_DIR: "/synthetic/data",
|
||||
}),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.8",
|
||||
"hy-memory==2.0.9",
|
||||
# chromadb arrives through the HY SDK; locked here so an unrelated upgrade
|
||||
# cannot move the SDK's store. Built-in embedding consumes numpy directly.
|
||||
"chromadb==1.5.9",
|
||||
|
||||
@@ -14,6 +14,7 @@ from collections.abc import (
|
||||
MutableMapping,
|
||||
)
|
||||
from contextlib import asynccontextmanager, nullcontext, suppress
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -117,6 +118,7 @@ from one_memory.imports.access import (
|
||||
AuthorizedSourceRoot,
|
||||
SourceAccessError,
|
||||
root_identity_matches,
|
||||
source_root_identity,
|
||||
)
|
||||
from one_memory.imports.adapters import (
|
||||
ScanFailure,
|
||||
@@ -157,6 +159,7 @@ from one_memory.imports.sources.common import SourceInstallState
|
||||
from one_memory.imports.sources.registry import (
|
||||
RootCandidateKind,
|
||||
SourceRegistration,
|
||||
SourceRootCandidate,
|
||||
build_source_catalog,
|
||||
workbuddy_default_homes,
|
||||
)
|
||||
@@ -244,7 +247,17 @@ from one_memory.service_levels import (
|
||||
from one_memory.source_import_receipts import SourceImportReceipt, build_source_import_receipt
|
||||
from one_memory.source_read_access import SourceReadAccess
|
||||
from one_memory.source_totals import SourceTotals
|
||||
from one_memory.strict_query import forbid_unknown_query_params
|
||||
from one_memory.target_schema import bootstrap_fresh_management_schema
|
||||
from one_memory.user_scope import (
|
||||
DEFAULT_USER_ID,
|
||||
MULTI_USER_SOURCES,
|
||||
USER_ID_PATTERN,
|
||||
UserId,
|
||||
lane_key,
|
||||
owns,
|
||||
split_lane,
|
||||
)
|
||||
from one_memory.workspace_resolution import resolve_workspace_scopes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -387,6 +400,12 @@ class CreateImportJobRequest(BaseModel):
|
||||
|
||||
class ConnectSourcesRequest(BaseModel):
|
||||
source_ids: tuple[str, ...] = Field(min_length=1, max_length=32)
|
||||
#: Whose grant this is. Only Hermes may connect a root for a user other
|
||||
#: than `local`: a Hermes profile is another person's history.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
#: A root chosen by the caller instead of the source's candidate, such as
|
||||
#: `~/.hermes/profiles/<name>`. Hermes only; Core computes its identity.
|
||||
root: str | None = Field(default=None, min_length=1, max_length=4096)
|
||||
confirm_environment_overrides: bool = False
|
||||
full_rescan: bool = False
|
||||
onboarding: bool = False
|
||||
@@ -422,6 +441,8 @@ class SdkMemoryUpdateRequest(BaseModel):
|
||||
variant: SdkMemoryVariant | None = None
|
||||
# Experiences only: also replace the task.
|
||||
task: str | None = Field(default=None, min_length=1, max_length=200_000)
|
||||
#: Whose memory this is; another user's id answers 404.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
|
||||
class SdkUserProfileResponse(BaseModel):
|
||||
@@ -584,11 +605,15 @@ class ManualExtractReceipt(BaseModel):
|
||||
|
||||
|
||||
class AskMemoryRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
question: str = Field(min_length=1, max_length=2_000)
|
||||
scope: str = Field(default="all-apps", min_length=1, max_length=512)
|
||||
# Host-injected project directory, same boundary as recall. Optional so
|
||||
# the web UI and older callers keep working unchanged.
|
||||
workspace_path: str | None = Field(default=None, max_length=4_096)
|
||||
#: Whose memories answer. Omitted, it is `local`, the App's library.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
|
||||
class SdkHomeSummary(BaseModel):
|
||||
@@ -626,6 +651,27 @@ class MemoryGraphResponse(BaseModel):
|
||||
hy_graph_available: bool = True
|
||||
|
||||
|
||||
class SourceGrantView(BaseModel):
|
||||
"""One `(user_id, source_id)` grant, as `GET /api/v1/source-grants` lists it."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
user_id: str
|
||||
source_id: str
|
||||
root_label: str
|
||||
root_path: str
|
||||
authorized: bool
|
||||
revoked_at: datetime | None
|
||||
sync_enabled: bool
|
||||
|
||||
|
||||
class SourceGrantList(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
schema_version: Literal[1] = 1
|
||||
grants: tuple[SourceGrantView, ...]
|
||||
|
||||
|
||||
class SdkMigrationRequired(RuntimeError):
|
||||
"""A serving Core was started on a home that is not on SDK authority."""
|
||||
|
||||
@@ -928,6 +974,7 @@ def create_app(
|
||||
write_queue=memory_write_queue,
|
||||
extraction_settings=app.state.extraction_settings_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
user_ids=lambda: _known_user_ids(app),
|
||||
)
|
||||
# C2 owns the single production budget (handle 5s + reserve 250ms,
|
||||
# total 5.25s, lease 10s, renew 1s, claim 250ms). The composition
|
||||
@@ -1100,6 +1147,13 @@ def create_app(
|
||||
source_id
|
||||
for source_id, registration in app.state.source_catalog.items()
|
||||
if registration.capabilities.live_sync is CapabilityState.IMPLEMENTED
|
||||
) + tuple(
|
||||
lane
|
||||
for lane in _other_user_lanes(
|
||||
app.state.source_catalog, app.state.source_grant_store
|
||||
)
|
||||
if app.state.source_catalog[split_lane(lane)[1]].capabilities.live_sync
|
||||
is CapabilityState.IMPLEMENTED
|
||||
)
|
||||
# Resolved before the supervisor is built so it can state truthfully
|
||||
# how it is being driven: watching only applies to roots that exist
|
||||
@@ -1496,9 +1550,12 @@ def create_app(
|
||||
Depends(local_api_guard(runtime_settings, valid_ui_session=ui_session_valid)),
|
||||
Depends(require_external_endpoint),
|
||||
]
|
||||
app.include_router(memory_add_router(protected))
|
||||
# Memory reads and writes refuse a query parameter they do not declare: an
|
||||
# ignored `user_id` would read as isolation that never happened.
|
||||
strict = [*protected, Depends(forbid_unknown_query_params)]
|
||||
app.include_router(memory_add_router(strict))
|
||||
app.include_router(file_import_router(protected))
|
||||
app.include_router(sdk_context_router(protected))
|
||||
app.include_router(sdk_context_router(strict))
|
||||
|
||||
# A4-01: scan-root configuration, so the CLI's `path` command has something
|
||||
# to read instead of refusing.
|
||||
@@ -1512,7 +1569,7 @@ def create_app(
|
||||
|
||||
if getattr(app.state, "memory_write_registry", None) is None:
|
||||
app.state.memory_write_registry = WriteRegistry()
|
||||
app.include_router(sdk_memory_write_router(protected))
|
||||
app.include_router(sdk_memory_write_router(strict))
|
||||
from one_memory.call_event_routes import call_event_router
|
||||
|
||||
app.include_router(call_event_router(protected))
|
||||
@@ -2287,7 +2344,11 @@ def create_app(
|
||||
return SourceSyncState(source_id=source_id, sync_enabled=request.enabled)
|
||||
|
||||
async def _prepare_source_resume(source_id: str) -> None:
|
||||
"""Prepare catch-up work before committing the new desired state."""
|
||||
"""Prepare catch-up work before committing the new desired state.
|
||||
|
||||
`source_id` is a lane key, so another user's lane prepares that
|
||||
user's own cursors and history job.
|
||||
"""
|
||||
await _live_sync(app).initialize_source(source_id, baseline_missing_files=True)
|
||||
active = {
|
||||
ImportStatus.PENDING,
|
||||
@@ -2297,14 +2358,17 @@ def create_app(
|
||||
ImportStatus.FAILED,
|
||||
}
|
||||
if any(
|
||||
job.source_id == source_id and job.operation == "history" and job.status in active
|
||||
lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation == "history"
|
||||
and job.status in active
|
||||
for job in _job_store(app).list_jobs()
|
||||
):
|
||||
return
|
||||
user_id, bare_source_id = split_lane(source_id)
|
||||
await asyncio.to_thread(
|
||||
_job_store(app).create,
|
||||
user_id="local",
|
||||
source_id=source_id,
|
||||
user_id=user_id,
|
||||
source_id=bare_source_id,
|
||||
operation="history",
|
||||
)
|
||||
|
||||
@@ -2312,7 +2376,7 @@ def create_app(
|
||||
"""Run work prepared for a source after config.json has committed."""
|
||||
try:
|
||||
for job in _job_store(app).list_jobs():
|
||||
if job.source_id != source_id:
|
||||
if lane_key(job.user_id, job.source_id) != source_id:
|
||||
continue
|
||||
if job.status is ImportStatus.PENDING:
|
||||
_supervisor(app).enqueue(job.job_id)
|
||||
@@ -2341,8 +2405,13 @@ def create_app(
|
||||
response_model=SourceGrantReceipt,
|
||||
dependencies=protected,
|
||||
)
|
||||
async def revoke_source(source_id: str) -> SourceGrantReceipt:
|
||||
async def revoke_source(
|
||||
source_id: str,
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> SourceGrantReceipt:
|
||||
_require_registration(app, source_id)
|
||||
if user_id != DEFAULT_USER_ID:
|
||||
return await _revoke_other_user_grant(source_id, user_id)
|
||||
receipt = _grant_store(app).receipt(
|
||||
user_id="local",
|
||||
source_id=source_id,
|
||||
@@ -2378,6 +2447,65 @@ def create_app(
|
||||
await _refresh_live_watch(app)
|
||||
return receipt
|
||||
|
||||
async def _revoke_other_user_grant(source_id: str, user_id: str) -> SourceGrantReceipt:
|
||||
"""Stop reading another user's root. The App's switch is left alone."""
|
||||
if source_id not in MULTI_USER_SOURCES:
|
||||
raise HTTPException(status_code=422, detail="user_id_requires_multi_user_source")
|
||||
lane = lane_key(user_id, source_id)
|
||||
receipt = await asyncio.to_thread(
|
||||
partial(_grant_store(app).receipt, user_id=user_id, source_id=source_id)
|
||||
)
|
||||
if receipt is None:
|
||||
raise HTTPException(status_code=404, detail="grant_not_found")
|
||||
active_statuses = {
|
||||
ImportStatus.PENDING,
|
||||
ImportStatus.RUNNING,
|
||||
ImportStatus.PAUSED,
|
||||
ImportStatus.INTERRUPTED,
|
||||
ImportStatus.FAILED,
|
||||
}
|
||||
for job in _job_store(app).list_jobs():
|
||||
if lane_key(job.user_id, job.source_id) == lane and job.status in active_statuses:
|
||||
await _supervisor(app).cancel(job.job_id)
|
||||
await _live_sync(app).stop_source(lane)
|
||||
_supervisor(app).unregister_adapter(lane)
|
||||
receipt = await asyncio.to_thread(
|
||||
partial(_grant_store(app).revoke, user_id=user_id, source_id=source_id)
|
||||
)
|
||||
await _refresh_live_watch(app)
|
||||
return receipt
|
||||
|
||||
@app.get(
|
||||
"/api/v1/source-grants",
|
||||
response_model=SourceGrantList,
|
||||
dependencies=protected,
|
||||
)
|
||||
async def list_source_grants() -> SourceGrantList:
|
||||
"""Every grant of every user. `/api/v1/sources` stays `local`'s, as the App reads it."""
|
||||
|
||||
def describe() -> SourceGrantList:
|
||||
config_store = _config_store(app)
|
||||
views = []
|
||||
for receipt, root_path in _grant_store(app).grants():
|
||||
lane = lane_key(receipt.user_id, receipt.source_id)
|
||||
authorized = receipt.revoked_at is None and receipt.revision > 0
|
||||
views.append(
|
||||
SourceGrantView(
|
||||
user_id=receipt.user_id,
|
||||
source_id=receipt.source_id,
|
||||
root_label=receipt.root_label,
|
||||
root_path=_display_candidate_path(
|
||||
Path(root_path), runtime_settings.source_home
|
||||
),
|
||||
authorized=authorized,
|
||||
revoked_at=receipt.revoked_at,
|
||||
sync_enabled=authorized and _lane_sync_enabled(config_store, lane),
|
||||
)
|
||||
)
|
||||
return SourceGrantList(grants=tuple(views))
|
||||
|
||||
return await asyncio.to_thread(describe)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/import-jobs",
|
||||
response_model=ImportJob,
|
||||
@@ -2406,6 +2534,8 @@ def create_app(
|
||||
async def connect_sources(
|
||||
request: ConnectSourcesRequest,
|
||||
) -> ConnectSourcesResponse:
|
||||
if request.user_id != DEFAULT_USER_ID or request.root is not None:
|
||||
return await _connect_other_root(request)
|
||||
if request.onboarding:
|
||||
app.state.history_import_gate_released = False
|
||||
source_ids = tuple(dict.fromkeys(request.source_ids))
|
||||
@@ -2511,6 +2641,103 @@ def create_app(
|
||||
raise
|
||||
return ConnectSourcesResponse(**result.model_dump())
|
||||
|
||||
async def _connect_other_root(request: ConnectSourcesRequest) -> ConnectSourcesResponse:
|
||||
"""Grant one Hermes root to a user: `~/.hermes/profiles/<name>` for its own user.
|
||||
|
||||
Only Hermes holds more than one person's history (one profile per
|
||||
person), so a non-`local` user or a caller-chosen root is refused for
|
||||
every other source. The grant is `(user_id, source_id)`; its cursors,
|
||||
jobs and live sync follow that key.
|
||||
"""
|
||||
source_ids = tuple(dict.fromkeys(request.source_ids))
|
||||
if any(source_id not in MULTI_USER_SOURCES for source_id in source_ids):
|
||||
raise HTTPException(status_code=422, detail="user_id_requires_multi_user_source")
|
||||
if request.root is not None and len(source_ids) != 1:
|
||||
raise HTTPException(status_code=422, detail="source_root_requires_one_source")
|
||||
if request.user_id != DEFAULT_USER_ID and request.root is None:
|
||||
# The user is the root that is read: another user must name theirs,
|
||||
# or they would be handed `local`'s history.
|
||||
raise HTTPException(status_code=422, detail="source_root_required_for_user")
|
||||
if request.onboarding or request.import_upload_id is not None:
|
||||
raise HTTPException(status_code=422, detail="user_id_unsupported_for_request")
|
||||
if request.request_id is None:
|
||||
raise HTTPException(status_code=409, detail="source_confirmation_required")
|
||||
registrations = []
|
||||
source_roots = dict(request.source_roots)
|
||||
for source_id in source_ids:
|
||||
registration = _require_registration(app, source_id)
|
||||
if request.root is not None:
|
||||
root = Path(request.root)
|
||||
if not root.is_absolute():
|
||||
raise HTTPException(status_code=422, detail="source_root_must_be_absolute")
|
||||
# Named outright by the caller, like an environment override:
|
||||
# no saved candidate may replace it, and naming it confirms it.
|
||||
registration = replace(
|
||||
registration,
|
||||
candidate=SourceRootCandidate(
|
||||
path=root, kind=RootCandidateKind.ENVIRONMENT_OVERRIDE
|
||||
),
|
||||
)
|
||||
try:
|
||||
source_roots[source_id] = await asyncio.to_thread(source_root_identity, root)
|
||||
except SourceAccessError as error:
|
||||
raise HTTPException(status_code=409, detail=error.code) from None
|
||||
registrations.append(registration)
|
||||
existing_intent = await asyncio.to_thread(
|
||||
partial(
|
||||
app.state.source_connection_store.get,
|
||||
request.request_id,
|
||||
user_id=request.user_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
partial(
|
||||
app.state.source_connection_store.connect,
|
||||
request_id=request.request_id,
|
||||
registrations=tuple(registrations),
|
||||
source_roots=source_roots,
|
||||
confirm_overrides=(
|
||||
request.confirm_environment_overrides or request.root is not None
|
||||
),
|
||||
full_rescan=request.full_rescan,
|
||||
user_id=request.user_id,
|
||||
)
|
||||
)
|
||||
except ConnectionIntentError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from None
|
||||
if existing_intent is not None:
|
||||
return ConnectSourcesResponse(**result.model_dump())
|
||||
for job in result.jobs:
|
||||
registration = _require_registration(app, job.source_id)
|
||||
grant = await asyncio.to_thread(
|
||||
partial(
|
||||
_grant_store(app).authorized_root,
|
||||
user_id=job.user_id,
|
||||
source_id=job.source_id,
|
||||
)
|
||||
)
|
||||
if grant is None:
|
||||
continue
|
||||
lane = lane_key(job.user_id, job.source_id)
|
||||
_supervisor(app).register_adapter(
|
||||
registration.create_adapter(grant), user_id=job.user_id
|
||||
)
|
||||
_detection(app).pop(lane, None)
|
||||
if job.user_id != DEFAULT_USER_ID:
|
||||
if registration.capabilities.live_sync is CapabilityState.IMPLEMENTED:
|
||||
_live_sync(app).ensure_source(lane)
|
||||
# Another user's grant syncs while it stands (see
|
||||
# `_lane_sync_enabled`); the App's switch is not touched.
|
||||
await _prepare_source_resume(lane)
|
||||
await _activate_source_resume(lane)
|
||||
elif request.enable_sync:
|
||||
await _prepare_source_resume(lane)
|
||||
await asyncio.to_thread(_config_store(app).set_sync, job.source_id, True)
|
||||
await _activate_source_resume(lane)
|
||||
await _refresh_live_watch(app)
|
||||
return ConnectSourcesResponse(**result.model_dump())
|
||||
|
||||
@app.get(
|
||||
"/api/v1/source-connections/{request_id}",
|
||||
response_model=ConnectionIntentReceipt,
|
||||
@@ -2527,8 +2754,12 @@ def create_app(
|
||||
response_model=list[ImportJob],
|
||||
dependencies=protected,
|
||||
)
|
||||
def list_import_jobs() -> list[ImportJob]:
|
||||
return _job_store(app).list_jobs()
|
||||
def list_import_jobs(
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> list[ImportJob]:
|
||||
# One user's jobs: the App reads `local`'s and must not show another
|
||||
# user's Hermes import as its own.
|
||||
return [job for job in _job_store(app).list_jobs() if job.user_id == user_id]
|
||||
|
||||
@app.get(
|
||||
"/api/v1/live-sync",
|
||||
@@ -2857,9 +3088,11 @@ def create_app(
|
||||
@app.get(
|
||||
"/api/v2/home-summary",
|
||||
response_model=SdkHomeSummary,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def sdk_home_summary() -> SdkHomeSummary:
|
||||
async def sdk_home_summary(
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> SdkHomeSummary:
|
||||
"""The home card: the memory page's count and its newest memory.
|
||||
|
||||
The count is `memory_stats` total (chat + working + coding
|
||||
@@ -2871,12 +3104,12 @@ def create_app(
|
||||
client = await _sdk_client_handle(app).client()
|
||||
stats = await asyncio.to_thread(
|
||||
client.memory_stats,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
statuses=["active"],
|
||||
)
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
scene="all",
|
||||
statuses=["active"],
|
||||
limit=1,
|
||||
@@ -2897,7 +3130,7 @@ def create_app(
|
||||
@app.get(
|
||||
"/api/v2/memory-library",
|
||||
response_model=HySdkMemoryPage,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def sdk_memory_library(
|
||||
statuses: list[str] = Query(default=["active"]), # noqa: B008
|
||||
@@ -2907,6 +3140,7 @@ def create_app(
|
||||
limit: int = Query(default=LIBRARY_PAGE_SIZE, ge=1, le=500),
|
||||
window_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||
scene: Literal["all", "chat", "working", "coding"] = Query(default="all"),
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> HySdkMemoryPage:
|
||||
"""One page of memories across chat, working and coding.
|
||||
|
||||
@@ -2941,10 +3175,11 @@ def create_app(
|
||||
limit=limit,
|
||||
window_id=window_id,
|
||||
scene=scene,
|
||||
user_id=user_id,
|
||||
)
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
scene=scene,
|
||||
statuses=list(statuses),
|
||||
include_sources=[source] if source else None,
|
||||
@@ -2965,12 +3200,13 @@ def create_app(
|
||||
@app.get(
|
||||
"/api/v2/memories/graph",
|
||||
response_model=MemoryGraphResponse,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def sdk_memory_graph(
|
||||
limit: int = Query(default=160, ge=1, le=300),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
source: str | None = Query(default=None, min_length=1),
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> MemoryGraphResponse:
|
||||
"""The memory map on SDK authority: SDK memories plus the Kuzu overlay.
|
||||
|
||||
@@ -2980,11 +3216,13 @@ def create_app(
|
||||
"""
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
# The map draws the page the list shows, so it reads the same
|
||||
# source filter and window rather than the newest memories overall.
|
||||
# The map visualizes the active source scope, independently of the
|
||||
# left list's 20-row pagination window.
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
scene="all",
|
||||
statuses=["active"],
|
||||
include_sources=[source] if source else None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -3052,7 +3290,9 @@ def create_app(
|
||||
# source withholds the overlay, as on the legacy map.
|
||||
if not await asyncio.to_thread(_read_access(app).has_revoked_sources):
|
||||
try:
|
||||
hy_graph = await _sdk_memory_runtime(app).graph_snapshot(limit=limit)
|
||||
hy_graph = await _sdk_memory_runtime(app).graph_snapshot(
|
||||
limit=limit, user_id=user_id
|
||||
)
|
||||
except HySdkError:
|
||||
hy_graph = None
|
||||
if hy_graph is not None:
|
||||
@@ -3102,12 +3342,14 @@ def create_app(
|
||||
@app.get(
|
||||
"/api/v2/user-profile",
|
||||
response_model=SdkUserProfileResponse,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def sdk_user_profile() -> SdkUserProfileResponse:
|
||||
async def sdk_user_profile(
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> SdkUserProfileResponse:
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
value = await asyncio.to_thread(client.get_user_profile, user_id="local")
|
||||
value = await asyncio.to_thread(client.get_user_profile, user_id=user_id)
|
||||
profile = HySdkUserProfile.from_sdk(value)
|
||||
except Exception as error:
|
||||
raise _sdk_http_error(error, "hy_memory_profile_read_failed") from None
|
||||
@@ -3116,18 +3358,23 @@ def create_app(
|
||||
@app.get(
|
||||
"/api/v2/memories/{memory_id}",
|
||||
response_model=HySdkMemoryRecord,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def get_sdk_memory(
|
||||
memory_id: str,
|
||||
kind: SdkMemoryKind | None = None,
|
||||
variant: SdkMemoryVariant | None = None,
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> HySdkMemoryRecord:
|
||||
_check_memory_namespace(kind, variant)
|
||||
try:
|
||||
memory = await _get_sdk_memory(
|
||||
_sdk_client_handle(app), memory_id, kind=kind, variant=variant
|
||||
)
|
||||
# The SDK's `get` takes no user: another user's memory reads as
|
||||
# missing, never as existing.
|
||||
if memory is not None and not owns(memory.user_id, user_id):
|
||||
memory = None
|
||||
except AmbiguousMemoryIdError:
|
||||
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
|
||||
except Exception as error:
|
||||
@@ -3139,7 +3386,7 @@ def create_app(
|
||||
@app.put(
|
||||
"/api/v2/memories/{memory_id}",
|
||||
response_model=HySdkMemoryRecord,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def update_sdk_memory(
|
||||
memory_id: str,
|
||||
@@ -3151,6 +3398,13 @@ def create_app(
|
||||
namespace = _namespace_kwargs(request.kind, request.variant)
|
||||
try:
|
||||
async with app.state.sdk_write_coordinator.use_client() as client:
|
||||
# `update` takes no user: read the memory first and refuse
|
||||
# another user's id exactly as a missing one.
|
||||
current = await asyncio.to_thread(client.get, memory_id, **namespace)
|
||||
if current is None or not owns(
|
||||
HySdkMemoryRecord.from_sdk(current).user_id, request.user_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="memory_not_found")
|
||||
update_kwargs: dict[str, object] = dict(namespace)
|
||||
if request.task is not None:
|
||||
update_kwargs["task"] = request.task
|
||||
@@ -3188,16 +3442,27 @@ def create_app(
|
||||
@app.delete(
|
||||
"/api/v2/memories/{memory_id}",
|
||||
status_code=204,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def delete_sdk_memory(
|
||||
memory_id: str,
|
||||
kind: SdkMemoryKind | None = None,
|
||||
variant: SdkMemoryVariant | None = None,
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> Response:
|
||||
_check_memory_namespace(kind, variant)
|
||||
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.
|
||||
current = await asyncio.to_thread(
|
||||
client.get, memory_id, **_namespace_kwargs(kind, variant)
|
||||
)
|
||||
if current is not None and not owns(
|
||||
HySdkMemoryRecord.from_sdk(current).user_id, user_id
|
||||
):
|
||||
return Response(status_code=204)
|
||||
result = await asyncio.to_thread(
|
||||
client.delete, memory_id, **_namespace_kwargs(kind, variant)
|
||||
)
|
||||
@@ -3208,6 +3473,8 @@ def create_app(
|
||||
raise HySdkError(str(error_code or "hy_memory_delete_incomplete"))
|
||||
if result.get("success") is not True and result.get("deleted_count") != 0:
|
||||
raise HySdkError("hy_memory_delete_rejected")
|
||||
except HTTPException:
|
||||
raise
|
||||
except AmbiguousMemoryIdError:
|
||||
raise HTTPException(status_code=409, detail="memory_id_ambiguous") from None
|
||||
except Exception as error:
|
||||
@@ -3217,18 +3484,19 @@ def create_app(
|
||||
@app.get(
|
||||
"/api/v2/raw",
|
||||
response_model=SdkRawPage,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def sdk_raw_library(
|
||||
source: str | None = Query(default=None, min_length=1),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> SdkRawPage:
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
value = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
layers=["l1_raw"],
|
||||
include_sources=[source] if source else None,
|
||||
offset=offset,
|
||||
@@ -3242,7 +3510,7 @@ def create_app(
|
||||
@app.post(
|
||||
"/api/v2/memories/ask",
|
||||
response_model=SdkMemoryAnswer,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def ask_sdk_memories(
|
||||
request: AskMemoryRequest,
|
||||
@@ -3259,7 +3527,9 @@ def create_app(
|
||||
"om_ask",
|
||||
)
|
||||
try:
|
||||
answer = await _sdk_request_answerer(app).answer(request.question)
|
||||
answer = await _sdk_request_answerer(app).answer(
|
||||
request.question, user_id=request.user_id
|
||||
)
|
||||
except MemoryAnswerError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from None
|
||||
if request.question != "__om_probe__" and access is not None:
|
||||
@@ -3276,7 +3546,7 @@ def create_app(
|
||||
|
||||
@app.post(
|
||||
"/api/v2/memories/ask/stream",
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def ask_sdk_memories_stream(
|
||||
request: AskMemoryRequest,
|
||||
@@ -3298,7 +3568,9 @@ def create_app(
|
||||
|
||||
final: dict[str, object] | None = None
|
||||
try:
|
||||
async for event in answerer.answer_stream(request.question):
|
||||
async for event in answerer.answer_stream(
|
||||
request.question, user_id=request.user_id
|
||||
):
|
||||
if event.get("type") == "final":
|
||||
final = event
|
||||
yield _sse(_serialize_ask_event(event))
|
||||
@@ -3329,7 +3601,7 @@ def create_app(
|
||||
@app.post(
|
||||
"/api/v1/recall",
|
||||
response_model=RecallReceipt,
|
||||
dependencies=protected,
|
||||
dependencies=strict,
|
||||
)
|
||||
async def recall_memories(
|
||||
request: RecallRequest,
|
||||
@@ -3342,16 +3614,20 @@ def create_app(
|
||||
await _require_external_recall_scope(
|
||||
app, http_request, request.scope, request.workspace_path
|
||||
)
|
||||
target = _recall_target(app)
|
||||
target = (
|
||||
_recall_target(app)
|
||||
if request.user_id == DEFAULT_USER_ID
|
||||
else _sdk_recall_target(app, request.user_id)
|
||||
)
|
||||
access = external_access(http_request.headers)
|
||||
receipt = await recall_with_fallback(
|
||||
request,
|
||||
model_ready=lambda: _recall_model_ready(app),
|
||||
recall_memories=target.recall_async,
|
||||
has_memories=lambda: _library_has_memories(app),
|
||||
has_memories=lambda: _library_has_memories(app, request.user_id),
|
||||
raw_sources=lambda: _recall_raw_sources(app, request, access, started),
|
||||
search_raw=lambda query, limit, sources: _search_sdk_raw(
|
||||
app, query, limit=limit, sources=sources
|
||||
app, query, limit=limit, sources=sources, user_id=request.user_id
|
||||
),
|
||||
)
|
||||
event_store = getattr(app.state, "call_event_store", None)
|
||||
@@ -3728,21 +4004,60 @@ def _sync_refused(code: str) -> HTTPException:
|
||||
return HTTPException(status_code=409, detail=code)
|
||||
|
||||
|
||||
def _lane_sync_enabled(config_store: ConfigStore, lane: str) -> bool:
|
||||
"""Whether a lane may sync, before its grant is asked.
|
||||
|
||||
`local` keeps the App's per-source switch in the configuration document,
|
||||
which stays global. Another user's Hermes grant syncs for as long as it
|
||||
stands: connecting it is the consent, revoking it stops it, and it never
|
||||
turns the App's switch on or off.
|
||||
"""
|
||||
user_id, source_id = split_lane(lane)
|
||||
if user_id == DEFAULT_USER_ID:
|
||||
return config_store.sync_enabled(source_id)
|
||||
return source_id in MULTI_USER_SOURCES
|
||||
|
||||
|
||||
def _other_user_lanes(
|
||||
catalog: dict[str, SourceRegistration],
|
||||
grant_store: SourceGrantStore,
|
||||
) -> tuple[str, ...]:
|
||||
"""Lanes of every unrevoked grant held by a user other than `local`."""
|
||||
return tuple(
|
||||
lane_key(receipt.user_id, receipt.source_id)
|
||||
for receipt, _root in grant_store.grants()
|
||||
if receipt.user_id != DEFAULT_USER_ID
|
||||
and receipt.revoked_at is None
|
||||
and receipt.source_id in MULTI_USER_SOURCES
|
||||
and receipt.source_id in catalog
|
||||
)
|
||||
|
||||
|
||||
def _known_user_ids(app: FastAPI) -> tuple[str, ...]:
|
||||
"""`local` and every user holding a grant: the users whose data Core keeps."""
|
||||
users = [DEFAULT_USER_ID]
|
||||
for receipt, _root in _grant_store(app).grants():
|
||||
if receipt.user_id not in users:
|
||||
users.append(receipt.user_id)
|
||||
return tuple(users)
|
||||
|
||||
|
||||
def _granted_roots(
|
||||
source_ids: Iterable[str],
|
||||
grant_store: SourceGrantStore,
|
||||
config_store: ConfigStore,
|
||||
) -> dict[str, Path]:
|
||||
roots: dict[str, Path] = {}
|
||||
for source_id in source_ids:
|
||||
if not config_store.sync_enabled(source_id):
|
||||
for lane in source_ids:
|
||||
if not _lane_sync_enabled(config_store, lane):
|
||||
continue
|
||||
user_id, source_id = split_lane(lane)
|
||||
try:
|
||||
grant = grant_store.authorized_root(user_id="local", source_id=source_id)
|
||||
grant = grant_store.authorized_root(user_id=user_id, source_id=source_id)
|
||||
except SourceAccessError:
|
||||
continue
|
||||
if grant is not None:
|
||||
roots[source_id] = grant.root
|
||||
roots[lane] = grant.root
|
||||
return roots
|
||||
|
||||
|
||||
@@ -3751,6 +4066,14 @@ def _active_adapters(
|
||||
grant_store: SourceGrantStore,
|
||||
) -> dict[str, SourceAdapter]:
|
||||
adapters: dict[str, SourceAdapter] = {}
|
||||
for lane in _other_user_lanes(catalog, grant_store):
|
||||
user_id, source_id = split_lane(lane)
|
||||
try:
|
||||
grant = grant_store.authorized_root(user_id=user_id, source_id=source_id)
|
||||
if grant is not None:
|
||||
adapters[lane] = catalog[source_id].create_adapter(grant)
|
||||
except SourceAccessError:
|
||||
continue
|
||||
for source_id, registration in catalog.items():
|
||||
try:
|
||||
grant = grant_store.authorized_root(
|
||||
@@ -3913,10 +4236,14 @@ def _config_store(app: FastAPI) -> ConfigStore:
|
||||
|
||||
|
||||
def _source_processing_enabled(app: FastAPI, source_id: str) -> bool:
|
||||
if not _config_store(app).sync_enabled(source_id):
|
||||
"""`source_id` is a lane key: another user's lane asks that user's grant."""
|
||||
if not _lane_sync_enabled(_config_store(app), source_id):
|
||||
return False
|
||||
user_id, bare_source_id = split_lane(source_id)
|
||||
try:
|
||||
return _grant_store(app).authorized_root(user_id="local", source_id=source_id) is not None
|
||||
return (
|
||||
_grant_store(app).authorized_root(user_id=user_id, source_id=bare_source_id) is not None
|
||||
)
|
||||
except SourceAccessError:
|
||||
return False
|
||||
|
||||
@@ -4435,13 +4762,32 @@ async def _recall_model_ready(app: FastAPI) -> bool:
|
||||
return profile is not None and bool(profile.capabilities.model_available)
|
||||
|
||||
|
||||
async def _library_has_memories(app: FastAPI) -> bool:
|
||||
def _sdk_recall_target(app: FastAPI, user_id: str) -> SdkRecallTarget:
|
||||
"""Recall over one user's SDK memories; `get` results are kept only if theirs."""
|
||||
handle = _sdk_client_handle(app)
|
||||
|
||||
async def owned(memory_id: str) -> HySdkMemoryRecord | None:
|
||||
memory = await _get_sdk_memory(handle, memory_id)
|
||||
return memory if memory is not None and owns(memory.user_id, user_id) else None
|
||||
|
||||
return SdkRecallTarget(
|
||||
semantic_search=lambda query: _search_sdk_memories(
|
||||
handle,
|
||||
query,
|
||||
user_id=user_id,
|
||||
agent_id="*",
|
||||
),
|
||||
get_memory=owned,
|
||||
)
|
||||
|
||||
|
||||
async def _library_has_memories(app: FastAPI, user_id: str = DEFAULT_USER_ID) -> bool:
|
||||
"""Any active memory at all. Asked only after an empty memory search."""
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
statuses=["active"],
|
||||
limit=1,
|
||||
offset=0,
|
||||
@@ -4499,13 +4845,14 @@ async def _search_sdk_raw(
|
||||
*,
|
||||
limit: int,
|
||||
sources: frozenset[str] | None,
|
||||
user_id: str = DEFAULT_USER_ID,
|
||||
) -> tuple[HySdkRawHit, ...]:
|
||||
"""The imported conversations nearest the query: the SDK raw layer."""
|
||||
client = await _sdk_client_handle(app).client()
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
query,
|
||||
user_ids=["local"],
|
||||
user_ids=[user_id],
|
||||
agent_ids=[],
|
||||
include_sources=sorted(sources) if sources else None,
|
||||
limit=limit,
|
||||
@@ -4608,6 +4955,7 @@ async def _library_search_page(
|
||||
limit: int,
|
||||
window_id: str | None,
|
||||
scene: str = "all",
|
||||
user_id: str = DEFAULT_USER_ID,
|
||||
) -> HySdkMemoryPage:
|
||||
"""One page of a keyword search, cut from a cached result window.
|
||||
|
||||
@@ -4619,7 +4967,9 @@ async def _library_search_page(
|
||||
SDK so edits and deletes show. The whole library is never listed in Core.
|
||||
"""
|
||||
windows = _library_search_windows(app)
|
||||
key = window_key(query, source, statuses, scene)
|
||||
# The user is part of the window's identity: one user's `window_id` must
|
||||
# never cut a page from another user's matches.
|
||||
key = window_key(query, source, statuses, scene, user_id)
|
||||
window = windows.get(window_id, key) if window_id else None
|
||||
window_reset = window_id is not None and window is None
|
||||
if window is None:
|
||||
@@ -4629,7 +4979,7 @@ async def _library_search_page(
|
||||
query,
|
||||
scene="all",
|
||||
rerank=False,
|
||||
user_ids=["local"],
|
||||
user_ids=[user_id],
|
||||
agent_ids=[],
|
||||
include_sources=[source] if source else None,
|
||||
limit=fetch,
|
||||
@@ -4665,7 +5015,7 @@ async def _library_search_page(
|
||||
except HySdkReadError:
|
||||
skipped += 1
|
||||
continue
|
||||
if record.status == "active":
|
||||
if record.status == "active" and owns(record.user_id, user_id):
|
||||
memories.append(record)
|
||||
_log_skipped_sdk_memories(skipped)
|
||||
return HySdkMemoryPage(
|
||||
@@ -4722,8 +5072,10 @@ def _source_writes_on(app: FastAPI, source_id: str) -> bool:
|
||||
forever with attempts=0 while later jobs ran. Intake, claim and execute
|
||||
all ask this one question.
|
||||
"""
|
||||
if source_id == "one-memory-manual":
|
||||
receipt = _grant_store(app).receipt(user_id="local", source_id=source_id)
|
||||
if split_lane(source_id)[1] == "one-memory-manual":
|
||||
# One switch for hand-added memories, whoever adds them: it is a
|
||||
# management setting, and management stays global.
|
||||
receipt = _grant_store(app).receipt(user_id="local", source_id="one-memory-manual")
|
||||
return receipt is None or receipt.revoked_at is None
|
||||
return _source_processing_enabled(app, source_id)
|
||||
|
||||
@@ -4757,6 +5109,7 @@ def _history_import_gate_released(app: FastAPI) -> bool:
|
||||
job
|
||||
for job in jobs.list_jobs()
|
||||
if job.operation == "history"
|
||||
and job.user_id == DEFAULT_USER_ID
|
||||
and job.source_id in selected
|
||||
and (state.started_at is None or job.created_at >= state.started_at)
|
||||
]
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, UserId
|
||||
|
||||
ContextKind = Literal["raw", "memory"]
|
||||
ContextSearchChannel = Literal["lexical", "semantic"]
|
||||
ContextSemanticStatus = Literal["not_searched", "completed", "failed", "timed_out"]
|
||||
@@ -58,6 +60,8 @@ class SdkContextSearchRequest(ContextSearchRequest):
|
||||
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)
|
||||
#: Whose memories to search. The App never sends it and reads `local`.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
|
||||
class ContextMessage(BaseModel):
|
||||
|
||||
@@ -28,6 +28,7 @@ from one_memory.hy_sdk_types import (
|
||||
parse_sdk_search,
|
||||
)
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.user_scope import owns
|
||||
|
||||
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
||||
|
||||
@@ -73,7 +74,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
value.query,
|
||||
user_ids=["local"],
|
||||
user_ids=[value.user_id],
|
||||
agent_ids=[],
|
||||
include_sources=list(authority.raw_sources or ()),
|
||||
limit=value.limit,
|
||||
@@ -108,7 +109,7 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
value.query,
|
||||
user_ids=["local"],
|
||||
user_ids=[value.user_id],
|
||||
agent_ids=[],
|
||||
limit=value.limit,
|
||||
min_score=sdk_relevance_floor(),
|
||||
@@ -123,7 +124,12 @@ def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
for hit in hits:
|
||||
record = await asyncio.to_thread(client.get, hit.memory_id)
|
||||
memory = None if record is None else HySdkMemoryRecord.from_sdk(record)
|
||||
if memory is None or memory.status != "active":
|
||||
# `get` takes no user: a hit is kept only if it is the caller's.
|
||||
if (
|
||||
memory is None
|
||||
or memory.status != "active"
|
||||
or not owns(memory.user_id, value.user_id)
|
||||
):
|
||||
continue
|
||||
items.append(
|
||||
SdkMemoryContextItem(
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.8"
|
||||
HY_MEMORY_VERSION = "2.0.9"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ def _ensure_coding_schema(path: Path) -> None:
|
||||
CodingMemoryStore,
|
||||
)
|
||||
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.8 is pinned exactly).
|
||||
# Relies on the pinned SDK's internals (hy-memory==2.0.9 is pinned exactly).
|
||||
store = CodingMemoryStore.__new__(CodingMemoryStore)
|
||||
store._db_path = str(path)
|
||||
store._conn = None
|
||||
|
||||
@@ -31,6 +31,16 @@ class ScanIssueCode(StrEnum):
|
||||
#: import it. Named for the reason, so a reader can tell a setting from
|
||||
#: a record the parser could not use.
|
||||
SUBAGENT_TRACE_DISABLED = "subagent_trace_disabled"
|
||||
#: A host's own notice wearing the user's role — Hermes posts a finished
|
||||
#: background process, and its gateway marks the post as not written by a
|
||||
#: human. Nothing in it is the person's, so it never reaches extraction.
|
||||
INJECTED_MACHINE_NOTICE = "injected_machine_notice"
|
||||
#: The host could not transcribe a voice message and left a placeholder
|
||||
#: naming the audio file. The person did speak, but the placeholder holds
|
||||
#: only a path, which an extractor would otherwise read as a fact.
|
||||
VOICE_PLACEHOLDER = "voice_placeholder"
|
||||
#: A health check such as "Reply with exactly: PONG": no one's words.
|
||||
PROBE = "probe"
|
||||
SOURCE_BUSY = "source_busy"
|
||||
UNSUPPORTED_SCHEMA = "unsupported_schema"
|
||||
BUDGET_EXCEEDED = "budget_exceeded"
|
||||
|
||||
@@ -27,6 +27,7 @@ def discovered_off_loop(
|
||||
*,
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_top_directories: frozenset[str] = frozenset(),
|
||||
) -> AsyncIterator[SourceFileDiscoveryEvent]:
|
||||
"""The same walk, for callers that have an event loop to keep free.
|
||||
|
||||
@@ -41,6 +42,7 @@ def discovered_off_loop(
|
||||
grant,
|
||||
suffixes=suffixes,
|
||||
max_targets=max_targets,
|
||||
excluded_top_directories=excluded_top_directories,
|
||||
),
|
||||
scope_key=grant.source_id,
|
||||
)
|
||||
@@ -51,12 +53,15 @@ def discover_source_files(
|
||||
*,
|
||||
suffixes: frozenset[str],
|
||||
max_targets: int,
|
||||
excluded_top_directories: 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.
|
||||
"""
|
||||
grant.check_root()
|
||||
inspected = 0
|
||||
@@ -64,8 +69,12 @@ 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()
|
||||
child
|
||||
for child in child_directories
|
||||
if not (Path(directory) / child).is_symlink()
|
||||
and not (at_root and child in excluded_top_directories)
|
||||
)
|
||||
for filename in sorted(filenames):
|
||||
if Path(filename).suffix.lower() not in suffixes:
|
||||
|
||||
@@ -252,6 +252,18 @@ class SourceGrantStore:
|
||||
).fetchone()
|
||||
return self._to_receipt(row) if row else None
|
||||
|
||||
def grants(self) -> tuple[tuple[SourceGrantReceipt, str], ...]:
|
||||
"""Every grant of every user, with its granted root path.
|
||||
|
||||
One source may hold several grants, one per `(user_id, source_id)`:
|
||||
a Hermes root for `local` and a Hermes profile for another user.
|
||||
"""
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM source_grants ORDER BY source_id, user_id"
|
||||
).fetchall()
|
||||
return tuple((self._to_receipt(row), str(row["requested_path"])) for row in rows)
|
||||
|
||||
def require_receipt(self, *, user_id: str, source_id: str) -> SourceGrantReceipt:
|
||||
receipt = self.receipt(user_id=user_id, source_id=source_id)
|
||||
if receipt is None:
|
||||
|
||||
@@ -28,6 +28,7 @@ from one_memory.imports.adapters import (
|
||||
)
|
||||
from one_memory.imports.jobs import ImportJob, ImportJobStore, ImportStatus
|
||||
from one_memory.imports.supervisor import ImportSupervisor
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, split_lane
|
||||
|
||||
#: How long an automatic retry will wait for a wedged worker to notice cancel.
|
||||
ABANDON_TIMEOUT_SECONDS = 5.0
|
||||
@@ -64,7 +65,12 @@ class LiveSyncHealth(BaseModel):
|
||||
|
||||
|
||||
class PollingLiveSyncSupervisor:
|
||||
"""Near-live sync that reuses each adapter's persisted incremental cursor."""
|
||||
"""Near-live sync that reuses each adapter's persisted incremental cursor.
|
||||
|
||||
A "source" here is a lane key (`user_scope.lane_key`): the bare source id
|
||||
for `local`, `source@user` for another user's grant. Cursors, jobs and
|
||||
locks are therefore per `(user_id, source_id)`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -92,8 +98,22 @@ class PollingLiveSyncSupervisor:
|
||||
}
|
||||
self._locks = {source_id: asyncio.Lock() for source_id in self.source_ids}
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._group: asyncio.TaskGroup | None = None
|
||||
self._observers: set[asyncio.Task[None]] = set()
|
||||
|
||||
def ensure_source(self, source_id: str) -> None:
|
||||
"""Follow a lane granted after start, such as a new Hermes profile."""
|
||||
if source_id in self._locks:
|
||||
return
|
||||
self.source_ids = (*self.source_ids, source_id)
|
||||
self._health.setdefault(
|
||||
source_id,
|
||||
LiveSyncHealth(source_id=source_id, mode=self.mode, state=LiveSyncState.IDLE),
|
||||
)
|
||||
self._locks[source_id] = asyncio.Lock()
|
||||
if self._group is not None:
|
||||
self._group.create_task(self._poll_source(source_id))
|
||||
|
||||
def _set_health(self, source_id: str, value: LiveSyncHealth) -> None:
|
||||
previous = self._health.get(source_id)
|
||||
self._health[source_id] = value
|
||||
@@ -152,7 +172,12 @@ class PollingLiveSyncSupervisor:
|
||||
self._watched_sources = frozenset(source_ids)
|
||||
|
||||
def all_health(self) -> tuple[LiveSyncHealth, ...]:
|
||||
return tuple(self.health(source_id) for source_id in self.source_ids)
|
||||
"""`local`'s lanes only: what the App has always been shown."""
|
||||
return tuple(
|
||||
self.health(source_id)
|
||||
for source_id in self.source_ids
|
||||
if split_lane(source_id)[0] == DEFAULT_USER_ID
|
||||
)
|
||||
|
||||
async def initialize_source(
|
||||
self, source_id: str, *, baseline_missing_files: bool = True
|
||||
@@ -164,11 +189,12 @@ class PollingLiveSyncSupervisor:
|
||||
baselines = (
|
||||
await adapter.live_baseline() if isinstance(adapter, _LiveBaselineAdapter) else {}
|
||||
)
|
||||
user_id, bare_source_id = split_lane(source_id)
|
||||
await in_the_background(
|
||||
partial(
|
||||
self.job_store.ensure_live_cursors,
|
||||
user_id="local",
|
||||
source_id=source_id,
|
||||
user_id=user_id,
|
||||
source_id=bare_source_id,
|
||||
baselines=baselines,
|
||||
baseline_missing_files=baseline_missing_files,
|
||||
)
|
||||
@@ -228,7 +254,7 @@ class PollingLiveSyncSupervisor:
|
||||
(
|
||||
job
|
||||
for job in reversed(source_jobs)
|
||||
if job.source_id == source_id
|
||||
if lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation == "sync"
|
||||
and job.status
|
||||
in {
|
||||
@@ -277,13 +303,13 @@ class PollingLiveSyncSupervisor:
|
||||
competing = [
|
||||
job
|
||||
for job in source_jobs
|
||||
if job.source_id == source_id
|
||||
if lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation != "sync"
|
||||
and job.status
|
||||
in {ImportStatus.PENDING, ImportStatus.RUNNING, ImportStatus.INTERRUPTED}
|
||||
]
|
||||
bounded_history_incomplete = any(
|
||||
job.source_id == source_id
|
||||
lane_key(job.user_id, job.source_id) == source_id
|
||||
and job.operation == "history"
|
||||
and job.history_after is not None
|
||||
and job.status is not ImportStatus.COMPLETED
|
||||
@@ -379,11 +405,12 @@ class PollingLiveSyncSupervisor:
|
||||
await close()
|
||||
|
||||
async def _live_cursors(self, source_id: str, adapter: object) -> dict[str, SourceCursor]:
|
||||
user_id, bare_source_id = split_lane(source_id)
|
||||
cursors = await in_the_background(
|
||||
partial(
|
||||
self.job_store.get_source_cursors,
|
||||
user_id="local",
|
||||
source_id=source_id,
|
||||
user_id=user_id,
|
||||
source_id=bare_source_id,
|
||||
operation="sync",
|
||||
)
|
||||
)
|
||||
@@ -395,8 +422,8 @@ class PollingLiveSyncSupervisor:
|
||||
return await in_the_background(
|
||||
partial(
|
||||
self.job_store.ensure_live_cursors,
|
||||
user_id="local",
|
||||
source_id=source_id,
|
||||
user_id=user_id,
|
||||
source_id=bare_source_id,
|
||||
baselines=baselines,
|
||||
)
|
||||
)
|
||||
@@ -520,9 +547,13 @@ class PollingLiveSyncSupervisor:
|
||||
async def _run(self) -> None:
|
||||
# A source owns its cadence; one large catch-up cannot delay every
|
||||
# other source. The group keeps shutdown responsible for every poller.
|
||||
async with asyncio.TaskGroup() as group:
|
||||
for source_id in self.source_ids:
|
||||
group.create_task(self._poll_source(source_id))
|
||||
try:
|
||||
async with asyncio.TaskGroup() as group:
|
||||
self._group = group
|
||||
for source_id in self.source_ids:
|
||||
group.create_task(self._poll_source(source_id))
|
||||
finally:
|
||||
self._group = None
|
||||
|
||||
async def _poll_source(self, source_id: str) -> None:
|
||||
while True:
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import math
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import suppress
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
@@ -227,14 +227,17 @@ class ImportRunner:
|
||||
heartbeat = asyncio.create_task(self._keep_alive(job_id=job_id, runner_token=runner_token))
|
||||
pending = _PendingSkips()
|
||||
writes = _PendingWrites()
|
||||
stream = adapter.scan(
|
||||
ScanOptions(
|
||||
cursors=cursors,
|
||||
reuse_listing=(
|
||||
job.operation == "history"
|
||||
or (job.operation == "sync" and job.source_id == "workbuddy")
|
||||
),
|
||||
)
|
||||
stream = _owned_by(
|
||||
adapter.scan(
|
||||
ScanOptions(
|
||||
cursors=cursors,
|
||||
reuse_listing=(
|
||||
job.operation == "history"
|
||||
or (job.operation == "sync" and job.source_id == "workbuddy")
|
||||
),
|
||||
)
|
||||
),
|
||||
job.user_id,
|
||||
)
|
||||
slice_cursors = dict(cursors)
|
||||
next_event: asyncio.Future[ScanEvent] | None = None
|
||||
@@ -924,3 +927,30 @@ async def _settled_work[T](
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.result()
|
||||
raise
|
||||
|
||||
|
||||
def _owned_by(stream: AsyncIterator[ScanEvent], user_id: str) -> AsyncIterator[ScanEvent]:
|
||||
"""Stamp every scanned record with the user whose root is being read.
|
||||
|
||||
Adapters know nothing of users and emit `local`. Which user a record
|
||||
belongs to is decided by the grant the job reads (a Hermes profile's
|
||||
`state.db` is that profile's user), so the job's user is the record's.
|
||||
`local` passes the stream through untouched.
|
||||
"""
|
||||
if user_id == "local":
|
||||
return stream
|
||||
return _restamped(stream, user_id)
|
||||
|
||||
|
||||
async def _restamped(stream: AsyncIterator[ScanEvent], user_id: str) -> AsyncIterator[ScanEvent]:
|
||||
try:
|
||||
async for event in stream:
|
||||
if isinstance(event, ScannedEvidence) and event.evidence.user_id != user_id:
|
||||
event = event.model_copy(
|
||||
update={"evidence": event.evidence.model_copy(update={"user_id": user_id})}
|
||||
)
|
||||
yield event
|
||||
finally:
|
||||
close = getattr(stream, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from functools import partial
|
||||
@@ -87,6 +88,7 @@ class HermesSourceAdapter:
|
||||
self.grant,
|
||||
suffixes=frozenset({".jsonl", ".db"}),
|
||||
max_targets=10_000,
|
||||
excluded_top_directories=frozenset({HERMES_PROFILES_DIRECTORY}),
|
||||
):
|
||||
if isinstance(item, AuthorizedSourceFile) and _is_hermes_target(
|
||||
item.relative_path.as_posix()
|
||||
@@ -100,6 +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}),
|
||||
):
|
||||
yield ScanProgressEvent(
|
||||
phase=ScanPhase.DISCOVER,
|
||||
@@ -214,7 +217,11 @@ def parse_hermes_rollout_record(
|
||||
message.get("content"),
|
||||
allowed_block_types=frozenset({"text", "input_text", "output_text"}),
|
||||
)
|
||||
text = strip_injected_memory(text)
|
||||
text = strip_injected_memory(text or "")
|
||||
if role == "user":
|
||||
text, injected = gateway_user_text(text)
|
||||
if injected is not None:
|
||||
return ParsedSkip(reason_code=injected, conversation_id=conversation_id)
|
||||
if not text:
|
||||
return ParsedSkip(
|
||||
reason_code=ScanIssueCode.NO_TEXT,
|
||||
@@ -242,8 +249,17 @@ def parse_hermes_rollout_record(
|
||||
)
|
||||
|
||||
|
||||
#: Hermes keeps each extra profile under `<root>/profiles/<name>/`, with its
|
||||
#: 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.
|
||||
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:
|
||||
return False
|
||||
return path.name == "state.db" or (path.suffix == ".jsonl" and "sessions" in path.parts)
|
||||
|
||||
|
||||
@@ -389,6 +405,15 @@ def _read_state_db(
|
||||
# `tool` rows after it hold only what came back.
|
||||
calls = _tool_calls_text(row["tool_calls"]) if role == "assistant" else None
|
||||
content = strip_injected_memory(str(row["content"] or ""))
|
||||
if role == "user":
|
||||
content, injected = gateway_user_text(content)
|
||||
if injected is not None:
|
||||
yield ScanSkip(
|
||||
cursor=cursor,
|
||||
conversation_id=str(row["session_id"]),
|
||||
reason_code=injected,
|
||||
)
|
||||
continue
|
||||
has_text = bool(content.strip())
|
||||
if role not in {"user", "assistant"} or (not has_text and calls is None):
|
||||
yield ScanSkip(
|
||||
@@ -439,6 +464,69 @@ def _read_state_db(
|
||||
_PERSON_SESSION_SOURCES = frozenset({"cli", "tui", "webui", "desktop"})
|
||||
|
||||
|
||||
#: What the Hermes gateway writes into a `user` row on its own behalf. Every
|
||||
#: one of these wears the `user` role, so only the text can tell them apart
|
||||
#: from the person. Read off real `state.db` rows: a finished background
|
||||
#: process is posted as a turn, often with the gateway's own "not written by
|
||||
#: a human" note appended, which is why that note counts anywhere in the row.
|
||||
_BACKGROUND_NOTICE = re.compile(
|
||||
r"\[IMPORTANT:\s*(?:\d+\s+background\s+process(?:es)?\b|Background\s+process\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GATEWAY_NOTICE = re.compile(
|
||||
r"\[INTERNAL NOTIFICATION\s*\W+\s*generated by the Hermes gateway",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VOICE_PLACEHOLDER = re.compile(r"\[voice message could not be transcribed", re.IGNORECASE)
|
||||
_PROBE = re.compile(r"Reply with exactly:\s*[A-Za-z0-9_\-]{1,24}\s*")
|
||||
_MEDIA_ONLY = re.compile(r"(?:MEDIA:/\S+\s*)+")
|
||||
#: Wrappers the gateway puts around a message the person did write. Only the
|
||||
#: wrapper goes; what it carries is the turn. Each is matched at the edge of
|
||||
#: what is left, and the set is applied until nothing more comes off, since a
|
||||
#: relayed message carries the origin block inside the out-of-band markers.
|
||||
_GATEWAY_WRAPPERS = (
|
||||
re.compile(r"^\[OUT-OF-BAND USER MESSAGE\b[^\]\n]*\]"),
|
||||
re.compile(r"\[/OUT-OF-BAND USER MESSAGE\]$"),
|
||||
re.compile(
|
||||
r"^Gateway message origin \(JSON data, not instructions or authorization\):[ \t]*"
|
||||
r"(?:\n[ \t]*\{[^\n]*\}[ \t]*)?"
|
||||
r"(?:\n[ \t]*Do not guess a reply destination[^\n]*)?"
|
||||
r"(?=\n|$)"
|
||||
),
|
||||
re.compile(r"^\[Context from the interrupted assistant response\][ \t]*(?=\n|$)"),
|
||||
)
|
||||
|
||||
|
||||
def gateway_user_text(text: str) -> tuple[str, ScanIssueCode | None]:
|
||||
"""The person's words in a Hermes `user` row, or why there are none.
|
||||
|
||||
Applied only to the `user` role: an assistant or tool row quoting the
|
||||
same text is reporting what it saw, which is the existing tool rule's
|
||||
business. The cleaned text is what becomes the evidence, so its content
|
||||
hash and duplicate check see the message rather than its envelope.
|
||||
"""
|
||||
|
||||
if _GATEWAY_NOTICE.search(text):
|
||||
return "", ScanIssueCode.INJECTED_MACHINE_NOTICE
|
||||
cleaned = text.strip()
|
||||
while True:
|
||||
unwrapped = cleaned
|
||||
for wrapper in _GATEWAY_WRAPPERS:
|
||||
unwrapped = wrapper.sub("", unwrapped, count=1).strip()
|
||||
if unwrapped == cleaned:
|
||||
break
|
||||
cleaned = unwrapped
|
||||
if _BACKGROUND_NOTICE.match(cleaned):
|
||||
return "", ScanIssueCode.INJECTED_MACHINE_NOTICE
|
||||
if _VOICE_PLACEHOLDER.match(cleaned):
|
||||
return "", ScanIssueCode.VOICE_PLACEHOLDER
|
||||
if _PROBE.fullmatch(cleaned):
|
||||
return "", ScanIssueCode.PROBE
|
||||
if _MEDIA_ONLY.fullmatch(cleaned):
|
||||
return "", ScanIssueCode.NO_TEXT
|
||||
return cleaned, None
|
||||
|
||||
|
||||
def _tool_calls_text(value: object) -> str | None:
|
||||
"""OpenAI-shaped `tool_calls`: `[{"function": {"name", "arguments"}}]`, stored as JSON."""
|
||||
if isinstance(value, str):
|
||||
|
||||
@@ -24,6 +24,7 @@ from one_memory.service_levels import (
|
||||
ServiceLevelScheduler,
|
||||
ServiceResource,
|
||||
)
|
||||
from one_memory.user_scope import lane_key, split_lane
|
||||
|
||||
|
||||
class UnknownSourceError(KeyError):
|
||||
@@ -82,36 +83,45 @@ class ImportSupervisor:
|
||||
self._tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._drains: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
def register_adapter(self, adapter: SourceAdapter) -> None:
|
||||
self.adapters[adapter.descriptor.source_id] = adapter
|
||||
def register_adapter(self, adapter: SourceAdapter, *, user_id: str = "local") -> None:
|
||||
"""Adapters are keyed by lane: the source id for `local`, `source@user`
|
||||
for another user's grant of the same source."""
|
||||
self.adapters[lane_key(user_id, adapter.descriptor.source_id)] = adapter
|
||||
|
||||
def unregister_adapter(self, source_id: str) -> None:
|
||||
self.adapters.pop(source_id, None)
|
||||
|
||||
@staticmethod
|
||||
def lane_of(job: ImportJob) -> str:
|
||||
return lane_key(job.user_id, job.source_id)
|
||||
|
||||
def active_job_ids(self) -> frozenset[str]:
|
||||
return frozenset(job_id for job_id, task in self._tasks.items() if not task.done())
|
||||
|
||||
def create_job(
|
||||
self, source_id: str, *, operation: Literal["history", "sync"] = "history"
|
||||
) -> ImportJob:
|
||||
"""`source_id` is a lane key: another user's lane creates that user's job."""
|
||||
if self.source_enabled is not None and not self.source_enabled(source_id):
|
||||
raise SourceAccessError(SourceAccessErrorCode.GRANT_REQUIRED)
|
||||
return self.job_store.create(user_id="local", source_id=source_id, operation=operation)
|
||||
user_id, bare_source_id = split_lane(source_id)
|
||||
return self.job_store.create(user_id=user_id, source_id=bare_source_id, operation=operation)
|
||||
|
||||
def enqueue(self, job_id: str) -> ImportJob:
|
||||
if job_id in self._drains:
|
||||
raise InvalidJobTransitionError("import_cleanup_in_progress")
|
||||
job = self.job_store.require(job_id)
|
||||
if job.source_id not in self.adapters:
|
||||
raise UnknownSourceError(job.source_id)
|
||||
if self.source_enabled is not None and not self.source_enabled(job.source_id):
|
||||
lane = self.lane_of(job)
|
||||
if lane not in self.adapters:
|
||||
raise UnknownSourceError(lane)
|
||||
if self.source_enabled is not None and not self.source_enabled(lane):
|
||||
return job
|
||||
existing = self._tasks.get(job_id)
|
||||
if existing is None or existing.done():
|
||||
self._tasks[job_id] = asyncio.create_task(
|
||||
self._execute(
|
||||
job_id,
|
||||
source_id=job.source_id,
|
||||
source_id=lane,
|
||||
operation=job.operation,
|
||||
)
|
||||
)
|
||||
@@ -130,8 +140,10 @@ class ImportSupervisor:
|
||||
# pending history. Each resumed runner will yield after one bounded slice.
|
||||
for status in (ImportStatus.INTERRUPTED, ImportStatus.PENDING):
|
||||
for job in self.job_store.list_jobs(status=status):
|
||||
if job.source_id in self.adapters:
|
||||
if self.source_enabled is not None and not self.source_enabled(job.source_id):
|
||||
if self.lane_of(job) in self.adapters:
|
||||
if self.source_enabled is not None and not self.source_enabled(
|
||||
self.lane_of(job)
|
||||
):
|
||||
continue
|
||||
if job.status is not ImportStatus.PENDING:
|
||||
self.job_store.queue_for_resume(job.job_id)
|
||||
@@ -227,7 +239,7 @@ class ImportSupervisor:
|
||||
}:
|
||||
return
|
||||
if self.source_enabled is not None and not await in_the_background(
|
||||
self.source_enabled, job.source_id
|
||||
self.source_enabled, self.lane_of(job)
|
||||
):
|
||||
return
|
||||
runner = ImportRunner(
|
||||
@@ -238,7 +250,7 @@ class ImportSupervisor:
|
||||
sdk_queue=self.sdk_queue,
|
||||
sdk_authority=self.sdk_authority,
|
||||
)
|
||||
adapter = self.adapters[job.source_id]
|
||||
adapter = self.adapters[self.lane_of(job)]
|
||||
if self.prepare_adapter is not None:
|
||||
adapter = await self.prepare_adapter(job, adapter)
|
||||
|
||||
@@ -273,7 +285,7 @@ class ImportSupervisor:
|
||||
and retry_attempt < self.max_history_retries
|
||||
and (
|
||||
self.source_enabled is None
|
||||
or await in_the_background(self.source_enabled, current.source_id)
|
||||
or await in_the_background(self.source_enabled, self.lane_of(current))
|
||||
)
|
||||
):
|
||||
delay = self.retry_base_seconds * (2**retry_attempt)
|
||||
|
||||
@@ -7,9 +7,9 @@ between pages. The matches are kept here under an opaque ``window_id`` that
|
||||
the client passes back with the next offset.
|
||||
|
||||
A window is bound to the filters it was searched with (query, source,
|
||||
statuses, scene). It expires after ``ttl_seconds`` without being read, and the
|
||||
store keeps at most ``max_windows`` windows, dropping the least recently read
|
||||
first.
|
||||
statuses, scene) and to the user whose library it searched. It expires after
|
||||
``ttl_seconds`` without being read, and the store keeps at most
|
||||
``max_windows`` windows, dropping the least recently read first.
|
||||
Only memory identities are kept, never content: each page is read fresh from
|
||||
the SDK, so an edit or a delete shows on the next page read.
|
||||
"""
|
||||
@@ -28,13 +28,17 @@ from one_memory.hy_sdk_types import LibraryHit
|
||||
DEFAULT_WINDOW_TTL_SECONDS = 300.0
|
||||
DEFAULT_MAX_WINDOWS = 32
|
||||
|
||||
WindowKey = tuple[str, str | None, tuple[str, ...], str]
|
||||
WindowKey = tuple[str, str | None, tuple[str, ...], str, str]
|
||||
|
||||
|
||||
def window_key(
|
||||
query: str, source: str | None, statuses: list[str], scene: str = "all"
|
||||
query: str,
|
||||
source: str | None,
|
||||
statuses: list[str],
|
||||
scene: str = "all",
|
||||
user_id: str = "local",
|
||||
) -> WindowKey:
|
||||
return (query, source, tuple(sorted(set(statuses))), scene)
|
||||
return (query, source, tuple(sorted(set(statuses))), scene, user_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -11,10 +11,11 @@ import asyncio
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from typing import Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Request
|
||||
from fastapi.params import Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
@@ -34,6 +35,7 @@ from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
||||
from one_memory.memory_tasks import MemoryTaskStatus, MemoryTaskStore
|
||||
from one_memory.secrets import SecretStoreUnavailableError
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
||||
|
||||
SOURCE_ID = "one-memory-manual"
|
||||
MemoryAddState = Literal[
|
||||
@@ -53,6 +55,8 @@ class MemoryAddInput(BaseModel):
|
||||
request_id: UUID
|
||||
text: str = Field(min_length=1, max_length=12_000)
|
||||
scope: Literal["all-apps"]
|
||||
#: Whose memory this is. The App never sends it and writes `local`.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
@@ -108,7 +112,11 @@ class MemoryAdditionStore:
|
||||
|
||||
def save(self, addition: MemoryAddInput) -> EvidenceRecord:
|
||||
now = datetime.now(UTC)
|
||||
if addition.user_id != DEFAULT_USER_ID and not self.sdk_authority:
|
||||
# Legacy authority keeps one person's library only.
|
||||
raise ValueError("memory_add_user_requires_sdk_authority")
|
||||
value = EvidenceInput(
|
||||
user_id=addition.user_id,
|
||||
source_id=SOURCE_ID,
|
||||
conversation_id=f"note:{addition.request_id}",
|
||||
message_id=str(addition.request_id),
|
||||
@@ -200,7 +208,7 @@ class MemoryAdditionStore:
|
||||
)
|
||||
return self.require(value.evidence_id)
|
||||
|
||||
def require(self, evidence_id: str) -> EvidenceRecord:
|
||||
def require(self, evidence_id: str, *, user_id: str = DEFAULT_USER_ID) -> EvidenceRecord:
|
||||
if self.sdk_authority:
|
||||
if self.write_queue is None:
|
||||
raise KeyError(evidence_id)
|
||||
@@ -219,11 +227,11 @@ class MemoryAdditionStore:
|
||||
(item for item in payload.input_records if item.evidence_id == evidence_id),
|
||||
None,
|
||||
)
|
||||
if record is None:
|
||||
if record is None or record.user_id != user_id:
|
||||
raise KeyError(evidence_id)
|
||||
return record
|
||||
record = self.evidence.get(evidence_id)
|
||||
if record is None or record.user_id != "local" or record.source_id != SOURCE_ID:
|
||||
if record is None or record.user_id != user_id or record.source_id != SOURCE_ID:
|
||||
raise KeyError(evidence_id)
|
||||
metadata = record.origin_meta.get(EvidenceOrigin.MANUAL_IMPORT)
|
||||
if metadata is None or metadata.raw_meta.get("input_mode") != "direct_memory_note":
|
||||
@@ -237,8 +245,8 @@ class MemoryAdditionStore:
|
||||
raise ValueError("memory_add_source_revoked")
|
||||
return record
|
||||
|
||||
def receipt(self, evidence_id: str) -> MemoryAddReceipt:
|
||||
record = self.require(evidence_id)
|
||||
def receipt(self, evidence_id: str, *, user_id: str = DEFAULT_USER_ID) -> MemoryAddReceipt:
|
||||
record = self.require(evidence_id, user_id=user_id)
|
||||
if self.sdk_authority:
|
||||
assert self.write_queue is not None
|
||||
with main_database(self.write_queue.database_path).read() as database:
|
||||
@@ -300,10 +308,15 @@ class MemoryAdditionStore:
|
||||
error=error,
|
||||
)
|
||||
|
||||
def receipt_for_request(self, request_id: UUID) -> MemoryAddReceipt:
|
||||
def receipt_for_request(
|
||||
self, request_id: UUID, *, user_id: str = DEFAULT_USER_ID
|
||||
) -> MemoryAddReceipt:
|
||||
identity = str(request_id)
|
||||
if self.sdk_authority:
|
||||
# The Evidence id carries the user, so another user's request id
|
||||
# names a different note and reads as not found.
|
||||
probe = EvidenceInput(
|
||||
user_id=user_id,
|
||||
source_id=SOURCE_ID,
|
||||
conversation_id=f"note:{identity}",
|
||||
message_id=identity,
|
||||
@@ -316,16 +329,16 @@ class MemoryAdditionStore:
|
||||
)
|
||||
},
|
||||
)
|
||||
return self.receipt(probe.evidence_id)
|
||||
return self.receipt(probe.evidence_id, user_id=user_id)
|
||||
with main_database(self.evidence.database_path).read() as db:
|
||||
row = db.execute(
|
||||
"SELECT evidence_id FROM evidence WHERE user_id='local' AND source_id=? "
|
||||
"SELECT evidence_id FROM evidence WHERE user_id=? AND source_id=? "
|
||||
"AND conversation_id=? AND message_id=?",
|
||||
(SOURCE_ID, f"note:{identity}", identity),
|
||||
(user_id, SOURCE_ID, f"note:{identity}", identity),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(identity)
|
||||
return self.receipt(str(row[0]))
|
||||
return self.receipt(str(row[0]), user_id=user_id)
|
||||
|
||||
|
||||
def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
@@ -376,14 +389,20 @@ def memory_add_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
record = await asyncio.to_thread(additions.save, addition)
|
||||
except ValueError as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
saved = await asyncio.to_thread(additions.receipt, record.evidence_id)
|
||||
saved = await asyncio.to_thread(
|
||||
partial(additions.receipt, record.evidence_id, user_id=addition.user_id)
|
||||
)
|
||||
background.add_task(schedule, additions, record)
|
||||
return saved
|
||||
|
||||
@router.get("/additions/{request_id}", response_model=MemoryAddReceipt)
|
||||
def memory_add_status(request_id: UUID, request: Request) -> MemoryAddReceipt:
|
||||
def memory_add_status(
|
||||
request_id: UUID,
|
||||
request: Request,
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> MemoryAddReceipt:
|
||||
try:
|
||||
return store(request).receipt_for_request(request_id)
|
||||
return store(request).receipt_for_request(request_id, user_id=user_id)
|
||||
except KeyError as error:
|
||||
raise HTTPException(404, "memory_add_not_found") from error
|
||||
except ValueError as error:
|
||||
|
||||
@@ -75,7 +75,11 @@ def batch_id(*, internal_session_id: str, first_round: int, last_round: int) ->
|
||||
INTERNAL_SESSION_ID_MAX = 128
|
||||
|
||||
|
||||
def internal_session_id_for(conversation_id: str, source_id: str) -> str:
|
||||
def internal_session_id_for(
|
||||
conversation_id: str,
|
||||
source_id: str,
|
||||
user_id: str = "local",
|
||||
) -> str:
|
||||
"""Normalise a conversation identity to the durable ``internal_session_id``.
|
||||
|
||||
This is the single derivation shared by every Evidence-first intake path.
|
||||
@@ -86,7 +90,17 @@ def internal_session_id_for(conversation_id: str, source_id: str) -> str:
|
||||
per-session serialisation split). Only a value that the contract would have
|
||||
rejected before it ever reached the queue is replaced by the versioned
|
||||
session id, which is deterministic for a given (source, conversation).
|
||||
|
||||
Another user's conversation is always derived with the user in it: two
|
||||
Hermes profiles can hold the same session id, and sharing a session key
|
||||
would serialise and batch one person's turns behind the other's. `local`
|
||||
keeps the identity it always had.
|
||||
"""
|
||||
if user_id != "local":
|
||||
return session_id(
|
||||
harness_id=source_id,
|
||||
external_session_id=f"user:{user_id}|{conversation_id}",
|
||||
)
|
||||
if len(conversation_id) <= INTERNAL_SESSION_ID_MAX:
|
||||
return conversation_id
|
||||
derived = session_id(harness_id=source_id, external_session_id=conversation_id)
|
||||
|
||||
@@ -54,6 +54,7 @@ from one_memory.memory_tasks import (
|
||||
)
|
||||
from one_memory.sdk_migration_log import error_chain_text
|
||||
from one_memory.service_levels import ServiceLevel, service_level_context
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, lane_key, other_user_lane_prefix
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -345,6 +346,27 @@ EVENT_TIME_WINDOW = timedelta(minutes=10)
|
||||
DIRECT_WRITE_SOURCES = frozenset({"one-memory-manual"})
|
||||
|
||||
|
||||
def request_user_id(request: WriteRequest) -> str:
|
||||
"""Whose write this is: its frozen input's user, `local` for id-only rows."""
|
||||
users = {record.user_id for record in request.input_records}
|
||||
if len(users) > 1:
|
||||
raise ValueError("durable_write_multiple_users")
|
||||
return next(iter(users), DEFAULT_USER_ID)
|
||||
|
||||
|
||||
def request_source_key(request: WriteRequest) -> str:
|
||||
"""The FIFO/claim key of a write: its source, per user.
|
||||
|
||||
`local` keeps the bare source id it always had. Another user's Hermes
|
||||
lane is `hermes@user`, so one person's running job never holds the other
|
||||
person's queue and the claim gate answers for that user's grant.
|
||||
"""
|
||||
return lane_key(
|
||||
request_user_id(request),
|
||||
request.source_id or request.internal_session_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableWriteRecord:
|
||||
job_id: str
|
||||
@@ -862,7 +884,7 @@ class DurableWriteQueue:
|
||||
job_id,
|
||||
queue_seq,
|
||||
request.internal_session_id,
|
||||
request.source_id or request.internal_session_id,
|
||||
request_source_key(request),
|
||||
request.batch_identity.batch_id,
|
||||
key,
|
||||
payload,
|
||||
@@ -929,7 +951,9 @@ class DurableWriteQueue:
|
||||
f"{hashlib.sha256(file_key.encode()).hexdigest()}:"
|
||||
f"{hashlib.sha256(record.conversation_id.encode()).hexdigest()}"
|
||||
)
|
||||
session_key = internal_session_id_for(record.conversation_id, record.source_id)
|
||||
session_key = internal_session_id_for(
|
||||
record.conversation_id, record.source_id, record.user_id
|
||||
)
|
||||
written = connection.execute(
|
||||
"SELECT 1 FROM memory_write_jobs AS job, json_each(job.evidence_ids_json) AS ref "
|
||||
"WHERE job.session_key=? AND job.state!='assembling' AND ref.value=? LIMIT 1",
|
||||
@@ -998,7 +1022,7 @@ class DurableWriteQueue:
|
||||
str(uuid4()),
|
||||
queue_seq,
|
||||
session_key,
|
||||
record.source_id,
|
||||
lane_key(record.user_id, record.source_id),
|
||||
assembly_key,
|
||||
assembly_key,
|
||||
payload_json,
|
||||
@@ -1034,10 +1058,13 @@ class DurableWriteQueue:
|
||||
`flush` (history at end of file) completes the last turn and writes a
|
||||
short batch; without it (live polling) those stay in the row.
|
||||
"""
|
||||
# Two users' roots can hold a file with the same relative key (each
|
||||
# Hermes profile has its own `state.db`): only this user's rows.
|
||||
assembly_prefix = f"assembly:{user_id}:{source_id}:"
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM memory_write_jobs WHERE state='assembling' "
|
||||
"AND source_key=? ORDER BY queue_seq",
|
||||
(source_id,),
|
||||
"AND source_key=? AND substr(idempotency_key,1,?)=? ORDER BY queue_seq",
|
||||
(lane_key(user_id, source_id), len(assembly_prefix), assembly_prefix),
|
||||
).fetchall()
|
||||
finalized: list[str] = []
|
||||
for row in rows:
|
||||
@@ -1160,7 +1187,7 @@ class DurableWriteQueue:
|
||||
Same records, same key: a re-read returns the job already written.
|
||||
"""
|
||||
first = records[0]
|
||||
session_id = internal_session_id_for(first.conversation_id, first.source_id)
|
||||
session_id = internal_session_id_for(first.conversation_id, first.source_id, first.user_id)
|
||||
material = ":".join(f"{item.evidence_id}:{item.content_hash}" for item in records)
|
||||
request_id = f"turn:v2:{hashlib.sha256(material.encode()).hexdigest()}"
|
||||
existing = connection.execute(
|
||||
@@ -1205,7 +1232,7 @@ class DurableWriteQueue:
|
||||
job_id,
|
||||
queue_seq,
|
||||
session_id,
|
||||
first.source_id,
|
||||
lane_key(first.user_id, first.source_id),
|
||||
request.batch_identity.batch_id,
|
||||
request_id,
|
||||
json.dumps(
|
||||
@@ -2494,6 +2521,12 @@ async def execute_job(
|
||||
source_id = request.internal_session_id
|
||||
if source_ids and source_ids != {source_id}:
|
||||
raise RuntimeError("durable_write_multiple_sources")
|
||||
users = {getattr(record, "user_id", DEFAULT_USER_ID) for record in records}
|
||||
if len(users) > 1:
|
||||
raise RuntimeError("durable_write_multiple_users")
|
||||
# The gates answer per user: another user's Hermes job asks about
|
||||
# that user's grant, not `local`'s.
|
||||
source_id = lane_key(next(iter(users), DEFAULT_USER_ID), source_id)
|
||||
|
||||
async def mark() -> bool:
|
||||
return await in_the_background(queue.mark_invoked, job_id, owner=owner)
|
||||
@@ -2799,6 +2832,19 @@ class DurableWriteDrain:
|
||||
source_clause = f"AND candidate.source_key IN ({placeholders}) "
|
||||
source_params = tuple(sorted(enabled_sources))
|
||||
direct_sources = ",".join("?" for _ in DIRECT_WRITE_SOURCES)
|
||||
# Another user's hand-added memories are `one-memory-manual@user`:
|
||||
# the same lane ahead of history, still serial per user.
|
||||
direct_lanes = " OR ".join(
|
||||
"substr(candidate.source_key,1,?)=?" for _ in DIRECT_WRITE_SOURCES
|
||||
)
|
||||
direct_lane_params = tuple(
|
||||
value
|
||||
for source in sorted(DIRECT_WRITE_SOURCES)
|
||||
for value in (
|
||||
len(other_user_lane_prefix(source)),
|
||||
other_user_lane_prefix(source),
|
||||
)
|
||||
)
|
||||
# Same eligibility as the window claim below (free lease, no
|
||||
# running job of its source, nothing earlier in its session), but
|
||||
# not bound to the window: a note must not wait for history.
|
||||
@@ -2808,7 +2854,7 @@ class DurableWriteDrain:
|
||||
"WHERE candidate.state=? "
|
||||
"AND (candidate.lease_expires_at IS NULL OR candidate.lease_expires_at < ?) "
|
||||
+ source_clause
|
||||
+ f"AND candidate.source_key IN ({direct_sources}) "
|
||||
+ f"AND (candidate.source_key IN ({direct_sources}) OR {direct_lanes}) "
|
||||
"AND NOT EXISTS ("
|
||||
"SELECT 1 FROM memory_write_jobs AS held "
|
||||
"WHERE held.source_key=candidate.source_key AND held.state=? "
|
||||
@@ -2826,6 +2872,7 @@ class DurableWriteDrain:
|
||||
now.isoformat(),
|
||||
*source_params,
|
||||
*sorted(DIRECT_WRITE_SOURCES),
|
||||
*direct_lane_params,
|
||||
RUNNING,
|
||||
now.isoformat(),
|
||||
*_FIFO_BLOCKING_STATES,
|
||||
|
||||
@@ -19,10 +19,11 @@ does the writing. That separation is D52.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from one_memory.imports.models import (
|
||||
@@ -43,6 +44,24 @@ from one_memory.memory_server.durable_queue import (
|
||||
)
|
||||
from one_memory.memory_server.registry import WriteRegistry, WriteState
|
||||
from one_memory.memory_server.submit import WriteConflictError
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, USER_ID_PATTERN, UserId
|
||||
|
||||
#: Durable keys of another user's writes start with this. A `local` request
|
||||
#: id never may, so `local` cannot name another user's receipt.
|
||||
_USER_KEY_PREFIX = "user:"
|
||||
|
||||
|
||||
def durable_request_key(request_id: str, user_id: str) -> str:
|
||||
"""The queue key of a caller's request id, namespaced per user.
|
||||
|
||||
Two users choosing the same request id must not collide: the key is the
|
||||
queue's idempotency key and the SDK's `request_id`. `local` keeps its own
|
||||
request id unchanged.
|
||||
"""
|
||||
if user_id == DEFAULT_USER_ID:
|
||||
return request_id
|
||||
digest = hashlib.sha256(request_id.encode()).hexdigest()
|
||||
return f"{_USER_KEY_PREFIX}{user_id}:{digest}"
|
||||
|
||||
|
||||
class MemoryWriteReceipt(BaseModel):
|
||||
@@ -71,6 +90,7 @@ class SdkMemoryWriteInput(BaseModel):
|
||||
messages: list[OpenAIMessage] = Field(min_length=1, max_length=200)
|
||||
memory_at: datetime | None = None
|
||||
priority: Literal["live", "normal"] = "normal"
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
|
||||
def _registry(
|
||||
@@ -124,6 +144,9 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
) -> MemoryWriteReceipt:
|
||||
if not any(message.role == "user" for message in payload.messages):
|
||||
raise HTTPException(422, "messages_require_user_turn")
|
||||
if payload.user_id == DEFAULT_USER_ID and payload.request_id.startswith(_USER_KEY_PREFIX):
|
||||
raise HTTPException(422, "request_id_reserved_prefix")
|
||||
request_key = durable_request_key(payload.request_id, payload.user_id)
|
||||
observed_at = payload.memory_at or datetime(1970, 1, 1, tzinfo=UTC)
|
||||
written_by = {
|
||||
"user": WrittenBy.PERSON,
|
||||
@@ -133,6 +156,7 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
records = tuple(
|
||||
materialize_write_input(
|
||||
EvidenceInput(
|
||||
user_id=payload.user_id,
|
||||
source_id=payload.source_id,
|
||||
conversation_id=payload.session_id,
|
||||
message_id=f"{payload.request_id}:{index}",
|
||||
@@ -157,6 +181,7 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
internal_session_id = internal_session_id_for(
|
||||
payload.session_id,
|
||||
payload.source_id,
|
||||
payload.user_id,
|
||||
)
|
||||
contract = WriteRequest(
|
||||
internal_session_id=internal_session_id,
|
||||
@@ -168,7 +193,7 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
),
|
||||
evidence_ids=[record.evidence_id for record in records],
|
||||
input_records=records,
|
||||
idempotency_key=payload.request_id,
|
||||
idempotency_key=request_key,
|
||||
priority=payload.priority,
|
||||
event_at=payload.memory_at,
|
||||
time_quality="exact" if payload.memory_at is not None else "approximate",
|
||||
@@ -178,7 +203,7 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
except (WriteConflictError, ValueError) as error:
|
||||
raise HTTPException(409, str(error)) from error
|
||||
return MemoryWriteReceipt(
|
||||
request_id=record.request_id,
|
||||
request_id=payload.request_id,
|
||||
state=record.state,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
@@ -186,16 +211,24 @@ def sdk_router(dependencies: list[Any] | None = None) -> APIRouter:
|
||||
)
|
||||
|
||||
@instance.get("/{request_id}", response_model=MemoryWriteReceipt)
|
||||
def status(request_id: str, request: Request) -> MemoryWriteReceipt:
|
||||
def status(
|
||||
request_id: str,
|
||||
request: Request,
|
||||
user_id: str = Query(default=DEFAULT_USER_ID, pattern=USER_ID_PATTERN),
|
||||
) -> MemoryWriteReceipt:
|
||||
# A receipt belongs to the user that submitted it. Another user's key
|
||||
# is namespaced, and `local` may not name a namespaced key directly.
|
||||
if user_id == DEFAULT_USER_ID and request_id.startswith(_USER_KEY_PREFIX):
|
||||
raise HTTPException(404, "unknown request id")
|
||||
registry = _registry(request, write=False)
|
||||
try:
|
||||
record = registry.get(request_id)
|
||||
record = registry.get(durable_request_key(request_id, user_id))
|
||||
except DurableQueueSchemaUnavailableError as error:
|
||||
raise HTTPException(503, str(error)) from error
|
||||
if record is None:
|
||||
raise HTTPException(404, "unknown request id")
|
||||
return MemoryWriteReceipt(
|
||||
request_id=record.request_id,
|
||||
request_id=request_id,
|
||||
state=record.state,
|
||||
created_at=record.created_at,
|
||||
updated_at=record.updated_at,
|
||||
|
||||
@@ -271,7 +271,9 @@ def build_request_from_evidence(
|
||||
# Keep the exact conversation id when it fits the 128-char contract;
|
||||
# otherwise derive the versioned session id, so a long branch id does
|
||||
# not reject the whole history import.
|
||||
internal_session_id=internal_session_id_for(seed.conversation_id, seed.source_id),
|
||||
internal_session_id=internal_session_id_for(
|
||||
seed.conversation_id, seed.source_id, seed.user_id
|
||||
),
|
||||
first_round=first_round,
|
||||
last_round=last_round,
|
||||
evidence_ids=list(ordered),
|
||||
|
||||
@@ -28,6 +28,7 @@ from one_memory.memory_server.contracts import (
|
||||
internal_session_id_for,
|
||||
)
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue, DurableWriteRecord
|
||||
from one_memory.user_scope import lane_key
|
||||
|
||||
WritePriority = Literal["live", "normal"]
|
||||
|
||||
@@ -74,7 +75,7 @@ class UnifiedWriteIntake:
|
||||
if priority not in {"live", "normal"}:
|
||||
raise ValueError("priority must be 'live' or 'normal'")
|
||||
if self.source_writes_permitted is not None and not self.source_writes_permitted(
|
||||
record.source_id
|
||||
lane_key(record.user_id, record.source_id)
|
||||
):
|
||||
# Freeze is the product's one Harness processing decision. Refuse
|
||||
# new intake so a frozen source creates no new durable write row;
|
||||
@@ -106,7 +107,9 @@ class UnifiedWriteIntake:
|
||||
last_round: int,
|
||||
priority: WritePriority,
|
||||
) -> WriteRequest:
|
||||
session_id = internal_session_id_for(record.conversation_id, record.source_id)
|
||||
session_id = internal_session_id_for(
|
||||
record.conversation_id, record.source_id, record.user_id
|
||||
)
|
||||
event_at, time_quality = _job_time((record,))
|
||||
request_key = (
|
||||
f"entry:{record.evidence_id}"
|
||||
@@ -145,14 +148,14 @@ class UnifiedWriteIntake:
|
||||
):
|
||||
raise ValueError("records must belong to one conversation")
|
||||
if self.source_writes_permitted is not None and not self.source_writes_permitted(
|
||||
first.source_id
|
||||
lane_key(first.user_id, first.source_id)
|
||||
):
|
||||
raise SourceWriteDisabledError(first.evidence_id)
|
||||
material = ":".join(
|
||||
f"{record.evidence_id}:{record.revision}:{record.content_hash}" for record in records
|
||||
)
|
||||
request_id = f"turn:v1:{sha256(material.encode()).hexdigest()}"
|
||||
session_id = internal_session_id_for(first.conversation_id, first.source_id)
|
||||
session_id = internal_session_id_for(first.conversation_id, first.source_id, first.user_id)
|
||||
event_at, time_quality = _job_time(records)
|
||||
durable = self.queue.submit(
|
||||
WriteRequest(
|
||||
|
||||
@@ -22,6 +22,7 @@ from one_memory.semantic_deadline import (
|
||||
normalize_semantic_hits,
|
||||
search_within,
|
||||
)
|
||||
from one_memory.user_scope import DEFAULT_USER_ID, UserId
|
||||
|
||||
|
||||
def recall_blend_mode() -> str:
|
||||
@@ -67,6 +68,8 @@ class RecallRequest(BaseModel):
|
||||
#: Wall-clock budget for candidate gather + score. Not a substitute for
|
||||
#: raising the old newest-500 page; it is the third parallel budget.
|
||||
timeout_ms: int = Field(default=2_000, ge=50, le=30_000)
|
||||
#: Whose memories to recall. Omitted, it is `local`, the App's library.
|
||||
user_id: UserId = DEFAULT_USER_ID
|
||||
|
||||
|
||||
class RecallHit(BaseModel):
|
||||
|
||||
@@ -32,6 +32,7 @@ from one_memory.model_profiles import (
|
||||
attach_disable_thinking,
|
||||
)
|
||||
from one_memory.secrets import SecretStore, SecretStoreUnavailableError
|
||||
from one_memory.user_scope import owns
|
||||
|
||||
_NO_GROUNDING = "现有记忆不足以回答这个问题。"
|
||||
_GROUNDING_LIMIT = 6
|
||||
@@ -117,7 +118,12 @@ class SdkMemoryAnswerer:
|
||||
if hit.memory_id in seen:
|
||||
continue
|
||||
record = await _get_memory(client, hit.memory_id)
|
||||
if record is None or record.status != "active" or not record.is_latest:
|
||||
if (
|
||||
record is None
|
||||
or record.status != "active"
|
||||
or not record.is_latest
|
||||
or not owns(record.user_id, user_id)
|
||||
):
|
||||
continue
|
||||
seen.add(record.memory_id)
|
||||
found.append(_wire_memory(record))
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -56,8 +56,11 @@ class SdkMemoryRuntime:
|
||||
write_queue: DurableWriteQueue,
|
||||
extraction_settings: ExtractionSettingsStore,
|
||||
data_dir: Path,
|
||||
user_ids: Callable[[], Iterable[str]] = lambda: ("local",),
|
||||
) -> None:
|
||||
self.coordinator = coordinator
|
||||
#: Every user whose raw layer is kept to the retention window.
|
||||
self.user_ids = user_ids
|
||||
self.read_handle = read_handle
|
||||
self.write_queue = write_queue
|
||||
self.extraction_settings = extraction_settings
|
||||
@@ -72,6 +75,12 @@ class SdkMemoryRuntime:
|
||||
)
|
||||
|
||||
async def prune_expired_raw(self, *, now: datetime | None = None) -> int:
|
||||
deleted = 0
|
||||
for user_id in dict.fromkeys(await asyncio.to_thread(lambda: tuple(self.user_ids()))):
|
||||
deleted += await self._prune_expired_raw_for(user_id, now=now)
|
||||
return deleted
|
||||
|
||||
async def _prune_expired_raw_for(self, user_id: str, *, now: datetime | None) -> int:
|
||||
cutoff = (now or datetime.now(UTC)) - timedelta(days=RAW_RETENTION_DAYS)
|
||||
cutoff_timestamp = int(cutoff.timestamp())
|
||||
client = await self.read_handle.client()
|
||||
@@ -79,7 +88,7 @@ class SdkMemoryRuntime:
|
||||
while True:
|
||||
page = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
user_id=user_id,
|
||||
layers=["l1_raw"],
|
||||
statuses=list(_RAW_STATUSES),
|
||||
order="asc",
|
||||
@@ -95,8 +104,7 @@ class SdkMemoryRuntime:
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(row.get("memory_id"), str)
|
||||
and int(row.get("memory_at") or row.get("gmt_created") or 0)
|
||||
< cutoff_timestamp
|
||||
and int(row.get("memory_at") or row.get("gmt_created") or 0) < cutoff_timestamp
|
||||
]
|
||||
if not expired:
|
||||
return deleted
|
||||
@@ -154,11 +162,10 @@ class SdkMemoryRuntime:
|
||||
limit: int = 12,
|
||||
) -> tuple[tuple[str, float], ...]:
|
||||
return tuple(
|
||||
(hit.memory_id, hit.score)
|
||||
for hit in await self.semantic_recall(query, limit=limit)
|
||||
(hit.memory_id, hit.score) for hit in await self.semantic_recall(query, limit=limit)
|
||||
)
|
||||
|
||||
async def graph_snapshot(self, *, limit: int = 300) -> HyGraphSnapshot:
|
||||
async def graph_snapshot(self, *, limit: int = 300, user_id: str = "local") -> HyGraphSnapshot:
|
||||
try:
|
||||
await self.coordinator.active_profile_credentials()
|
||||
except RuntimeError as error:
|
||||
@@ -173,7 +180,7 @@ class SdkMemoryRuntime:
|
||||
return await asyncio.to_thread(
|
||||
read_graph_snapshot,
|
||||
client,
|
||||
"local",
|
||||
user_id,
|
||||
"*",
|
||||
limit,
|
||||
)
|
||||
|
||||
@@ -259,6 +259,7 @@ def prepare_legacy_write_queue_for_cutover(database_path: Path) -> tuple[int, in
|
||||
internal_session_id = internal_session_id_for(
|
||||
source.conversation_id,
|
||||
source.source_id,
|
||||
source.user_id,
|
||||
)
|
||||
queue.submit(
|
||||
WriteRequest(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Refuse query parameters a route does not declare.
|
||||
|
||||
FastAPI ignores an unknown query parameter. For a memory read that is a
|
||||
silent widening: `memory-library?user_id=__nobody__` against a Core that did
|
||||
not know `user_id` answered with the whole library, and the caller believed
|
||||
it was isolated. Routes that carry this dependency answer such a request with
|
||||
the same 422 a malformed body gets.
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.dependencies.models import Dependant
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
|
||||
def _declared_query_names(dependant: Dependant) -> set[str]:
|
||||
names = {parameter.alias for parameter in dependant.query_params}
|
||||
for dependency in dependant.dependencies:
|
||||
names |= _declared_query_names(dependency)
|
||||
return names
|
||||
|
||||
|
||||
async def forbid_unknown_query_params(request: Request) -> None:
|
||||
route = request.scope.get("route")
|
||||
if not isinstance(route, APIRoute):
|
||||
return
|
||||
unknown = sorted(set(request.query_params) - _declared_query_names(route.dependant))
|
||||
if unknown:
|
||||
raise RequestValidationError(
|
||||
[
|
||||
{
|
||||
"type": "extra_forbidden",
|
||||
"loc": ("query", name),
|
||||
"msg": "Extra inputs are not permitted",
|
||||
"input": None,
|
||||
}
|
||||
for name in unknown
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Who a memory belongs to: one Runtime, many `user_id`s.
|
||||
|
||||
A user is decided by which source root is read (a Hermes profile is its own
|
||||
`state.db`), never by session or platform fields. The App never names a user
|
||||
and keeps reading and writing `local`; every other harness stays on `local`
|
||||
too. Only Hermes may connect a second root for another user.
|
||||
|
||||
In memory, a source's import lane is keyed by `lane_key(user_id, source_id)`.
|
||||
For `local` that is the bare source id, so every existing key, health record
|
||||
and log line stays byte-identical; another user's lane is `source@user`.
|
||||
`@` cannot occur in a user id or a source id, so the key splits back exactly.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
DEFAULT_USER_ID = "local"
|
||||
|
||||
#: `:` is excluded on purpose: the SDK joins ids with it when it keys history,
|
||||
#: so a user id holding one could read another user's history.
|
||||
USER_ID_PATTERN = r"^[A-Za-z0-9._-]{1,64}$"
|
||||
_USER_ID = re.compile(USER_ID_PATTERN)
|
||||
|
||||
#: The only source whose roots may belong to a user other than `local`.
|
||||
MULTI_USER_SOURCES = frozenset({"hermes"})
|
||||
|
||||
UserId = Annotated[str, Field(min_length=1, max_length=64, pattern=USER_ID_PATTERN)]
|
||||
|
||||
_LANE_SEPARATOR = "@"
|
||||
|
||||
|
||||
def valid_user_id(value: str) -> bool:
|
||||
return bool(_USER_ID.fullmatch(value))
|
||||
|
||||
|
||||
def lane_key(user_id: str, source_id: str) -> str:
|
||||
if user_id == DEFAULT_USER_ID:
|
||||
return source_id
|
||||
return f"{source_id}{_LANE_SEPARATOR}{user_id}"
|
||||
|
||||
|
||||
def other_user_lane_prefix(source_id: str) -> str:
|
||||
"""What every other user's lane of `source_id` starts with."""
|
||||
return f"{source_id}{_LANE_SEPARATOR}"
|
||||
|
||||
|
||||
def split_lane(key: str) -> tuple[str, str]:
|
||||
"""`(user_id, source_id)` of a lane key; a bare source id is `local`'s."""
|
||||
source_id, separator, user_id = key.partition(_LANE_SEPARATOR)
|
||||
if not separator or not valid_user_id(user_id):
|
||||
return DEFAULT_USER_ID, key
|
||||
return user_id, source_id
|
||||
|
||||
|
||||
def owns(record_user_id: str | None, user_id: str) -> bool:
|
||||
"""Whether a stored memory belongs to `user_id`.
|
||||
|
||||
The SDK's `get`/`update`/`delete`/`history`/`get_write_outcome` take no
|
||||
user, so MemHub checks this before acting on an id. A memory written
|
||||
before MemHub named users carries `local` or nothing; both are `local`'s.
|
||||
"""
|
||||
stored = record_user_id or DEFAULT_USER_ID
|
||||
return stored == user_id
|
||||
@@ -105,6 +105,7 @@
|
||||
| `test_graph_width_guard.py` | `unit` | Graph width mismatch must ask, never silently archive. |
|
||||
| `test_harness_contract_suite.py` | `contract` | A4-09: one contract, ten harnesses. |
|
||||
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
|
||||
| `test_hermes_gateway_injection.py` | `contract` | Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped. |
|
||||
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
|
||||
| `test_host_capabilities.py` | `unit` | A plugin does not become realtime on every host or every unknown version. |
|
||||
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
|
||||
@@ -196,6 +197,7 @@
|
||||
| `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 |
|
||||
| `test_model_request_audit.py` | `contract` | 覆盖 failure is recorded without error message、missing usage stays unknown in detail and zero in summary、records one physical request with provider usage and no content 等 5 个场景。 |
|
||||
| `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 |
|
||||
| `test_multi_user_runtime.py` | `contract` | One Runtime, many users: `user_id` on the memory API, per-user Hermes profiles. |
|
||||
| `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. |
|
||||
| `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. |
|
||||
| `test_obs03_health_probe.py` | `contract` | OBS-03: '/health' answered with the web app instead of a health payload. |
|
||||
@@ -309,4 +311,4 @@
|
||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||
|
||||
共 304 个测试文件。
|
||||
共 306 个测试文件。
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 304
|
||||
EXPECTED_TEST_FILES_SHA256 = "b28f022b9688c96ca563c59de63e95d344d2f01ba3b6020b422bf4dfb27520d6"
|
||||
EXPECTED_TEST_FILE_COUNT = 306
|
||||
EXPECTED_TEST_FILES_SHA256 = "e616dcf9fea288bf15a4445045f7b4278517f3304091c04fffd55be80308d481"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{"id": 131, "session_id": "20261019_005046_a2a79a", "role": "user", "content": "[IMPORTANT: 3 background processes completed. Treat these results as one batch and give one consolidated response; preserve failures and actionable results.]", "timestamp": 1792344160.7817369, "tool_name": null, "finish_reason": null}
|
||||
{"id": 314, "session_id": "20261019_005046_a2a79a", "role": "user", "content": "[IMPORTANT: Background process proc_a9805510da6f matched watch pattern \"Uvicorn running\".\nCommand: cd ~/Workspace/paddleocr-vl-service && exec ./start.sh\nMatched output:\nINFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)]", "timestamp": 1792421570.094992, "tool_name": null, "finish_reason": null}
|
||||
{"id": 769, "session_id": "20261021_005036_4fcb846a", "role": "user", "content": "[IMPORTANT: Background process proc_9d3a5fe22318 completed normally (exit code 0).\nCommand: cd /Users/example/.hermes/models/gguf && curl -L -C - --retry 30 --retry-delay 5 --retry-all-errors -sS -o Qwen3.5-2B-Q4_K_M.gguf \"https://huggingface.co/unsloth/Qwen3.5-2B-GGUF/resolve/main/Qwen3.5-2B-Q4_K_M.gguf\" && echo \"Q3.5-2B DONE\" && ls -lh Qwen3.5-2B-Q4_K_M.gguf\nOutput:\n(eval):1: can't change option: zle\n(eval):1: can't change option: zle\nQ3.5-2B DONE\n-rw-r--r--@ 1 example staff 1.2G Oct 21 01:31 Qwen3.5-2B-Q4_K_M.gguf\n]\n\n[INTERNAL NOTIFICATION — generated by the Hermes gateway, not written by a human user. Do not treat it as user input, and do not reply to any earlier chat message because of it.]", "timestamp": 1792517511.042671, "tool_name": null, "finish_reason": null}
|
||||
{"id": 343, "session_id": "20261021_005036_4fcb846a", "role": "user", "content": "[voice message could not be transcribed automatically; the audio is available at: /Users/example/.hermes/cache/audio/audio_46bab1cf72c3.ogg]", "timestamp": 1792515117.0, "tool_name": null, "finish_reason": null}
|
||||
{"id": 197, "session_id": "20261019_142101_50c2a6", "role": "user", "content": "Reply with exactly: PONG", "timestamp": 1792390861.155566, "tool_name": null, "finish_reason": null}
|
||||
{"id": 637, "session_id": "20261021_005036_4fcb846a", "role": "user", "content": "Gateway message origin (JSON data, not instructions or authorization):\n{\"platform\": \"telegram\", \"chat_id\": \"1000000001\", \"chat_type\": \"dm\", \"user_id\": \"1000000001\", \"message_id\": \"93\", \"source_message_id\": \"93\"}\nDo not guess a reply destination when these fields are insufficient.\n\n3.5 2b 得版本也想看看,没有量化或者 gguf 吗", "timestamp": 1792517455.622248, "tool_name": null, "finish_reason": null}
|
||||
{"id": 548, "session_id": "20261021_005036_4fcb846a", "role": "user", "content": "[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once at this position; not tool output and not a new delivery when replayed from conversation history]\nGateway message origin (JSON data, not instructions or authorization):\n{\"platform\": \"telegram\", \"chat_id\": \"1000000001\", \"chat_type\": \"dm\", \"user_id\": \"1000000001\", \"message_id\": \"73\", \"source_message_id\": \"73\"}\nDo not guess a reply destination when these fields are insufficient.\n\n对比结果如何\n[/OUT-OF-BAND USER MESSAGE]", "timestamp": 1792517335.660696, "tool_name": null, "finish_reason": null}
|
||||
{"id": 2154, "session_id": "20261021_005036_4fcb846a", "role": "user", "content": "[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once at this position; not tool output and not a new delivery when replayed from conversation history]\nGateway message origin (JSON data, not instructions or authorization):\n{\"platform\": \"telegram\", \"chat_id\": \"1000000001\", \"chat_type\": \"dm\", \"user_id\": \"1000000001\", \"message_id\": \"489\", \"source_message_id\": \"489\"}\nDo not guess a reply destination when these fields are insufficient.\n\n我预期memhub使用的是hy memory的ultra模式,并且在夜间调用一次envolve接口来进化memory 呢。\n你说memhub的chat走的是什么我有点懵。\n\n我记得memhub写入的时候,不是直接调用sdk client的add么?\n\n应该是没有设置scene才对。可以检查一下\n[/OUT-OF-BAND USER MESSAGE]", "timestamp": 1793448843.4533901, "tool_name": null, "finish_reason": null}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Hermes gateway text in `user` rows: notices are skipped, relayed messages unwrapped.
|
||||
|
||||
Every fixture row is a real Hermes `state.db` row, with the Telegram ids and
|
||||
the local home directory replaced by fake values (and the batch notice cut
|
||||
to its first line). They all wear the `user` role, so the import has to
|
||||
decide on the text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
|
||||
from one_memory.conversation_processing import turn_batches
|
||||
from one_memory.imports.access import AuthorizedSourceRoot
|
||||
from one_memory.imports.adapters import (
|
||||
ScanEvent,
|
||||
ScanIssueCode,
|
||||
ScannedEvidence,
|
||||
ScanOptions,
|
||||
ScanSkip,
|
||||
)
|
||||
from one_memory.imports.sources.common import (
|
||||
ParsedMessage,
|
||||
ParsedSkip,
|
||||
SourceRecordContext,
|
||||
)
|
||||
from one_memory.imports.sources.hermes import (
|
||||
HERMES_DESCRIPTOR,
|
||||
create_hermes_adapter,
|
||||
gateway_user_text,
|
||||
parse_hermes_rollout_record,
|
||||
)
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
|
||||
_ROWS = {
|
||||
row["id"]: row
|
||||
for row in (
|
||||
json.loads(line)
|
||||
for line in (Path(__file__).parent / "fixtures" / "hermes_gateway_rows.jsonl")
|
||||
.read_text(encoding="utf-8")
|
||||
.splitlines()
|
||||
if line.strip()
|
||||
)
|
||||
}
|
||||
|
||||
_DROPPED = {
|
||||
131: ScanIssueCode.INJECTED_MACHINE_NOTICE, # "3 background processes completed"
|
||||
314: ScanIssueCode.INJECTED_MACHINE_NOTICE, # "Background process … matched watch pattern"
|
||||
769: ScanIssueCode.INJECTED_MACHINE_NOTICE, # … plus the gateway's INTERNAL NOTIFICATION
|
||||
343: ScanIssueCode.VOICE_PLACEHOLDER,
|
||||
197: ScanIssueCode.PROBE,
|
||||
}
|
||||
|
||||
_UNWRAPPED = {
|
||||
548: "对比结果如何",
|
||||
637: "3.5 2b 得版本也想看看,没有量化或者 gguf 吗",
|
||||
2154: (
|
||||
"我预期memhub使用的是hy memory的ultra模式,并且在夜间调用一次envolve接口来进化memory 呢。\n"
|
||||
"你说memhub的chat走的是什么我有点懵。\n\n"
|
||||
"我记得memhub写入的时候,不是直接调用sdk client的add么?\n\n"
|
||||
"应该是没有设置scene才对。可以检查一下"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row_id", sorted(_DROPPED))
|
||||
def test_a_machine_turn_in_the_users_role_is_skipped_with_its_reason(row_id: int) -> None:
|
||||
assert gateway_user_text(_ROWS[row_id]["content"]) == ("", _DROPPED[row_id])
|
||||
|
||||
|
||||
def test_the_gateway_note_drops_a_turn_wherever_it_sits() -> None:
|
||||
note = _ROWS[769]["content"].rsplit("\n\n", 1)[1]
|
||||
assert note.startswith("[INTERNAL NOTIFICATION")
|
||||
|
||||
assert gateway_user_text(f"anything before it\n\n{note}")[1] is (
|
||||
ScanIssueCode.INJECTED_MACHINE_NOTICE
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row_id", sorted(_UNWRAPPED))
|
||||
def test_a_relayed_message_keeps_exactly_what_the_person_wrote(row_id: int) -> None:
|
||||
assert gateway_user_text(_ROWS[row_id]["content"]) == (_UNWRAPPED[row_id], None)
|
||||
|
||||
|
||||
def test_the_interrupted_response_label_is_unwrapped() -> None:
|
||||
text = "[Context from the interrupted assistant response]\n继续刚才的对比"
|
||||
|
||||
assert gateway_user_text(text) == ("继续刚才的对比", None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "reason"),
|
||||
[
|
||||
("MEDIA:/Users/example/.hermes/cache/images/a.jpg", ScanIssueCode.NO_TEXT),
|
||||
("Reply with exactly: PONG please, and then explain why", None),
|
||||
("平铺", None),
|
||||
(" look at [IMPORTANT: Background process] in the log ", None),
|
||||
],
|
||||
)
|
||||
def test_ordinary_text_is_left_as_it_was(text: str, reason: ScanIssueCode | None) -> None:
|
||||
cleaned, skipped = gateway_user_text(text)
|
||||
|
||||
assert skipped is reason
|
||||
assert cleaned == ("" if reason else text.strip())
|
||||
|
||||
|
||||
def _state_db(root: Path, rows: list[tuple[int, str, str, str]]) -> None:
|
||||
root.mkdir()
|
||||
connection = sqlite3.connect(root / "state.db")
|
||||
connection.execute(
|
||||
"CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT,"
|
||||
" content TEXT, timestamp REAL, tool_name TEXT, active INTEGER)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?, 1000, ?, 1)",
|
||||
[
|
||||
(row_id, session, role, content, "terminal" if role == "tool" else None)
|
||||
for row_id, session, role, content in rows
|
||||
],
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def _scan(root: Path, home: Path) -> list[ScanEvent]:
|
||||
adapter = create_hermes_adapter(
|
||||
AuthorizedSourceRoot.grant(
|
||||
source_id=HERMES_DESCRIPTOR.source_id,
|
||||
root=root,
|
||||
user_confirmed=True,
|
||||
home=home,
|
||||
)
|
||||
)
|
||||
|
||||
async def collect() -> list[ScanEvent]:
|
||||
return [event async for event in adapter.scan(ScanOptions(cursors={}))]
|
||||
|
||||
return asyncio.run(collect())
|
||||
|
||||
|
||||
def test_state_db_import_writes_the_cleaned_turn_and_counts_the_skipped_ones(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
rows = [
|
||||
(row_id, "session-h", "user", _ROWS[row_id]["content"])
|
||||
for row_id in [*_DROPPED, *_UNWRAPPED]
|
||||
]
|
||||
_state_db(tmp_path / "hermes", rows)
|
||||
events = _scan(tmp_path / "hermes", tmp_path)
|
||||
evidence = [event.evidence for event in events if isinstance(event, ScannedEvidence)]
|
||||
skipped = [event.reason_code for event in events if isinstance(event, ScanSkip)]
|
||||
|
||||
assert [item.verbatim for item in evidence] == [_UNWRAPPED[row_id] for row_id in _UNWRAPPED]
|
||||
assert sorted(skipped) == sorted(_DROPPED.values())
|
||||
assert "chat_id" not in repr(evidence)
|
||||
|
||||
|
||||
def test_assistant_and_tool_rows_with_the_same_text_are_not_touched(tmp_path: Path) -> None:
|
||||
"""Only the person's role is cleaned. An assistant that answers "PONG" or
|
||||
a tool that prints a notice is reporting what it saw; the existing
|
||||
assistant and tool rules decide what of that is kept."""
|
||||
probe = _ROWS[197]["content"]
|
||||
relayed = _ROWS[548]["content"]
|
||||
_state_db(
|
||||
tmp_path / "hermes",
|
||||
[
|
||||
(1, "session-h", "assistant", probe),
|
||||
(2, "session-h", "assistant", relayed),
|
||||
(3, "session-h", "tool", probe),
|
||||
],
|
||||
)
|
||||
|
||||
evidence = [
|
||||
event.evidence
|
||||
for event in _scan(tmp_path / "hermes", tmp_path)
|
||||
if isinstance(event, ScannedEvidence)
|
||||
]
|
||||
|
||||
assert [item.role for item in evidence] == ["assistant", "assistant", "tool"]
|
||||
assert evidence[0].verbatim == probe
|
||||
assert evidence[1].verbatim == relayed
|
||||
assert probe in evidence[2].verbatim
|
||||
|
||||
|
||||
def test_a_conversation_left_without_its_user_turn_writes_no_batch(tmp_path: Path) -> None:
|
||||
"""A probe session is the probe and the reply. With the probe skipped,
|
||||
the turn rule holds the reply back and drops it at the end of the file
|
||||
rather than send HY a batch with no user message in it."""
|
||||
_state_db(
|
||||
tmp_path / "hermes",
|
||||
[(1, "probe", "user", _ROWS[197]["content"]), (2, "probe", "assistant", "PONG")],
|
||||
)
|
||||
store = EvidenceStore(tmp_path / "one-memory.sqlite3")
|
||||
records = [
|
||||
store.put(event.evidence).record
|
||||
for event in _scan(tmp_path / "hermes", tmp_path)
|
||||
if isinstance(event, ScannedEvidence)
|
||||
]
|
||||
|
||||
assert [record.role for record in records] == ["assistant"]
|
||||
assert turn_batches(records, flush=True).batches == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["user", "assistant"])
|
||||
def test_session_transcripts_follow_the_same_rule(role: str) -> None:
|
||||
context = SourceRecordContext(
|
||||
file_key="hermes",
|
||||
relative_path="sessions/session.jsonl",
|
||||
ordinal="1",
|
||||
)
|
||||
|
||||
def parse(text: str) -> ParsedMessage | ParsedSkip:
|
||||
record: dict[str, JsonValue] = {
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": text,
|
||||
"sessionId": "s",
|
||||
}
|
||||
return parse_hermes_rollout_record(record, context)
|
||||
|
||||
notice = parse(_ROWS[314]["content"])
|
||||
relayed = parse(_ROWS[548]["content"])
|
||||
|
||||
if role == "user":
|
||||
assert notice == ParsedSkip(
|
||||
reason_code=ScanIssueCode.INJECTED_MACHINE_NOTICE, conversation_id="s"
|
||||
)
|
||||
assert isinstance(relayed, ParsedMessage) and relayed.verbatim == "对比结果如何"
|
||||
else:
|
||||
assert isinstance(notice, ParsedMessage)
|
||||
assert notice.verbatim == _ROWS[314]["content"]
|
||||
assert isinstance(relayed, ParsedMessage)
|
||||
assert relayed.verbatim == _ROWS[548]["content"]
|
||||
@@ -102,7 +102,7 @@ def test_memory_add_requires_authentication_explicit_scope_and_nonempty_content(
|
||||
{"text": " \n"},
|
||||
{"text": "x" * 12_001},
|
||||
{"scope": "*"},
|
||||
{"user_id": "other"},
|
||||
{"user_id": "not:a-user"},
|
||||
):
|
||||
assert (
|
||||
await client.post("/api/v2/memories", json={**base, **change})
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_sdk_ask_v2_stream_is_native(tmp_path, monkeypatch) -> None:
|
||||
)
|
||||
|
||||
class Answerer:
|
||||
async def answer_stream(self, _question):
|
||||
async def answer_stream(self, _question, *, user_id="local"):
|
||||
yield {"type": "recall", "memories": (memory,)}
|
||||
yield {"type": "delta", "text": "你要求真实数据。"}
|
||||
yield {
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
"""One Runtime, many users: `user_id` on the memory API, per-user Hermes profiles.
|
||||
|
||||
A user is the root that is read (a Hermes profile's own `state.db`), never a
|
||||
session or platform field. The App never names a user and keeps `local`;
|
||||
every other harness stays on `local`. These tests drive the production app
|
||||
against a user-aware stand-in for the public SDK client, whose `get`,
|
||||
`update` and `delete` take no user exactly like hy-memory 2.0.9's.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from one_memory.api import _sdk_recall_target, create_app
|
||||
from one_memory.config import Settings
|
||||
from one_memory.imports.access import AuthorizedSourceRoot
|
||||
from one_memory.imports.adapters import ScannedEvidence, ScanOptions
|
||||
from one_memory.imports.models import (
|
||||
EvidenceInput,
|
||||
EvidenceOrigin,
|
||||
OriginMetadata,
|
||||
WrittenBy,
|
||||
materialize_write_input,
|
||||
)
|
||||
from one_memory.imports.sources.hermes import HERMES_DESCRIPTOR, create_hermes_adapter
|
||||
from one_memory.imports.sources.registry import build_source_catalog
|
||||
from one_memory.memory_server.contracts import internal_session_id_for
|
||||
from one_memory.memory_server.durable_queue import (
|
||||
DurableWriteDrain,
|
||||
DurableWriteQueue,
|
||||
execute_job,
|
||||
)
|
||||
from one_memory.sdk_write_processor import SdkWriteProcessor
|
||||
from one_memory.secrets import InMemorySecretStore
|
||||
from one_memory.user_scope import lane_key, split_lane, valid_user_id
|
||||
|
||||
TOKEN = "multi-user-test"
|
||||
|
||||
|
||||
class UserAwareSdk:
|
||||
"""The public client surface MemHub calls, holding memories per user."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.memories: dict[str, dict[str, object]] = {}
|
||||
self.add_calls: list[dict[str, object]] = []
|
||||
|
||||
def _store(self, user_id: str, content: str, *, source: str, session_id: str) -> str:
|
||||
memory_id = f"memory-{len(self.memories) + 1}"
|
||||
self.memories[memory_id] = {
|
||||
"memory_id": memory_id,
|
||||
"content": content,
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": user_id,
|
||||
"agent_id": "all-apps",
|
||||
"session_id": session_id,
|
||||
"source": source,
|
||||
"tags": [],
|
||||
"gmt_created": 1_790_000_000,
|
||||
}
|
||||
return memory_id
|
||||
|
||||
def add(self, messages, *, user_id, session_id, source, request_id, **_kwargs):
|
||||
self.add_calls.append({"user_id": user_id, "request_id": request_id})
|
||||
text = " ".join(item["content"] for item in messages if item["role"] == "user")
|
||||
memory_id = self._store(user_id, text, source=source, session_id=session_id)
|
||||
return {"success": True, "memory_ids": [memory_id]}
|
||||
|
||||
def add_extracted(self, contents, *, user_id, session_id, source, request_id, **_kwargs):
|
||||
self.add_calls.append({"user_id": user_id, "request_id": request_id})
|
||||
memory_id = self._store(user_id, " ".join(contents), source=source, session_id=session_id)
|
||||
return {"success": True, "memory_ids": [memory_id]}
|
||||
|
||||
def _rows(self, user_ids) -> list[dict[str, object]]:
|
||||
return [dict(row) for row in self.memories.values() if row["user_id"] in user_ids]
|
||||
|
||||
def list_memories(self, *, user_id, scene=None, offset=0, limit=100, **_kwargs):
|
||||
rows = [] if _kwargs.get("layers") == ["l1_raw"] else self._rows({user_id})
|
||||
page = rows[offset : offset + limit]
|
||||
if scene == "all":
|
||||
return {
|
||||
"scene": "all",
|
||||
"memories": [{**row, "scene": "chat", "kind": "chat"} for row in page],
|
||||
"total": len(rows),
|
||||
"totals": {"chat": len(rows), "working": 0, "coding": 0},
|
||||
"errors": {},
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"request_id": "list",
|
||||
"elapsed_ms": 1.0,
|
||||
}
|
||||
return {"vdb": {"memories": page, "total": len(rows), "limit": limit, "offset": offset}}
|
||||
|
||||
def memory_stats(self, *, user_id, statuses=None):
|
||||
return {"total": len(self._rows({user_id})), "request_id": "stats", "elapsed_ms": 1.0}
|
||||
|
||||
def search(self, query, *, user_ids, scene=None, **kwargs):
|
||||
rows = [] if kwargs.get("layers") == ["l1_raw"] else self._rows(set(user_ids))
|
||||
items = [{**row, "scene": "chat", "kind": "chat", "score": 0.9} for row in rows]
|
||||
if scene == "all":
|
||||
return {
|
||||
"request_id": "search",
|
||||
"scene": "all",
|
||||
"memories": items,
|
||||
"lanes": {"chat": len(items), "working": 0, "coding": 0},
|
||||
"errors": {},
|
||||
"elapsed_ms": 1.0,
|
||||
}
|
||||
return {"request_id": "search", "scene": scene or "normal", "memories": items}
|
||||
|
||||
def get(self, memory_id, kind=None, variant=None):
|
||||
row = self.memories.get(memory_id)
|
||||
return None if row is None else {**row, "kind": "chat", "variant": None}
|
||||
|
||||
def update(self, memory_id, content, kind=None, variant=None, task=None):
|
||||
if memory_id not in self.memories:
|
||||
return {"success": False, "error_message": f"memory_id '{memory_id}' not found"}
|
||||
self.memories[memory_id]["content"] = content
|
||||
return {"success": True, "memory_id": memory_id}
|
||||
|
||||
def delete(self, memory_id, kind=None, variant=None):
|
||||
if self.memories.pop(memory_id, None) is None:
|
||||
return {"success": False, "deleted_count": 0}
|
||||
return {"success": True, "deleted_count": 1}
|
||||
|
||||
def get_user_profile(self, *, user_id):
|
||||
return {
|
||||
"request_id": f"profile-{user_id}",
|
||||
"profile": {
|
||||
"memory_id": f"l0-{user_id}",
|
||||
"content": f"name is {user_id}",
|
||||
"layer": "L0_BASIC_INFO",
|
||||
"basic_info": {"name": user_id},
|
||||
},
|
||||
"elapsed_ms": 1.0,
|
||||
}
|
||||
|
||||
|
||||
class Handle:
|
||||
def __init__(self, sdk: UserAwareSdk) -> None:
|
||||
self.sdk = sdk
|
||||
|
||||
async def client(self) -> UserAwareSdk:
|
||||
return self.sdk
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Coordinator:
|
||||
"""The production write processor over the stand-in client."""
|
||||
|
||||
def __init__(self, sdk: UserAwareSdk) -> None:
|
||||
self.processor = SdkWriteProcessor(Handle(sdk)) # type: ignore[arg-type]
|
||||
|
||||
async def process_sdk_job(self, records, *, request_id, before_hy_invocation):
|
||||
return await self.processor.process_sdk_job(
|
||||
records, request_id=request_id, before_hy_invocation=before_hy_invocation
|
||||
)
|
||||
|
||||
|
||||
async def _never(_job_id: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def drain_all(queue: DurableWriteQueue, sdk: UserAwareSdk) -> None:
|
||||
"""Claim and run every pending job, ungated, the way the drain would."""
|
||||
drain = DurableWriteDrain(queue, execute=_never, lease=timedelta(seconds=30))
|
||||
while claimed := drain.claim(limit=8):
|
||||
for job_id in claimed:
|
||||
await execute_job(
|
||||
job_id,
|
||||
queue=queue,
|
||||
supervisor=Coordinator(sdk),
|
||||
evidence=None, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def application(tmp_path: Path, *, home: Path | None = None):
|
||||
"""The production app; with `home`, imports run over that home's sources."""
|
||||
settings = Settings(
|
||||
data_dir=tmp_path / "data",
|
||||
source_home=home or tmp_path,
|
||||
api_token=SecretStr(TOKEN),
|
||||
imports_enabled=home is not None,
|
||||
automatic_memory_processing=False,
|
||||
live_sync_enabled=False,
|
||||
)
|
||||
return create_app(
|
||||
settings,
|
||||
secret_store=InMemorySecretStore(),
|
||||
source_catalog=(
|
||||
build_source_catalog(home=home, environment={}, platform="darwin")
|
||||
if home is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def client_for(app) -> AsyncClient:
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
)
|
||||
|
||||
|
||||
def run_with_sdk(tmp_path, monkeypatch, scenario) -> None:
|
||||
async def main() -> None:
|
||||
app = application(tmp_path)
|
||||
sdk = UserAwareSdk()
|
||||
|
||||
@asynccontextmanager
|
||||
async def write_client():
|
||||
yield sdk
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
app.state.sdk_client_handle = Handle(sdk)
|
||||
monkeypatch.setattr(app.state.sdk_write_coordinator, "use_client", write_client)
|
||||
async with client_for(app) as client:
|
||||
await scenario(app, sdk, client)
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
def _messages(text: str) -> list[dict[str, str]]:
|
||||
return [{"role": "user", "content": text}, {"role": "assistant", "content": "noted"}]
|
||||
|
||||
|
||||
async def _write_both_users(app, sdk, client) -> dict[str, str]:
|
||||
"""`local` and `wife` each add one memory and submit one conversation."""
|
||||
receipts = {}
|
||||
for user, word in (("local", "tea"), ("wife", "coffee")):
|
||||
request_id = str(uuid4())
|
||||
body = {"request_id": request_id, "text": f"{user} drinks {word}", "scope": "all-apps"}
|
||||
if user != "local":
|
||||
body["user_id"] = user
|
||||
added = await client.post("/api/v2/memories", json=body)
|
||||
assert added.status_code == 201, added.text
|
||||
receipts[f"add:{user}"] = request_id
|
||||
write = {
|
||||
"request_id": "same-request-id",
|
||||
"session_id": "same-session",
|
||||
"source_id": "one-memory-manual",
|
||||
"messages": _messages(f"{user} walks the {word} route"),
|
||||
}
|
||||
if user != "local":
|
||||
write["user_id"] = user
|
||||
written = await client.post("/api/v2/memory-writes", json=write)
|
||||
assert written.status_code == 202, written.text
|
||||
assert written.json()["request_id"] == "same-request-id"
|
||||
await drain_all(app.state.memory_write_queue, sdk)
|
||||
return receipts
|
||||
|
||||
|
||||
def _contents(page: dict) -> set[str]:
|
||||
return {memory["content"] for memory in page["memories"]}
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_each_user_reads_only_their_own_library_search_profile_and_graph(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
await _write_both_users(app, sdk, client)
|
||||
assert {call["user_id"] for call in sdk.add_calls} == {"local", "wife"}
|
||||
# The same caller request id from two users is two SDK requests.
|
||||
assert len({call["request_id"] for call in sdk.add_calls}) == len(sdk.add_calls) == 4
|
||||
|
||||
local = (await client.get("/api/v2/memory-library")).json()
|
||||
wife = (await client.get("/api/v2/memory-library", params={"user_id": "wife"})).json()
|
||||
assert _contents(local) == {"local drinks tea", "local walks the tea route"}
|
||||
assert _contents(wife) == {"wife drinks coffee", "wife walks the coffee route"}
|
||||
nobody = await client.get("/api/v2/memory-library", params={"user_id": "__nobody__"})
|
||||
assert nobody.status_code == 200 and nobody.json()["memories"] == []
|
||||
|
||||
searched = (
|
||||
await client.get("/api/v2/memory-library", params={"query": "route", "user_id": "wife"})
|
||||
).json()
|
||||
assert _contents(searched) == {"wife walks the coffee route"}
|
||||
|
||||
context = await client.post(
|
||||
"/api/v2/context/search",
|
||||
json={"query": "drinks", "kinds": ["memory"], "user_id": "wife"},
|
||||
)
|
||||
assert context.status_code == 200, context.text
|
||||
assert {item["content"] for item in context.json()["items"]} == {
|
||||
"wife drinks coffee",
|
||||
"wife walks the coffee route",
|
||||
}
|
||||
local_context = await client.post(
|
||||
"/api/v2/context/search", json={"query": "drinks", "kinds": ["memory"]}
|
||||
)
|
||||
assert all("wife" not in item["content"] for item in local_context.json()["items"])
|
||||
|
||||
profile = (await client.get("/api/v2/user-profile", params={"user_id": "wife"})).json()
|
||||
assert profile["profile"]["basic_info"] == {"name": "wife"}
|
||||
assert (await client.get("/api/v2/user-profile")).json()["profile"]["basic_info"] == {
|
||||
"name": "local"
|
||||
}
|
||||
|
||||
graph = (await client.get("/api/v2/memories/graph", params={"user_id": "wife"})).json()
|
||||
labels = {node["label"] for node in graph["nodes"] if node["node_type"] == "memory"}
|
||||
assert labels == {"wife drinks coffee", "wife walks the coffee route"}
|
||||
|
||||
summary = (await client.get("/api/v2/home-summary", params={"user_id": "wife"})).json()
|
||||
assert summary["memory_count"] == 2
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_another_users_memory_id_answers_404_for_read_update_and_delete(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
await _write_both_users(app, sdk, client)
|
||||
[wife_memory] = [
|
||||
memory_id
|
||||
for memory_id, row in sdk.memories.items()
|
||||
if row["user_id"] == "wife" and row["content"] == "wife drinks coffee"
|
||||
]
|
||||
path = f"/api/v2/memories/{wife_memory}"
|
||||
|
||||
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
|
||||
assert sdk.memories[wife_memory]["content"] == "wife drinks coffee"
|
||||
|
||||
own = await client.get(path, params={"user_id": "wife"})
|
||||
assert own.status_code == 200 and own.json()["user_id"] == "wife"
|
||||
updated = await client.put(path, json={"content": "wife drinks tea", "user_id": "wife"})
|
||||
assert updated.status_code == 200
|
||||
assert (await client.delete(path, params={"user_id": "wife"})).status_code == 204
|
||||
assert wife_memory not in sdk.memories
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_receipts_belong_to_the_user_that_submitted_them(tmp_path, monkeypatch) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
receipts = await _write_both_users(app, sdk, client)
|
||||
wife_add = f"/api/v2/memories/additions/{receipts['add:wife']}"
|
||||
assert (await client.get(wife_add)).status_code == 404
|
||||
own = await client.get(wife_add, params={"user_id": "wife"})
|
||||
assert own.status_code == 200 and own.json()["state"] == "ready"
|
||||
local_add = f"/api/v2/memories/additions/{receipts['add:local']}"
|
||||
assert (await client.get(local_add, params={"user_id": "wife"})).status_code == 404
|
||||
|
||||
# Both users used `same-request-id`; each reads only their own receipt.
|
||||
local_write = await client.get("/api/v2/memory-writes/same-request-id")
|
||||
wife_write = await client.get(
|
||||
"/api/v2/memory-writes/same-request-id", params={"user_id": "wife"}
|
||||
)
|
||||
assert local_write.status_code == wife_write.status_code == 200
|
||||
assert local_write.json()["memory_ids"] != wife_write.json()["memory_ids"]
|
||||
assert (
|
||||
await client.get("/api/v2/memory-writes/same-request-id", params={"user_id": "kid"})
|
||||
).status_code == 404
|
||||
# `local` cannot name another user's namespaced queue key directly.
|
||||
with sqlite3.connect(app.state.settings.data_dir / "one-memory.sqlite3") as db:
|
||||
keys = [row[0] for row in db.execute("SELECT idempotency_key FROM memory_write_jobs")]
|
||||
wife_key = next(key for key in keys if key.startswith("user:wife:"))
|
||||
assert (await client.get(f"/api/v2/memory-writes/{wife_key}")).status_code == 404
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_omitting_user_id_keeps_local_and_recall_and_ask_pass_it_through(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
async def scenario(app, sdk, client):
|
||||
await _write_both_users(app, sdk, client)
|
||||
seen: list[str] = []
|
||||
original = sdk.list_memories
|
||||
|
||||
def recording(**kwargs):
|
||||
seen.append(kwargs["user_id"])
|
||||
return original(**kwargs)
|
||||
|
||||
sdk.list_memories = recording # type: ignore[method-assign]
|
||||
# Built at startup over the real handle; rebuilt here over the stand-in.
|
||||
app.state.recall_target = _sdk_recall_target(app, "local")
|
||||
await client.get("/api/v2/memory-library")
|
||||
await client.get("/api/v2/raw")
|
||||
await client.get("/api/v2/home-summary")
|
||||
assert set(seen) == {"local"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"one_memory.api._recall_model_ready", lambda _app: _true(), raising=True
|
||||
)
|
||||
recalled = await client.post("/api/v1/recall", json={"query": "drinks", "user_id": "wife"})
|
||||
assert recalled.status_code == 200, recalled.text
|
||||
assert {hit["content"] for hit in recalled.json()["hits"]} == {
|
||||
"wife drinks coffee",
|
||||
"wife walks the coffee route",
|
||||
}
|
||||
local = await client.post("/api/v1/recall", json={"query": "drinks"})
|
||||
assert all("wife" not in hit["content"] for hit in local.json()["hits"])
|
||||
|
||||
answered = await client.post(
|
||||
"/api/v2/memories/ask", json={"question": "what does she drink", "user_id": "wife"}
|
||||
)
|
||||
assert answered.status_code == 200, answered.text
|
||||
assert {memory["content"] for memory in answered.json()["memories"]} <= {
|
||||
"wife drinks coffee",
|
||||
"wife walks the coffee route",
|
||||
}
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
|
||||
|
||||
async def _true() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "kwargs"),
|
||||
[
|
||||
("get", "/api/v2/memory-library", {"params": {"nobody": "1"}}),
|
||||
("get", "/api/v2/home-summary", {"params": {"userid": "wife"}}),
|
||||
("get", "/api/v2/user-profile", {"params": {"user": "wife"}}),
|
||||
("get", "/api/v2/raw", {"params": {"scope": "x"}}),
|
||||
("get", "/api/v2/memories/graph", {"params": {"scene": "all"}}),
|
||||
("get", "/api/v2/memories/some-id", {"params": {"owner": "wife"}}),
|
||||
("delete", "/api/v2/memories/some-id", {"params": {"owner": "wife"}}),
|
||||
("get", "/api/v2/memory-writes/some-id", {"params": {"owner": "wife"}}),
|
||||
("get", f"/api/v2/memories/additions/{uuid4()}", {"params": {"owner": "wife"}}),
|
||||
("post", "/api/v2/memories/ask", {"json": {"question": "q", "user": "wife"}}),
|
||||
("post", "/api/v1/recall", {"json": {"query": "q", "user": "wife"}}),
|
||||
("post", "/api/v1/recall", {"json": {"query": "q"}, "params": {"user_id": "wife"}}),
|
||||
("post", "/api/v2/context/search", {"json": {"query": "q", "owner": "wife"}}),
|
||||
(
|
||||
"post",
|
||||
"/api/v2/memories",
|
||||
{"json": {"request_id": str(uuid4()), "text": "t", "scope": "all-apps", "u": 1}},
|
||||
),
|
||||
("put", "/api/v2/memories/some-id", {"json": {"content": "c", "owner": "wife"}}),
|
||||
],
|
||||
)
|
||||
def test_unknown_query_and_body_parameters_answer_422(tmp_path, method, path, kwargs) -> None:
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
response = await client.request(method.upper(), path, **kwargs)
|
||||
assert response.status_code == 422, response.text
|
||||
assert response.json()["detail"] == "request_validation_failed"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
@pytest.mark.parametrize("bad", ["a:b", "", "x" * 65, "white space", "ü", "a/b"])
|
||||
def test_a_malformed_user_id_answers_422(tmp_path, bad) -> None:
|
||||
async def scenario():
|
||||
app = application(tmp_path)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
responses = [
|
||||
await client.get("/api/v2/memory-library", params={"user_id": bad}),
|
||||
await client.get("/api/v2/user-profile", params={"user_id": bad}),
|
||||
await client.post(
|
||||
"/api/v2/memories",
|
||||
json={
|
||||
"request_id": str(uuid4()),
|
||||
"text": "t",
|
||||
"scope": "all-apps",
|
||||
"user_id": bad,
|
||||
},
|
||||
),
|
||||
await client.post(
|
||||
"/api/v2/memory-writes",
|
||||
json={
|
||||
"request_id": "r",
|
||||
"session_id": "s",
|
||||
"messages": _messages("hello"),
|
||||
"user_id": bad,
|
||||
},
|
||||
),
|
||||
await client.post("/api/v1/recall", json={"query": "q", "user_id": bad}),
|
||||
]
|
||||
assert [response.status_code for response in responses] == [422] * len(responses)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_user_ids_and_lanes() -> None:
|
||||
assert valid_user_id("wife") and valid_user_id("A.b_c-9")
|
||||
assert not valid_user_id("a:b") and not valid_user_id("wife\n")
|
||||
assert lane_key("local", "hermes") == "hermes"
|
||||
assert lane_key("wife", "hermes") == "hermes@wife"
|
||||
assert split_lane("hermes@wife") == ("wife", "hermes")
|
||||
assert split_lane("hermes") == ("local", "hermes")
|
||||
# `local` keeps the session identity it always had; another user's never equals it.
|
||||
assert internal_session_id_for("s-1", "hermes") == "s-1"
|
||||
assert internal_session_id_for("s-1", "hermes", "wife") != "s-1"
|
||||
assert internal_session_id_for("s-1", "hermes", "wife") != internal_session_id_for(
|
||||
"s-1", "hermes", "kid"
|
||||
)
|
||||
|
||||
|
||||
def _hermes_state_db(root: Path, rows: list[tuple[int, str, str, str]]) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(root / "state.db")
|
||||
connection.execute(
|
||||
"CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT,"
|
||||
" content TEXT, timestamp REAL, tool_name TEXT, active INTEGER)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?, 1790000000, NULL, 1)",
|
||||
rows,
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
|
||||
def _hermes_homes(home: Path) -> tuple[Path, Path]:
|
||||
hermes = home / ".hermes"
|
||||
wife = hermes / "profiles" / "wife"
|
||||
# The same Hermes session id in both profiles: identities must not merge.
|
||||
_hermes_state_db(
|
||||
hermes,
|
||||
[(1, "session-1", "user", "I am the husband"), (2, "session-1", "assistant", "hi")],
|
||||
)
|
||||
_hermes_state_db(
|
||||
wife,
|
||||
[(1, "session-1", "user", "I am the wife"), (2, "session-1", "assistant", "hello")],
|
||||
)
|
||||
return hermes, wife
|
||||
|
||||
|
||||
def _scan_texts(root: Path, home: Path) -> list[str]:
|
||||
adapter = create_hermes_adapter(
|
||||
AuthorizedSourceRoot.grant(
|
||||
source_id=HERMES_DESCRIPTOR.source_id, root=root, user_confirmed=True, home=home
|
||||
)
|
||||
)
|
||||
|
||||
async def collect() -> list[str]:
|
||||
return [
|
||||
event.evidence.verbatim
|
||||
async for event in adapter.scan(ScanOptions(cursors={}))
|
||||
if isinstance(event, ScannedEvidence)
|
||||
]
|
||||
|
||||
return asyncio.run(collect())
|
||||
|
||||
|
||||
def test_a_hermes_root_scan_never_reads_its_profiles(tmp_path) -> None:
|
||||
hermes, wife = _hermes_homes(tmp_path)
|
||||
(hermes / "profiles" / "wife" / "sessions").mkdir()
|
||||
(hermes / "profiles" / "wife" / "sessions" / "s.jsonl").write_text(
|
||||
json.dumps({"type": "message", "role": "user", "content": "wife's transcript"}) + "\n"
|
||||
)
|
||||
|
||||
assert _scan_texts(hermes, tmp_path) == ["I am the husband", "hi"]
|
||||
assert _scan_texts(wife, tmp_path) == ["I am the wife", "hello", "wife's transcript"]
|
||||
|
||||
|
||||
def _jobs(database: Path) -> list[sqlite3.Row]:
|
||||
with sqlite3.connect(database) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
return db.execute(
|
||||
"SELECT session_key,source_key,idempotency_key,state,payload_json "
|
||||
"FROM memory_write_jobs ORDER BY queue_seq"
|
||||
).fetchall()
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_two_hermes_grants_each_read_their_own_root_into_separate_batches(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:
|
||||
location = (await client.post("/api/v1/sources/hermes/location")).json()
|
||||
local = await client.post(
|
||||
"/api/v1/sources/connect",
|
||||
json={
|
||||
"source_ids": ["hermes"],
|
||||
"request_id": str(uuid4()),
|
||||
"source_roots": {"hermes": location["root_id"]},
|
||||
"enable_sync": True,
|
||||
},
|
||||
)
|
||||
assert local.status_code == 200, local.text
|
||||
profile = await client.post(
|
||||
"/api/v1/sources/connect",
|
||||
json={
|
||||
"source_ids": ["hermes"],
|
||||
"request_id": str(uuid4()),
|
||||
"user_id": "wife",
|
||||
"root": str(wife),
|
||||
},
|
||||
)
|
||||
assert profile.status_code == 200, profile.text
|
||||
[wife_job] = profile.json()["jobs"]
|
||||
assert wife_job["user_id"] == "wife"
|
||||
jobs = [*local.json()["jobs"], wife_job]
|
||||
for job in jobs:
|
||||
finished = await app.state.import_supervisor.wait(job["job_id"])
|
||||
assert finished.status == "completed", finished
|
||||
|
||||
rows = [row for row in _jobs(app.state.settings.data_dir / "one-memory.sqlite3")]
|
||||
assert rows, "history import wrote no batches"
|
||||
by_user: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
records = json.loads(row["payload_json"])["input_records"]
|
||||
users = {record["user_id"] for record in records}
|
||||
assert len(users) == 1, "a batch mixed users"
|
||||
[user] = users
|
||||
by_user.setdefault(user, []).extend(record["verbatim"] for record in records)
|
||||
assert row["source_key"] == lane_key(user, "hermes")
|
||||
assert row["session_key"] == internal_session_id_for("session-1", "hermes", user)
|
||||
assert sorted(by_user["local"]) == ["I am the husband", "hi"]
|
||||
assert sorted(by_user["wife"]) == ["I am the wife", "hello"]
|
||||
|
||||
grants = (await client.get("/api/v1/source-grants")).json()["grants"]
|
||||
assert {(grant["user_id"], grant["source_id"]) for grant in grants} >= {
|
||||
("local", "hermes"),
|
||||
("wife", "hermes"),
|
||||
}
|
||||
# The App's view stays `local`'s.
|
||||
listed = (await client.get("/api/v1/import-jobs")).json()
|
||||
assert {job["user_id"] for job in listed} == {"local"}
|
||||
wife_jobs = (await client.get("/api/v1/import-jobs", params={"user_id": "wife"})).json()
|
||||
assert {job["user_id"] for job in wife_jobs} == {"wife"}
|
||||
assert all(
|
||||
item["source_id"] != "hermes@wife"
|
||||
for item in (await client.get("/api/v1/live-sync")).json()
|
||||
)
|
||||
|
||||
revoked = await client.post("/api/v1/sources/hermes/revoke", params={"user_id": "wife"})
|
||||
assert revoked.status_code == 200 and revoked.json()["revoked_at"] is not None
|
||||
local_grant = app.state.source_grant_store.receipt(user_id="local", source_id="hermes")
|
||||
assert local_grant.revoked_at is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_only_hermes_takes_another_user_or_a_custom_root(tmp_path) -> None:
|
||||
async def scenario():
|
||||
home = tmp_path / "home"
|
||||
(home / ".claude" / "projects").mkdir(parents=True)
|
||||
app = application(tmp_path, home=home)
|
||||
async with app.router.lifespan_context(app), client_for(app) as client:
|
||||
for extra in ({"user_id": "wife"}, {"root": str(home / ".claude" / "projects")}):
|
||||
response = await client.post(
|
||||
"/api/v1/sources/connect",
|
||||
json={
|
||||
"source_ids": ["claude-code"],
|
||||
"request_id": str(uuid4()),
|
||||
"source_roots": {},
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422, response.text
|
||||
assert response.json()["detail"] == "user_id_requires_multi_user_source"
|
||||
revoke = await client.post(
|
||||
"/api/v1/sources/claude-code/revoke", params={"user_id": "wife"}
|
||||
)
|
||||
assert revoke.status_code == 422
|
||||
# Hermes too: another user must name their own root.
|
||||
rootless = await client.post(
|
||||
"/api/v1/sources/connect",
|
||||
json={
|
||||
"source_ids": ["hermes"],
|
||||
"request_id": str(uuid4()),
|
||||
"source_roots": {},
|
||||
"user_id": "wife",
|
||||
},
|
||||
)
|
||||
assert rootless.status_code == 422
|
||||
assert rootless.json()["detail"] == "source_root_required_for_user"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def _record(user_id: str, message_id: str, role: str, text: str):
|
||||
return materialize_write_input(
|
||||
EvidenceInput(
|
||||
user_id=user_id,
|
||||
source_id="hermes",
|
||||
conversation_id="session-1",
|
||||
message_id=message_id,
|
||||
role=role,
|
||||
written_by=WrittenBy.PERSON if role == "user" else WrittenBy.AGENT,
|
||||
created_at=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
verbatim=text,
|
||||
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_assembling_rows_and_batches_are_keyed_by_user(tmp_path) -> None:
|
||||
"""Two users' `state.db` share a relative file key and a session id.
|
||||
|
||||
Finishing one user's file must neither take nor drop the other user's
|
||||
staged turns, and the batches it writes hold one user each.
|
||||
"""
|
||||
path = tmp_path / "one-memory.sqlite3"
|
||||
queue = DurableWriteQueue(path, sdk_authority=True)
|
||||
file_key = "f" * 64
|
||||
from one_memory.database_access import main_database
|
||||
|
||||
with main_database(path).write(row_factory=sqlite3.Row) as connection, connection:
|
||||
for user in ("local", "wife"):
|
||||
for index, role in enumerate(("user", "assistant")):
|
||||
queue.stage_sdk_input(
|
||||
_record(user, f"m{index}", role, f"{user} {role}"),
|
||||
file_key=file_key,
|
||||
priority="normal",
|
||||
connection=connection,
|
||||
)
|
||||
queue.finalize_sdk_file(
|
||||
user_id="local", source_id="hermes", file_key=file_key, connection=connection
|
||||
)
|
||||
|
||||
rows = _jobs(path)
|
||||
pending = [row for row in rows if row["state"] == "pending"]
|
||||
assembling = [row for row in rows if row["state"] == "assembling"]
|
||||
assert [
|
||||
{record["user_id"] for record in json.loads(row["payload_json"])["input_records"]}
|
||||
for row in pending
|
||||
] == [{"local"}]
|
||||
assert [split_lane(row["source_key"]) for row in assembling] == [("wife", "hermes")]
|
||||
assert pending[0]["session_key"] != assembling[0]["session_key"]
|
||||
|
||||
|
||||
def _observed(response) -> tuple[int, bytes]:
|
||||
return response.status_code, response.content
|
||||
|
||||
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_another_users_id_answers_exactly_like_an_id_that_does_not_exist(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""Comparing responses must not tell whether another user's id exists."""
|
||||
|
||||
async def scenario(app, sdk, client):
|
||||
receipts = await _write_both_users(app, sdk, client)
|
||||
[wife_memory] = [
|
||||
memory_id
|
||||
for memory_id, row in sdk.memories.items()
|
||||
if row["user_id"] == "wife" and row["content"] == "wife drinks coffee"
|
||||
]
|
||||
before = {key: dict(row) for key, row in sdk.memories.items()}
|
||||
with sqlite3.connect(app.state.settings.data_dir / "one-memory.sqlite3") as db:
|
||||
keys = [row[0] for row in db.execute("SELECT idempotency_key FROM memory_write_jobs")]
|
||||
wife_key = next(key for key in keys if key.startswith("user:wife:"))
|
||||
|
||||
pairs = [
|
||||
("GET", f"/api/v2/memories/{wife_memory}", "/api/v2/memories/no-such-memory", {}),
|
||||
(
|
||||
"PUT",
|
||||
f"/api/v2/memories/{wife_memory}",
|
||||
"/api/v2/memories/no-such-memory",
|
||||
{"json": {"content": "stolen"}},
|
||||
),
|
||||
("DELETE", f"/api/v2/memories/{wife_memory}", "/api/v2/memories/no-such-memory", {}),
|
||||
(
|
||||
"GET",
|
||||
f"/api/v2/memories/additions/{receipts['add:wife']}",
|
||||
f"/api/v2/memories/additions/{uuid4()}",
|
||||
{},
|
||||
),
|
||||
("GET", f"/api/v2/memory-writes/{wife_key}", "/api/v2/memory-writes/no-such-id", {}),
|
||||
]
|
||||
for method, theirs, missing, kwargs in pairs:
|
||||
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 sdk.memories == before
|
||||
|
||||
run_with_sdk(tmp_path, monkeypatch, scenario)
|
||||
@@ -173,12 +173,14 @@ def test_first_import_gate_waits_for_every_selected_history_job() -> None:
|
||||
jobs = [
|
||||
SimpleNamespace(
|
||||
operation="history",
|
||||
user_id="local",
|
||||
source_id="cursor",
|
||||
created_at=datetime(2026, 10, 18, tzinfo=UTC),
|
||||
status="completed",
|
||||
),
|
||||
SimpleNamespace(
|
||||
operation="history",
|
||||
user_id="local",
|
||||
source_id="codex",
|
||||
created_at=datetime(2026, 10, 18, tzinfo=UTC),
|
||||
status="running",
|
||||
|
||||
@@ -210,7 +210,7 @@ def test_recall_route_falls_back_and_keeps_the_raw_grant(tmp_path, monkeypatch):
|
||||
async def no_model(_app):
|
||||
return False
|
||||
|
||||
async def raw(_app, query, *, limit, sources):
|
||||
async def raw(_app, query, *, limit, sources, user_id="local"):
|
||||
searched.append(sources)
|
||||
return (_raw("r1", "上周决定用 Postgres", source="cursor"),)
|
||||
|
||||
|
||||
@@ -1353,7 +1353,7 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
original = app.state.sdk_client_handle
|
||||
app.state.sdk_client_handle = Handle()
|
||||
|
||||
async def graph_snapshot(*, limit):
|
||||
async def graph_snapshot(*, limit, user_id="local"):
|
||||
del limit
|
||||
return HyGraphSnapshot(
|
||||
nodes=(HyGraphNode(node_id="t1", content="topic", layer="TOPIC"),),
|
||||
@@ -1375,6 +1375,8 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert listed[0]["include_sources"] == ["cursor"]
|
||||
assert listed[0]["scene"] == "all"
|
||||
assert listed[0]["statuses"] == ["active"]
|
||||
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
|
||||
assert {node["node_id"] for node in graph["nodes"]} == {
|
||||
@@ -1399,6 +1401,71 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
assert graph["hy_graph_available"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_sdk_memory_map_still_draws_memories_from_a_revoked_source(tmp_path) -> None:
|
||||
# Revoking a source stops new reads from it; memories already distilled
|
||||
# from it stay visible, so the map agrees with the list.
|
||||
async def scenario():
|
||||
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
|
||||
|
||||
class Client:
|
||||
def list_memories(self, **_kwargs):
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "kept",
|
||||
"content": "distilled before revocation",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Client()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer authority-test"},
|
||||
) as client,
|
||||
):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
database = app.state.settings.data_dir / "one-memory.sqlite3"
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute(
|
||||
"INSERT INTO source_grants "
|
||||
"(user_id,source_id,root_path,requested_path,root_identity,"
|
||||
"candidate_kind,granted_at,revoked_at,revision) VALUES "
|
||||
"('local','cursor','/fixture','/fixture','fixture','default',"
|
||||
"'2026-10-01T00:00:00Z','2026-10-02T00:00:00Z',1)"
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v2/memories/graph",
|
||||
params={"source": "cursor", "limit": 100},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (1, 1)
|
||||
assert "memory:kept" in {node["node_id"] for node in graph["nodes"]}
|
||||
|
||||
|
||||
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
|
||||
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_bundle_contains_management_sdk_config_and_verified_manifest(tmp_path):
|
||||
assert manifest["bundle_id"] == "migration-1"
|
||||
assert manifest["sdk_namespace"] == "governed-v2"
|
||||
assert manifest["runtime_build"] == "build-13"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.8"
|
||||
assert manifest["snapshot_sdk_version"] == "2.0.9"
|
||||
paths = {item["path"]: item for item in manifest["files"]}
|
||||
assert {
|
||||
"management/one-memory.sqlite3",
|
||||
|
||||
@@ -57,10 +57,10 @@ def client_for(app, headers=None):
|
||||
|
||||
|
||||
class Answerer:
|
||||
async def answer(self, question):
|
||||
async def answer(self, question, *, user_id="local"):
|
||||
return SdkMemoryAnswer(answer="真实数据。", memories=(MEMORY,), mode="extractive")
|
||||
|
||||
async def answer_stream(self, question):
|
||||
async def answer_stream(self, question, *, user_id="local"):
|
||||
yield {"type": "recall", "memories": (MEMORY,)}
|
||||
yield {
|
||||
"type": "final",
|
||||
|
||||
Generated
+4
-4
@@ -991,7 +991,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hy-memory"
|
||||
version = "2.0.8"
|
||||
version = "2.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chromadb" },
|
||||
@@ -1008,9 +1008,9 @@ dependencies = [
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/15/83ca83b649ead238f186fdb33efdc73615fbba279efbbdca3857c1a86beb/hy_memory-2.0.8.tar.gz", hash = "sha256:0fa0ed1a1160b389a3d7c13a9fb6528b140d7ec23cf49e65f1be5974820a576b", size = 856025, upload-time = "2026-10-28T07:35:47.783Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/28/e01fd9a92cd090c8236df0dd06dae049c5de66345e5bd9653497ed56125e/hy_memory-2.0.9.tar.gz", hash = "sha256:5f55ea92044ced19a00b4e56c565215adedd3439a1bdb9cfe9ffe42387c8f1a1", size = 858323, upload-time = "2026-11-01T00:29:49.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/6a/c699cac0ff06bd089fceeff90446130763d8187e649dbdb0313014491a10/hy_memory-2.0.8-py3-none-any.whl", hash = "sha256:68bd789df24e51e64bb231e97a8d24ee89b4aa5f9ba14c3e0824589e9213bf7c", size = 921119, upload-time = "2026-10-28T07:35:45.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/44/4368555a408a58daf902c81b62184a3c99f12abd45d4f1144bf79f171a6f/hy_memory-2.0.9-py3-none-any.whl", hash = "sha256:95dbea232d2041f9b0ea3d08dd751603c2a5c0e4422b97e74f664e6ca913549e", size = 923250, upload-time = "2026-11-01T00:29:47.814Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1837,7 +1837,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
||||
{ name = "filelock", specifier = ">=3.32.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "hy-memory", specifier = "==2.0.8" },
|
||||
{ name = "hy-memory", specifier = "==2.0.9" },
|
||||
{ name = "keyring", specifier = ">=25.7.0" },
|
||||
{ name = "numpy", specifier = "==2.5.2" },
|
||||
{ name = "onnxruntime", specifier = "==1.23.2" },
|
||||
|
||||
@@ -172,7 +172,7 @@ it.each([
|
||||
expect(sedimentation).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
it("localizes failure and empty states without leaking errors", async () => {
|
||||
it("localizes startup recovery and empty states without leaking errors", async () => {
|
||||
summary.mockRejectedValue(new Error("synthetic_private_error"));
|
||||
Object.assign(window.oneMemory ?? {}, {
|
||||
getLanguagePreference: vi
|
||||
@@ -191,12 +191,15 @@ it("localizes failure and empty states without leaking errors", async () => {
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("No se puede leer el estado de las memorias"),
|
||||
await screen.findByText(
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Reintentar lectura" }),
|
||||
).toBeTruthy();
|
||||
screen.queryByRole("button", { name: "Reintentar lectura" }),
|
||||
).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("synthetic_private_error");
|
||||
});
|
||||
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
|
||||
@@ -765,7 +768,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
|
||||
expect(screen.getAllByText("整理中")).toHaveLength(2);
|
||||
expect(screen.queryByText("状态待核对")).toBeNull();
|
||||
});
|
||||
it("does not turn a failed home summary into zero or expose a private error", async () => {
|
||||
it("keeps a neutral zero while the first home summary read recovers", async () => {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -775,10 +778,117 @@ it("does not turn a failed home summary into zero or expose a private error", as
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("暂无法读取")).toBeTruthy());
|
||||
expect(screen.queryByText("0")).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
|
||||
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("private offline detail");
|
||||
});
|
||||
it("offers a calm retry after repeated home summary failures", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("后台恢复后会自动更新,也可以重新读取。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "重新读取" })).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("counts repeated stale summaries and recovers on the next confirmed read", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const stale = {
|
||||
memory_count: 0,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
stale: true,
|
||||
};
|
||||
summary
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValue({
|
||||
memory_count: 4,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:01:00Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
expect(await screen.findByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText("记忆概览还没准备好")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("labels a retained count as previous data after repeated stale reads", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const stale = {
|
||||
memory_count: 0,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
stale: true,
|
||||
};
|
||||
summary
|
||||
.mockResolvedValueOnce({
|
||||
memory_count: 601,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
})
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValue({
|
||||
memory_count: 4,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:01:00Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("601")).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(20_000));
|
||||
expect(screen.getByText("601")).toBeTruthy();
|
||||
expect(screen.getByText("正在显示上次数据,稍后自动更新")).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText("正在显示上次数据,稍后自动更新")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("never calls a recall event a success when only stored memories are available", async () => {
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -1200,7 +1310,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
|
||||
expect(screen.queryByText(/个应用已开启/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not claim there are no memories while the first summary read is pending", async () => {
|
||||
it("shows a provisional zero while the first summary read is pending", async () => {
|
||||
summary.mockImplementation(() => new Promise(() => {}));
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
@@ -1216,7 +1326,9 @@ it("does not claim there are no memories while the first summary read is pending
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("正在读取动态");
|
||||
await screen.findByText("正在准备记忆概览");
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ export function ApplicationsHome({
|
||||
null,
|
||||
);
|
||||
const [syncHealth, setSyncHealth] = useState<LiveSyncHealth[]>([]);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [summaryFailures, setSummaryFailures] = useState(0);
|
||||
const [recentCall, setRecentCall] = useState<CallEvent | null>(null);
|
||||
const [callEvents, setCallEvents] = useState<CallEvent[]>([]);
|
||||
const [callState, setCallState] = useState<
|
||||
@@ -554,13 +554,17 @@ export function ApplicationsHome({
|
||||
busy = false;
|
||||
if (stopped) return;
|
||||
if (home.status === "fulfilled") {
|
||||
setFailed(false);
|
||||
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
|
||||
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
|
||||
if (!("stale" in home.value) || !home.value.stale)
|
||||
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
|
||||
// 不能永远停在“正在核对”。
|
||||
if (!("stale" in home.value) || !home.value.stale) {
|
||||
setSummaryFailures(0);
|
||||
setSummary(home.value);
|
||||
} else {
|
||||
setSummaryFailures((count) => count + 1);
|
||||
}
|
||||
} else {
|
||||
setFailed(true);
|
||||
setSummaryFailures((count) => count + 1);
|
||||
}
|
||||
if (state.status === "fulfilled") setProcessing(state.value);
|
||||
if (sync.status === "fulfilled") setSyncHealth(sync.value);
|
||||
@@ -578,7 +582,8 @@ export function ApplicationsHome({
|
||||
}, [active, requestKey]);
|
||||
const visible = summary;
|
||||
const latest = visible?.latest;
|
||||
const summaryUnavailable = failed;
|
||||
const summaryUnavailable = visible === null && summaryFailures >= 2;
|
||||
const summaryStale = visible !== null && summaryFailures >= 2;
|
||||
const hasCall =
|
||||
recentCall !== null &&
|
||||
(callState === "ready" || callState === "incomplete");
|
||||
@@ -642,7 +647,7 @@ export function ApplicationsHome({
|
||||
? t("apps.running")
|
||||
: sedimentation?.enabled === false
|
||||
? t("apps.paused")
|
||||
: t("apps.unchecked");
|
||||
: t("apps.checkingStatus");
|
||||
const stateDetail =
|
||||
sedimentation?.enabled === false
|
||||
? t("apps.stoppedNew")
|
||||
@@ -821,42 +826,27 @@ export function ApplicationsHome({
|
||||
<h2>{t("apps.overview")}</h2>
|
||||
</header>
|
||||
<div className="r6-home-metrics">
|
||||
<div>
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>{t("apps.memories")}</p>
|
||||
{/* The figure slot used to hold the word 读取中 while the first
|
||||
read was in flight, which is how "大数字不可见" (D51) shows up
|
||||
on a slow link: the biggest type on the page spends the first
|
||||
frame saying something that is not a number. A bar of the same
|
||||
height reserves the digits instead, and the wording moves to a
|
||||
live region so screen readers still hear it. 暂无法读取 stays a
|
||||
word, because that is a settled state rather than progress. */}
|
||||
{!visible && !summaryUnavailable ? (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<span
|
||||
className="r6-home-figure-pending"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
<small role="status">{t("apps.readingCount")}</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<strong className={visible ? "" : "r6-home-word"}>
|
||||
{visible
|
||||
? new Intl.NumberFormat(locale).format(
|
||||
visible.memory_count,
|
||||
)
|
||||
: t("apps.unreadable")}
|
||||
</strong>
|
||||
{visible && t("apps.unit") ? (
|
||||
<i>{t("apps.unit")}</i>
|
||||
) : null}
|
||||
</p>
|
||||
<small>{t("apps.scope")}</small>
|
||||
</>
|
||||
)}
|
||||
<p className="r6-home-figure">
|
||||
<strong>
|
||||
{new Intl.NumberFormat(locale).format(
|
||||
visible?.memory_count ?? 0,
|
||||
)}
|
||||
</strong>
|
||||
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
|
||||
</p>
|
||||
<small>
|
||||
{visible
|
||||
? summaryStale
|
||||
? t("apps.showingPrevious")
|
||||
: t("apps.scope")
|
||||
: summaryUnavailable
|
||||
? t("apps.countWillUpdate")
|
||||
: summaryFailures
|
||||
? t("apps.startingRetry")
|
||||
: t("apps.readingCount")}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>{t("apps.processing")}</p>
|
||||
@@ -867,12 +857,16 @@ export function ApplicationsHome({
|
||||
<p>{t("apps.recentUpdate")}</p>
|
||||
<strong className={time ? "r6-home-time" : "r6-home-word"}>
|
||||
{time ??
|
||||
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
|
||||
(visible
|
||||
? t("apps.notGenerated")
|
||||
: t("apps.checkingRecent"))}
|
||||
</strong>
|
||||
<small>
|
||||
{time
|
||||
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
|
||||
: t("apps.separate")}
|
||||
: visible
|
||||
? t("apps.separate")
|
||||
: t("apps.recentWillUpdate")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -972,7 +966,9 @@ export function ApplicationsHome({
|
||||
.join(" · ")
|
||||
: activity === "importing"
|
||||
? t("apps.savingConversations")
|
||||
: t("apps.waitingMemories")}
|
||||
: activity === "loading"
|
||||
? t("apps.recentWillUpdate")
|
||||
: t("apps.waitingMemories")}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { translate } from "@one-memory/i18n";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { optionMenuPosition } from "./OptionMenu";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -159,3 +166,141 @@ it("announces storage errors without replacing the confirmed choice", async () =
|
||||
"zh-CN",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a right-edge menu inside the viewport", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 8, right: 1016, bottom: 40, left: 872, width: 144 },
|
||||
160,
|
||||
1024,
|
||||
600,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 44,
|
||||
left: 868,
|
||||
width: 144,
|
||||
maxHeight: 280,
|
||||
placement: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("flips the menu above when the lower edge has no room", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 540, right: 500, bottom: 572, left: 356, width: 144 },
|
||||
180,
|
||||
1024,
|
||||
600,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 356,
|
||||
left: 356,
|
||||
width: 144,
|
||||
maxHeight: 280,
|
||||
placement: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("limits the panel to the actual space when neither side has 280px", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 40, right: 160, bottom: 60, left: 16, width: 144 },
|
||||
180,
|
||||
320,
|
||||
100,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 64,
|
||||
left: 16,
|
||||
width: 144,
|
||||
maxHeight: 24,
|
||||
placement: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("portals the open menu outside clipping layout containers", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<div data-testid="clipping-parent">
|
||||
<LanguageSelector compact />
|
||||
</div>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox.parentElement).toBe(document.body);
|
||||
expect(
|
||||
listbox.classList.contains("r6-language-option-menu-panel--compact"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the menu open while its portal scrollbar or padding is pressed", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
const listbox = screen.getByRole("listbox");
|
||||
fireEvent.mouseDown(listbox);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.mouseUp(document);
|
||||
});
|
||||
|
||||
it("clears the portal pointer guard when the menu closes mid-press", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
fireEvent.mouseDown(screen.getByRole("listbox"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await userEvent.click(selector);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("clears the portal pointer guard when the window loses focus", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
fireEvent.mouseDown(screen.getByRole("listbox"));
|
||||
fireEvent.blur(window);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("closes the portal when its anchor scrolls completely out of view", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
vi.spyOn(selector, "getBoundingClientRect").mockReturnValue({
|
||||
top: 20,
|
||||
right: 1168,
|
||||
bottom: 52,
|
||||
left: 1024,
|
||||
width: 144,
|
||||
height: 32,
|
||||
x: 1024,
|
||||
y: 20,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
await userEvent.click(selector);
|
||||
await waitFor(() =>
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false"),
|
||||
);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
@@ -54,6 +54,11 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
disabled={pending}
|
||||
options={options}
|
||||
className="r6-language-option-menu"
|
||||
panelClassName={
|
||||
compact
|
||||
? "r6-language-option-menu-panel--compact"
|
||||
: "r6-language-option-menu-panel--settings"
|
||||
}
|
||||
onChange={async (value) => {
|
||||
if (!isLanguagePreference(value)) return;
|
||||
setPending(true);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -259,6 +258,40 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
});
|
||||
|
||||
it("shows a stable loading surface when the selected memory is read again", async () => {
|
||||
const view = render(<Host simplified />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
view.rerender(<Host simplified deferMemoryId="m0" />);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第一条/ },
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
expect(document.querySelector(".map-detail-loading")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows retry instead of stale content when the selected memory reread fails", async () => {
|
||||
const view = render(<Host simplified />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
view.rerender(<Host simplified failMemoryId="m0" />);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第一条/ },
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"这条记忆的原文暂时无法读取",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "重试读取原文" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
|
||||
const { container } = render(
|
||||
<Host simplified sources={[importedSources[0]]} />,
|
||||
@@ -269,6 +302,14 @@ it("keeps the detail header outside the scrolling body and resets detail scroll
|
||||
const body = container.querySelector<HTMLElement>(".map-inspector-body");
|
||||
expect(heading?.nextElementSibling).toBe(body);
|
||||
if (!body) throw new Error("Memory detail scroll body is missing");
|
||||
body.scrollTop = 90;
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第一条/ },
|
||||
),
|
||||
);
|
||||
expect(body.scrollTop).toBe(0);
|
||||
body.scrollTop = 180;
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
@@ -348,6 +389,8 @@ function Host({
|
||||
processing = null,
|
||||
loaded = true,
|
||||
deferMemoryId,
|
||||
failMemoryId,
|
||||
graphValue = graph,
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
@@ -363,11 +406,17 @@ function Host({
|
||||
processing?: MemoryProcessingSummary | null;
|
||||
loaded?: boolean;
|
||||
deferMemoryId?: string;
|
||||
failMemoryId?: string;
|
||||
graphValue?: MemoryGraphResponse;
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
} = {}) {
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
} | null>(null);
|
||||
return (
|
||||
<MemoryMap
|
||||
simplified={simplified}
|
||||
@@ -375,7 +424,7 @@ function Host({
|
||||
hidden={hidden}
|
||||
overlayBlocked={overlayBlocked}
|
||||
memories={items}
|
||||
graph={graph}
|
||||
graph={graphValue}
|
||||
sources={sources}
|
||||
arrival={arrival}
|
||||
onViewHistory={onViewHistory}
|
||||
@@ -389,14 +438,25 @@ function Host({
|
||||
}
|
||||
: null
|
||||
}
|
||||
detailLoad={detailLoad}
|
||||
draft={detail?.memory.content ?? ""}
|
||||
busy={false}
|
||||
answer={null}
|
||||
streaming={null}
|
||||
requestIds={{ current: new Map() }}
|
||||
onOpenMemory={(id) => {
|
||||
if (failMemoryId && id === failMemoryId) {
|
||||
setDetail(null);
|
||||
setDetailLoad({ memoryId: id, status: "failed" });
|
||||
return;
|
||||
}
|
||||
// Lets a test leave one read unanswered.
|
||||
if (deferMemoryId && id === deferMemoryId) return;
|
||||
if (deferMemoryId && id === deferMemoryId) {
|
||||
setDetail(null);
|
||||
setDetailLoad({ memoryId: id, status: "loading" });
|
||||
return;
|
||||
}
|
||||
setDetailLoad(null);
|
||||
setDetail({
|
||||
memory: items.find((m) => m.memory_id === id) ?? items[0],
|
||||
evidence: [
|
||||
@@ -414,7 +474,10 @@ function Host({
|
||||
index_sync: null,
|
||||
});
|
||||
}}
|
||||
onCloseDetail={() => setDetail(null)}
|
||||
onCloseDetail={() => {
|
||||
setDetail(null);
|
||||
setDetailLoad(null);
|
||||
}}
|
||||
onAsk={() => {}}
|
||||
onDraft={() => {}}
|
||||
onSave={() => {}}
|
||||
@@ -467,6 +530,57 @@ it("does not present an unverified empty page as zero memories", () => {
|
||||
expect(screen.queryByText(/0 条/)).toBeNull();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show the previous source graph while the new scope's graph loads", () => {
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
}),
|
||||
query: "项目",
|
||||
graphLoading: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.getByText("正在读取记忆地图…")).toBeTruthy();
|
||||
// The list keeps its own state: graph loading is not page staleness.
|
||||
expect(screen.queryByText("正在核对来源…")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a retry instead of collapsing a failed graph to the current page", async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
}),
|
||||
graphFailed: true,
|
||||
onRetry,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.getByText("记忆暂时无法读取")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toBe("记忆暂时无法读取");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "重新读取地图记忆" }),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts with receipt-backed source grouping, keeping topics secondary", () => {
|
||||
render(<Host />);
|
||||
expect(
|
||||
@@ -478,6 +592,118 @@ it("starts with receipt-backed source grouping, keeping topics secondary", () =>
|
||||
expect(screen.getByRole("button", { name: "Codex2" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("restores the full graph scope when the source filter returns to all applications", async () => {
|
||||
const workbuddyMemory = {
|
||||
...memories[1],
|
||||
memory_id: "workbuddy-memory",
|
||||
content: "较早的 WorkBuddy 记忆",
|
||||
};
|
||||
const allGraph = {
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
hy_graph_available: false,
|
||||
nodes: [
|
||||
graph.nodes.find((node) => node.node_id === "memory:m0"),
|
||||
graph.nodes.find((node) => node.node_id === "source:codex"),
|
||||
{
|
||||
node_id: "memory:workbuddy-memory",
|
||||
node_type: "memory",
|
||||
memory_id: "workbuddy-memory",
|
||||
label: workbuddyMemory.content,
|
||||
},
|
||||
{
|
||||
node_id: "source:workbuddy",
|
||||
node_type: "source",
|
||||
source_id: "workbuddy",
|
||||
label: "WorkBuddy",
|
||||
},
|
||||
].filter(Boolean),
|
||||
edges: [
|
||||
{
|
||||
edge_id: "cursor-memory",
|
||||
source_node_id: "source:codex",
|
||||
target_node_id: "memory:m0",
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
},
|
||||
{
|
||||
edge_id: "workbuddy-memory",
|
||||
source_node_id: "source:workbuddy",
|
||||
target_node_id: "memory:workbuddy-memory",
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
},
|
||||
],
|
||||
} as MemoryGraphResponse;
|
||||
const cursorGraph = {
|
||||
...allGraph,
|
||||
total_memories: 1,
|
||||
projected_memories: 1,
|
||||
nodes: allGraph.nodes.filter(
|
||||
(node) => node.node_id === "memory:m0" || node.node_id === "source:codex",
|
||||
),
|
||||
edges: [allGraph.edges[0]],
|
||||
};
|
||||
|
||||
function ScopeHarness() {
|
||||
const [source, setSource] = useState("codex");
|
||||
return (
|
||||
<Host
|
||||
simplified
|
||||
items={[memories[0]]}
|
||||
graphValue={source ? cursorGraph : allGraph}
|
||||
sources={
|
||||
[
|
||||
{
|
||||
source_id: "codex",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
source_id: "workbuddy",
|
||||
display_name: "WorkBuddy",
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
source_id: "retired",
|
||||
display_name: "Retired",
|
||||
authorized: false,
|
||||
},
|
||||
] as SourceResponse[]
|
||||
}
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: [memories[0]],
|
||||
total: source ? 1 : 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"] },
|
||||
}),
|
||||
source,
|
||||
onSource: setSource,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ScopeHarness />);
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "图中选择 较早的 WorkBuddy 记忆",
|
||||
}),
|
||||
).toBeNull();
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "全部应用" }));
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "图中选择 较早的 WorkBuddy 记忆",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "图中选择 WorkBuddy" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
function pagedView(
|
||||
page: Parameters<typeof MemoryMap>[0]["libraryView"] extends infer V
|
||||
? V extends { page: infer P }
|
||||
@@ -580,6 +806,8 @@ it("keeps pagination and current detail stable while the next page is loading",
|
||||
const libraryView = { ...pagedView(page), onPage };
|
||||
const view = render(<Host simplified libraryView={libraryView} />);
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
const statusSlot = document.querySelector(".map-list-status");
|
||||
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
|
||||
list.scrollTop = 160;
|
||||
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
|
||||
await userEvent.click(
|
||||
@@ -594,6 +822,8 @@ it("keeps pagination and current detail stable while the next page is loading",
|
||||
.getByRole("navigation", { name: "地图记忆分页" })
|
||||
.getAttribute("aria-busy"),
|
||||
).toBe("true");
|
||||
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
|
||||
expect(statusSlot?.getAttribute("data-visible")).toBe("true");
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
@@ -629,10 +859,131 @@ it("keeps pagination and current detail stable while the next page is loading",
|
||||
/>,
|
||||
);
|
||||
expect(list.scrollTop).toBe(0);
|
||||
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
|
||||
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
|
||||
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "下一页第 1 条" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(list)
|
||||
.getByRole("button", { name: /下一页第 1 条/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the page intent through a failed read and selects the first row after retry", async () => {
|
||||
const onPage = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 202,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const base = { ...pagedView(page), onPage, onRetry };
|
||||
const view = render(<Host simplified libraryView={base} />);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
expect(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" }))
|
||||
.getByRole("button", { name: /第一条/ })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...base,
|
||||
loading: false,
|
||||
failed: true,
|
||||
requestedOffset: 100,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "重新读取地图记忆" }),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" }))
|
||||
.getByRole("button", { name: /第一条/ })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
name: "搜索",
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
const nextItems = memories.map((memory, index) => ({
|
||||
...memory,
|
||||
memory_id: `retry-${index}`,
|
||||
content: `重试页第 ${index + 1} 条`,
|
||||
}));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
items={nextItems}
|
||||
libraryView={{
|
||||
...base,
|
||||
requestedOffset: 100,
|
||||
page: {
|
||||
...page,
|
||||
items: nextItems,
|
||||
offset: 100,
|
||||
source_ids_by_memory: {
|
||||
"retry-0": ["codex"],
|
||||
"retry-1": ["codex"],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "重试页第 1 条" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns to the shown page when a failed page request is cancelled by a row click", async () => {
|
||||
const onPage = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 202,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const base = { ...pagedView(page), onPage };
|
||||
const view = render(<Host simplified libraryView={base} />);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...base,
|
||||
failed: true,
|
||||
requestedOffset: 100,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第二条/ },
|
||||
),
|
||||
);
|
||||
expect(onPage.mock.calls).toEqual([[100], [0]]);
|
||||
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
||||
@@ -712,8 +1063,10 @@ it("badges each listed memory with its scene and pages 20 at a time", () => {
|
||||
expect((next as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(next);
|
||||
expect(view.onPage).toHaveBeenCalledWith(40);
|
||||
fireEvent.click(screen.getByRole("button", { name: "上一页记忆" }));
|
||||
expect(view.onPage).toHaveBeenCalledWith(0);
|
||||
const previous = screen.getByRole("button", { name: "上一页记忆" });
|
||||
expect((previous as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(previous);
|
||||
expect(view.onPage).not.toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("says a filtered source is empty rather than that nothing matched", () => {
|
||||
@@ -1238,7 +1591,8 @@ it("drops a pending source selection when that source is revoked", async () => {
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
|
||||
// Memories already distilled from a revoked source stay on the map, but the
|
||||
// revoked source can no longer be the selection.
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
);
|
||||
|
||||
@@ -95,9 +95,13 @@ type Props = {
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
/** The graph for the current source/query scope has not arrived yet. */
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
requestedOffset?: number;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
@@ -221,6 +225,15 @@ export function MemoryMap(props: Props) {
|
||||
const [pendingSourceSelection, setPendingSourceSelection] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [pendingPageOffset, setPendingPageOffset] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [pageRetrying, setPageRetrying] = useState(false);
|
||||
const pageRetrySawLoading = useRef(false);
|
||||
const [detailResetRevision, setDetailResetRevision] = useState(0);
|
||||
const pageTransitioning =
|
||||
!!library?.loading ||
|
||||
(pendingPageOffset !== null && (!library?.failed || pageRetrying));
|
||||
const [question, setQuestion] = useState("");
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [filterNotice, setFilterNotice] = useState("");
|
||||
@@ -316,6 +329,31 @@ export function MemoryMap(props: Props) {
|
||||
[active, group, search, serverPaged, axis, groupKey],
|
||||
);
|
||||
const projection = useMemo(() => {
|
||||
// Until the new scope's graph arrives (or after it failed), drawing the
|
||||
// previous scope's graph would show the wrong source.
|
||||
if (serverPaged && (library?.graphLoading || library?.graphFailed))
|
||||
return {
|
||||
total_memories: library.page?.total ?? 0,
|
||||
projected_memories: 0,
|
||||
hy_graph_available: false,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
// Source filtering owns the graph scope, while pagination owns only the
|
||||
// left-hand list window. Projecting the graph back down to the current
|
||||
// 20-row page made "All applications" look like whichever source happened
|
||||
// to dominate that page. Keyword search is intentionally page-scoped.
|
||||
if (serverPaged && !library?.query) {
|
||||
return (
|
||||
baseGraph ??
|
||||
projectMap(
|
||||
graph,
|
||||
active,
|
||||
axis,
|
||||
filtered.map((m) => m.memory_id),
|
||||
)
|
||||
);
|
||||
}
|
||||
const result = projectMap(
|
||||
graph,
|
||||
active,
|
||||
@@ -347,7 +385,21 @@ export function MemoryMap(props: Props) {
|
||||
confidence: null,
|
||||
})),
|
||||
};
|
||||
}, [graph, axis, filtered, active, groupKey, search, sources, serverPaged]);
|
||||
}, [
|
||||
baseGraph,
|
||||
graph,
|
||||
axis,
|
||||
filtered,
|
||||
active,
|
||||
groupKey,
|
||||
search,
|
||||
sources,
|
||||
serverPaged,
|
||||
library?.query,
|
||||
library?.graphLoading,
|
||||
library?.graphFailed,
|
||||
library?.page?.total,
|
||||
]);
|
||||
const visibleGroupNode = groupNode
|
||||
? (projection.nodes.find(
|
||||
(node) =>
|
||||
@@ -384,6 +436,7 @@ export function MemoryMap(props: Props) {
|
||||
: selectedSource
|
||||
? `source:${selectedSource.source_id}`
|
||||
: (visibleGroupNode?.node_id ?? "empty");
|
||||
const inspectorResetKey = `${inspectorIdentity}:${detailResetRevision}`;
|
||||
const visibleDetail =
|
||||
detail &&
|
||||
(!activeId || activeId === detail.memory.memory_id) &&
|
||||
@@ -395,10 +448,24 @@ export function MemoryMap(props: Props) {
|
||||
const inspectorDetail =
|
||||
visibleDetail ??
|
||||
(activeId && lastDetail.current ? lastDetail.current : null);
|
||||
const readingActiveDetail =
|
||||
!!activeId &&
|
||||
props.detailLoad?.memoryId === activeId &&
|
||||
props.detailLoad.status === "loading";
|
||||
const failedActiveDetail =
|
||||
!!activeId &&
|
||||
props.detailLoad?.memoryId === activeId &&
|
||||
props.detailLoad.status === "failed";
|
||||
const pick = useCallback(
|
||||
(memoryId: string, scroll = true) => {
|
||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||
if (pendingPageOffset !== null && library?.failed && library.page)
|
||||
library.onPage(library.page.offset);
|
||||
setPendingSourceSelection(null);
|
||||
setPendingPageOffset(null);
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setDetailResetRevision((current) => current + 1);
|
||||
scrollSelection.current = scroll;
|
||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
setGroupKey("");
|
||||
@@ -410,7 +477,7 @@ export function MemoryMap(props: Props) {
|
||||
setInspectorOpen(true);
|
||||
onOpenMemory(memoryId);
|
||||
},
|
||||
[active, filtered, onOpenMemory],
|
||||
[active, filtered, library, onOpenMemory, pendingPageOffset],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -438,6 +505,24 @@ export function MemoryMap(props: Props) {
|
||||
pick,
|
||||
selectedSource,
|
||||
]);
|
||||
useLayoutEffect(() => {
|
||||
if (pendingPageOffset === null || !library || library.loading) return;
|
||||
if (library.failed || library.stale) return;
|
||||
const expectedOffset = library.requestedOffset ?? pendingPageOffset;
|
||||
if (library.page?.offset !== expectedOffset) return;
|
||||
setPendingPageOffset(null);
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
const first = active[0];
|
||||
if (first) {
|
||||
pick(first.memory_id, false);
|
||||
return;
|
||||
}
|
||||
setSelectedId(null);
|
||||
setGroupNode(null);
|
||||
setInspectorOpen(false);
|
||||
onCloseDetail();
|
||||
}, [active, library, onCloseDetail, pendingPageOffset, pick]);
|
||||
useEffect(() => {
|
||||
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
||||
if (library?.loading && filtered.length === 0) return;
|
||||
@@ -455,6 +540,17 @@ export function MemoryMap(props: Props) {
|
||||
detail,
|
||||
pick,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!pageRetrying || !library) return;
|
||||
if (library.loading) {
|
||||
pageRetrySawLoading.current = true;
|
||||
return;
|
||||
}
|
||||
if (pageRetrySawLoading.current && library.failed) {
|
||||
pageRetrySawLoading.current = false;
|
||||
setPageRetrying(false);
|
||||
}
|
||||
}, [library, pageRetrying]);
|
||||
useEffect(() => {
|
||||
if (!activeId || !scrollSelection.current) return;
|
||||
scrollSelection.current = false;
|
||||
@@ -474,9 +570,9 @@ export function MemoryMap(props: Props) {
|
||||
useLayoutEffect(() => {
|
||||
// Detail content owns its own scrollport. A newly selected memory must
|
||||
// start at its heading instead of inheriting the previous inspector view.
|
||||
if (inspectorIdentity === "empty") return;
|
||||
if (inspectorResetKey.startsWith("empty:")) return;
|
||||
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
|
||||
}, [inspectorIdentity]);
|
||||
}, [inspectorResetKey]);
|
||||
const settledPageOffset = library?.page?.offset;
|
||||
useLayoutEffect(() => {
|
||||
// A settled page is a new reading surface, not a continuation of the
|
||||
@@ -489,12 +585,16 @@ export function MemoryMap(props: Props) {
|
||||
setSelectedId(null);
|
||||
setGroupNode(null);
|
||||
setPendingSourceSelection(null);
|
||||
setPendingPageOffset(null);
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setInspectorOpen(false);
|
||||
onCloseDetail();
|
||||
}, [onCloseDetail]);
|
||||
useEffect(() => {
|
||||
// A background map does not own the shared inspector in another mode.
|
||||
if (overlaysSuspended || library?.loading) return;
|
||||
if (overlaysSuspended || library?.loading || pendingPageOffset !== null)
|
||||
return;
|
||||
if (
|
||||
requestedId &&
|
||||
!active.some((memory) => memory.memory_id === requestedId)
|
||||
@@ -514,6 +614,7 @@ export function MemoryMap(props: Props) {
|
||||
clear,
|
||||
overlaysSuspended,
|
||||
library?.loading,
|
||||
pendingPageOffset,
|
||||
]);
|
||||
function closeInspector() {
|
||||
if (!compact) {
|
||||
@@ -523,6 +624,14 @@ export function MemoryMap(props: Props) {
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
}
|
||||
function retryLibrary() {
|
||||
if (!library) return;
|
||||
if (pendingPageOffset !== null) {
|
||||
pageRetrySawLoading.current = false;
|
||||
setPageRetrying(true);
|
||||
}
|
||||
library.onRetry();
|
||||
}
|
||||
function chooseGroup(key: string) {
|
||||
setGroupNode(null);
|
||||
if (library) {
|
||||
@@ -577,6 +686,17 @@ export function MemoryMap(props: Props) {
|
||||
? text("未识别来源")
|
||||
: null) ??
|
||||
text("全部记忆");
|
||||
const libraryStatus = !library
|
||||
? ""
|
||||
: library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: pageTransitioning
|
||||
? refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")
|
||||
: "";
|
||||
if (
|
||||
props.simplified &&
|
||||
(!library ||
|
||||
@@ -777,21 +897,20 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
className="map-search-submit"
|
||||
disabled={library.loading || hidden}
|
||||
disabled={pageTransitioning || hidden}
|
||||
>
|
||||
{text("搜索")}
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
{library && (library.loading || library.stale || library.failed) ? (
|
||||
<p className="map-list-status" aria-live="polite">
|
||||
{library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")}
|
||||
{library ? (
|
||||
<p
|
||||
className="map-list-status"
|
||||
aria-live="polite"
|
||||
aria-hidden={!libraryStatus || undefined}
|
||||
data-visible={libraryStatus ? "true" : "false"}
|
||||
>
|
||||
{libraryStatus || "\u00a0"}
|
||||
</p>
|
||||
) : null}
|
||||
{!props.simplified && (
|
||||
@@ -861,6 +980,7 @@ export function MemoryMap(props: Props) {
|
||||
type="button"
|
||||
className="map-memory-item"
|
||||
aria-pressed={activeId === m.memory_id}
|
||||
disabled={pageTransitioning}
|
||||
onClick={() => pick(m.memory_id)}
|
||||
>
|
||||
<span className={`map-state ${m.status}`} aria-hidden="true" />
|
||||
@@ -914,7 +1034,7 @@ export function MemoryMap(props: Props) {
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
<p>{text("记忆读取失败,请重试。")}</p>
|
||||
<R6Button onClick={library.onRetry}>
|
||||
<R6Button onClick={retryLibrary}>
|
||||
{text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
</div>
|
||||
@@ -949,7 +1069,7 @@ export function MemoryMap(props: Props) {
|
||||
!library.stale && (
|
||||
<nav
|
||||
aria-label={text("地图记忆分页")}
|
||||
aria-busy={library.loading || undefined}
|
||||
aria-busy={pageTransitioning || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
@@ -957,11 +1077,16 @@ export function MemoryMap(props: Props) {
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={library.loading || library.page.offset === 0}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset - library.page.limit);
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
@@ -987,13 +1112,18 @@ export function MemoryMap(props: Props) {
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
library.loading ||
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >= library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset + library.page.limit);
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
@@ -1059,22 +1189,33 @@ export function MemoryMap(props: Props) {
|
||||
) : (
|
||||
<div className="map-empty-stage">
|
||||
<Network size={32} strokeWidth={1} />
|
||||
<p>
|
||||
<p
|
||||
{...(library?.graphFailed || library?.graphLoading
|
||||
? { role: "status", "aria-live": "polite" as const }
|
||||
: {})}
|
||||
>
|
||||
{library?.stale
|
||||
? text("可在沉淀记忆后查看视图")
|
||||
: library?.failed
|
||||
: library?.graphFailed || library?.failed
|
||||
? text("记忆暂时无法读取")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
: library?.graphLoading
|
||||
? text("正在读取记忆地图…")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
</p>
|
||||
{library?.graphFailed ? (
|
||||
<R6Button onClick={library.onRetry}>
|
||||
{text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1167,7 +1308,7 @@ export function MemoryMap(props: Props) {
|
||||
<MemoryEvidencePanel
|
||||
open={inspectorOpen && !!(activeId || visibleGroupNode)}
|
||||
suspended={overlaysSuspended}
|
||||
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
|
||||
scrollResetKey={inspectorResetKey}
|
||||
onClose={closeInspector}
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
@@ -1202,7 +1343,7 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={library.onRetry}
|
||||
onClick={retryLibrary}
|
||||
>
|
||||
{text("重试")}
|
||||
</button>
|
||||
@@ -1223,8 +1364,17 @@ export function MemoryMap(props: Props) {
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
key={`${inspectorDetail.memory.memory_id}:${detailResetRevision}:${
|
||||
readingActiveDetail
|
||||
? "loading"
|
||||
: failedActiveDetail
|
||||
? "failed"
|
||||
: "ready"
|
||||
}`}
|
||||
className="map-detail-surface"
|
||||
aria-busy={
|
||||
refreshing ||
|
||||
readingActiveDetail ||
|
||||
inspectorDetail.memory.memory_id !== activeId ||
|
||||
undefined
|
||||
}
|
||||
@@ -1249,7 +1399,21 @@ export function MemoryMap(props: Props) {
|
||||
until the new one arrived, which read as "nothing happened"
|
||||
for two or three seconds. A read is not a value: show that a
|
||||
read is happening instead. */}
|
||||
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
|
||||
{failedActiveDetail ? (
|
||||
<div className="map-detail-loading">
|
||||
<p role="alert">
|
||||
{text("这条记忆的原文暂时无法读取,选择已保留。")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => activeId && onOpenMemory(activeId)}
|
||||
>
|
||||
{text("重试读取原文")}
|
||||
</button>
|
||||
</div>
|
||||
) : readingActiveDetail ||
|
||||
(activeId && inspectorDetail.memory.memory_id !== activeId) ? (
|
||||
<div className="map-detail-loading">
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -63,6 +64,9 @@ vi.mock("./MemoryMap", () => ({
|
||||
source_ids_by_memory?: Record<string, string[]>;
|
||||
offset: number;
|
||||
} | null;
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
stale: boolean;
|
||||
unattributedSource?: string;
|
||||
onSource(value: string): void;
|
||||
onQuery(value: string): void;
|
||||
@@ -91,6 +95,13 @@ vi.mock("./MemoryMap", () => ({
|
||||
<span data-testid="map-busy">{String(busy)}</span>
|
||||
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
|
||||
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
|
||||
<span data-testid="map-stale">{String(libraryView?.stale)}</span>
|
||||
<span data-testid="map-graph-loading">
|
||||
{String(libraryView?.graphLoading)}
|
||||
</span>
|
||||
<span data-testid="map-graph-failed">
|
||||
{String(libraryView?.graphFailed)}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenMemory("e1")}>
|
||||
open e1
|
||||
</button>
|
||||
@@ -133,6 +144,15 @@ vi.mock("./MemoryMap", () => ({
|
||||
>
|
||||
pick workbuddy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
libraryView?.onSource("");
|
||||
libraryView?.resetPage();
|
||||
}}
|
||||
>
|
||||
pick all
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -214,11 +234,23 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.sdkGraph).toHaveBeenCalledWith(
|
||||
{ source: undefined, offset: 0, limit: 100 },
|
||||
{ source: undefined, offset: 0, limit: 300 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a graph read failure explicit instead of presenting the list page as the graph", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkGraph.mockRejectedValue(new Error("graph unavailable"));
|
||||
render(<MemorySpace simplified active />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-failed").textContent).toBe("true");
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps rows, sources, count and graph on the one filtered page", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
@@ -251,12 +283,150 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.sdkGraph).toHaveBeenLastCalledWith(
|
||||
{ source: "workbuddy", offset: 0, limit: 100 },
|
||||
{ source: "workbuddy", offset: 0, limit: 300 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(await screen.findByText(/566/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the previous source graph until the all-applications graph settles", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("3"),
|
||||
);
|
||||
|
||||
const workbuddyPage = {
|
||||
memories: [{ ...record("w1"), source: "workbuddy" }],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
const workbuddyGraph: MemoryGraphResponse = {
|
||||
total_memories: 1,
|
||||
projected_memories: 1,
|
||||
nodes: [
|
||||
{
|
||||
node_id: "memory:w1",
|
||||
node_type: "memory",
|
||||
label: "w1",
|
||||
memory_id: "w1",
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: "active",
|
||||
confidence: null,
|
||||
},
|
||||
{
|
||||
node_id: "source:workbuddy",
|
||||
node_type: "source",
|
||||
label: "WorkBuddy",
|
||||
source_id: "workbuddy",
|
||||
memory_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
hy_graph_available: false,
|
||||
};
|
||||
api.sdkLibrary.mockResolvedValue(workbuddyPage);
|
||||
api.sdkGraph.mockResolvedValue(workbuddyGraph);
|
||||
fireEvent.click(screen.getByText("pick workbuddy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("2"),
|
||||
);
|
||||
|
||||
const allPage = {
|
||||
memories: [record("a"), { ...record("w1"), source: "workbuddy" }],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
const allGraph: MemoryGraphResponse = {
|
||||
...workbuddyGraph,
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
nodes: [
|
||||
...workbuddyGraph.nodes,
|
||||
{
|
||||
node_id: "memory:a",
|
||||
node_type: "memory",
|
||||
label: "a",
|
||||
memory_id: "a",
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: "active",
|
||||
confidence: null,
|
||||
},
|
||||
{
|
||||
node_id: "source:cursor",
|
||||
node_type: "source",
|
||||
label: "Cursor",
|
||||
source_id: "cursor",
|
||||
memory_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
let releasePage: (value: typeof allPage) => void = () => {};
|
||||
let releaseGraph: (value: MemoryGraphResponse) => void = () => {};
|
||||
api.sdkLibrary.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releasePage = resolve;
|
||||
}),
|
||||
);
|
||||
api.sdkGraph.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseGraph = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("pick all"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
// Waiting for the graph is not page staleness: the list stays usable.
|
||||
expect(screen.getByTestId("map-stale").textContent).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
releasePage(allPage);
|
||||
releaseGraph(allGraph);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("4"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
|
||||
const searchPage = {
|
||||
memories: [record("search-result")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
let releaseSearch: (value: typeof searchPage) => void = () => {};
|
||||
api.sdkLibrary.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
}),
|
||||
);
|
||||
const graphCalls = api.sdkGraph.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("search deploy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
|
||||
await act(async () => releaseSearch(searchPage));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkMemory.mockResolvedValue(record("a"));
|
||||
|
||||
@@ -166,11 +166,14 @@ export function MemorySpace({
|
||||
// The SDK library is no longer where a memory opens from the map; the id is
|
||||
// kept so the panel still has a focus target for its own tests.
|
||||
const [sdkFocusedMemoryId] = useState<string | null>(null);
|
||||
// The list, its count, its source and time labels and the graph are all one
|
||||
// page: the library page and the graph for that same source and window,
|
||||
// read together. Reading the graph from a different, unfiltered window made
|
||||
// rows, sources and totals disagree.
|
||||
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
|
||||
// The graph covers the active source scope (up to its visual cap), while the
|
||||
// list pages through that scope. Keep source+query with the value so a
|
||||
// filter change cannot paint the previous filter's graph during the new read.
|
||||
const [sdkGraph, setSdkGraph] = useState<{
|
||||
scope: string;
|
||||
status: "ready" | "failed";
|
||||
value: MemoryGraphResponse | null;
|
||||
} | null>(null);
|
||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
@@ -191,6 +194,17 @@ export function MemorySpace({
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
|
||||
// A keyword search pages through one server-side result window. The id is
|
||||
// kept for the query and source it was searched with; a new query or
|
||||
// source starts without one, so the server searches afresh.
|
||||
@@ -233,22 +247,26 @@ export function MemorySpace({
|
||||
request.signal,
|
||||
),
|
||||
// The overlay is decoration: the list stands without it. It reads
|
||||
// chat memories only, newest first; a mixed page's chat rows are
|
||||
// among the newest offset+page chat memories, so that prefix holds
|
||||
// every relation the page can draw.
|
||||
getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: query
|
||||
? 100
|
||||
: Math.min(
|
||||
300,
|
||||
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
|
||||
),
|
||||
},
|
||||
request.signal,
|
||||
).catch(() => null),
|
||||
// the full active/source-filtered scene up to the API's visual cap.
|
||||
// Reading only the first list-page prefix made "All applications"
|
||||
// look identical to Cursor whenever newer Cursor memories occupied
|
||||
// that prefix.
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (sdkSearchWindow.current.key === windowKey)
|
||||
@@ -265,7 +283,7 @@ export function MemorySpace({
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
});
|
||||
setSdkGraph(graph);
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkLibrary((current) => ({
|
||||
@@ -1026,9 +1044,12 @@ export function MemorySpace({
|
||||
page: sdkLibrary.page,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
requestedOffset: sdkLibraryOffset,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
@@ -1040,7 +1061,7 @@ export function MemorySpace({
|
||||
}
|
||||
hidden={!active || displayedMode !== "map"}
|
||||
memories={sdkLibrary.page?.items ?? []}
|
||||
graph={sdkGraph}
|
||||
graph={visibleSdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
arrival={memoryArrival}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
|
||||
export interface OptionMenuItem {
|
||||
@@ -8,6 +17,43 @@ export interface OptionMenuItem {
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const PANEL_GAP = 4;
|
||||
const MAX_PANEL_HEIGHT = 280;
|
||||
|
||||
export function optionMenuPosition(
|
||||
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
|
||||
panelHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const width = Math.min(
|
||||
anchor.width,
|
||||
Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_MARGIN, anchor.left),
|
||||
Math.max(VIEWPORT_MARGIN, viewportWidth - VIEWPORT_MARGIN - width),
|
||||
);
|
||||
const below = Math.max(
|
||||
0,
|
||||
viewportHeight - VIEWPORT_MARGIN - anchor.bottom - PANEL_GAP,
|
||||
);
|
||||
const above = Math.max(0, anchor.top - VIEWPORT_MARGIN - PANEL_GAP);
|
||||
const placement: "above" | "below" =
|
||||
below >= Math.min(panelHeight, 120) || below >= above ? "below" : "above";
|
||||
const maxHeight = Math.min(
|
||||
MAX_PANEL_HEIGHT,
|
||||
placement === "below" ? below : above,
|
||||
);
|
||||
const height = Math.min(panelHeight, maxHeight);
|
||||
const top =
|
||||
placement === "below"
|
||||
? anchor.bottom + PANEL_GAP
|
||||
: anchor.top - PANEL_GAP - height;
|
||||
return { top, left, width, maxHeight, placement };
|
||||
}
|
||||
|
||||
/** Replaces a native select so the open list follows the product field, not the OS menu. */
|
||||
export function OptionMenu(props: {
|
||||
id?: string;
|
||||
@@ -17,6 +63,7 @@ export function OptionMenu(props: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
options: readonly OptionMenuItem[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
@@ -32,8 +79,14 @@ export function OptionMenu(props: {
|
||||
? selectedIndex
|
||||
: firstEnabledIndex;
|
||||
const [activeIndex, setActiveIndex] = useState(preferredIndex);
|
||||
const [panelStyle, setPanelStyle] = useState<CSSProperties>({
|
||||
visibility: "hidden",
|
||||
});
|
||||
const [placement, setPlacement] = useState<"above" | "below">("below");
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const panelMouseDown = useRef(false);
|
||||
const selected = props.options.find((item) => item.value === props.value);
|
||||
const visibleLabel = selected?.label ?? t("wb.select");
|
||||
const controlId = props.id ?? `${generatedId}-control`;
|
||||
@@ -62,20 +115,77 @@ export function OptionMenu(props: {
|
||||
button.current?.focus();
|
||||
}
|
||||
|
||||
const placePanel = useCallback(() => {
|
||||
const anchor = button.current?.getBoundingClientRect();
|
||||
const menu = panel.current;
|
||||
if (!anchor || !menu) return;
|
||||
if (
|
||||
anchor.width > 0 &&
|
||||
(anchor.bottom <= 0 ||
|
||||
anchor.top >= window.innerHeight ||
|
||||
anchor.right <= 0 ||
|
||||
anchor.left >= window.innerWidth)
|
||||
) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const measuredWidth = Math.min(
|
||||
anchor.width,
|
||||
Math.max(0, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
menu.style.width = `${measuredWidth}px`;
|
||||
menu.style.maxHeight = `${MAX_PANEL_HEIGHT}px`;
|
||||
const next = optionMenuPosition(
|
||||
anchor,
|
||||
menu.scrollHeight,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
setPlacement(next.placement);
|
||||
setPanelStyle({
|
||||
top: next.top,
|
||||
left: next.left,
|
||||
width: next.width,
|
||||
maxHeight: next.maxHeight,
|
||||
visibility: "visible",
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
placePanel();
|
||||
window.addEventListener("resize", placePanel);
|
||||
window.addEventListener("scroll", placePanel, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", placePanel);
|
||||
window.removeEventListener("scroll", placePanel, true);
|
||||
};
|
||||
}, [open, placePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveIndex(preferredIndex);
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !panel.current?.contains(target))
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
const releasePanelPointer = () => {
|
||||
panelMouseDown.current = false;
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
document.addEventListener("mouseup", releasePanelPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", releasePanelPointer);
|
||||
return () => {
|
||||
panelMouseDown.current = false;
|
||||
document.removeEventListener("mousedown", close);
|
||||
document.removeEventListener("mouseup", releasePanelPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", releasePanelPointer);
|
||||
};
|
||||
}, [open, preferredIndex]);
|
||||
return (
|
||||
@@ -105,7 +215,12 @@ export function OptionMenu(props: {
|
||||
}
|
||||
disabled={props.disabled}
|
||||
onBlur={(event) => {
|
||||
if (!root.current?.contains(event.relatedTarget as Node | null))
|
||||
if (panelMouseDown.current) return;
|
||||
const target = event.relatedTarget as Node | null;
|
||||
if (
|
||||
!root.current?.contains(target) &&
|
||||
!panel.current?.contains(target)
|
||||
)
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -152,29 +267,43 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{visibleLabel}
|
||||
</button>
|
||||
{open && (
|
||||
<div id={panelId} className="om-option-menu-panel" role="listbox">
|
||||
{props.options.map((item, index) => (
|
||||
<button
|
||||
key={item.value || item.label}
|
||||
id={`${panelId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
id={panelId}
|
||||
className={["om-option-menu-panel", props.panelClassName]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="listbox"
|
||||
data-placement={placement}
|
||||
style={panelStyle}
|
||||
onMouseDownCapture={() => {
|
||||
panelMouseDown.current = true;
|
||||
}}
|
||||
>
|
||||
{props.options.map((item, index) => (
|
||||
<button
|
||||
key={item.value || item.label}
|
||||
id={`${panelId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +136,70 @@ it("splits long memory text into a bounded heading and readable body", () => {
|
||||
"selected-memory-body",
|
||||
);
|
||||
});
|
||||
it("renders an SDK experience from its structured task and solution", () => {
|
||||
const task =
|
||||
"review an uncommitted diff for privacy and correctness blockers";
|
||||
const solution = `Reusable checklist:\n1. Run \`git diff --check\`.\n2. Inspect ${"/very-long-path".repeat(20)}.`;
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: {
|
||||
...detail.memory,
|
||||
content: `${task}\n${solution}`,
|
||||
revision: 0,
|
||||
},
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
sdk_ref: {
|
||||
scene: "coding",
|
||||
kind: "experience",
|
||||
variant: "coding",
|
||||
},
|
||||
sdk_task: task,
|
||||
sdk_solution: solution,
|
||||
}}
|
||||
sources={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: task })).toBeTruthy();
|
||||
const body = document.querySelector(".selected-memory-body");
|
||||
expect(body?.textContent).toBe(solution);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("heading", { name: task })
|
||||
.closest("[data-memory-kind]")
|
||||
?.getAttribute("data-memory-kind"),
|
||||
).toBe("experience");
|
||||
});
|
||||
it("does not apply experience fields to a fact memory", () => {
|
||||
const content = `${"事实".repeat(40)}。补充说明`;
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, content, revision: 0 },
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
sdk_ref: {
|
||||
scene: "working",
|
||||
kind: "fact",
|
||||
variant: "working",
|
||||
},
|
||||
sdk_task: "不应显示的任务标题",
|
||||
sdk_solution: "不应显示的经验正文",
|
||||
}}
|
||||
sources={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("不应显示的任务标题")).toBeNull();
|
||||
expect(screen.queryByText("不应显示的经验正文")).toBeNull();
|
||||
expect(
|
||||
document
|
||||
.querySelector(".selected-memory-detail")
|
||||
?.getAttribute("data-memory-kind"),
|
||||
).toBe("memory");
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
"a late sentence end",
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SelectedMemoryDetail({
|
||||
// (CJK text has none, so it breaks at the limit itself).
|
||||
const punctuation = memory.content.search(/[。!?!?\n]/);
|
||||
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
|
||||
const split =
|
||||
const fallbackSplit =
|
||||
punctuation > 0 && punctuation < HEADING_LIMIT
|
||||
? punctuation + 1
|
||||
: memory.content.length > HEADING_LIMIT
|
||||
@@ -73,11 +73,26 @@ export function SelectedMemoryDetail({
|
||||
? space
|
||||
: HEADING_LIMIT
|
||||
: -1;
|
||||
const heading =
|
||||
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
|
||||
const body = split > 0 ? memory.content.slice(split).trim() : "";
|
||||
const fallbackHeading =
|
||||
fallbackSplit > 0
|
||||
? memory.content.slice(0, fallbackSplit).trim()
|
||||
: memory.content;
|
||||
const fallbackBody =
|
||||
fallbackSplit > 0 ? memory.content.slice(fallbackSplit).trim() : "";
|
||||
const experience =
|
||||
detail.sdk_ref?.kind === "experience" &&
|
||||
Boolean(detail.sdk_task?.trim() || detail.sdk_solution?.trim());
|
||||
const heading = experience
|
||||
? detail.sdk_task?.trim() || fallbackHeading
|
||||
: fallbackHeading;
|
||||
const body = experience
|
||||
? detail.sdk_solution?.trim() || (detail.sdk_task ? "" : fallbackBody)
|
||||
: fallbackBody;
|
||||
return (
|
||||
<article className="selected-memory-detail">
|
||||
<article
|
||||
className="selected-memory-detail"
|
||||
data-memory-kind={experience ? "experience" : "memory"}
|
||||
>
|
||||
<p className="selected-memory-status">
|
||||
{statusLabel}
|
||||
{!detail.sdk_status &&
|
||||
|
||||
@@ -144,13 +144,12 @@ button svg {
|
||||
background: #edf0f3;
|
||||
}
|
||||
.om-option-menu-panel {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #c0ccd5;
|
||||
@@ -178,10 +177,8 @@ button svg {
|
||||
.om-option-menu-panel [role="option"]:hover:not(:disabled),
|
||||
button.om-option-menu-button:hover:not(:disabled),
|
||||
.cold-start button.om-option-menu-button:hover:not(:disabled),
|
||||
.cold-start .om-option-menu-panel button[role="option"]:hover:not(:disabled),
|
||||
.r6-settings button.om-option-menu-button:hover:not(:disabled),
|
||||
.r6-settings .om-option-menu-panel button[role="option"]:hover:not(:disabled),
|
||||
.r6-memory .om-option-menu-panel button[role="option"]:hover:not(:disabled) {
|
||||
.r6-memory button.om-option-menu-button:hover:not(:disabled) {
|
||||
color: #262a2e;
|
||||
background: #e7eef2;
|
||||
border-color: transparent;
|
||||
@@ -791,12 +788,16 @@ button.rail-button.active::before {
|
||||
}
|
||||
.map-list-status {
|
||||
flex: none;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.map-list-status[data-visible="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
.map-filter-notice {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
@@ -882,6 +883,10 @@ button.rail-button.active::before {
|
||||
background: #fdfefe;
|
||||
border-color: #c7d3dc;
|
||||
}
|
||||
.map-memory-item:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
.map-state {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@@ -1145,6 +1150,7 @@ button.map-tool {
|
||||
}
|
||||
.map-detail-loading {
|
||||
display: grid;
|
||||
min-height: 280px;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -1155,8 +1161,13 @@ button.map-tool {
|
||||
}
|
||||
@keyframes map-detail-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.map-detail-surface,
|
||||
.map-detail-loading {
|
||||
animation: map-detail-enter 140ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
.map-inspector .wb-detail {
|
||||
|
||||
@@ -184,6 +184,9 @@ const SCAN_ISSUE_LABELS: Record<string, string> = {
|
||||
tool_content_excluded: "工具输出不计入原文",
|
||||
internal_reasoning_excluded: "模型的内部思考不计入原文",
|
||||
not_written_by_person: "不是本人写的,未计入",
|
||||
injected_machine_notice: "来源程序自己发的通知,未计入",
|
||||
voice_placeholder: "语音没有转成文字,未计入",
|
||||
probe: "连通性检查消息,未计入",
|
||||
source_busy: "来源正被占用,稍后会自动补读",
|
||||
unsupported_schema: "这个来源的文件格式暂不支持",
|
||||
budget_exceeded: "超出本次处理上限,其余未读",
|
||||
|
||||
@@ -72,37 +72,27 @@
|
||||
border-color: #7892a6;
|
||||
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-panel {
|
||||
top: calc(100% + 6px);
|
||||
.r6-language-option-menu-panel--settings {
|
||||
padding: 6px;
|
||||
border-color: #c6d2da;
|
||||
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
|
||||
.r6-language-option-menu-panel--settings [role="option"] {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][aria-selected="true"] {
|
||||
.r6-language-option-menu-panel--settings [role="option"][aria-selected="true"] {
|
||||
background: #dfe9ef;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][data-active="true"] {
|
||||
.r6-language-option-menu-panel--settings [role="option"][data-active="true"] {
|
||||
color: #304757;
|
||||
background: #e9f0f4;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"]:active:not(:disabled) {
|
||||
.r6-language-option-menu-panel--settings [role="option"]:active:not(:disabled) {
|
||||
color: #304757;
|
||||
background: #d4e2ea;
|
||||
}
|
||||
|
||||
@@ -263,12 +263,14 @@
|
||||
display: block;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 17px;
|
||||
line-height: 29px;
|
||||
letter-spacing: normal;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.selected-memory-source {
|
||||
margin: 32px 0 22px;
|
||||
|
||||
@@ -282,7 +282,7 @@ Support is declared per immutable GitHub Release manifest:
|
||||
- Linux x64/arm64 Runtime on native GHA runners.
|
||||
- macOS arm64/x64 App/Runtime: Developer ID, notarization, native acceptance.
|
||||
- Windows is not supported.
|
||||
- `hy-memory==2.0.8` is the current stable SDK dependency; the release
|
||||
- `hy-memory==2.0.9` is the current stable SDK dependency; the release
|
||||
lock and [`../MEMORY.md`](../MEMORY.md) are authoritative when the pin changes.
|
||||
|
||||
See [`INSTALLATION.md`](INSTALLATION.md).
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.5`(build 14)的发行目标如下:
|
||||
MemHub `v1.0.5`(build 36)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
| Linux x64 | Runtime tar.gz | GitHub Actions 原生 x64 runner |
|
||||
| Linux arm64 | Runtime tar.gz | GitHub Actions 原生 arm64 runner |
|
||||
| macOS arm64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 arm64 验收 |
|
||||
| macOS x64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 Intel 验收 |
|
||||
| macOS x64 | 无 | 本版本不提供 Intel 构建 |
|
||||
| Windows | 无 | 当前不支持 |
|
||||
|
||||
正式支持以对应 GitHub Release 的 `release-manifest.json` 为准。Manifest 记录版本、
|
||||
|
||||
@@ -53,6 +53,26 @@ node apps/cli/dist/one-memory.cjs sources connect --all-detected --import
|
||||
(如 `DSH_HOME`)的来源必须加 `--confirm-overrides`,否则被拒绝(或在
|
||||
`--all-detected` 时跳过并列出)。用同一个 `--request-id <uuid>` 重试不会重复导入。
|
||||
|
||||
### 接入 Hermes 的另一个 profile(多用户)
|
||||
|
||||
一个 Runtime 可以给多个用户记忆。**用户由读的是哪个目录决定**,不看会话或平台账号:
|
||||
`~/.hermes/state.db` 属于默认用户 `local`,`~/.hermes/profiles/<name>/state.db` 是另一个用户。
|
||||
|
||||
```bash
|
||||
# 把 wife 这个 Hermes profile 接成用户 wife:授权 + 导入历史,一步完成
|
||||
node apps/cli/dist/one-memory.cjs sources connect hermes --user-id wife --root ~/.hermes/profiles/wife
|
||||
# 也可以显式加 --import,效果相同;可带 --request-id <uuid> 幂等重试
|
||||
node apps/cli/dist/one-memory.cjs sources connect hermes --user-id wife --root ~/.hermes/profiles/wife --import --json
|
||||
|
||||
# 列出来源;其他用户的连接单独列在「其他用户的连接」下,--json 里在 grants 字段
|
||||
node apps/cli/dist/one-memory.cjs sources list
|
||||
```
|
||||
|
||||
- 只有 `hermes` 能带 `--user-id`(非 `local`)或 `--root`;其他来源带了会退 2,什么都不发。
|
||||
- `--root` 里的 `~` 由 CLI 展开为主目录,相对路径按当前目录解析成绝对路径。
|
||||
- 扫描 `~/.hermes` 时会排除 `profiles/**`,所以 `local` 不会读到 wife 的 `state.db`。
|
||||
- App 不变:它只读写 `local`,其他来源也都固定是 `local`。
|
||||
|
||||
## 3. 看导入进度
|
||||
|
||||
```bash
|
||||
@@ -105,6 +125,23 @@ node apps/cli/dist/one-memory.cjs write status <request_id> --json
|
||||
|
||||
两条都返回 request id;它是幂等键,结果未知时用**同一个** `--request-id` 重试不会重复写入。
|
||||
|
||||
### 按用户读写(`--user-id`)
|
||||
|
||||
记忆命令都接受全局 `--user-id <id>`(默认 `local`,即 App 看到的那份):
|
||||
`memories list/show/add/add-status/update/delete`、`recall`、`profile`、`write submit/status`。
|
||||
|
||||
```bash
|
||||
node apps/cli/dist/one-memory.cjs memories list --user-id wife --json
|
||||
node apps/cli/dist/one-memory.cjs memories add "我更喜欢喝绿茶" --user-id wife
|
||||
node apps/cli/dist/one-memory.cjs recall "喝什么茶" --user-id wife
|
||||
node apps/cli/dist/one-memory.cjs profile --user-id wife
|
||||
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 也只属于提交它的用户。
|
||||
|
||||
## 8. 召回(recall)
|
||||
|
||||
`recall` 两条路径(raw / memory)此前已真机验证。文本输出会标明每条是「原始对话摘录」
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 35,
|
||||
"build_number": 36,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -7,7 +7,10 @@ export const applicationsChineseMessages = {
|
||||
"apps.back": "返回首页",
|
||||
"apps.overview": "记忆概览",
|
||||
"apps.memories": "已沉淀记忆",
|
||||
"apps.readingCount": "正在读取已沉淀记忆数量…",
|
||||
"apps.readingCount": "正在核对本机记忆…",
|
||||
"apps.startingRetry": "后台正在启动,稍后自动更新",
|
||||
"apps.countWillUpdate": "记忆数量稍后自动更新",
|
||||
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
|
||||
"apps.unreadable": "暂无法读取",
|
||||
"apps.unit": "条",
|
||||
"apps.scope": "按应用授权范围使用",
|
||||
@@ -16,9 +19,12 @@ export const applicationsChineseMessages = {
|
||||
"apps.paused": "已暂停",
|
||||
"apps.stoppedNew": "已停止生成新记忆",
|
||||
"apps.checkingProcessing": "正在核对整理状态",
|
||||
"apps.checkingStatus": "正在核对",
|
||||
"apps.unchecked": "待核对",
|
||||
"apps.organizingNew": "新的对话正在整理",
|
||||
"apps.recentUpdate": "最近更新",
|
||||
"apps.checkingRecent": "正在核对",
|
||||
"apps.recentWillUpdate": "最近更新会自动显示",
|
||||
"apps.notGenerated": "尚未生成",
|
||||
"apps.saved": "记忆已保存",
|
||||
"apps.separate": "历史与记忆分别处理",
|
||||
@@ -26,14 +32,14 @@ export const applicationsChineseMessages = {
|
||||
"apps.activityUnavailable": "动态暂不可用",
|
||||
"apps.firstBatch": "首批沉淀",
|
||||
"apps.callsUnavailable": "暂时无法读取调用记录",
|
||||
"apps.memoryUnavailable": "暂时无法读取记忆状态",
|
||||
"apps.memoryUnavailable": "记忆概览还没准备好",
|
||||
"apps.importing": "对话持续导入中,即将沉淀你的记忆",
|
||||
"apps.firstRunning": "正在沉淀第一批记忆",
|
||||
"apps.firstQueued": "准备沉淀第一批记忆",
|
||||
"apps.readingActivity": "正在读取动态",
|
||||
"apps.readingActivity": "正在准备记忆概览",
|
||||
"apps.emptyMemories": "还没有已生成的记忆",
|
||||
"apps.callReadFailure": "已有记忆仍可查看;重试不会发起新的调用。",
|
||||
"apps.memoryReadFailure": "读取失败不会当作没有记忆。",
|
||||
"apps.memoryReadFailure": "后台恢复后会自动更新,也可以重新读取。",
|
||||
"apps.savingConversations": "对话正在后台持续保存。",
|
||||
"apps.waitingMemories": "历史已保存,记忆生成后会显示在这里。",
|
||||
"apps.reread": "重新读取",
|
||||
@@ -121,7 +127,12 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.back": "Volver al inicio",
|
||||
"apps.overview": "Resumen de memorias",
|
||||
"apps.memories": "Memorias guardadas",
|
||||
"apps.readingCount": "Leyendo el total de memorias…",
|
||||
"apps.readingCount": "Comprobando las memorias locales…",
|
||||
"apps.startingRetry":
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
|
||||
"apps.showingPrevious":
|
||||
"Se muestran los datos anteriores; se actualizarán automáticamente",
|
||||
"apps.unreadable": "No disponible",
|
||||
"apps.unit": "",
|
||||
"apps.scope": "Según los permisos de cada aplicación",
|
||||
@@ -130,9 +141,12 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.paused": "En pausa",
|
||||
"apps.stoppedNew": "Se dejó de generar memorias nuevas",
|
||||
"apps.checkingProcessing": "Comprobando el estado del procesamiento",
|
||||
"apps.checkingStatus": "Verificando",
|
||||
"apps.unchecked": "Sin verificar",
|
||||
"apps.organizingNew": "Organizando nuevas conversaciones",
|
||||
"apps.recentUpdate": "Última actualización",
|
||||
"apps.checkingRecent": "Verificando",
|
||||
"apps.recentWillUpdate": "La última actualización aparecerá automáticamente",
|
||||
"apps.notGenerated": "Sin generar",
|
||||
"apps.saved": "Memoria guardada",
|
||||
"apps.separate": "Historial y memorias se procesan por separado",
|
||||
@@ -140,16 +154,16 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.activityUnavailable": "Actividad no disponible",
|
||||
"apps.firstBatch": "Primeras memorias",
|
||||
"apps.callsUnavailable": "No se pueden leer las consultas",
|
||||
"apps.memoryUnavailable": "No se puede leer el estado de las memorias",
|
||||
"apps.memoryUnavailable": "El resumen de memorias aún no está listo",
|
||||
"apps.importing": "Importando conversaciones para generar memorias",
|
||||
"apps.firstRunning": "Generando las primeras memorias",
|
||||
"apps.firstQueued": "Preparando las primeras memorias",
|
||||
"apps.readingActivity": "Leyendo actividad",
|
||||
"apps.readingActivity": "Preparando el resumen de memorias",
|
||||
"apps.emptyMemories": "Aún no hay memorias generadas",
|
||||
"apps.callReadFailure":
|
||||
"Puedes ver las memorias guardadas. Reintentar no inicia otra consulta.",
|
||||
"apps.memoryReadFailure":
|
||||
"Un error de lectura no significa que no haya memorias.",
|
||||
"Se actualizará cuando el servicio se recupere. También puedes volver a leerlo.",
|
||||
"apps.savingConversations": "Las conversaciones se guardan en segundo plano.",
|
||||
"apps.waitingMemories":
|
||||
"El historial está guardado. Las memorias aparecerán cuando se generen.",
|
||||
|
||||
@@ -1305,7 +1305,12 @@ const baseEnglishMessages = {
|
||||
"apps.back": "Back to home",
|
||||
"apps.overview": "Memory overview",
|
||||
"apps.memories": "Saved memories",
|
||||
"apps.readingCount": "Loading memory count…",
|
||||
"apps.readingCount": "Checking local memories…",
|
||||
"apps.startingRetry":
|
||||
"The background service is starting; this will update automatically",
|
||||
"apps.countWillUpdate": "The memory count will update automatically",
|
||||
"apps.showingPrevious":
|
||||
"Showing the previous data; this will update automatically",
|
||||
"apps.unreadable": "Unavailable",
|
||||
"apps.unit": "items",
|
||||
"apps.scope": "Used within app permissions",
|
||||
@@ -1314,9 +1319,12 @@ const baseEnglishMessages = {
|
||||
"apps.paused": "Paused",
|
||||
"apps.stoppedNew": "No new memories are being created",
|
||||
"apps.checkingProcessing": "Checking processing status",
|
||||
"apps.checkingStatus": "Checking",
|
||||
"apps.unchecked": "Needs review",
|
||||
"apps.organizingNew": "Processing new conversations",
|
||||
"apps.recentUpdate": "Recent update",
|
||||
"apps.checkingRecent": "Checking",
|
||||
"apps.recentWillUpdate": "The latest update will appear automatically",
|
||||
"apps.notGenerated": "Not created yet",
|
||||
"apps.saved": "Memory saved",
|
||||
"apps.separate": "History and memories are processed separately",
|
||||
@@ -1324,16 +1332,16 @@ const baseEnglishMessages = {
|
||||
"apps.activityUnavailable": "Activity unavailable",
|
||||
"apps.firstBatch": "First memories",
|
||||
"apps.callsUnavailable": "Could not read call history",
|
||||
"apps.memoryUnavailable": "Could not read memory status",
|
||||
"apps.memoryUnavailable": "The memory overview is not ready yet",
|
||||
"apps.importing": "Importing conversations before creating memories",
|
||||
"apps.firstRunning": "Creating the first memories",
|
||||
"apps.firstQueued": "Preparing the first memories",
|
||||
"apps.readingActivity": "Loading activity",
|
||||
"apps.readingActivity": "Preparing memory overview",
|
||||
"apps.emptyMemories": "No memories created yet",
|
||||
"apps.callReadFailure":
|
||||
"You can still view saved memories. Retrying will not make a new call.",
|
||||
"apps.memoryReadFailure":
|
||||
"A failed read does not mean there are no memories.",
|
||||
"This will update when the service recovers. You can also retry now.",
|
||||
"apps.savingConversations":
|
||||
"Conversations are still being saved in the background.",
|
||||
"apps.waitingMemories": "History is saved. New memories will appear here.",
|
||||
@@ -1613,6 +1621,7 @@ const baseEnglishMessages = {
|
||||
"Newly imported original text, not yet memories",
|
||||
"memory:来源归属": "Source connections",
|
||||
"memory:可在沉淀记忆后查看视图": "Create memories to view the graph",
|
||||
"memory:正在读取记忆地图…": "Loading memory map…",
|
||||
"memory:查看选中来源原文": "View selected source text",
|
||||
"memory:查看选中记忆原文": "View selected memory text",
|
||||
"memory:查看原文": "View original conversation",
|
||||
|
||||
@@ -187,6 +187,7 @@ export const memoryChineseMessages = {
|
||||
"memory:刚导入的原话,还不是沉淀后的记忆": "刚导入的原话,还不是沉淀后的记忆",
|
||||
"memory:来源归属": "来源归属",
|
||||
"memory:可在沉淀记忆后查看视图": "可在沉淀记忆后查看视图",
|
||||
"memory:正在读取记忆地图…": "正在读取记忆地图…",
|
||||
"memory:查看选中来源原文": "查看选中来源原文",
|
||||
"memory:查看选中记忆原文": "查看选中记忆原文",
|
||||
"memory:查看原文": "查看原文",
|
||||
@@ -589,6 +590,7 @@ export const memorySpanishMessages: Record<
|
||||
"memory:来源归属": "Relación con el origen",
|
||||
"memory:可在沉淀记忆后查看视图":
|
||||
"La vista estará disponible cuando se generen memorias",
|
||||
"memory:正在读取记忆地图…": "Leyendo el mapa de memorias…",
|
||||
"memory:查看选中来源原文": "Ver texto del origen seleccionado",
|
||||
"memory:查看选中记忆原文": "Ver texto de la memoria seleccionada",
|
||||
"memory:查看原文": "Ver texto original",
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("diagnostic log", () => {
|
||||
expect(await readDiagnosticEvents(dataDir)).toEqual({
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,6 +497,52 @@ describe("diagnostic log", () => {
|
||||
expect(await readdir(outputDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts only non-JSON lines as damaged and leaves older records out", async () => {
|
||||
const dataDir = await root();
|
||||
const logger = new DiagnosticLogger({ dataDir, service: "core" });
|
||||
await logger.log({
|
||||
level: "info",
|
||||
component: "core.service",
|
||||
event: "core.ready",
|
||||
outcome: "succeeded",
|
||||
});
|
||||
const older = {
|
||||
schema_version: 0,
|
||||
service: "desktop",
|
||||
component: "desktop.lifecycle",
|
||||
event: "desktop.started",
|
||||
};
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-desktop.jsonl"),
|
||||
[
|
||||
JSON.stringify(older),
|
||||
JSON.stringify({ ...older, schema_version: 1 }),
|
||||
JSON.stringify(["not", "an", "event"]),
|
||||
"",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const result = await readDiagnosticEvents(dataDir);
|
||||
expect(result.events.map((event) => event.event)).toEqual(["core.ready"]);
|
||||
expect(result.invalid_lines).toBe(0);
|
||||
expect(result.unrecognized_lines).toBe(3);
|
||||
// An older build's records do not stop a support export either.
|
||||
const target = path.join(await root(), "bundle.json");
|
||||
await expect(
|
||||
exportDiagnosticBundle({ dataDir, target, productVersion: "1.2.3" }),
|
||||
).resolves.toEqual({ event_count: 1, invalid_lines: 0 });
|
||||
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-cli.jsonl"),
|
||||
'{"schema_version": 1, "event": \n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const damaged = await readDiagnosticEvents(dataDir);
|
||||
expect(damaged.invalid_lines).toBe(1);
|
||||
expect(damaged.unrecognized_lines).toBe(3);
|
||||
});
|
||||
|
||||
it("fails closed when the diagnostic directory cannot be read", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const dataDir = await root();
|
||||
|
||||
@@ -429,10 +429,18 @@ export async function readDiagnosticEvents(
|
||||
errorsOnly?: boolean;
|
||||
tail?: number;
|
||||
} = {},
|
||||
): Promise<{ events: DiagnosticEventV1[]; invalid_lines: number }> {
|
||||
): Promise<{
|
||||
events: DiagnosticEventV1[];
|
||||
invalid_lines: number;
|
||||
unrecognized_lines: number;
|
||||
}> {
|
||||
const files = await diagnosticLogFiles(dataDir);
|
||||
let events: DiagnosticEventV1[] = [];
|
||||
// Only a line that is not JSON at all is damage. A whole JSON line this
|
||||
// reader does not accept (an older build's record, another schema) is left
|
||||
// out and counted apart, so an upgrade does not read as a broken log.
|
||||
let invalidLines = 0;
|
||||
let unrecognizedLines = 0;
|
||||
for (const file of files) {
|
||||
const content = await readRegularFileNoFollow(
|
||||
file,
|
||||
@@ -440,11 +448,17 @@ export async function readDiagnosticEvents(
|
||||
);
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line) continue;
|
||||
let value: unknown;
|
||||
try {
|
||||
const item = parseDiagnosticEvent(JSON.parse(line));
|
||||
events.push(item);
|
||||
value = JSON.parse(line);
|
||||
} catch {
|
||||
invalidLines += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
events.push(parseDiagnosticEvent(value));
|
||||
} catch {
|
||||
unrecognizedLines += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,7 +494,11 @@ export async function readDiagnosticEvents(
|
||||
return !(filter.errorsOnly && item.level !== "error");
|
||||
});
|
||||
const tail = Math.min(Math.max(filter.tail ?? 200, 1), 5_000);
|
||||
return { events: events.slice(-tail), invalid_lines: invalidLines };
|
||||
return {
|
||||
events: events.slice(-tail),
|
||||
invalid_lines: invalidLines,
|
||||
unrecognized_lines: unrecognizedLines,
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportDiagnosticBundle(options: {
|
||||
|
||||
@@ -36,7 +36,7 @@ MEMMY_LICENSE = (
|
||||
MEMMY_PROVENANCE = MEMMY_LICENSE.with_name("PROVENANCE.md")
|
||||
MEMMY_PIN = "25acd5f29fc7df73b943713abe61f1b2f9839887"
|
||||
HY_MEMORY_NAME = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.8"
|
||||
HY_MEMORY_VERSION = "2.0.9"
|
||||
|
||||
|
||||
class SbomError(RuntimeError):
|
||||
|
||||
@@ -12,7 +12,7 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(readme, /`hy-memory==2\.0\.8`/);
|
||||
assert.match(readme, /`hy-memory==2\.0\.9`/);
|
||||
assert.match(readme, /`v1\.0\.5` 的发行目标是 Linux x64\/arm64 Runtime/);
|
||||
assert.match(readme, /Linux 制品由 GitHub Actions/);
|
||||
assert.match(readme, /macOS.*Developer ID 签名、公证/s);
|
||||
@@ -25,7 +25,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
assert.match(installation, /Linux x64.*GitHub Actions/s);
|
||||
assert.match(installation, /Linux arm64.*GitHub Actions/s);
|
||||
assert.match(installation, /macOS arm64.*Developer ID/s);
|
||||
assert.match(installation, /macOS x64.*Developer ID/s);
|
||||
// v1.0.5 ships no Intel build; the table has to say so rather than omit it.
|
||||
assert.match(installation, /\| macOS x64 \| 无 \|/);
|
||||
assert.match(installation, /release-manifest\.json/);
|
||||
assert.match(installation, /fake Release/);
|
||||
assert.match(installation, /--source/);
|
||||
|
||||
@@ -13,7 +13,7 @@ spec.loader.exec_module(module)
|
||||
|
||||
|
||||
def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) -> None:
|
||||
dist_info = tmp_path / "hy_memory-2.0.8.dist-info"
|
||||
dist_info = tmp_path / "hy_memory-2.0.9.dist-info"
|
||||
licenses = dist_info / "licenses"
|
||||
licenses.mkdir(parents=True)
|
||||
(dist_info / "METADATA").write_text(
|
||||
@@ -21,7 +21,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
(
|
||||
"Metadata-Version: 2.4",
|
||||
"Name: hy-memory",
|
||||
"Version: 2.0.8",
|
||||
"Version: 2.0.9",
|
||||
"License-Expression: Apache-2.0",
|
||||
"",
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def test_hy_memory_notice_comes_from_the_installed_dist_info(tmp_path: Path) ->
|
||||
notice = module.hy_memory_notice(dist_info)
|
||||
|
||||
assert notice["name"] == "hy-memory"
|
||||
assert notice["version"] == "2.0.8"
|
||||
assert notice["version"] == "2.0.9"
|
||||
assert notice["license"] == "Apache-2.0"
|
||||
assert notice["copyright"] == "Copyright (C) 2026 Tencent. All rights reserved."
|
||||
assert "Licensed under the Apache License" in notice["text"]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.8"
|
||||
version = "2.0.9"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""P0-1:占位符不得污染 L0 基础画像。
|
||||
|
||||
弱模型(qwen3.5-2b)在用户什么都没说时把 basic_info 填成 {"name": "用户"} /
|
||||
"The user" / "N/A",每个占位符都 supersede 掉真实值,演化链变成
|
||||
费天翔 → Fei → 用户 → The user。防御:(a) upsert 占位符守卫 + 读侧愈合;
|
||||
(c) prompt 硬约束。验收 2(assistant 引用 "name is 用户")由占位符守卫单独兜住。
|
||||
本文件覆盖改进建议第 10 节验收 1–4。
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.agent.extractor import EXTRACT_FEW_SHOT_EN
|
||||
from hy_memory.agent.prompts_zh import EXTRACT_FEW_SHOT_ZH
|
||||
from hy_memory.agent.tools.basic_profile import (
|
||||
_assemble_full_kv_from_nodes,
|
||||
is_placeholder_value,
|
||||
upsert_basic_profile,
|
||||
)
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
||||
|
||||
FIELDS = ["name", "age", "location", "occupation", "employer"]
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, nodes=None):
|
||||
self.nodes = list(nodes or [])
|
||||
|
||||
async def list_by_user(self, **kwargs):
|
||||
return [n for n in self.nodes if n.user_id == kwargs.get("user_id")]
|
||||
|
||||
async def get_by_id(self, node_id):
|
||||
return next((n for n in self.nodes if n.node_id == node_id), None)
|
||||
|
||||
async def upsert(self, node):
|
||||
self.nodes.append(node)
|
||||
|
||||
async def update_payload(self, node_id, updates):
|
||||
node = await self.get_by_id(node_id)
|
||||
for key, value in updates.items():
|
||||
if key == "status" and isinstance(value, str):
|
||||
value = MemoryStatus(value)
|
||||
setattr(node, key, value)
|
||||
|
||||
|
||||
class FakeEmbed:
|
||||
async def embed_queued(self, text):
|
||||
return [0.1] * 8
|
||||
|
||||
|
||||
def l0_versions(store):
|
||||
return [n for n in store.nodes if n.layer == MemoryLayer.L0_BASIC_INFO]
|
||||
|
||||
|
||||
def write_basic_info(store, llm_basic_info):
|
||||
"""模拟 writer 的 basic_info 段:LLM 给出的非空 basic_info 直接 upsert。"""
|
||||
if not llm_basic_info:
|
||||
return None
|
||||
return asyncio.run(upsert_basic_profile(
|
||||
user_id="u1", kv=llm_basic_info, vector_store=store,
|
||||
embed_service=FakeEmbed(), allowed_fields=FIELDS,
|
||||
))
|
||||
|
||||
|
||||
# ── 验收 1:寒暄不产生新的 L0 版本 ──
|
||||
|
||||
def test_small_talk_creates_no_l0_version():
|
||||
# [user]: 你好 / [assistant]: 我可以帮你。弱模型输出 {"name": "用户"}
|
||||
store = FakeStore()
|
||||
write_basic_info(store, {"name": "用户"})
|
||||
write_basic_info(store, {})
|
||||
assert l0_versions(store) == []
|
||||
|
||||
|
||||
def test_placeholder_only_upsert_writes_nothing():
|
||||
store = FakeStore()
|
||||
for kv in ({"name": "用户"}, {"name": "The user"}, {"name": "N/A"},
|
||||
{"location": "unknown", "occupation": "未提供"}, {"name": "name"}):
|
||||
res = write_basic_info(store, kv)
|
||||
assert res.success and not res.changed
|
||||
assert l0_versions(store) == []
|
||||
|
||||
|
||||
# ── 验收 2:assistant 引用 "name is 用户" 不写入(仅靠占位符守卫)──
|
||||
|
||||
def test_assistant_quoting_placeholder_does_not_write():
|
||||
# [assistant]: 那条 The user's name is 用户. 更说明问题
|
||||
store = FakeStore()
|
||||
for kv in ({"name": "用户"}, {"name": "The user"}, {"name": "The user."}):
|
||||
res = write_basic_info(store, kv)
|
||||
assert res.success and not res.changed
|
||||
assert l0_versions(store) == []
|
||||
|
||||
|
||||
# ── 验收 3:用户自报姓名 ──
|
||||
|
||||
def test_user_states_name():
|
||||
# [user]: 我叫费天翔,常住杭州,做 AI 基础设施。
|
||||
store = FakeStore()
|
||||
res = write_basic_info(store, {
|
||||
"name": "费天翔", "location": "杭州", "occupation": "AI 基础设施从业者",
|
||||
})
|
||||
assert res.success and res.created
|
||||
assert l0_versions(store)[0].custom["basic_info_kv"] == {
|
||||
"name": "费天翔", "location": "杭州", "occupation": "AI 基础设施从业者",
|
||||
}
|
||||
|
||||
|
||||
# ── 验收 4:已有真实值不被后续占位符覆盖 ──
|
||||
|
||||
def test_existing_name_not_overwritten_by_placeholder():
|
||||
store = FakeStore()
|
||||
write_basic_info(store, {"name": "费天翔"})
|
||||
assert len(l0_versions(store)) == 1
|
||||
for kv in ({"name": "用户"}, {"name": "The user."}, {"name": "N/A"}):
|
||||
res = write_basic_info(store, kv)
|
||||
assert not res.changed
|
||||
assert len(l0_versions(store)) == 1
|
||||
assert _assemble_full_kv_from_nodes(list(store.nodes)) == {"name": "费天翔"}
|
||||
|
||||
|
||||
def test_mixed_kv_keeps_real_fields_and_drops_placeholders():
|
||||
store = FakeStore()
|
||||
res = write_basic_info(store, {"name": "用户", "location": "杭州"})
|
||||
assert res.diff_kv == {"location": "杭州"}
|
||||
|
||||
|
||||
def test_already_poisoned_chain_heals_on_read():
|
||||
now = datetime.utcnow()
|
||||
|
||||
def node(nid, kv, days_ago):
|
||||
return MemoryNode(
|
||||
node_id=nid, user_id="u1", agent_id="",
|
||||
layer=MemoryLayer.L0_BASIC_INFO, content="profile",
|
||||
custom={"basic_info_kv": kv},
|
||||
gmt_created=now - timedelta(days=days_ago),
|
||||
)
|
||||
|
||||
chain = [
|
||||
node("a", {"name": "费天翔"}, 4), node("b", {"name": "Fei"}, 3),
|
||||
node("c", {"name": "用户"}, 2), node("d", {"name": "The user"}, 1),
|
||||
]
|
||||
assert _assemble_full_kv_from_nodes(chain) == {"name": "Fei"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"", " ", "用户", "用户。", "The user", "the user.", "THE USER", "user",
|
||||
"The user's", "n/a", "N/A", "na", "None", "null", "unknown", "未提供",
|
||||
"未知", "不详", "-", "?", "name", " Name ", "姓名",
|
||||
])
|
||||
def test_placeholder_values(value):
|
||||
assert is_placeholder_value("name", value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("name", "费天翔"), ("name", "Fei"), ("name", "Username42"),
|
||||
("age", 30), ("age", 0), ("location", "杭州"), ("employer", "Tencent"),
|
||||
])
|
||||
def test_real_values_are_not_placeholders(field, value):
|
||||
assert not is_placeholder_value(field, value)
|
||||
|
||||
|
||||
# ── (c) prompt 硬约束 ──
|
||||
|
||||
def test_prompts_carry_hard_rule_and_small_talk_counter_example():
|
||||
from hy_memory.agent import extractor, prompts_zh
|
||||
|
||||
en_rules = [v for v in vars(extractor).values()
|
||||
if isinstance(v, str) and "HARD RULE: fill a field ONLY" in v]
|
||||
zh_rules = [v for v in vars(prompts_zh).values()
|
||||
if isinstance(v, str) and "**硬性规则**" in v]
|
||||
assert len(en_rules) >= 2 and len(zh_rules) >= 2
|
||||
assert "small talk has no basic_info" in EXTRACT_FEW_SHOT_EN
|
||||
assert "寒暄没有 basic_info" in EXTRACT_FEW_SHOT_ZH
|
||||
Reference in new issue
Block a user