Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae19112cfd | ||
|
|
0aeb3e8760 | ||
|
|
70aaa7be85 | ||
|
|
18046152ab | ||
|
|
7b946a28ef | ||
|
|
e5ce0cd964 | ||
|
|
d8837b4fb2 | ||
|
|
0c36a6c960 | ||
|
|
9bdce08847 | ||
|
|
7cbc2fd632 | ||
|
|
6b0bd86679 | ||
|
|
91250d7c87 | ||
|
|
3c89a535fb | ||
|
|
1151878265 | ||
|
|
e82c4c939e | ||
|
|
ca4a66349b | ||
|
|
82044d6172 | ||
|
|
7f23b74c36 | ||
|
|
617e2d0ba6 | ||
|
|
083b837f32 | ||
|
|
1dc7e957c0 | ||
|
|
3b44d8381c | ||
|
|
790d6d3d5e | ||
|
|
4c311b2e93 | ||
|
|
801a2af36b | ||
|
|
0a8e43e570 | ||
|
|
f5a38c38b3 | ||
|
|
d08e070816 | ||
|
|
29bd9146a4 | ||
|
|
26c33ee40f | ||
|
|
d926844173 | ||
|
|
de4292ed4e | ||
|
|
366befc2aa | ||
|
|
a4c8c7e5fb | ||
|
|
b7d9f0e0f4 | ||
|
|
6d1d29e16d | ||
|
|
c0737acb44 | ||
|
|
0244ebe38c | ||
|
|
e3b5a1bc9c | ||
|
|
8aeb042bb7 | ||
|
|
635d2572c9 | ||
|
|
6113e2351e | ||
|
|
b5a8a78c69 | ||
|
|
fd12c8f1c0 | ||
|
|
e089d4b800 | ||
|
|
b370663870 | ||
|
|
8ea6996859 | ||
|
|
dc740604d2 | ||
|
|
55bf946bcc | ||
|
|
14fcc6fe38 | ||
|
|
1ef0766891 | ||
|
|
3370a715dd | ||
|
|
638d1b7bde | ||
|
|
d6890793ec | ||
|
|
32bf05f821 | ||
|
|
b6e6a3fb43 | ||
|
|
4e44f59026 | ||
|
|
35bd56ddd0 | ||
|
|
2cea39da4f |
No files matched your search
@@ -86,6 +86,7 @@ MEMORY_VECTOR_STORE=chroma
|
||||
# MEMORY_CODING_DB_PATH= # coding_memory.db 路径(默认 {DATA_DIR}/data/coding_memory.db)
|
||||
# MEMORY_CODING_TOOL_RESULT_MAX_BYTES=2048 # 单条 tool_result 截断字节上限
|
||||
# MEMORY_CODING_VERBATIM_FALLBACK_ENABLED=false # 模型无结果时是否保存原文 fact;默认关,避免 tool trace 外泄
|
||||
# MEMORY_CODING_CHAIN_BLOCK_ENABLED=false # search 是否注入版本演进链块(diff);默认关,省 token
|
||||
# MEMORY_EXTRACT_HISTORY_TURNS=5 # 除本次 write 外,额外注入 extractor 的历史 user-turn 数
|
||||
|
||||
# ── 写入流程开关 ──
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Hermes provider
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "plugins/hermes/**"
|
||||
- ".github/workflows/hermes-provider.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/hermes/**"
|
||||
- ".github/workflows/hermes-provider.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
provider:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: false
|
||||
- name: Download and verify native Hermes contract
|
||||
run: |
|
||||
uv run --no-project --python 3.12 python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
|
||||
url = "https://raw.githubusercontent.com/NousResearch/hermes-agent/818c13be1dc4fd28987e1e881a9408224afd4535/agent/memory_provider.py"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
data = response.read()
|
||||
expected = "a4e44a293013fff831ac136ab77c556541cd18817f53bd662e97090cd80bd5a6"
|
||||
if hashlib.sha256(data).hexdigest() != expected:
|
||||
raise SystemExit("Hermes contract SHA-256 mismatch")
|
||||
snapshot = Path(os.environ["RUNNER_TEMP"]) / "hermes-memory-provider.py"
|
||||
snapshot.write_bytes(data)
|
||||
with open(os.environ["GITHUB_ENV"], "a") as env:
|
||||
env.write(f"HERMES_MEMORY_PROVIDER_SNAPSHOT={snapshot}\n")
|
||||
PY
|
||||
- name: Test provider with native contract
|
||||
run: uv run --no-project --python 3.12 --with pytest python -m pytest plugins/hermes/tests -o addopts= -o log_cli=false -q
|
||||
@@ -122,19 +122,25 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
version=$(node -p 'require("./package.json").version')
|
||||
node scripts/verify-release-version.mjs \
|
||||
--channel stable \
|
||||
--version "$version" \
|
||||
--build-number "$(node -p 'require("./package.json").release.build_number')" \
|
||||
--tag "$GITHUB_REF_NAME"
|
||||
mkdir -p publish-assets
|
||||
for arch in x64 arm64; do
|
||||
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
|
||||
test -f "$archive"
|
||||
test -f "$archive.sha256"
|
||||
name="memhub-runtime-v${version}-linux-${arch}.tar.gz"
|
||||
archives=(release-assets/**/"$name")
|
||||
checksums=(release-assets/**/"$name.sha256")
|
||||
test "${#archives[@]}" -eq 1
|
||||
test "${#checksums[@]}" -eq 1
|
||||
cp -- "${archives[0]}" "publish-assets/$name"
|
||||
cp -- "${checksums[0]}" "publish-assets/$name.sha256"
|
||||
(
|
||||
cd release-assets
|
||||
sha256sum -c "$(basename "$archive.sha256")"
|
||||
cd publish-assets
|
||||
sha256sum -c "$name.sha256"
|
||||
)
|
||||
done
|
||||
- name: Upload Linux assets to a draft GitHub Release
|
||||
@@ -160,7 +166,7 @@ jobs:
|
||||
--title "$GITHUB_REF_NAME"
|
||||
fi
|
||||
gh release upload "$GITHUB_REF_NAME" \
|
||||
release-assets/memhub-runtime-v*-linux-*.tar.gz \
|
||||
release-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
|
||||
publish-assets/memhub-runtime-v*-linux-*.tar.gz \
|
||||
publish-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
@@ -26,6 +26,9 @@ package-lock.json
|
||||
ui_walkthrough_*/
|
||||
|
||||
# Internal release/open-source coordination must never enter the public tree.
|
||||
HANDSOFF.md
|
||||
新鲜度_todo.md
|
||||
memhub/docs/LOCAL_PRODUCT_APP_MIGRATION_TEST.md
|
||||
OPEN_SOURCE_TODO.md
|
||||
release_todo.md
|
||||
|
||||
|
||||
+110
-30
@@ -2,6 +2,105 @@
|
||||
|
||||
Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
tools, and enforces the same rolling retention after cutover.
|
||||
- Paused host capture writes with a live Desktop migration fence; Cursor
|
||||
History catch-up resumes from its existing checkpoint after cutover.
|
||||
- Removed MemHub's private `hy_memory_bridge` implementation. MemHub now calls
|
||||
public `HyMemoryClient` methods at runtime; prompt/reconcile/response
|
||||
monkey-patches and private VDB/Coding runtime access were deleted. Kuzu Graph
|
||||
projection/admin and stopped-writer App migration are the documented product
|
||||
exceptions.
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
- Added an extensible MemHub interface-localization layer with Simplified
|
||||
Chinese, English, and Spanish catalogs shared by Web and Electron-native
|
||||
surfaces. Interface language remains independent from memory content and
|
||||
model processing language.
|
||||
- Fixed governed-memory revisions and repeated evidence links failing when the
|
||||
visibility dirty-marker trigger encounters the same memory twice. Startup
|
||||
now replaces the affected legacy trigger without changing schema versions
|
||||
or governed and visibility data.
|
||||
- Kept post-write authority verification failures available for read-only
|
||||
reconciliation while preventing automatic or generic confirmed retries from
|
||||
invoking the model again. Automatic reconciliation skips those sticky rows
|
||||
before applying its batch limit, and linked durable jobs exclusively own
|
||||
retry scheduling for their memory tasks.
|
||||
- Removed MemHub's task-scoped HyPermission store wrappers and global
|
||||
model-content consent switch. The trusted embedded Hy Memory SDK now uses
|
||||
its native local stores directly.
|
||||
- Existing installations ignore the retired config flag, readmit previously
|
||||
parked pre-invocation work, and drop only the obsolete
|
||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||
profiles, SDK stores, and source/host grants are preserved.
|
||||
|
||||
## Hy Memory 2.0.5 — 2026-10-22
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.5`](https://pypi.org/project/hy-memory/2.0.5/).
|
||||
- Extended `get()` across Chat, Coding Experience, Working Fact, and raw IDs;
|
||||
the returned `layer`, `kind`, and `variant` identify the actual record.
|
||||
- Added explicit raw retrieval through the existing `search()` and
|
||||
`list_memories()` contracts, including user, agent, session, and source
|
||||
filters.
|
||||
- Added public embedding-service injection and corrected extraction language
|
||||
detection for structured OpenAI-style messages.
|
||||
- Added lifecycle status filters, durable idempotent write outcomes, and
|
||||
quiesced local data-plane snapshot/restore.
|
||||
- Kept legacy migration mechanics out of the public Client API; separate
|
||||
migration-only raw/import methods from development previews were removed.
|
||||
- Physical delete now reports partial VDB/Graph cleanup as failure.
|
||||
- Coding search no longer injects the version-evolution diff block by default;
|
||||
opt in with `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`.
|
||||
- Serialized SDK SQLite cache/history connection lifecycle and tightened
|
||||
Coding reconciliation to its agent/session/workspace authority.
|
||||
- Clarified persistent caller context through `meta`, while `metadata` remains
|
||||
legacy processing configuration.
|
||||
|
||||
## Hy Memory 2.0.5.dev4 — 2026-10-21
|
||||
|
||||
- Development preview used for SDK-authority migration validation; not the
|
||||
final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev4`](https://pypi.org/project/hy-memory/2.0.5.dev4/).
|
||||
|
||||
## Hy Memory 2.0.5.dev3 — 2026-10-21
|
||||
|
||||
- Development preview; not the final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev3`](https://pypi.org/project/hy-memory/2.0.5.dev3/).
|
||||
- Lets the existing `search` API explicitly query SDK raw with
|
||||
`layers=["l1_raw"]`, including existing source filters.
|
||||
|
||||
## Hy Memory 2.0.5.dev2 — 2026-10-21
|
||||
|
||||
- Development preview; not the final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev2`](https://pypi.org/project/hy-memory/2.0.5.dev2/).
|
||||
- Adds exact, idempotent semantic-memory import for legacy MemHub cutover
|
||||
without extraction, reconciliation or identity changes.
|
||||
|
||||
## Hy Memory 2.0.5.dev1 — 2026-10-21
|
||||
|
||||
- Development preview for the MemHub SDK-authority migration; this is not the
|
||||
final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev1`](https://pypi.org/project/hy-memory/2.0.5.dev1/).
|
||||
- Adds lifecycle status filtering, durable idempotent write outcomes, exact raw
|
||||
APIs, offline local data-plane snapshot/restore, and fail-closed physical
|
||||
delete receipts.
|
||||
|
||||
## Hy Memory 2.0.4 — 2026-10-17
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.4`](https://pypi.org/project/hy-memory/2.0.4/) as a
|
||||
maintenance release.
|
||||
|
||||
## Hy Memory 2.0.3 — 2026-10-17
|
||||
|
||||
- Added an optional runtime HTTP transport factory so embedded products can
|
||||
@@ -12,35 +111,6 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
unchanged memory extraction, reconciliation, storage, and Coding/Working
|
||||
behavior.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed governed-memory revisions and repeated evidence links failing when the
|
||||
visibility dirty-marker trigger encounters the same memory twice. Startup
|
||||
now replaces the affected legacy trigger without changing schema versions
|
||||
or governed and visibility data.
|
||||
- Serialized each SDK SQLite store's shared connection lifecycle so concurrent
|
||||
history/cache operations cannot race with `close()` and crash the process.
|
||||
- Scoped Coding reconciliation targets to the current HY authority,
|
||||
conversation, and workspace, preventing near-duplicate sessions from
|
||||
mutating an experience or working fact owned by another evidence context.
|
||||
- Kept post-write authority verification failures available for read-only
|
||||
reconciliation while preventing automatic or generic confirmed retries from
|
||||
invoking the model again. Automatic reconciliation skips those sticky rows
|
||||
before applying its batch limit, and linked durable jobs exclusively own
|
||||
retry scheduling for their memory tasks.
|
||||
- Clarified that `add(metadata=...)` carries legacy SDK processing options,
|
||||
while persistent caller business context belongs in `meta`.
|
||||
- Added an opt-in `get(..., include_raw=True)` path for reading `L1_RAW`
|
||||
memories, including memories written in lite mode. The default remains
|
||||
hidden for backward compatibility.
|
||||
- Removed MemHub's task-scoped HyPermission store wrappers and global
|
||||
model-content consent switch. The trusted embedded Hy Memory SDK now uses
|
||||
its native local stores directly.
|
||||
- Existing installations ignore the retired config flag, readmit previously
|
||||
parked pre-invocation work, and drop only the obsolete
|
||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||
profiles, SDK stores, and source/host grants are preserved.
|
||||
|
||||
## Hy Memory 2.0.2 — 2026-10-17
|
||||
|
||||
- Published
|
||||
@@ -55,7 +125,17 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
Because PyPI release metadata is immutable, the next SDK patch release must
|
||||
carry the updated license metadata; `2.0.1` retains its published metadata.
|
||||
|
||||
## MemHub 1.0.3 — Unreleased
|
||||
## MemHub 1.0.5 — Unreleased
|
||||
|
||||
- Updated the source version to `1.0.5` (build 14).
|
||||
- Pinned the public SDK to the stable `hy-memory==2.0.5` release.
|
||||
|
||||
## MemHub 1.0.4 — 2026-10-18
|
||||
|
||||
- Updated the source version to `1.0.4` (build 13).
|
||||
- Published the `v1.0.4` source and release metadata.
|
||||
|
||||
## MemHub 1.0.3 — 2026-10-17
|
||||
|
||||
- Updated the source version to `1.0.3` (build 7).
|
||||
- Pinned the public SDK to `hy-memory==2.0.3`.
|
||||
|
||||
@@ -65,13 +65,13 @@ Welcome to the **Hy Memory Research Family**, covering cognitive chat memory, lo
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.3 # core (includes ChromaDB and Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.3" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.3" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.3" # + MySQL cache
|
||||
pip install "hy-memory[rerank]==2.0.3" # + local multilingual cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.3" # everything
|
||||
pip install hy-memory==2.0.4 # core (includes ChromaDB and Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.4" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.4" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.4" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.4" # + MySQL cache
|
||||
pip install "hy-memory[rerank]==2.0.4" # + local multilingual cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.4" # everything
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
@@ -153,7 +153,7 @@ result = client.search(
|
||||
|
||||
result["facts"] # fact layer
|
||||
result["experiences"] # experience layer
|
||||
result["chain_block"] # coding only: version-evolution context
|
||||
result["chain_block"] # coding only: version-evolution context (empty unless MEMORY_CODING_CHAIN_BLOCK_ENABLED)
|
||||
```
|
||||
|
||||
### 5. Routing, Source Isolation & Advanced APIs
|
||||
@@ -253,6 +253,7 @@ That version is usually not in the working tree or at HEAD either, so it cannot
|
||||
- [Graph memory design](docs/pro_graph_design.md) — graph projection, rerank, relation paths, and deployment limits
|
||||
- [Environment template](.env.example) — a copy-ready configuration template
|
||||
- [MemHub README](memhub/README.md) — local-first desktop & CLI product overview, runtime commands, and privacy boundaries
|
||||
- [Hermes native memory provider](plugins/hermes/README.md) — explicit owner/agent scope; separate HTTP service; automatic capture and recall opt-in.
|
||||
- [OpenClaw plugin](plugins/openclaw/README.md) — agent integrations live under `plugins/` (overview: [plugins/README.md](plugins/README.md))
|
||||
|
||||
### License
|
||||
|
||||
+9
-8
@@ -65,13 +65,13 @@
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.3 # 核心(含 ChromaDB 与 Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.3" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.3" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.3" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.3" # + MySQL 缓存
|
||||
pip install "hy-memory[rerank]==2.0.3" # + 本地多语言 cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.3" # 全部
|
||||
pip install hy-memory==2.0.4 # 核心(含 ChromaDB 与 Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.4" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.4" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.4" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.4" # + MySQL 缓存
|
||||
pip install "hy-memory[rerank]==2.0.4" # + 本地多语言 cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.4" # 全部
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
@@ -153,7 +153,7 @@ result = client.search(
|
||||
|
||||
result["facts"] # 事实层
|
||||
result["experiences"] # 经验层
|
||||
result["chain_block"] # 仅 coding:版本演进上下文
|
||||
result["chain_block"] # 仅 coding:版本演进上下文(默认空;需 MEMORY_CODING_CHAIN_BLOCK_ENABLED)
|
||||
```
|
||||
|
||||
### 5. 自动路由、来源过滤与进阶 API
|
||||
@@ -253,6 +253,7 @@ Coding 与 Working **复用同一套双层 pipeline 和数据 schema**,区别
|
||||
- [Graph 记忆设计](docs/pro_graph_design.md) — 图投影、重排、关系路径与部署限制
|
||||
- [环境变量模板](.env.example) — 可直接复制的配置模板
|
||||
- [MemHub 说明文档](memhub/README.md) — 本地优先桌面端与 CLI 产品总览、Runtime 命令与隐私边界
|
||||
- [Hermes 原生记忆 Provider](plugins/hermes/README.md) — 显式用户与 Agent 隔离,独立 HTTP 服务,自动记录与召回默认关闭。
|
||||
- [OpenClaw 插件](plugins/openclaw/README.md) — Agent 集成见 `plugins/`(总览:[plugins/README.md](plugins/README.md))
|
||||
|
||||
### License
|
||||
|
||||
@@ -197,6 +197,7 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
||||
| `MEMORY_HISTORY_DB_PATH` | `{DATA_DIR}/data/history.db` | History SQLite 路径 |
|
||||
| `MEMORY_HISTORY_RECORD_SEARCHES` | `false` | 是否记录 Search 操作 |
|
||||
| `MEMORY_CODING_CHAIN_REPO_ROOT` | — | coding 版本链读取本地 Git diff 的仓库根目录 |
|
||||
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search(scene=coding) 是否注入版本演进链块(diff / `chain_block`);默认关 |
|
||||
|
||||
## HTTP Server
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ default 多-query 超过 `MEMORY_QUERY_REWRITE_MAX_CHARS` 时不改写:Coding
|
||||
Coding 关闭则直接使用最后一条 query。
|
||||
|
||||
路由到 `working` / `coding` 后,Search 读取独立生产力记忆库,返回 `facts` /
|
||||
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`。
|
||||
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`
|
||||
(默认空;需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 才注入 diff 块)。
|
||||
|
||||
## Chat 成功返回结构
|
||||
|
||||
@@ -152,7 +153,7 @@ client = HyMemoryClient.from_config({
|
||||
安装可选依赖:
|
||||
|
||||
```bash
|
||||
pip install 'hy-memory[rerank]==2.0.1'
|
||||
pip install 'hy-memory[rerank]==2.0.4'
|
||||
```
|
||||
|
||||
启用后,`HyMemoryClient(...)` 构造阶段会完成模型下载/读取、device 放置和一次 dummy
|
||||
|
||||
+2
-3
@@ -37,8 +37,7 @@ do not belong in the public source tree.
|
||||
- Root GitHub workflows run SDK and MemHub checks from their respective working
|
||||
directories. Native lifecycle workflows produce reviewable Actions artifacts;
|
||||
tag releases remain fail-closed on public identity, signing and notarization.
|
||||
- Hy Memory is published as `hy-memory==2.0.3`. MemHub pins that public
|
||||
distribution; its selected npm package is `memhub-cli`, which is not yet
|
||||
published.
|
||||
- Hy Memory is published as `hy-memory==2.0.4`. MemHub pins that public
|
||||
distribution; its selected npm package is `memhub-cli` 1.0.5.
|
||||
- Hy-Memory source uses Apache-2.0 at the repository root. Third-party
|
||||
components retain their own licenses and attribution notices.
|
||||
@@ -168,9 +168,11 @@ working/coding 搜索返回:
|
||||
|
||||
`limit` 分别限制 facts 与 experiences,因此两层最多各返回 `limit` 条。
|
||||
|
||||
`scene="coding"` 额外返回 `chain_block` 和 `chain_used`:
|
||||
`scene="coding"` 额外返回 `chain_block` 和 `chain_used` 字段(键始终存在;默认内容为空):
|
||||
|
||||
- `chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
|
||||
- 注入 diff / 版本演进块需显式开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`
|
||||
(或 `CodingConfig.chain_block_enabled=True`)。默认关闭,以节约 prompt token。
|
||||
- 开启后,`chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
|
||||
- `chain_used` 是已经进入 chain block 的 `facts` 下标。
|
||||
- SDK 不会从 `facts` 删除这些条目。调用方若要拼成“chain block + 剩余 facts”,需自行按 `chain_used` 过滤。
|
||||
|
||||
@@ -222,10 +224,12 @@ working/coding 搜索返回:
|
||||
| `MEMORY_CODING_DB_PATH` | `{DATA_DIR}/data/coding_memory.db` | working 基础 SQLite 路径;coding/trace 路径由此派生 |
|
||||
| `MEMORY_CODING_TOOL_RESULT_MAX_BYTES` | `2048` | 单条 tool result 的字节上限 |
|
||||
| `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` | `false` | 模型无结果时是否保存原文 fact;默认关闭,避免 assistant/tool trace 直接展示 |
|
||||
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search 是否注入版本演进链块(`chain_block` / diff);默认关闭(opt-in) |
|
||||
| `MEMORY_EXTRACT_HISTORY_TURNS` | `5` | 回读的历史 user-turn 数;当前 prompt 最终最多使用 5 |
|
||||
| `MEMORY_CODING_CHAIN_REPO_ROOT` | 空 | coding 版本链读取本地 Git diff 的仓库路径 |
|
||||
|
||||
`HCM_*` 变量属于版本链的高级调优项,不是稳定的公共配置契约;普通接入不应依赖其默认值。
|
||||
其中 `HCM_BLOCK_OFF=1` 可在已开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 时做消融(完全不出块)。
|
||||
|
||||
## 8. 定向验证
|
||||
|
||||
|
||||
+15
-13
@@ -5,14 +5,14 @@
|
||||
## 安装与快速开始
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.3
|
||||
pip install hy-memory==2.0.4
|
||||
|
||||
# 可选后端
|
||||
pip install 'hy-memory[qdrant]==2.0.1'
|
||||
pip install 'hy-memory[faiss]==2.0.1'
|
||||
pip install 'hy-memory[graph]==2.0.1' # Neo4j;Kuzu 已在核心依赖中
|
||||
pip install 'hy-memory[mysql]==2.0.1'
|
||||
pip install 'hy-memory[rerank]==2.0.1' # 本地多语言 cross-encoder rerank
|
||||
pip install 'hy-memory[qdrant]==2.0.4'
|
||||
pip install 'hy-memory[faiss]==2.0.4'
|
||||
pip install 'hy-memory[graph]==2.0.4' # Neo4j;Kuzu 已在核心依赖中
|
||||
pip install 'hy-memory[mysql]==2.0.4'
|
||||
pip install 'hy-memory[rerank]==2.0.4' # 本地多语言 cross-encoder rerank
|
||||
```
|
||||
|
||||
默认 LLM 和 Embedding 均使用 OpenAI-compatible 接口。使用 OpenAI 时可只设置:
|
||||
@@ -210,7 +210,8 @@ result = client.search(
|
||||
```
|
||||
|
||||
`working` / `coding` 路径返回 `scene="productivity"`、`facts`、`experiences`,不使用
|
||||
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used`。
|
||||
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used` 字段(默认空;
|
||||
需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` 才注入 diff 块)。
|
||||
|
||||
#### `search_mode`
|
||||
|
||||
@@ -272,8 +273,8 @@ catalog 也可以在没有基础 L0 时形成只读的合成画像。
|
||||
|
||||
```python
|
||||
memory = client.get("memory_id")
|
||||
# lite 模式写入的是 L1_RAW;按 ID 读取时必须显式选择返回原文。
|
||||
raw_memory = client.get("memory_id", include_raw=True)
|
||||
# raw ID 返回 L1_RAW,fact ID 返回对应事实层。
|
||||
raw_memory = client.get("raw_id")
|
||||
result = client.update("memory_id", "更新后的内容")
|
||||
result = client.delete("memory_id")
|
||||
result = client.delete_all(user_id="user_001")
|
||||
@@ -283,7 +284,7 @@ page = client.list_memories(
|
||||
limit=100,
|
||||
offset=0,
|
||||
order="desc",
|
||||
layers=None,
|
||||
layers=["l1_raw"], # 显式列出 raw;不传仍沿用默认语义层列表
|
||||
with_evolution=False,
|
||||
)
|
||||
|
||||
@@ -293,9 +294,10 @@ result = client.clone_user("user_001", "user_002", agent_id=None)
|
||||
对应异步方法为 `async_get()`、`async_update()`、`async_delete()`、
|
||||
`async_delete_all()`、`async_list_memories()`、`async_clone_user()`。
|
||||
|
||||
`get()` / `async_get()` 默认继续隐藏 `L1_RAW`,因此不会改变现有调用方的原文访问
|
||||
边界。只有显式传入 `include_raw=True` 才返回该层;这也是 lite 模式按 ID 读取
|
||||
写入内容及其 `meta` 的方式。MemHub 的 HTTP 读取接口不开放此参数。
|
||||
`get()` / `async_get()` 是统一的 ID 精确读取:返回结果的 `layer`、`kind` 和
|
||||
`variant` 表明实际类型。授权由调用 SDK 前的产品边界完成,不由 SDK 根据 layer
|
||||
猜测。`list_memories()` 默认仍不包含 `L1_RAW`;显式传
|
||||
`layers=["l1_raw"]` 时按同一列表合同返回 raw。
|
||||
|
||||
普通 `list_memories()` 返回 `vdb` 桶,并在 Graph 有结果时附带 `graph`;传入
|
||||
`workspace_id` 时返回生产力记忆的 `scene="productivity"` 和 `experiences`。
|
||||
|
||||
@@ -55,9 +55,22 @@ except Exception:
|
||||
from .client import HyMemoryClient
|
||||
from .inspector import MemoryInspector
|
||||
from .runtime import SharedRuntime
|
||||
from .snapshot import (
|
||||
DataPlaneRestore,
|
||||
DataPlaneSnapshot,
|
||||
DataPlaneSnapshotRejected,
|
||||
restore_local_data_plane,
|
||||
snapshot_local_data_plane,
|
||||
)
|
||||
|
||||
# ====== 配置 ======
|
||||
from .config import MemoryConfig
|
||||
from .embedding import (
|
||||
EmbeddingDimensionMismatchError,
|
||||
EmbeddingService,
|
||||
EmbeddingServiceProtocol,
|
||||
)
|
||||
from .retrieval import AmbiguousMemoryIdError, MemoryKind, MemoryVariant
|
||||
|
||||
# ====== Embedding 端点覆盖(per-call,不改 model/dims,只换接入端点)======
|
||||
from .core.embed_service import embed_endpoint_override
|
||||
@@ -111,8 +124,20 @@ __all__ = [
|
||||
"HyMemoryClient",
|
||||
"MemoryInspector",
|
||||
"SharedRuntime",
|
||||
"DataPlaneRestore",
|
||||
"DataPlaneSnapshot",
|
||||
"DataPlaneSnapshotRejected",
|
||||
"restore_local_data_plane",
|
||||
"snapshot_local_data_plane",
|
||||
# 配置
|
||||
"MemoryConfig",
|
||||
# 公共扩展协议 / 精确读取类型
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceProtocol",
|
||||
"EmbeddingDimensionMismatchError",
|
||||
"MemoryKind",
|
||||
"MemoryVariant",
|
||||
"AmbiguousMemoryIdError",
|
||||
# Embedding 端点覆盖
|
||||
"embed_endpoint_override",
|
||||
# 高级 API
|
||||
|
||||
@@ -22,7 +22,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ================================================================
|
||||
# 数据结构
|
||||
# ================================================================@dataclass
|
||||
# ================================================================
|
||||
@dataclass
|
||||
class SessionSummaryResult:
|
||||
"""Session 摘要结果 (V2)"""
|
||||
success: bool
|
||||
|
||||
@@ -39,6 +39,7 @@ import logging
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
|
||||
@@ -252,6 +253,17 @@ def _series_episode_id(
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, "hy-memory:series-episode:" + raw))
|
||||
|
||||
|
||||
def _series_memory_at(times: List[int]) -> Optional[datetime]:
|
||||
"""Earliest member timestamp as the same local wall clock it came from.
|
||||
|
||||
``_node_timestamp`` produces ``datetime.timestamp()`` values. Reading them
|
||||
back with ``utcfromtimestamp`` turns them into a naive *UTC* datetime, which
|
||||
the vector payload later re-interprets as local time and shifts by the
|
||||
machine's UTC offset.
|
||||
"""
|
||||
return datetime.fromtimestamp(min(times)) if times else None
|
||||
|
||||
|
||||
def _node_timestamp(node: Any) -> Optional[int]:
|
||||
value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None)
|
||||
if value is None:
|
||||
@@ -974,8 +986,6 @@ async def finalize_series_episode_containers(
|
||||
Stored text is only a title plus a structural intro. Exact L2 bodies remain in
|
||||
their canonical nodes and are referenced by ID from dated timeline segments.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType
|
||||
from ..pipelines._retrieval.session_episode import (
|
||||
add_parent_episode_membership,
|
||||
@@ -1061,7 +1071,7 @@ async def finalize_series_episode_containers(
|
||||
segment["memory_at"] for segment in plan["timeline"]
|
||||
if segment.get("memory_at") is not None
|
||||
]
|
||||
memory_at = datetime.utcfromtimestamp(min(times)) if times else None
|
||||
memory_at = _series_memory_at(times)
|
||||
content = f"{plan['title']}. {plan['intro']}".strip()
|
||||
series = MemoryNode(
|
||||
node_id=series_id, user_id=user_id,
|
||||
|
||||
+763
-154
File diff suppressed because it is too large.
Load diff
@@ -647,7 +647,9 @@ class HyCodeMem:
|
||||
top-k,但它们的 diff 对当前工作区不可能有用,因此挂链前必须做同仓过滤。
|
||||
"""
|
||||
facts = facts or []
|
||||
# 开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
|
||||
# 高级消融开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
|
||||
# 公共 opt-in 见 CodingConfig.chain_block_enabled /
|
||||
# MEMORY_CODING_CHAIN_BLOCK_ENABLED(默认关;客户端在调用 decorate 前拦截)。
|
||||
if os.environ.get("HCM_BLOCK_OFF") == "1":
|
||||
return "", []
|
||||
picked, used = [], []
|
||||
|
||||
@@ -416,6 +416,36 @@ class CodingMemoryStore:
|
||||
|
||||
self._initialized = False
|
||||
|
||||
@staticmethod
|
||||
def database_path(
|
||||
config: MemoryConfig,
|
||||
db_path: Optional[str] = None,
|
||||
variant: str = "working",
|
||||
) -> str:
|
||||
"""Return the authoritative SQLite path without creating anything."""
|
||||
if db_path is None:
|
||||
data_dir = (
|
||||
os.path.dirname(config.history.db_path)
|
||||
if config.history.db_path else "./data"
|
||||
)
|
||||
db_path = os.path.join(data_dir, "coding_memory.db")
|
||||
normalized = (
|
||||
"coding"
|
||||
if str(variant or "").strip().lower() == "coding"
|
||||
else "working"
|
||||
)
|
||||
return _variant_db_path(db_path, "_cv" if normalized == "coding" else "")
|
||||
|
||||
@classmethod
|
||||
def database_exists(
|
||||
cls,
|
||||
config: MemoryConfig,
|
||||
db_path: Optional[str] = None,
|
||||
variant: str = "working",
|
||||
) -> bool:
|
||||
"""Whether a coding namespace has an existing SQLite database."""
|
||||
return os.path.isfile(cls.database_path(config, db_path, variant))
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
@@ -50,6 +50,7 @@ class CodingWriter:
|
||||
embed_service: EmbedService,
|
||||
fact_reconciler: Optional[WorkingFactReconciler] = None,
|
||||
cache: Any = None,
|
||||
variant: str = "working",
|
||||
):
|
||||
self.store = store
|
||||
self.extractor = extractor
|
||||
@@ -59,6 +60,11 @@ class CodingWriter:
|
||||
self.fact_reconciler = fact_reconciler or WorkingFactReconciler(
|
||||
store, embed_service, llm_provider
|
||||
)
|
||||
self.variant = (
|
||||
"coding"
|
||||
if str(variant or "").strip().lower() == "coding"
|
||||
else "working"
|
||||
)
|
||||
# cache 仅用于把 request_id 串给各阶段(各阶段自己打点);writer 不集中打点。
|
||||
self.cache = cache
|
||||
|
||||
@@ -217,6 +223,7 @@ class CodingWriter:
|
||||
return {
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
"variant": self.variant,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
"ops": [op.to_dict() for op in ops],
|
||||
@@ -278,11 +285,11 @@ class CodingWriter:
|
||||
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_response(request_id: str, t0: float) -> Dict[str, Any]:
|
||||
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
"variant": self.variant,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
"ops": [],
|
||||
|
||||
@@ -1136,6 +1136,9 @@ class CodingConfig:
|
||||
# LLM 失败或没有抽出 fact 时,是否把对话原文存成 working fact。
|
||||
# 默认关闭,避免 assistant/tool 轨迹以 statement 形式直接暴露给消费方。
|
||||
verbatim_fallback_enabled: bool = None
|
||||
# search(scene="coding") 是否注入版本演进链块(diff / chain_block)。
|
||||
# 默认关闭(opt-in):中重度 coding 用户可省下大量 prompt token;写入侧仍可 observe。
|
||||
chain_block_enabled: bool = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.enable is None:
|
||||
@@ -1153,6 +1156,10 @@ class CodingConfig:
|
||||
self.verbatim_fallback_enabled = _get_env_bool(
|
||||
"MEMORY_CODING_VERBATIM_FALLBACK_ENABLED", False
|
||||
)
|
||||
if self.chain_block_enabled is None:
|
||||
self.chain_block_enabled = _get_env_bool(
|
||||
"MEMORY_CODING_CHAIN_BLOCK_ENABLED", False
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -114,6 +114,37 @@ class CacheBase(ABC):
|
||||
"""查询 pipeline 中间结果日志"""
|
||||
...
|
||||
|
||||
# ================================================================
|
||||
# Durable write outcomes
|
||||
# ================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def claim_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
operation: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Claim one idempotent write; return the existing row if already claimed."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def finish_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
status: str,
|
||||
result: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Persist the terminal/unknown result for a claimed write."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read one durable write outcome by exact request id."""
|
||||
...
|
||||
|
||||
# ================================================================
|
||||
# Summary Buffer(rolling summary 的未消费 turn 暂存)
|
||||
#
|
||||
|
||||
+130
-16
@@ -81,6 +81,21 @@ _CREATE_TABLES_SQL = [
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS write_outcomes (
|
||||
request_id VARCHAR(256) NOT NULL,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
agent_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
session_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
result_json LONGTEXT NOT NULL,
|
||||
created_at VARCHAR(64) NOT NULL,
|
||||
updated_at VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (request_id),
|
||||
KEY idx_write_outcomes_owner (user_id, agent_id, session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS system_metrics (
|
||||
minute_ts VARCHAR(32) NOT NULL,
|
||||
data LONGTEXT NOT NULL,
|
||||
@@ -459,29 +474,128 @@ class MysqlCache(CacheBase):
|
||||
logger.warning(f"get_pipeline_logs failed: {e}")
|
||||
return []
|
||||
|
||||
async def claim_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
operation: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
now = datetime.now().isoformat()
|
||||
inserted = await self._execute(
|
||||
"""
|
||||
INSERT IGNORE INTO write_outcomes (
|
||||
request_id, user_id, agent_id, session_id, operation,
|
||||
status, result_json, created_at, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, 'running', '{}', %s, %s)
|
||||
""",
|
||||
(request_id, user_id, agent_id, session_id, operation, now, now),
|
||||
)
|
||||
if inserted == 1:
|
||||
return None
|
||||
return await self.get_write_outcome(request_id)
|
||||
|
||||
async def finish_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
status: str,
|
||||
result: Dict[str, Any],
|
||||
) -> bool:
|
||||
updated = await self._execute(
|
||||
"""
|
||||
UPDATE write_outcomes
|
||||
SET status = %s, result_json = %s, updated_at = %s
|
||||
WHERE request_id = %s
|
||||
""",
|
||||
(
|
||||
status,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
datetime.now().isoformat(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
return updated == 1
|
||||
|
||||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
row = await self._fetchone(
|
||||
"SELECT * FROM write_outcomes WHERE request_id = %s",
|
||||
(request_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
row["result"] = json.loads(row.pop("result_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
row["result"] = {}
|
||||
return row
|
||||
|
||||
# ================================================================
|
||||
# System Metrics(分钟粒度落盘)
|
||||
# ================================================================
|
||||
|
||||
async def store_metrics_minute(self, minute_ts: str, data: dict) -> None:
|
||||
import aiomysql
|
||||
|
||||
try:
|
||||
now = datetime.now().isoformat()
|
||||
# 尝试 UPSERT
|
||||
existing = await self._fetchone(
|
||||
"SELECT data FROM system_metrics WHERE minute_ts = %s", (minute_ts,)
|
||||
)
|
||||
if existing:
|
||||
old_data = json.loads(existing["data"])
|
||||
merged = self._merge_metric_buckets(old_data, data)
|
||||
await self._execute(
|
||||
"UPDATE system_metrics SET data = %s, created_at = %s WHERE minute_ts = %s",
|
||||
(json.dumps(merged, default=str), now, minute_ts),
|
||||
)
|
||||
else:
|
||||
await self._execute(
|
||||
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (%s, %s, %s)",
|
||||
(minute_ts, json.dumps(data, default=str), now),
|
||||
)
|
||||
# Read-merge-write must be one transaction: two instances flushing
|
||||
# the same minute used to overwrite each other's counters, and a
|
||||
# concurrent insert could lose the whole batch to a duplicate key.
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.begin()
|
||||
try:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT data FROM system_metrics "
|
||||
"WHERE minute_ts = %s FOR UPDATE",
|
||||
(minute_ts,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is not None:
|
||||
old_raw = (
|
||||
row["data"]
|
||||
if isinstance(row, dict)
|
||||
else row[0]
|
||||
)
|
||||
merged = self._merge_metric_buckets(
|
||||
json.loads(old_raw), data,
|
||||
)
|
||||
await cur.execute(
|
||||
"UPDATE system_metrics SET data = %s, "
|
||||
"created_at = %s WHERE minute_ts = %s",
|
||||
(
|
||||
json.dumps(merged, default=str),
|
||||
now,
|
||||
minute_ts,
|
||||
),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"INSERT INTO system_metrics "
|
||||
"(minute_ts, data, created_at) "
|
||||
"VALUES (%s, %s, %s)",
|
||||
(
|
||||
minute_ts,
|
||||
json.dumps(data, default=str),
|
||||
now,
|
||||
),
|
||||
)
|
||||
await conn.commit()
|
||||
return
|
||||
except Exception:
|
||||
await conn.rollback()
|
||||
raise
|
||||
except (aiomysql.IntegrityError, aiomysql.OperationalError) as error:
|
||||
code = error.args[0] if error.args else None
|
||||
deadlock = code in (1205, 1213)
|
||||
if attempt or (isinstance(error, aiomysql.OperationalError) and not deadlock):
|
||||
raise
|
||||
# A competing writer inserted the row, or InnoDB chose us
|
||||
# as the deadlock victim. Retry so this batch merges.
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"store_metrics_minute failed: {e}")
|
||||
|
||||
|
||||
@@ -87,6 +87,20 @@ CREATE TABLE IF NOT EXISTS pipeline_logs_v2 (
|
||||
CREATE INDEX IF NOT EXISTS idx_plog_request ON pipeline_logs_v2(request_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plog_user ON pipeline_logs_v2(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS write_outcomes (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL DEFAULT '',
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_write_outcomes_owner
|
||||
ON write_outcomes(user_id, agent_id, session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_metrics (
|
||||
minute_ts TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
@@ -414,6 +428,88 @@ class SqliteCache(CacheBase):
|
||||
logger.warning(f"get_pipeline_logs failed: {e}")
|
||||
return []
|
||||
|
||||
# ================================================================
|
||||
# Durable write outcomes
|
||||
# ================================================================
|
||||
|
||||
async def claim_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
operation: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def _claim():
|
||||
with self._lock:
|
||||
now = datetime.now().isoformat()
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO write_outcomes (
|
||||
request_id, user_id, agent_id, session_id, operation,
|
||||
status, result_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'running', '{}', ?, ?)
|
||||
""",
|
||||
(request_id, user_id, agent_id, session_id, operation, now, now),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cursor.rowcount == 1:
|
||||
return None
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM write_outcomes WHERE request_id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
existing = await _run_in_sqlite_pool(_claim)
|
||||
if existing is None:
|
||||
return None
|
||||
return await self.get_write_outcome(request_id)
|
||||
|
||||
async def finish_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
status: str,
|
||||
result: Dict[str, Any],
|
||||
) -> bool:
|
||||
def _finish():
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
UPDATE write_outcomes
|
||||
SET status = ?, result_json = ?, updated_at = ?
|
||||
WHERE request_id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
datetime.now().isoformat(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.rowcount == 1
|
||||
|
||||
return await _run_in_sqlite_pool(_finish)
|
||||
|
||||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _get():
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM write_outcomes WHERE request_id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
row = await _run_in_sqlite_pool(_get)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
row["result"] = json.loads(row.pop("result_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
row["result"] = {}
|
||||
return row
|
||||
|
||||
# ================================================================
|
||||
# System Metrics(分钟粒度落盘)
|
||||
# ================================================================
|
||||
|
||||
@@ -66,6 +66,11 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape LIKE wildcards so a user id is matched literally."""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
class HistoryStore:
|
||||
"""
|
||||
SQLite 历史存储 - 记录所有内存操作的审计日志。
|
||||
@@ -261,6 +266,34 @@ class HistoryStore:
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
async def get_recent_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取某 user 下所有 agent 的最近操作。
|
||||
|
||||
isolation_key 形如 ``"{user_id}:{agent_id}"``。user_id 里的 LIKE
|
||||
通配符会被转义,保证只匹配这个 user,不会漏到 ``u1`` 之外。
|
||||
"""
|
||||
prefix = _escape_like(f"{user_id}:") + "%"
|
||||
return await _run_in_sqlite_pool(
|
||||
self._get_recent_prefix_sync, prefix, limit,
|
||||
)
|
||||
|
||||
def _get_recent_prefix_sync(
|
||||
self, pattern: str, limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
with self._conn_lock:
|
||||
if self._conn is None:
|
||||
return []
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM memory_history WHERE isolation_key LIKE ? ESCAPE '\\' "
|
||||
"ORDER BY created_at DESC LIMIT ?",
|
||||
(pattern, limit),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
async def count(self, isolation_key: str = "") -> int:
|
||||
"""
|
||||
统计历史记录数量。
|
||||
|
||||
@@ -462,14 +462,39 @@ class FaissVectorStore(VectorStoreBase):
|
||||
return None
|
||||
|
||||
async def get_embeddings(self, node_ids: List[str]) -> Dict[str, List[float]]:
|
||||
"""批量取向量(in-memory _data 直接读 embedding),供去重算 cosine。"""
|
||||
out: Dict[str, List[float]] = {}
|
||||
for nid in node_ids:
|
||||
point_id = self._node_id_to_point_id(nid)
|
||||
entry = self._data.get(point_id)
|
||||
if entry and entry.get("embedding"):
|
||||
out[nid] = list(entry["embedding"])
|
||||
return out
|
||||
"""批量取向量,供去重 / episode 摘要计算 cosine。
|
||||
|
||||
``_data`` 只保存 payload 与 FAISS 内部下标;普通 upsert 不额外存一份
|
||||
embedding,向量本体在 FAISS index 里。这里按当前有效下标 reconstruct,
|
||||
并用 ``_idx_to_id`` 校验,避免读到已被替换的旧下标。
|
||||
"""
|
||||
|
||||
def _get() -> Dict[str, List[float]]:
|
||||
out: Dict[str, List[float]] = {}
|
||||
with self._lock:
|
||||
for nid in node_ids:
|
||||
point_id = self._node_id_to_point_id(nid)
|
||||
entry = self._data.get(point_id)
|
||||
if entry is None:
|
||||
continue
|
||||
embedding = entry.get("embedding")
|
||||
if embedding:
|
||||
out[nid] = list(embedding)
|
||||
continue
|
||||
idx = entry.get("idx")
|
||||
if (
|
||||
idx is None
|
||||
or self._index is None
|
||||
or self._idx_to_id.get(int(idx)) != point_id
|
||||
):
|
||||
continue
|
||||
try:
|
||||
out[nid] = self._index.reconstruct(int(idx)).tolist()
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
return await _run_in_vdb_pool(_get)
|
||||
|
||||
# ================================================================
|
||||
# 删除
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Public structural contract for injected embedding services."""
|
||||
|
||||
from typing import Any, Dict, List, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingService(Protocol):
|
||||
"""Embedding service accepted by :class:`HyMemoryClient`.
|
||||
|
||||
Implementations are borrowed by the client: their lifecycle remains owned
|
||||
by the caller and ``HyMemoryClient.close()`` will not close them.
|
||||
"""
|
||||
|
||||
async def embed(self, text: str, use_cache: bool = True) -> List[float]:
|
||||
...
|
||||
|
||||
async def embed_with_meta(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> Tuple[List[float], Dict[str, Any]]:
|
||||
...
|
||||
|
||||
async def embed_batch(
|
||||
self, texts: List[str], use_cache: bool = True
|
||||
) -> List[List[float]]:
|
||||
...
|
||||
|
||||
async def embed_queued(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> List[float]:
|
||||
...
|
||||
|
||||
async def embed_queued_with_meta(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> Tuple[List[float], Dict[str, Any]]:
|
||||
...
|
||||
|
||||
def get_embedding_dims(self) -> int:
|
||||
...
|
||||
|
||||
|
||||
# Explicit alias for callers that prefer the conventional Protocol suffix.
|
||||
EmbeddingServiceProtocol = EmbeddingService
|
||||
|
||||
|
||||
class EmbeddingDimensionMismatchError(ValueError):
|
||||
"""Raised before initialization when service and store dimensions differ."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceProtocol",
|
||||
"EmbeddingDimensionMismatchError",
|
||||
]
|
||||
@@ -19,6 +19,7 @@ import logging
|
||||
from .base import WritePipeline, ReadPipeline
|
||||
from ..config import MemoryConfig
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..embedding import EmbeddingService
|
||||
from ..data.vector_store import create_vector_store
|
||||
from ..data.vector_store_base import VectorStoreBase
|
||||
|
||||
@@ -50,7 +51,12 @@ class ComponentFactory:
|
||||
s2 = await factory.get_system2_writer() # System2Writer (ultra mode)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[MemoryConfig] = None):
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[MemoryConfig] = None,
|
||||
*,
|
||||
embedding_service: Optional[EmbeddingService] = None,
|
||||
):
|
||||
self.config = config or MemoryConfig.from_env()
|
||||
|
||||
# 实例缓存(单例)
|
||||
@@ -62,7 +68,7 @@ class ComponentFactory:
|
||||
self._readers: Dict[str, ReadPipeline] = {}
|
||||
|
||||
# 共享资源
|
||||
self._shared_embed_service: Optional[EmbedService] = None
|
||||
self._shared_embed_service: Optional[EmbeddingService] = embedding_service
|
||||
self._shared_vector_store: Optional[VectorStoreBase] = None
|
||||
self._shared_vector_store_initialized: bool = False
|
||||
self._shared_graph_store = None
|
||||
@@ -72,7 +78,7 @@ class ComponentFactory:
|
||||
# 共享资源
|
||||
# ================================================================
|
||||
|
||||
def _get_shared_embed_service(self) -> EmbedService:
|
||||
def _get_shared_embed_service(self) -> EmbeddingService:
|
||||
if self._shared_embed_service is None:
|
||||
self._shared_embed_service = EmbedService(self.config)
|
||||
return self._shared_embed_service
|
||||
|
||||
@@ -968,7 +968,7 @@ class MemoryWriter(WritePipeline):
|
||||
async def _run():
|
||||
try:
|
||||
nid = await summarize_closed_episode(
|
||||
vector_store=self.vector_store,
|
||||
vector_store=await self._get_vector_store(),
|
||||
embed_service=self.embed_service,
|
||||
llm_provider=self.llm_provider,
|
||||
episode_id=episode_id,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Public types and errors for exact memory-ID lookup."""
|
||||
|
||||
from typing import Literal, Sequence
|
||||
|
||||
|
||||
MemoryKind = Literal["chat", "experience", "fact"]
|
||||
MemoryVariant = Literal["working", "coding"]
|
||||
|
||||
|
||||
class AmbiguousMemoryIdError(LookupError):
|
||||
"""An ID exists in more than one public memory namespace."""
|
||||
|
||||
def __init__(self, memory_id: str, namespaces: Sequence[str]):
|
||||
self.memory_id = memory_id
|
||||
self.namespaces = tuple(namespaces)
|
||||
joined = ", ".join(self.namespaces)
|
||||
super().__init__(
|
||||
f"memory ID {memory_id!r} is ambiguous across namespaces: {joined}; "
|
||||
"pass kind and/or variant to disambiguate"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MemoryKind", "MemoryVariant", "AmbiguousMemoryIdError"]
|
||||
+19
-6
@@ -29,7 +29,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -495,6 +495,16 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
_json_response(self, 200, result)
|
||||
|
||||
|
||||
def _request_shutdown(server: ThreadingHTTPServer) -> None:
|
||||
"""Ask ``serve_forever`` to stop without blocking the calling thread.
|
||||
|
||||
``BaseServer.shutdown()`` waits for the ``serve_forever`` loop to exit. A
|
||||
signal handler runs on the very thread executing that loop, so calling it
|
||||
there deadlocks forever. Hand the call to a helper thread instead.
|
||||
"""
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
|
||||
def run_server(port: int = 19527, host: str = "127.0.0.1"):
|
||||
"""Start the HTTP server (blocking)."""
|
||||
# Configure logging — basicConfig for root; hy_memory level set after client init
|
||||
@@ -523,13 +533,12 @@ def run_server(port: int = 19527, host: str = "127.0.0.1"):
|
||||
logger.info(f"[server] Listening on http://{host}:{port}")
|
||||
logger.info(f"[server] Health check: http://{host}:{port}/healthz")
|
||||
|
||||
# Graceful shutdown
|
||||
# Graceful shutdown. The signal handler only asks a helper thread to stop
|
||||
# the loop; cleanup runs after serve_forever() returns so it cannot race
|
||||
# the request threads or deadlock the serving thread.
|
||||
def _shutdown(signum, frame):
|
||||
logger.info("[server] Shutting down...")
|
||||
server.shutdown()
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
sys.exit(0)
|
||||
_request_shutdown(server)
|
||||
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
@@ -538,6 +547,10 @@ def run_server(port: int = 19527, host: str = "127.0.0.1"):
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
_shutdown(None, None)
|
||||
finally:
|
||||
server.server_close()
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Public, offline snapshot support for the SDK-owned local data plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DataPlaneSnapshotRejected(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataPlaneSnapshot:
|
||||
source_root: Path
|
||||
snapshot_root: Path
|
||||
files: tuple[dict[str, object], ...]
|
||||
total_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataPlaneRestore:
|
||||
restored_root: Path
|
||||
previous_root: Path | None
|
||||
files_restored: int
|
||||
total_bytes: int
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def snapshot_local_data_plane(
|
||||
*,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
writers_stopped: bool,
|
||||
) -> DataPlaneSnapshot:
|
||||
"""Copy one quiesced local SDK root and return a verified inventory.
|
||||
|
||||
Remote providers require their own export implementation and must not call
|
||||
this local-path contract.
|
||||
"""
|
||||
if not writers_stopped:
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_writers_running")
|
||||
source = source_root.resolve()
|
||||
target = target_root.resolve()
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_source_invalid")
|
||||
if target.exists():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_target_exists")
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True)
|
||||
for root, directories, files in os.walk(source, followlinks=False):
|
||||
root_path = Path(root)
|
||||
relative = root_path.relative_to(source)
|
||||
target_directory = target / relative
|
||||
target_directory.mkdir(parents=True, exist_ok=True)
|
||||
for name in directories:
|
||||
if (root_path / name).is_symlink():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_symlink")
|
||||
for name in files:
|
||||
item = root_path / name
|
||||
if item.is_symlink():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_symlink")
|
||||
shutil.copy2(item, target_directory / name)
|
||||
|
||||
inventory: list[dict[str, object]] = []
|
||||
for path in sorted(item for item in target.rglob("*") if item.is_file()):
|
||||
inventory.append(
|
||||
{
|
||||
"path": path.relative_to(target).as_posix(),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
for item in inventory:
|
||||
copied = target / str(item["path"])
|
||||
if copied.stat().st_size != item["bytes"] or _sha256(copied) != item["sha256"]:
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_verify_failed")
|
||||
except DataPlaneSnapshotRejected:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise
|
||||
except OSError as error:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_failed") from error
|
||||
|
||||
return DataPlaneSnapshot(
|
||||
source_root=source,
|
||||
snapshot_root=target,
|
||||
files=tuple(inventory),
|
||||
total_bytes=sum(int(item["bytes"]) for item in inventory),
|
||||
)
|
||||
|
||||
|
||||
def restore_local_data_plane(
|
||||
*,
|
||||
snapshot_root: Path,
|
||||
target_root: Path,
|
||||
writers_stopped: bool,
|
||||
protective_root: Path | None = None,
|
||||
) -> DataPlaneRestore:
|
||||
"""Replace one local SDK root from a verified snapshot with rollback."""
|
||||
if not writers_stopped:
|
||||
raise DataPlaneSnapshotRejected("data_plane_restore_writers_running")
|
||||
source = snapshot_root.resolve()
|
||||
target = target_root.resolve()
|
||||
protective = (
|
||||
protective_root.resolve()
|
||||
if protective_root is not None
|
||||
else target.parent / f".{target.name}.pre-restore"
|
||||
)
|
||||
staging = target.parent / f".{target.name}.restore.tmp"
|
||||
if target.is_symlink() or protective.exists() or staging.exists():
|
||||
raise DataPlaneSnapshotRejected("data_plane_restore_target_unsafe")
|
||||
|
||||
copied = snapshot_local_data_plane(
|
||||
source_root=source,
|
||||
target_root=staging,
|
||||
writers_stopped=True,
|
||||
)
|
||||
previous: Path | None = None
|
||||
try:
|
||||
if target.exists():
|
||||
target.replace(protective)
|
||||
previous = protective
|
||||
staging.replace(target)
|
||||
except OSError as error:
|
||||
if previous is not None and previous.exists() and not target.exists():
|
||||
previous.replace(target)
|
||||
previous = None
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise DataPlaneSnapshotRejected("data_plane_restore_replace_failed") from error
|
||||
|
||||
return DataPlaneRestore(
|
||||
restored_root=target,
|
||||
previous_root=previous,
|
||||
files_restored=len(copied.files),
|
||||
total_bytes=copied.total_bytes,
|
||||
)
|
||||
@@ -8,6 +8,7 @@ Uses a two-tier strategy:
|
||||
This avoids langdetect's known weakness with short Chinese text.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Optional
|
||||
@@ -135,6 +136,30 @@ def detect_language(text: str) -> str:
|
||||
return "zh" if ratio > 0.1 else "en"
|
||||
|
||||
|
||||
def _spoken_text(value: str) -> str:
|
||||
"""Ignore text-block metadata when a message content is structured JSON."""
|
||||
text = value.strip()
|
||||
if text.startswith("["):
|
||||
try:
|
||||
blocks = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(blocks, list)
|
||||
and blocks
|
||||
and all(
|
||||
isinstance(block, dict)
|
||||
and isinstance(block.get("type"), str)
|
||||
and block["type"] in {"text", "input_text", "output_text"}
|
||||
and isinstance(block.get("text"), str)
|
||||
for block in blocks
|
||||
)
|
||||
):
|
||||
return "\n".join(block["text"] for block in blocks)
|
||||
return text
|
||||
|
||||
|
||||
def extract_content_for_detection(
|
||||
content: str = "",
|
||||
messages: Optional[List] = None,
|
||||
@@ -153,20 +178,57 @@ def extract_content_for_detection(
|
||||
Concatenated content text suitable for language detection.
|
||||
"""
|
||||
if content and content.strip():
|
||||
return content.strip()
|
||||
value = content.strip()
|
||||
# Writer serializes message batches to JSON before calling Extractor.
|
||||
# Detect the spoken text, not the leading ASCII keys (role/content),
|
||||
# without changing the raw evidence sent to the model.
|
||||
if value.startswith("["):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(decoded, list)
|
||||
and decoded
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("role"), str)
|
||||
and isinstance(item.get("content"), str)
|
||||
for item in decoded
|
||||
)
|
||||
):
|
||||
# In a mixed batch, the latest user turn is the clearest
|
||||
# instruction for the language; assistant/tool metadata
|
||||
# must not decide it first.
|
||||
user_text = [
|
||||
_spoken_text(item["content"])
|
||||
for item in decoded
|
||||
if item["role"] == "user" and item["content"].strip()
|
||||
]
|
||||
if user_text:
|
||||
return user_text[-1]
|
||||
spoken = "\n".join(_spoken_text(item["content"]) for item in decoded)
|
||||
if spoken.strip():
|
||||
return spoken
|
||||
return _spoken_text(value)
|
||||
|
||||
if messages:
|
||||
parts = []
|
||||
user_parts = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
c = msg.get("content", "")
|
||||
c, role = msg.get("content", ""), msg.get("role")
|
||||
elif hasattr(msg, "content"):
|
||||
c = msg.content
|
||||
c, role = msg.content, getattr(msg, "role", None)
|
||||
else:
|
||||
continue
|
||||
if c and c.strip():
|
||||
parts.append(c.strip())
|
||||
return "\n".join(parts)
|
||||
if isinstance(c, str) and c.strip():
|
||||
text = _spoken_text(c)
|
||||
parts.append(text)
|
||||
if role == "user":
|
||||
user_parts.append(text)
|
||||
return user_parts[-1] if user_parts else "\n".join(parts)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
感谢你参与 One Memory。
|
||||
感谢你参与 MemHub。
|
||||
|
||||
## 开发环境
|
||||
|
||||
@@ -27,3 +27,10 @@ pnpm check
|
||||
|
||||
涉及外部源码时,必须记录项目、固定版本或 commit、许可证、采用文件和修改说明。不得提交
|
||||
密钥、内部地址、真实对话、个人路径、模型权重或签名证书。
|
||||
|
||||
## 界面翻译
|
||||
|
||||
新增或修改界面语言时遵循
|
||||
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md):使用稳定语义 key,保持所有
|
||||
locale 的 key 与占位符一致,不翻译用户内容、路径、标识符或外部错误正文。语言
|
||||
catalog 与无关产品行为应拆成不同 PR。
|
||||
+90
-182
@@ -1,97 +1,71 @@
|
||||
# Hy Memory 使用与配置
|
||||
|
||||
更新:2026-10-12
|
||||
更新:2026-10-21
|
||||
|
||||
本文说明 MemHub 当前使用的 Hy Memory SDK、配置边界,以及直接使用 SDK 时如何
|
||||
write、search,并把搜索结果格式化为可交给模型的 memory text chunk。
|
||||
架构边界以 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 为准。
|
||||
|
||||
仓库内仍以 One Memory 命名的 Evidence、权限和 Governance 层属于 MemHub 的历史兼容
|
||||
模块;产品代码不应绕过这些层直接调用 SDK。本文中的
|
||||
原生 SDK 示例主要用于开发、调试和独立验证。
|
||||
---
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. Memory 读写与存储 backend **只经 SDK**(config / env)。
|
||||
2. MemHub 运行时对 memory 的原子操作 **只经 SDK 公共 Client**;缺正常业务
|
||||
接口先补 SDK。Runtime writers 停止后的 App 启动迁移可直接操作底层存储,
|
||||
但不得把迁移专用方法加入公共 Client。
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
4. SDK raw 只保留滚动三天;一条 raw 对应一个完整 turn,只含真人 user 与
|
||||
assistant,tool 不持久化。提取后的高层 memory 不受该窗口影响。
|
||||
|
||||
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
|
||||
模型配置、密钥和 harness/model 门禁,不维护第二套 memory 正文权威。
|
||||
|
||||
来源授权、模型授权和宿主召回授权仍是 MemHub 管理面门禁;memory
|
||||
数据平面的权威在 SDK。
|
||||
|
||||
## 当前版本状态
|
||||
|
||||
当前源码固定使用:
|
||||
当前源码固定使用(以仓库 pin / lockfile 为准):
|
||||
|
||||
```text
|
||||
distribution: hy-memory
|
||||
version: 2.0.3
|
||||
version: 见 pyproject / lock(文档数字可能滞后)
|
||||
mode: 按运行时配置选择
|
||||
reader: hybrid
|
||||
```
|
||||
|
||||
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.3`。
|
||||
MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、
|
||||
Governance 和撤权边界。
|
||||
|
||||
## MemHub 运行时边界
|
||||
|
||||
MemHub 对 SDK 使用以下公开、可复现的本地策略:
|
||||
MemHub 通过公开 `HyMemoryClient` 配置使用可复现的本地策略:
|
||||
|
||||
- vector store 使用受管数据目录中的 Chroma;
|
||||
- graph store 使用受管数据目录中的 Kuzu;
|
||||
- cache 与 history 使用 SQLite;
|
||||
- reader 使用 `hybrid`;
|
||||
- reranker 默认关闭,不引入 Torch、Transformers 或额外模型;
|
||||
- 模型与远程 embedding 参数来自版本化 Model Profile;
|
||||
- API Key 进入系统安全存储,不写入仓库、安装包、日志或 HY metadata;
|
||||
- 服务端数据库、租户队列及宿主 shell 中的 ambient 凭据不会进入 SDK config。
|
||||
- vector / graph / cache / history / coding 等 **一律 SDK config/env**;
|
||||
- 模型与 embedding 来自版本化 Model Profile;密钥进安全存储;
|
||||
- MemHub 管理库可存 harness/model/队列状态与 **`memory_id` 引用**。
|
||||
|
||||
发布包固定应用以下 SDK 行为配置:
|
||||
MemHub 不再通过 import-time 环境变量覆盖 SDK 的提取、reconcile、episode、
|
||||
intention、few-shot、summary 或去重策略。产品只配置模型/密钥/endpoint、本地
|
||||
backend 路径、公开 embedding service,以及每次公开 Client 调用的参数。
|
||||
|
||||
| 环境变量 | 值 |
|
||||
|---|---|
|
||||
| `MEMORY_MODE` | `ultra` |
|
||||
| `MEMORY_ENABLE_GRAPH` | `true` |
|
||||
| `MEMORY_GRAPH_RERANK_ENABLED` | `false` |
|
||||
| `MEMORY_GRAPH_TEMPORAL_SPINE` | `true` |
|
||||
| `MEMORY_TOPIC_SUMMARY_ENABLED` | `false` |
|
||||
| `MEMORY_EPISODE_SUMMARY_ENABLED` | `false` |
|
||||
| `MEMORY_EPISODE_RECALL_ENABLED` | `true` |
|
||||
| `MEMORY_DYNAMIC_EPISODE_ENABLED` | `true` |
|
||||
| `MEMORY_SESSION_EPISODE_ENABLED` | `false` |
|
||||
| `MEMORY_SESSION_EPISODE_CONTEXT_ENABLED` | `false` |
|
||||
| `MEMORY_EPISODE_CAPSULE_CONTEXT_ENABLED` | `true` |
|
||||
| `MEMORY_EPISODE_CAPSULE_QUERY_MIN` | `0.65` |
|
||||
| `MEMORY_EPISODE_CAPSULE_ASSIGNMENT_MIN` | `0.60` |
|
||||
| `MEMORY_EPISODE_CAPSULE_FIRST_RANK_MAX` | `3` |
|
||||
| `MEMORY_EPISODE_CAPSULE_MEMBER_LIMIT` | `3` |
|
||||
| `MEMORY_ENABLE_INTENTION` | `false` |
|
||||
| `MEMORY_ENABLE_QUERY_REWRITE` | `false` |
|
||||
| `MEMORY_FEW_SHOT_ENABLED` | `true` |
|
||||
| `MEMORY_HYBRID_V2_QUERY_EXPAND` | `1` |
|
||||
| `MEMORY_HYBRID_V2_GEO_INTENT_PRIOR` | `0.6` |
|
||||
| `MEMORY_HYBRID_V2_DIVERSITY` | `1` |
|
||||
| `MEMORY_HYBRID_V2_TEMPORAL_ADMIT_PRIOR` | `0.55` |
|
||||
| `MEMORY_HYBRID_V2_TEMPORAL_BOOST` | `0.18` |
|
||||
| `MEMORY_CROSS_DOMAIN_CON_THRESHOLD` | `0.5` |
|
||||
| `MEMORY_READER_ENABLE_SUMMARY` | `false` |
|
||||
| `MEMORY_SUMMARY_ENABLED_IN_SYS2` | `false` |
|
||||
| `MEMORY_LOCAL_RERANK_ENABLED` | `false` |
|
||||
| `RECONCILE_ENABLED` | `true` |
|
||||
| `HY_MEMORY_READER` | `hybrid` |
|
||||
|
||||
这些默认值集中在
|
||||
`apps/core/src/one_memory/hy_runtime_config.py`,并由发布测试和 lockfile 共同约束。
|
||||
独立脚本若绕过 MemHub 直接导入 SDK,应自行显式配置存储目录和模型端点。
|
||||
独立脚本直连 SDK 时须自配存储目录与模型端点。
|
||||
|
||||
## 原生 SDK 初始化
|
||||
|
||||
下面是独立调试示例。产品代码应使用 One Memory 的 `HyMemoryBridge`,因为它额外提供来源
|
||||
授权、模型授权、Evidence 绑定、写后核验、Governance 和撤权保护。
|
||||
下面是独立调试示例。
|
||||
|
||||
产品代码经 `hy_sdk_runtime.SdkClientHandle` 获取已配置的公开 client;memory I/O
|
||||
由 SDK 完成。
|
||||
|
||||
```python
|
||||
from hy_memory import HyMemoryClient, MemoryConfig
|
||||
|
||||
config = MemoryConfig.from_env()
|
||||
|
||||
# 本地优先示例;实际目录应由调用方传入,不能依赖 ~/.hy_memory 默认值。
|
||||
# 本地优先示例;实际目录应由调用方传入。
|
||||
config.vector_store.provider = "chroma"
|
||||
config.vector_store.persist_directory = "/path/to/one-memory/hy-memory/governed-v2/vector"
|
||||
config.vector_store.persist_directory = "/path/to/one-memory/hy-memory/vector"
|
||||
config.graph_store.provider = "kuzu"
|
||||
config.graph_store.db_path = "/path/to/one-memory/hy-memory/governed-v2/graph"
|
||||
config.graph_store.db_path = "/path/to/one-memory/hy-memory/graph"
|
||||
config.cache.backend = "sqlite"
|
||||
config.cache.db_path = "/path/to/one-memory/hy-memory/governed-v2/cache.sqlite3"
|
||||
config.cache.db_path = "/path/to/one-memory/hy-memory/cache.sqlite3"
|
||||
|
||||
config.llm.provider = "openai"
|
||||
config.llm.model = "your-model"
|
||||
@@ -107,12 +81,11 @@ config.embedder.embedding_dims = 1024
|
||||
client = HyMemoryClient(config=config, mode="ultra")
|
||||
```
|
||||
|
||||
`embedding_dims` 必须与模型实际输出宽度以及已有向量/图存储一致。更换 embedding 模型、
|
||||
端点或维度不能直接复用旧 collection;One Memory 会检测图宽度不一致并拒绝静默重建。
|
||||
`embedding_dims` 必须与模型输出及已有向量/图存储一致。
|
||||
|
||||
## Write:写入对话
|
||||
|
||||
推荐把一段连续对话作为 OpenAI messages 写入:
|
||||
推荐把一段连续对话作为 OpenAI messages 写入;含原文时由 SDK 接管 raw:
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
@@ -131,47 +104,54 @@ result = client.add(
|
||||
request_id=str(uuid4()),
|
||||
metadata={
|
||||
"source_id": "cursor",
|
||||
"evidence_ids": ["evidence-001", "evidence-002"],
|
||||
"workspace_ids": ["workspace-abc"],
|
||||
"input_kind": "raw_conversation",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
参数规则:
|
||||
|
||||
| 参数 | 填法 | 约束 |
|
||||
| --- | --- | --- |
|
||||
| `data` | 字符串或 `[{role, content}]` | 对话优先用 messages;不混入 tool 参数、reasoning、密钥和隐藏系统提示 |
|
||||
| `user_id` | 稳定用户 ID | 一级隔离键;不能为空 |
|
||||
| `agent_id` | 记忆 scope | One Memory 中不是宿主 App 名的简单替代;跨 App scope 由授权和路由决定 |
|
||||
| `session_id` | 稳定会话 ID | 同一会话增量写入时保持一致 |
|
||||
| `memory_at` | 有时区的 `datetime` | 历史导入必须传原始发生时间,不能用导入时间替代 |
|
||||
| `scene` | `"chat"` 或 `"working"` | 普通对话明确传 `chat`;代码/tool 会话才用 `working` |
|
||||
| `request_id` | 每次写入唯一 ID | 用于幂等、追踪和写后核验 |
|
||||
| `metadata` | 来源和 Evidence 引用 | 不放秘密;One Memory 至少保存 source/evidence/workspace/input kind |
|
||||
| `meta` | 业务透传数据 | 不参与 embedding 和过滤;只放可安全返回的数据 |
|
||||
| `embed_override` | 临时 embedding 端点 | 只能换 endpoint/key/header/timeout;模型和维度不能改变 |
|
||||
| `data` | 字符串或 `[{role, content}]` | 对话优先 messages;不混入密钥与隐藏系统提示 |
|
||||
| `user_id` / `agent_id` / `session_id` | 隔离与会话键 | 产品侧授权决定合法 scope |
|
||||
| `memory_at` | 有时区 datetime | 历史导入用原始发生时间 |
|
||||
| `scene` | `chat` / `working` | 明确传入 |
|
||||
| `request_id` | 每次写入唯一 | 幂等与追踪 |
|
||||
| `metadata` / `meta` | 来源与透传 | 不放秘密 |
|
||||
|
||||
返回值至少检查:
|
||||
MemHub Runtime 对应用和 CLI 暴露等价的本机 HTTP 合同:
|
||||
|
||||
```text
|
||||
POST /api/v2/memory-writes
|
||||
GET /api/v2/user-profile
|
||||
|
||||
memhub write submit --messages-file messages.json --session <id>
|
||||
memhub profile
|
||||
```
|
||||
|
||||
`messages.json` 是 OpenAI 文本 messages 数组,当前接受 `user`、
|
||||
`assistant`、`tool`;系统/开发者隐藏提示不得作为记忆输入。Core 将输入冻结进
|
||||
`memory_write_jobs`,worker 对 SDK 执行一次 `add()`,成功后清除队列正文。
|
||||
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
|
||||
用户长期画像,不通过普通 Search 拼装。
|
||||
|
||||
返回值:
|
||||
|
||||
```python
|
||||
if result.get("success") is not True:
|
||||
raise RuntimeError("HY write failed")
|
||||
|
||||
memory_ids = result.get("memory_ids") or []
|
||||
memory_ids = list(result.get("memory_ids") or [])
|
||||
if result.get("memory_id"):
|
||||
memory_ids.append(result["memory_id"])
|
||||
# 需要组织或回执时,MemHub 只持久化这些 memory_id。
|
||||
```
|
||||
|
||||
不要只相信 `success=True`。One Memory 还会按返回 ID `get()` 回读、核对 request/source/
|
||||
Evidence 支持关系,并在 Governance 层记录版本。生产路径不能用直接 `client.add()` 代替这些
|
||||
检查。
|
||||
以 SDK 合同为准做成功判定;需要写后核验时使用 SDK `get` 回读。缺少所需
|
||||
核验能力时扩展 SDK API,不在 MemHub 新建镜像台账。
|
||||
|
||||
### 写入已抽取的记忆
|
||||
|
||||
只有输入本身就是已有记忆内容、且原始对话不可用时才使用:
|
||||
|
||||
```python
|
||||
result = client.add_extracted(
|
||||
["用户长期偏好简洁、直接的回答。"],
|
||||
@@ -182,18 +162,16 @@ result = client.add_extracted(
|
||||
request_id=str(uuid4()),
|
||||
metadata={
|
||||
"source_id": "memory-export",
|
||||
"evidence_ids": ["evidence-003"],
|
||||
"input_kind": "memory_content_without_raw",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
它跳过 Extractor,但不能跳过 HY reconcile、持久化、One Memory Evidence 绑定和 Governance。
|
||||
该入口仍走 SDK reconcile/持久化;MemHub 只做管理面门禁与可选
|
||||
`memory_id` 记录。
|
||||
|
||||
## Search:搜索记忆
|
||||
|
||||
普通 chat 搜索:
|
||||
|
||||
```python
|
||||
result = client.search(
|
||||
"用户偏好怎样的回答风格?",
|
||||
@@ -210,117 +188,47 @@ result = client.search(
|
||||
)
|
||||
```
|
||||
|
||||
参数规则:
|
||||
|
||||
| 参数 | 填法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `query` | 当前问题 | 与 `queries` 二选一 |
|
||||
| `queries` | 多轮查询文本 | 最后一条是目标;只有需要 SDK 判断上下文时使用 |
|
||||
| `user_ids` | 获授权用户列表 | 必填且不能空;不要用空列表扩大范围 |
|
||||
| `agent_ids` | 获授权 scope 列表 | 空列表在 SDK 中表示所有 agent,产品代码必须先完成授权判断 |
|
||||
| `session_ids` | 可选会话范围 | 多 agent 与多 session 不能同时使用 |
|
||||
| `limit` | normal top-k | 默认 10;注入模型前还要受字符预算限制 |
|
||||
| `min_score` | normal 阈值 | One Memory 当前内部语义检索使用 0.25 |
|
||||
| `profile_min_score` | profile 阈值 | One Memory 当前使用 0.25 |
|
||||
| `profile_limit` | profile 数量 | 通常与 limit 一致 |
|
||||
| `intention_limit` | intention 数量 | 当前配置关闭 intention,传 0 |
|
||||
| `created_after` | Unix 秒 | 可选时间下界 |
|
||||
| `scene` | `chat` / `working` | 明确传入可避免额外场景判断模型调用 |
|
||||
| `reader` | `hybrid` | One Memory 固定使用该 reader |
|
||||
| `workspace_id` / `branch` | working scope | 仅 coding/working 路径使用 |
|
||||
| `layers` | 指定层级列表 | 在召回阶段过滤,避免无关层占用 limit |
|
||||
|
||||
chat 返回结构:
|
||||
|
||||
```python
|
||||
{
|
||||
"request_id": "...",
|
||||
"memories": {
|
||||
"profile": [...],
|
||||
"proactive": [...],
|
||||
"normal": [...],
|
||||
},
|
||||
"rewrite": {...},
|
||||
"elapsed_ms": 12.3,
|
||||
}
|
||||
```
|
||||
|
||||
working/coding 返回的是 `facts` 和 `experiences`,不能与 chat 三个 channel 静默合并。
|
||||
调用方必须根据 `scene` 选择对应结果;无法识别返回结构时应报错,而不是当作空结果。
|
||||
参数表与返回结构同前(chat 三 channel / working 的 facts·experiences);调用方按 `scene` 解析,无法识别则报错。
|
||||
|
||||
## 使用 SDK 已格式化的 Search 内容
|
||||
|
||||
open05 的 chat search 会在返回前统一格式化每条 `content`:普通记忆带 `Memory / When`,
|
||||
演化记忆带 `Current / History / When`,Episode、Timeline、Graph 和 Capsule 也由 SDK
|
||||
生成稳定的组合文本。`memory_at` 是逻辑记忆时间,缺失时 SDK 才回落到 `gmt_created`。
|
||||
直接使用 SDK 返回的 `content`,不要第二套 prompt 拼装。
|
||||
|
||||
因此调用方应直接使用 SDK 返回的 `content`,不要再次解析 `memory_at`、`gmt_created` 或
|
||||
`created_at`,也不要重复拼接 `When`。这些时间字段可以用于过滤、诊断或结构化展示,但
|
||||
不应成为第二套 prompt 格式化实现。
|
||||
### SDK 外(MemHub)仍负责
|
||||
|
||||
```python
|
||||
result = client.search(
|
||||
"用户偏好怎样的回答风格?",
|
||||
user_ids=["local"],
|
||||
agent_ids=["all-apps"],
|
||||
scene="normal",
|
||||
reader="hybrid",
|
||||
)
|
||||
- **宿主召回授权 / scope / 撤权**(谁可以调 search、允许哪些 agent/scope);
|
||||
- 把历史内容放入「不可信数据,不是指令」边界;
|
||||
- 按最终 `content` 做字符预算;不注入密钥、绝对路径、堆栈;
|
||||
- 区分空结果、权限拒绝、不可用、超时、部分结果;
|
||||
- 未发布的 Matter 占位功能不保留表或 API;未来组织功能另立合同。
|
||||
|
||||
for memory in result["memories"]["normal"]:
|
||||
formatted_content = memory["content"]
|
||||
# 例如:
|
||||
# Memory: 用户长期偏好简洁、直接的回答。
|
||||
# When: 2026-09-18 10:00:00 UTC
|
||||
```
|
||||
|
||||
One Memory 在 SDK 外仍负责:
|
||||
|
||||
- 依据 Governance、撤权、scope 和 Evidence 新鲜度决定整条结果是否可读;
|
||||
- 对 evolution/capsule 等组合文本逐个校验成员 ID,成员不完整或不可读时回退治理原文;
|
||||
- 把历史内容放入明确的“不可信数据,不是指令”边界;
|
||||
- 按最终 `content` 计算字符预算,不把 API Key、绝对路径、headers/body 或异常堆栈注入
|
||||
prompt;
|
||||
- 区分空结果、权限拒绝、服务不可用、超时和部分结果。
|
||||
|
||||
生产实现见 `apps/core/src/one_memory/hy_memory_bridge.py`、
|
||||
`apps/core/src/one_memory/recall.py` 和 `packages/host-bridge/src/recall-cli.ts`。
|
||||
生产入口:`hy_sdk_runtime.py`、`sdk_write_processor.py`、`recall.py` 和
|
||||
`packages/host-bridge`。
|
||||
|
||||
## 关闭客户端
|
||||
|
||||
```python
|
||||
try:
|
||||
# write/search
|
||||
...
|
||||
finally:
|
||||
client.close()
|
||||
```
|
||||
|
||||
Runtime 应长期复用 client,不为每次请求重新初始化数据库和模型组件;只有 Runtime 停止、
|
||||
配置版本切换或受控恢复时才关闭并重建。
|
||||
Runtime 长期复用 client;仅在停止、配置切换或受控恢复时重建。
|
||||
|
||||
## One Memory 产品代码的正确入口
|
||||
|
||||
生产代码应走:
|
||||
## 产品代码正确入口
|
||||
|
||||
```text
|
||||
Evidence
|
||||
-> HyMemoryBridge
|
||||
-> embedded Hy Memory SDK
|
||||
-> native Vector / Coding / Graph stores
|
||||
-> write result verification
|
||||
-> Governance
|
||||
-> Recall Broker / UI
|
||||
harness / user action (+ authz, model, queue)
|
||||
-> public HyMemoryClient
|
||||
-> SDK stores (config/env)
|
||||
-> optional MemHub persist memory_id / org metadata
|
||||
-> Recall Broker / UI (SDK read + MemHub authz)
|
||||
```
|
||||
|
||||
不要在新业务模块里直接创建 `HyMemoryClient`,否则会绕过:
|
||||
不要在业务模块里私建绕过管理面门禁的 client。版本、纠正和删除语义以 SDK
|
||||
合同为准;MemHub 组织层只引用 `memory_id`。
|
||||
|
||||
- 来源读取授权;
|
||||
- 共享 Runtime、模型配置、API Key 安全存储与请求预算;
|
||||
- Evidence 引用与作者归属;
|
||||
- 写后回读验证;
|
||||
- Governance 状态、版本、更正和删除级联。
|
||||
|
||||
Hy Memory SDK 是 MemHub 的内置可信组件,可自由访问和管理本机的全部
|
||||
Hy Memory 数据。来源授权仍决定哪些宿主数据可以进入本机;宿主 Recall Grant
|
||||
仍决定哪些外部 AI 应用可以读取治理后的记忆。这两类边界不限制内置 SDK 的本地 CRUD。
|
||||
Hy Memory SDK 是 MemHub 内置可信引擎,管理本机 Hy Memory 数据。
|
||||
来源授权决定哪些宿主数据可进入写入路径;宿主 Recall Grant 决定外部应用可读范围。
|
||||
这两类边界 **不** 意味着 MemHub 再存一套 memory 实体。
|
||||
+11
-7
@@ -18,13 +18,13 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
公共 Python SDK 为 `hy-memory==2.0.3`。MemHub 源码版本为 `1.0.3`
|
||||
(build 7),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.5`。MemHub 源码版本为 `1.0.5`
|
||||
(build 14),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.3` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -37,23 +37,27 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
|
||||
## 核心原则
|
||||
|
||||
- 一次安装、一个 Runtime、一份数据、多种客户端。
|
||||
- Python Core 是唯一记忆数据面和唯一业务数据库写入者。
|
||||
- Hy Memory SDK 是 memory/raw/vector/graph/coding 数据面的唯一权威;Python Core
|
||||
管理授权、模型、异步队列和 Runtime 状态。
|
||||
- 来源读取、模型处理和宿主读取是相互独立的授权边界。
|
||||
- 正式记忆链路保持 `Evidence -> Hy Memory -> Governance -> Matters/Wiki`。
|
||||
- 正式写链路为 `memory_write_jobs -> SDK add() -> raw/memory ID receipt`。
|
||||
- 产品不包含屏幕、Accessibility / AXTree、OCR、键鼠、剪贴板或后台录音采集。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
- `apps/core`:Python / FastAPI Kernel,负责 Evidence、Hy Memory、治理、检索与 API。
|
||||
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK Runtime 边界与 API。
|
||||
- `apps/cli`:安装、控制和诊断共享 Runtime 的 `one-memory` CLI。
|
||||
- `apps/web`:React / TypeScript 管理界面。
|
||||
- `apps/desktop`:Electron 桌面壳与图形安装入口。
|
||||
- `packages/contracts`:公共 API 类型。
|
||||
- `packages/i18n`:Web 与 Electron 共用的界面语言合同和 catalog。
|
||||
- `packages/host-bridge`:AI App 的 Skill、Hook、插件和 MCP 接入。
|
||||
- `packages/runtime-control`:Core、Host Gateway 和 Runtime 生命周期控制。
|
||||
|
||||
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
|
||||
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
|
||||
界面语言架构与社区翻译规则见
|
||||
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md)。
|
||||
产品、测试和一线支持排查导入与记忆沉淀时,使用
|
||||
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
|
||||
|
||||
@@ -113,7 +117,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.3`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.5`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
+25
-3
@@ -3,9 +3,9 @@
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "one-memory",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T13:52:48Z",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T13:18:16Z",
|
||||
"creationInfo": {
|
||||
"created": "2026-10-17T13:52:48Z",
|
||||
"created": "2026-10-22T13:18:16Z",
|
||||
"creators": [
|
||||
"Tool: one-memory-generate-sbom"
|
||||
]
|
||||
@@ -88,6 +88,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-npm-i18next",
|
||||
"name": "i18next",
|
||||
"versionInfo": "25.10.10",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:npm/i18next@25.10.10"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-npm-lucide-react",
|
||||
"name": "lucide-react",
|
||||
@@ -2335,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.4",
|
||||
"versionInfo": "2.0.5",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
@@ -2380,6 +2397,11 @@
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-npm-https-proxy-agent"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-npm-i18next"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
|
||||
It bundles the runtime dependencies listed below.
|
||||
Generated from the installed dependency tree on 2026-10-17T13:52:48Z
|
||||
Generated from the installed dependency tree on 2026-10-22T13:18:16Z
|
||||
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
|
||||
|
||||
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.4
|
||||
## hy-memory 2.0.5
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
@@ -112,12 +112,13 @@ Adaptation boundary:
|
||||
|
||||
No Memmy brand, icon, font or mascot assets are distributed.
|
||||
|
||||
## JavaScript (npm) — 10 packages
|
||||
## JavaScript (npm) — 11 packages
|
||||
|
||||
- @tanstack/react-query 5.103.2
|
||||
- axios 1.20.0
|
||||
- fflate 0.8.3
|
||||
- https-proxy-agent 7.0.0
|
||||
- i18next 25.10.10
|
||||
- lucide-react 1.43.0
|
||||
- proxy-from-env 2.1.0
|
||||
- react 19.2.8
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -29,10 +29,9 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"sources",
|
||||
"sync",
|
||||
"memories",
|
||||
"profile",
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"extraction",
|
||||
"graph",
|
||||
"models",
|
||||
"host",
|
||||
@@ -55,7 +54,6 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"processing",
|
||||
"path",
|
||||
"adapter",
|
||||
"write",
|
||||
@@ -82,16 +80,15 @@ const REQUIRED_MARKERS = [
|
||||
*/
|
||||
const REQUIRED_SUBCOMMANDS = [
|
||||
{ command: "calls", marker: "memhub calls clear" },
|
||||
{ command: "import-file", marker: "memhub import-file upload" },
|
||||
{ command: "import-file", marker: "memhub import-file commit" },
|
||||
{ command: "import-file", marker: "memhub import-file discard" },
|
||||
{ command: "notes", marker: "memhub notes add" },
|
||||
{ command: "notes", marker: "memhub notes process" },
|
||||
{ command: "extraction", marker: "memhub extraction set" },
|
||||
{ command: "memories", marker: "memhub memories update" },
|
||||
{ command: "memories", marker: "memhub memories delete" },
|
||||
{ command: "write", marker: "memhub write submit" },
|
||||
{ command: "write", marker: "memhub write status" },
|
||||
{ command: "graph", marker: "memhub graph width" },
|
||||
{ command: "models", marker: "memhub models edit" },
|
||||
{ command: "models", marker: "memhub models activate" },
|
||||
{ command: "processing", marker: "memhub processing extract" },
|
||||
{ command: "jobs", marker: "memhub jobs start" },
|
||||
{ command: "jobs", marker: "memhub jobs <pause|resume|cancel>" },
|
||||
];
|
||||
@@ -122,24 +119,6 @@ const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
name: "calls_clear_all_apply",
|
||||
args: ["calls", "clear", "--all", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "import_file_commit_apply",
|
||||
args: [
|
||||
"import-file",
|
||||
"commit",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "import_file_discard_apply",
|
||||
args: [
|
||||
"import-file",
|
||||
"discard",
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "notes_process_apply",
|
||||
args: [
|
||||
@@ -149,18 +128,10 @@ const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "extraction_set_apply",
|
||||
args: ["extraction", "set", "--window", "6m", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "graph_width_apply",
|
||||
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "processing_extract_apply",
|
||||
args: ["processing", "extract", "--confirm"],
|
||||
},
|
||||
{
|
||||
name: "models_activate_apply",
|
||||
args: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -29,6 +30,27 @@ async function fixture() {
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function readZipEntry(archive: Buffer, expected: string): string {
|
||||
let offset = 0;
|
||||
while (archive.readUInt32LE(offset) === 0x04034b50) {
|
||||
const method = archive.readUInt16LE(offset + 8);
|
||||
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||
const nameLength = archive.readUInt16LE(offset + 26);
|
||||
const extraLength = archive.readUInt16LE(offset + 28);
|
||||
const nameStart = offset + 30;
|
||||
const dataStart = nameStart + nameLength + extraLength;
|
||||
const name = archive.subarray(nameStart, nameStart + nameLength).toString();
|
||||
const compressed = archive.subarray(dataStart, dataStart + compressedSize);
|
||||
if (name === expected) {
|
||||
if (method === 0) return compressed.toString();
|
||||
if (method === 8) return inflateRawSync(compressed).toString();
|
||||
throw new Error("unsupported zip compression");
|
||||
}
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
throw new Error(`missing ${expected}`);
|
||||
}
|
||||
|
||||
it("prints matching diagnostic events as NDJSON without starting Runtime", async () => {
|
||||
const dataDir = await fixture();
|
||||
let stdout = "";
|
||||
@@ -74,6 +96,25 @@ it("exports one support zip to the requested downloads directory", async () => {
|
||||
});
|
||||
expect(receipt.path).toMatch(/MemHub-日志-3800-\d{8}-\d{4}\.zip$/);
|
||||
expect(await readdir(downloads)).toEqual([path.basename(receipt.path)]);
|
||||
const archive = await readFile(receipt.path);
|
||||
expect(JSON.parse(readZipEntry(archive, "summary.json")).commit).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed for an explicitly supplied invalid commit", async () => {
|
||||
const dataDir = await fixture();
|
||||
const downloads = await mkdtemp(path.join(os.tmpdir(), "om-cli-downloads-"));
|
||||
roots.push(downloads);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
await expect(
|
||||
run(["logs", "export", "--json"], {
|
||||
ONE_MEMORY_DATA_DIR: dataDir,
|
||||
ONE_MEMORY_EXPORT_DIR: downloads,
|
||||
ONE_MEMORY_BUILD_NUMBER: "3800",
|
||||
ONE_MEMORY_COMMIT: "unknown",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_commit_invalid");
|
||||
expect(await readdir(downloads)).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits a secret from a malformed manually written event", async () => {
|
||||
|
||||
@@ -57,6 +57,11 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
uninstallUserService: vi.fn(async (..._args: unknown[]) => "removed"),
|
||||
restartUserService: vi.fn(async () => undefined),
|
||||
runSdkMigration: vi.fn(
|
||||
async (): Promise<{ state: string; code?: string }> => ({
|
||||
state: "not_required",
|
||||
}),
|
||||
),
|
||||
rollbackLocalInstall: vi.fn(async () => undefined),
|
||||
planCliLink: vi.fn(async ({ home, installRoot }) => ({
|
||||
schema_version: 1,
|
||||
@@ -126,6 +131,7 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => {
|
||||
installUserService: runtimeMocks.installUserService,
|
||||
uninstallUserService: runtimeMocks.uninstallUserService,
|
||||
restartUserService: runtimeMocks.restartUserService,
|
||||
runSdkMigration: runtimeMocks.runSdkMigration,
|
||||
rollbackLocalInstall: runtimeMocks.rollbackLocalInstall,
|
||||
planCliLink: runtimeMocks.planCliLink,
|
||||
installCliLink: runtimeMocks.installCliLink,
|
||||
@@ -889,6 +895,108 @@ describe("public One Memory CLI", () => {
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const upgradeArguments = [
|
||||
"upgrade",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--data-dir",
|
||||
"/tmp/custom data",
|
||||
"--yes",
|
||||
];
|
||||
const presentCliLink = {
|
||||
schema_version: 1 as const,
|
||||
platform: "linux" as const,
|
||||
home: "/tmp/home",
|
||||
install_root: "/tmp/install",
|
||||
bin_directory: "/tmp/bin",
|
||||
status: "present" as const,
|
||||
blocked_reason: null,
|
||||
link_path: "/tmp/bin/one-memory",
|
||||
target_path: "/tmp/install/current/bin/one-memory",
|
||||
};
|
||||
|
||||
it("migrates the home with the new Core before restarting the Runtime", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
const order: string[] = [];
|
||||
runtimeMocks.runSdkMigration.mockImplementationOnce(async () => {
|
||||
order.push("migrate");
|
||||
return { state: "cutover" };
|
||||
});
|
||||
runtimeMocks.restartUserService.mockImplementationOnce(async () => {
|
||||
order.push("restart");
|
||||
});
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(order).toEqual(["migrate", "restart"]);
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir: "/tmp/custom data" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rolls back to the previous version when the migration does not finish", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
});
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the new version when a migrated home is slow to come up", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({ state: "cutover" });
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => false },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "runtime_not_ready_after_sdk_migration",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports success once the upgraded Runtime is healthy", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
@@ -1785,14 +1893,11 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
["task", "--id", "nope", "--json"],
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--from",
|
||||
"1",
|
||||
"--to",
|
||||
"1",
|
||||
"--evidence",
|
||||
"e",
|
||||
"--messages-file",
|
||||
"/tmp/messages.json",
|
||||
"--json",
|
||||
],
|
||||
["backup", "create", "--target", "/tmp/x", "--yes", "--json"],
|
||||
|
||||
+353
-47
@@ -39,7 +39,9 @@ import {
|
||||
rollbackCliLink,
|
||||
rollbackLocalInstall,
|
||||
runRuntimeCli,
|
||||
runSdkMigration,
|
||||
runtimeInstallLayout,
|
||||
type SdkMigrationResult,
|
||||
stopRuntimeProcess,
|
||||
} from "@one-memory/runtime-control";
|
||||
import axios from "axios";
|
||||
@@ -80,6 +82,7 @@ import {
|
||||
createCoreReader,
|
||||
createUiSessionRequest,
|
||||
deleteModelProfile,
|
||||
parseOpenAIMessages,
|
||||
probeAdapter,
|
||||
readAdapters,
|
||||
readMemoryWrite,
|
||||
@@ -99,7 +102,6 @@ import {
|
||||
createCoreOperations,
|
||||
} from "./core-operations.js";
|
||||
import {
|
||||
MEMORY_STATUSES,
|
||||
type SourceConnectionCandidate,
|
||||
validateImportJob,
|
||||
validateSourceConnectionCandidates,
|
||||
@@ -354,6 +356,9 @@ export interface RunControls {
|
||||
/** Wait for a restarted Runtime to become healthy after `upgrade`.
|
||||
* Injectable so the upgrade tests do not spend real seconds polling. */
|
||||
waitForRuntimeReady?: (dataDir: string) => Promise<boolean>;
|
||||
/** Run the SDK data migration before install/upgrade starts the new
|
||||
* Runtime. Injectable so the tests do not spawn a real Core. */
|
||||
migrateSdkData?: typeof migrateSdkDataBeforeStart;
|
||||
restartManagedRuntime?: typeof restartManagedService;
|
||||
stopRuntime?: typeof stopRuntimeProcess;
|
||||
ensureRuntime?: typeof ensureRuntimeProcess;
|
||||
@@ -922,7 +927,36 @@ async function runCommand(
|
||||
if (attachment === null) {
|
||||
return runtimeUnavailable(descriptor, asJson);
|
||||
}
|
||||
const receipt = await submitMemoryWrite(attachment, parsed);
|
||||
if (parsed.kind === "status") {
|
||||
const receipt = await readMemoryWrite(attachment, parsed.id);
|
||||
process.stdout.write(
|
||||
asJson
|
||||
? `${JSON.stringify({ schema_version: 1, write: receipt })}\n`
|
||||
: receipt === null
|
||||
? "未找到该 request id。\n"
|
||||
: `request_id: ${receipt.request_id}\nstate: ${receipt.state}\n`,
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
const document = await readBoundedJson(parsed.messages_file);
|
||||
const messages = parseOpenAIMessages(document);
|
||||
if (messages === null) {
|
||||
return reject(
|
||||
descriptor,
|
||||
asJson,
|
||||
"messages 文件必须是含 user/assistant/tool 文本消息的 OpenAI messages 数组。",
|
||||
);
|
||||
}
|
||||
const receipt = await submitMemoryWrite(attachment, {
|
||||
session: parsed.session,
|
||||
source: parsed.source,
|
||||
request_id: parsed.request_id ?? randomUUID(),
|
||||
messages,
|
||||
...(parsed.memory_at === undefined
|
||||
? {}
|
||||
: { memory_at: parsed.memory_at }),
|
||||
...(parsed.priority === undefined ? {} : { priority: parsed.priority }),
|
||||
});
|
||||
process.stdout.write(
|
||||
asJson
|
||||
? `${JSON.stringify({ schema_version: 1, write: receipt })}\n`
|
||||
@@ -1019,15 +1053,21 @@ async function runCommand(
|
||||
entry: "cli",
|
||||
}));
|
||||
const target = path.join(downloads, diagnosticZipFilename(build));
|
||||
const environmentCommit = environment.ONE_MEMORY_COMMIT?.trim();
|
||||
const embeddedCommit =
|
||||
typeof __ONE_MEMORY_COMMIT__ === "string"
|
||||
? __ONE_MEMORY_COMMIT__.trim()
|
||||
: "";
|
||||
const commit =
|
||||
environmentCommit ||
|
||||
(embeddedCommit && embeddedCommit !== "unknown"
|
||||
? embeddedCommit
|
||||
: undefined);
|
||||
const receipt = await exportDiagnosticZip({
|
||||
dataDir: config.dataDir,
|
||||
target,
|
||||
productVersion: build,
|
||||
commit:
|
||||
environment.ONE_MEMORY_COMMIT?.trim() ||
|
||||
(typeof __ONE_MEMORY_COMMIT__ === "string"
|
||||
? __ONE_MEMORY_COMMIT__
|
||||
: "unknown"),
|
||||
...(commit === undefined ? {} : { commit }),
|
||||
coreSha256: await sha256File(config.coreCommand).catch(() => undefined),
|
||||
home: os.homedir(),
|
||||
crashReportsDir: path.join(
|
||||
@@ -2294,6 +2334,75 @@ async function runCommand(
|
||||
emitReadDocument(named, document, asJson);
|
||||
return sourceProgressExitCode(document);
|
||||
}
|
||||
if (named === "profile") {
|
||||
if (arguments_.slice(1).some((value) => value !== "--json")) return usage();
|
||||
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
|
||||
const attached = await attachClients(config.dataDir, {
|
||||
readerTimeoutMs: 15_000,
|
||||
});
|
||||
if (!attached.reader) {
|
||||
const document = {
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: "unavailable",
|
||||
code: attached.failure ?? "runtime_attach_failed",
|
||||
};
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
: `用户画像读取失败:${document.code}\n`,
|
||||
);
|
||||
return EXIT_RUNTIME_STOPPED;
|
||||
}
|
||||
const outcome = await attached.reader<unknown>("/api/v2/user-profile");
|
||||
if (outcome.state !== "ok") {
|
||||
const document = {
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: outcome.state,
|
||||
code: outcome.code,
|
||||
};
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
: `用户画像读取失败:${outcome.code}\n`,
|
||||
);
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
const value = outcome.data as {
|
||||
request_id?: unknown;
|
||||
profile?: unknown;
|
||||
elapsed_ms?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof value.request_id !== "string" ||
|
||||
!(
|
||||
value.profile === null ||
|
||||
(typeof value.profile === "object" && !Array.isArray(value.profile))
|
||||
) ||
|
||||
typeof value.elapsed_ms !== "number"
|
||||
) {
|
||||
return reject(descriptor, asJson, "用户画像响应格式无效。");
|
||||
}
|
||||
const document = {
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: "ok",
|
||||
...value,
|
||||
};
|
||||
const content =
|
||||
value.profile && typeof value.profile === "object"
|
||||
? (value.profile as { content?: unknown }).content
|
||||
: null;
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
: value.profile === null
|
||||
? "尚未形成用户长期画像。\n"
|
||||
: `${typeof content === "string" ? content : JSON.stringify(value.profile)}\n`,
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
if (named === "memories") {
|
||||
const parsed = await parseMemoryCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
@@ -2326,6 +2435,8 @@ async function runCommand(
|
||||
}
|
||||
if (blocked || !reader) {
|
||||
if (
|
||||
parsed.kind === "update" ||
|
||||
parsed.kind === "delete" ||
|
||||
parsed.kind === "plan-add" ||
|
||||
parsed.kind === "plan-existing" ||
|
||||
parsed.kind === "apply"
|
||||
@@ -2374,6 +2485,62 @@ async function runCommand(
|
||||
);
|
||||
return document.memory.state === "ok" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
if (parsed.kind === "update" || parsed.kind === "delete") {
|
||||
const content =
|
||||
parsed.kind === "update"
|
||||
? await readMemoryContentFile(parsed.contentFile)
|
||||
: null;
|
||||
if (parsed.kind === "update" && content === null) return usage();
|
||||
const plan = {
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: "preview",
|
||||
operation: parsed.kind,
|
||||
memory_id: parsed.memoryId,
|
||||
...(content === null
|
||||
? {}
|
||||
: {
|
||||
content_sha256: createHash("sha256")
|
||||
.update(content)
|
||||
.digest("hex"),
|
||||
}),
|
||||
};
|
||||
if (!parsed.confirmed) {
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(plan)}\n`
|
||||
: `计划:${parsed.kind} ${parsed.memoryId}\n使用 --confirm 执行。\n`,
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
if (!operations) return EXIT_FAILED;
|
||||
const writable = operations as CoreOperations &
|
||||
CorePutOperations &
|
||||
CoreDeleteOperations;
|
||||
const path = `/api/v2/memories/${encodeURIComponent(parsed.memoryId)}`;
|
||||
const outcome =
|
||||
parsed.kind === "update"
|
||||
? await writable.put<unknown>(path, { content })
|
||||
: await writable.delete<unknown>(path);
|
||||
const document = {
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: outcome.state,
|
||||
operation: parsed.kind,
|
||||
memory_id: parsed.memoryId,
|
||||
...(outcome.state === "committed"
|
||||
? { result: outcome.data }
|
||||
: { code: outcome.code }),
|
||||
};
|
||||
emit(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
: outcome.state === "committed"
|
||||
? `已${parsed.kind === "update" ? "更新" : "删除"}记忆 ${parsed.memoryId}\n`
|
||||
: `操作失败:${outcome.code}\n`,
|
||||
);
|
||||
return outcome.state === "committed" ? EXIT_OK : EXIT_FAILED;
|
||||
}
|
||||
if (parsed.kind === "plan-add") {
|
||||
const content = await readMemoryContentFile(parsed.contentFile);
|
||||
if (content === null) return usage();
|
||||
@@ -2397,7 +2564,6 @@ async function runCommand(
|
||||
operation: parsed.operation,
|
||||
memoryId: parsed.memoryId,
|
||||
...(content === undefined ? {} : { content }),
|
||||
...(parsed.scopes.length === 0 ? {} : { scopes: parsed.scopes }),
|
||||
});
|
||||
emitMemoryFlow(document, asJson);
|
||||
return memoryFlowExitCode(document);
|
||||
@@ -2988,6 +3154,7 @@ async function runCommand(
|
||||
null;
|
||||
let installReceiptWritten = false;
|
||||
let serviceApplied = false;
|
||||
let migrated = false;
|
||||
let previousOwner:
|
||||
| Awaited<ReturnType<typeof claimCliServiceOwnership>>
|
||||
| undefined;
|
||||
@@ -2998,6 +3165,23 @@ async function runCommand(
|
||||
}
|
||||
shellPathReceipt = await applyShellPath(shellPath);
|
||||
previousOwner = await claimCliServiceOwnership(layout.dataDir);
|
||||
// `install` over an existing home (reinstall, App-to-CLI takeover) must
|
||||
// migrate it too, before the new Runtime starts serving it.
|
||||
const migration = await (
|
||||
controls.migrateSdkData ?? migrateSdkDataBeforeStart
|
||||
)({
|
||||
coreCommand: layout.coreCommand,
|
||||
dataDir: layout.dataDir,
|
||||
runtimeBuild: String(plan.build_number ?? plan.version),
|
||||
asJson: true,
|
||||
});
|
||||
if (migration.state === "deferred") {
|
||||
throw new SdkMigrationDeferred(
|
||||
migration.code ?? "sdk_migration_incomplete",
|
||||
);
|
||||
}
|
||||
migrated =
|
||||
migration.state === "cutover" || migration.state === "complete";
|
||||
await applyPreparedServiceInstall(preparedService);
|
||||
serviceApplied = true;
|
||||
// The receipt exists so a later command can find the data dir without
|
||||
@@ -3049,6 +3233,13 @@ async function runCommand(
|
||||
);
|
||||
}
|
||||
};
|
||||
if (migrated) {
|
||||
// An older Runtime refuses a migrated home: keep this install.
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(notReadyAfterMigration("install"), null, 2)}\n`,
|
||||
);
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
if (serviceApplied) {
|
||||
await rollbackPreparedServiceInstall(
|
||||
preparedService,
|
||||
@@ -3058,7 +3249,13 @@ async function runCommand(
|
||||
await rollbackFilesAndOwner();
|
||||
}
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(serviceErrorDocument("install", error), null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
error instanceof SdkMigrationDeferred
|
||||
? sdkMigrationFailure("install", error.code)
|
||||
: serviceErrorDocument("install", error),
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
@@ -3420,6 +3617,7 @@ async function runCommand(
|
||||
let previousOwner:
|
||||
| Awaited<ReturnType<typeof claimCliServiceOwnership>>
|
||||
| undefined;
|
||||
let migrated = false;
|
||||
const rollbackUpgrade = async (
|
||||
dataDir: string | undefined,
|
||||
): Promise<void> => {
|
||||
@@ -3451,6 +3649,25 @@ async function runCommand(
|
||||
memhubReceipts.push(await installManagedCliLink(link));
|
||||
}
|
||||
previousOwner = await claimCliServiceOwnership(layout.dataDir);
|
||||
const migration = await (
|
||||
controls.migrateSdkData ?? migrateSdkDataBeforeStart
|
||||
)({
|
||||
coreCommand: layout.coreCommand,
|
||||
dataDir: parsed.dataDir ?? layout.dataDir,
|
||||
runtimeBuild: String(plan.build_number ?? plan.version),
|
||||
asJson,
|
||||
});
|
||||
if (migration.state === "deferred") {
|
||||
await rollbackUpgrade(parsed.dataDir);
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(sdkMigrationFailure("upgrade", migration.code ?? "sdk_migration_incomplete"), null, 2)}\n`,
|
||||
);
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
// An older Runtime refuses a migrated home, so from here a failure
|
||||
// keeps the new version instead of rolling back to one that cannot start.
|
||||
migrated =
|
||||
migration.state === "cutover" || migration.state === "complete";
|
||||
await restartUserService(layout, serviceOptions);
|
||||
// A restart that returns without error only means launchd/systemd
|
||||
// accepted the command. O-22: the first `upgrade` implementation
|
||||
@@ -3462,6 +3679,12 @@ async function runCommand(
|
||||
const ready = await (controls.waitForRuntimeReady ?? waitForRuntimeReady)(
|
||||
dataDir,
|
||||
);
|
||||
if (!ready && migrated) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(notReadyAfterMigration("upgrade"), null, 2)}\n`,
|
||||
);
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
if (!ready) {
|
||||
await rollbackUpgrade(parsed.dataDir);
|
||||
process.stderr.write(
|
||||
@@ -3489,9 +3712,9 @@ async function runCommand(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await rollbackUpgrade(parsed.dataDir);
|
||||
if (!migrated) await rollbackUpgrade(parsed.dataDir);
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(serviceErrorDocument("upgrade", error), null, 2)}\n`,
|
||||
`${JSON.stringify(migrated ? notReadyAfterMigration("upgrade") : serviceErrorDocument("upgrade", error), null, 2)}\n`,
|
||||
);
|
||||
return EXIT_FAILED;
|
||||
}
|
||||
@@ -3859,6 +4082,79 @@ export function runtimeIsReady(
|
||||
);
|
||||
}
|
||||
|
||||
const SDK_MIGRATION_STEPS: Record<string, string> = {
|
||||
bundle: "正在备份记忆数据,数据较多时需要几分钟……",
|
||||
prepare: "正在整理待处理的写入任务……",
|
||||
cutover: "正在切换记忆数据源……",
|
||||
cleanup: "正在清理旧数据表……",
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a home written by an older release before the new Runtime serves it.
|
||||
*
|
||||
* Runs the new Runtime's own Core with the Runtime stopped. Progress goes to
|
||||
* stderr so stdout stays one result document.
|
||||
*/
|
||||
async function migrateSdkDataBeforeStart(options: {
|
||||
coreCommand: string;
|
||||
dataDir: string;
|
||||
runtimeBuild: string;
|
||||
asJson: boolean;
|
||||
}): Promise<SdkMigrationResult> {
|
||||
return runSdkMigration({
|
||||
dataDir: options.dataDir,
|
||||
core: { command: options.coreCommand },
|
||||
runtimeBuild: options.runtimeBuild,
|
||||
onProgress: (step) => {
|
||||
process.stderr.write(
|
||||
options.asJson
|
||||
? `${JSON.stringify({ schema_version: 1, product: PRODUCT, event: "sdk_migration.progress", step })}\n`
|
||||
: `${SDK_MIGRATION_STEPS[step] ?? step}\n`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class SdkMigrationDeferred extends Error {
|
||||
constructor(readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function notReadyAfterMigration(command: "install" | "upgrade") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command,
|
||||
status: "error",
|
||||
code: "runtime_not_ready_after_sdk_migration",
|
||||
message:
|
||||
"记忆数据已迁移到新版本,但新版本 Runtime 没有在限定时间内就绪。旧版本无法打开已迁移的数据,所以没有回滚。请运行 memhub restart,或 memhub doctor 查看原因。",
|
||||
};
|
||||
}
|
||||
|
||||
const SDK_MIGRATION_FAILURES: Record<string, string> = {
|
||||
sdk_migration_disk_space_insufficient:
|
||||
"磁盘空间不足,记忆数据迁移没有完成,已回滚到上一版本。请腾出与记忆数据同等大小的空间后重试;原有记忆数据不会丢失。",
|
||||
sdk_migration_memory_unreadable:
|
||||
"核对发现新版本还读不到你的全部记忆,没有切换数据源,已回滚到上一版本。请把这个错误码反馈给我们;原有记忆数据不会丢失。",
|
||||
sdk_migration_model_profile_required:
|
||||
"迁移需要先有可用的模型配置,用来核对新版本能否读回记忆。已回滚到上一版本;请先用 memhub models setup 完成配置后再升级。",
|
||||
};
|
||||
|
||||
function sdkMigrationFailure(command: "install" | "upgrade", code: string) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command,
|
||||
status: "error",
|
||||
code,
|
||||
message:
|
||||
SDK_MIGRATION_FAILURES[code] ??
|
||||
"记忆数据迁移没有完成,已回滚到上一版本。请重试;如果仍然失败,请把这个错误码反馈给我们。原有记忆数据不会丢失。",
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForRuntimeReady(
|
||||
dataDir: string,
|
||||
timeoutMs = 45_000,
|
||||
@@ -4575,10 +4871,10 @@ async function runMaintenanceRestore(options: {
|
||||
const instanceId = randomUUID();
|
||||
const child = spawn(options.coreCommand, ["serve", "--maintenance"], {
|
||||
env: {
|
||||
// Do not pass the caller's Runtime tuning variables (for example
|
||||
// retrieval-index flags) into a maintenance process: a restore must
|
||||
// start the product's minimal maintenance configuration, not inherit
|
||||
// an unrelated experiment from the shell.
|
||||
// Do not pass the caller's Runtime tuning variables into a
|
||||
// maintenance process: a restore must start the product's minimal
|
||||
// maintenance configuration, not inherit an unrelated experiment from
|
||||
// the shell.
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
LANG: process.env.LANG,
|
||||
@@ -5784,6 +6080,13 @@ function emitReadDocument(
|
||||
type MemoryCommand =
|
||||
| { kind: "list"; query: MemoryQuery }
|
||||
| { kind: "show"; memoryId: string }
|
||||
| {
|
||||
kind: "update";
|
||||
memoryId: string;
|
||||
contentFile: string;
|
||||
confirmed: boolean;
|
||||
}
|
||||
| { kind: "delete"; memoryId: string; confirmed: boolean }
|
||||
| {
|
||||
kind: "plan-add";
|
||||
contentFile: string;
|
||||
@@ -5793,10 +6096,9 @@ type MemoryCommand =
|
||||
}
|
||||
| {
|
||||
kind: "plan-existing";
|
||||
operation: "correct" | "delete" | "scope";
|
||||
operation: "correct" | "delete";
|
||||
memoryId: string;
|
||||
contentFile?: string;
|
||||
scopes: string[];
|
||||
}
|
||||
| { kind: "apply"; planFile: string; contentFile?: string };
|
||||
|
||||
@@ -5812,6 +6114,33 @@ async function parseMemoryCommand(
|
||||
? { kind: "show", memoryId }
|
||||
: null;
|
||||
}
|
||||
if (action === "update" || action === "delete") {
|
||||
const memoryId = tokens[1];
|
||||
if (!memoryId || !isMemoryId(memoryId)) return null;
|
||||
let contentFile: string | undefined;
|
||||
let confirmed = false;
|
||||
for (let index = 2; index < tokens.length; index += 1) {
|
||||
const flag = tokens[index];
|
||||
if (flag === "--confirm") {
|
||||
if (confirmed) return null;
|
||||
confirmed = true;
|
||||
continue;
|
||||
}
|
||||
const value = tokens[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
if (flag !== "--content-file" || contentFile !== undefined) return null;
|
||||
contentFile = value;
|
||||
index += 1;
|
||||
}
|
||||
if (action === "update")
|
||||
return contentFile
|
||||
? { kind: "update", memoryId, contentFile, confirmed }
|
||||
: null;
|
||||
return contentFile === undefined
|
||||
? { kind: "delete", memoryId, confirmed }
|
||||
: null;
|
||||
}
|
||||
if (action === "plan" || action === "apply") return null;
|
||||
if (action === "plan") return parseMemoryPlanCommand(tokens.slice(1));
|
||||
if (action === "apply") {
|
||||
let planFile: string | undefined;
|
||||
@@ -5837,10 +6166,7 @@ async function parseMemoryCommand(
|
||||
: null;
|
||||
}
|
||||
if (action !== "list") return null;
|
||||
let query: string | undefined;
|
||||
let queryFile: string | undefined;
|
||||
let status: string | undefined;
|
||||
let source: string | undefined;
|
||||
let offset = 0;
|
||||
let limit = 100;
|
||||
let offsetSet = false;
|
||||
@@ -5849,11 +6175,7 @@ async function parseMemoryCommand(
|
||||
const flag = tokens[index];
|
||||
const value = tokens[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
if (flag === "--query" && query === undefined) query = value;
|
||||
else if (flag === "--query-file" && queryFile === undefined)
|
||||
queryFile = value;
|
||||
else if (flag === "--status" && status === undefined) status = value;
|
||||
else if (flag === "--source" && source === undefined) source = value;
|
||||
if (flag === "--status" && status === undefined) status = value;
|
||||
else if (flag === "--limit" && !limitSet) {
|
||||
limit = Number(value);
|
||||
limitSet = true;
|
||||
@@ -5862,24 +6184,11 @@ async function parseMemoryCommand(
|
||||
offsetSet = true;
|
||||
} else return null;
|
||||
}
|
||||
if (query !== undefined && queryFile !== undefined) return null;
|
||||
if (queryFile !== undefined) {
|
||||
query = (await readQueryFile(queryFile)) ?? undefined;
|
||||
if (query === undefined) return null;
|
||||
}
|
||||
if (
|
||||
query !== undefined &&
|
||||
(query.length === 0 || query.length > QUERY_MAX_CHARACTERS)
|
||||
) {
|
||||
if (status !== undefined && (status.length === 0 || status.length > 64))
|
||||
return null;
|
||||
}
|
||||
if (status !== undefined && !MEMORY_STATUSES.includes(status as never)) {
|
||||
return null;
|
||||
}
|
||||
if (source !== undefined && source.length === 0) return null;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) return null;
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) return null;
|
||||
return { kind: "list", query: { query, status, source, offset, limit } };
|
||||
return { kind: "list", query: { status, offset, limit } };
|
||||
}
|
||||
|
||||
function parseMemoryPlanCommand(
|
||||
@@ -5887,7 +6196,7 @@ function parseMemoryPlanCommand(
|
||||
): MemoryCommand | null {
|
||||
const operation = tokens[0];
|
||||
const add = operation === "add";
|
||||
if (!add && !["correct", "delete", "scope"].includes(operation)) {
|
||||
if (!add && !["correct", "delete"].includes(operation)) {
|
||||
return null;
|
||||
}
|
||||
const memoryId = add ? undefined : tokens[1];
|
||||
@@ -5906,7 +6215,7 @@ function parseMemoryPlanCommand(
|
||||
else if (flag === "--kind" && add && !kindSet) {
|
||||
memoryKind = value;
|
||||
kindSet = true;
|
||||
} else if (flag === "--scope" && !scopes.includes(value))
|
||||
} else if (flag === "--scope" && add && !scopes.includes(value))
|
||||
scopes.push(value);
|
||||
else if (flag === "--evidence" && add && !evidenceIds.includes(value))
|
||||
evidenceIds.push(value);
|
||||
@@ -5926,18 +6235,15 @@ function parseMemoryPlanCommand(
|
||||
}
|
||||
if (
|
||||
(operation === "correct" && !contentFile) ||
|
||||
(operation === "scope" && scopes.length === 0) ||
|
||||
(!["correct", "scope"].includes(operation) &&
|
||||
(contentFile !== undefined || scopes.length > 0))
|
||||
(operation === "delete" && (contentFile !== undefined || scopes.length > 0))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "plan-existing",
|
||||
operation: operation as "correct" | "delete" | "scope",
|
||||
operation: operation as "correct" | "delete",
|
||||
memoryId: memoryId as string,
|
||||
...(contentFile ? { contentFile } : {}),
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -347,22 +347,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "processing",
|
||||
summary: "手动推进待处理证据;默认只预览,确认后执行。",
|
||||
usage:
|
||||
"memhub processing extract [--evidence <id> ...] [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--evidence <id>",
|
||||
summary:
|
||||
"processing extract 要提前处理的证据 ID,可重复;不给则推进下一批。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
summary:
|
||||
@@ -387,25 +371,14 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{
|
||||
name: "memories",
|
||||
summary:
|
||||
"列出/查看记忆,或生成并显式确认带版本与 request identity 的治理操作计划。",
|
||||
summary: "通过 SDK 数据面列出、查看、修改或物理删除记忆。",
|
||||
usage:
|
||||
"memhub memories list [--query <text>] [--query-file <path>] [--status <state>] [--source <id>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--json]\n memhub memories plan add --content-file <path> --scope <scope> --evidence <id> [--kind <kind>] [--json]\n memhub memories plan <correct|delete|scope> <memory-id> [--content-file <path>] [--scope <scope>] [--json]\n memhub memories apply --plan-file <path> --confirm [--content-file <path>] [--json]",
|
||||
"memhub memories list [--status <state>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--json]\n memhub memories update <memory-id> --content-file <path> [--confirm] [--json]\n memhub memories delete <memory-id> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--query <text>",
|
||||
summary:
|
||||
"按内容过滤;会出现在 shell 历史里,敏感词请改用 --query-file。",
|
||||
},
|
||||
{
|
||||
flag: "--query-file <path>",
|
||||
summary: "从文件读取过滤词,避免查询内容进入命令行历史。",
|
||||
},
|
||||
{
|
||||
flag: "--status <state>",
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")}。`,
|
||||
},
|
||||
{ flag: "--source <id>", summary: "只看某个来源产生的记忆。" },
|
||||
{ flag: "--offset <n>", summary: "跳过前 n 条,默认 0。" },
|
||||
{
|
||||
flag: "--limit <n>",
|
||||
@@ -416,24 +389,18 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
summary:
|
||||
"从有界普通文件读取新增/纠正正文;计划只保存摘要,执行时再次校验。",
|
||||
},
|
||||
{
|
||||
flag: "--kind <chat|working|coding|schema|intention>",
|
||||
summary: "新增记忆的类型,默认 chat。",
|
||||
},
|
||||
{
|
||||
flag: "--scope <scope>",
|
||||
summary: "记忆使用范围,可重复;private 必须独占。",
|
||||
},
|
||||
{
|
||||
flag: "--evidence <id>",
|
||||
summary: "新增记忆引用的 Evidence ID,可重复。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
EXPLICIT_CONFIRM_OPTION,
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
summary: "读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充)。",
|
||||
usage: "memhub profile [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "calls",
|
||||
summary:
|
||||
@@ -454,28 +421,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "import-file",
|
||||
summary:
|
||||
"上传、提交或丢弃一份历史导入文件;提交与丢弃默认只出计划,需 --confirm。",
|
||||
usage:
|
||||
"memhub import-file upload --file <path> [--kind raw_conversation|memory_content] [--confirm] [--json]\n memhub import-file commit <upload-id> [--confirm] [--json]\n memhub import-file discard <upload-id> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--file <path>",
|
||||
summary:
|
||||
"要上传的普通文件(非符号链接,<=128 MiB,.zip/.json/.md/.txt)。",
|
||||
},
|
||||
{
|
||||
flag: "--kind <kind>",
|
||||
summary:
|
||||
"输入类型,默认 raw_conversation;memory_content 用于记忆正文。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "notes",
|
||||
summary:
|
||||
@@ -493,20 +438,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "extraction",
|
||||
summary: "设置提取窗口;默认只出计划,--confirm 后才写入。",
|
||||
usage: "memhub extraction set --window <3m|6m|1y|all> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--window <preset>",
|
||||
summary: "提取窗口预设:3m、6m、1y 或 all。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "graph",
|
||||
summary: "图谱宽度不匹配时的用户决定;默认只出计划,--confirm 后才执行。",
|
||||
@@ -722,16 +653,16 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
summary:
|
||||
"提交一次记忆写入,返回可查询的 request id;写入异步完成,不可重复提交。",
|
||||
summary: "提交 OpenAI messages 到 SDK 写入队列,或按 request id 查询状态。",
|
||||
usage:
|
||||
"memhub write --session <id> --from <round> --to <round> --evidence <id> [--evidence <id>...] [--live] [--json]",
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--json]\n memhub write status --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
summary: "查询写入任务状态;unknown 表示结果待定,不可据此重发。",
|
||||
summary:
|
||||
"兼容别名:按 request id 查询写入状态;新脚本请使用 write status。",
|
||||
usage: "memhub task --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
|
||||
@@ -74,14 +74,12 @@ const COMMANDS = [
|
||||
"sync",
|
||||
"progress",
|
||||
"jobs",
|
||||
"processing",
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
// C6 App BLOCK (25f4ecf): UI write operations that had no CLI equivalent.
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"extraction",
|
||||
"graph",
|
||||
"host",
|
||||
"recall",
|
||||
@@ -562,7 +560,7 @@ describe("documented commands", () => {
|
||||
// `sync <pause|resume>` is documented as one alternation, so it is
|
||||
// checked as an entry rather than as two literal subcommands.
|
||||
sync: ["status", "<pause|resume>"],
|
||||
memories: ["list", "show", "plan", "apply"],
|
||||
memories: ["list", "show", "update", "delete"],
|
||||
host: ["list", "status", "install", "uninstall", "sync"],
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v1/memory-tasks",
|
||||
"/api/v1/model-profiles",
|
||||
"/api/v1/sources",
|
||||
"/api/v2/memory-library",
|
||||
"/api/v2/user-profile",
|
||||
] as const;
|
||||
const SDK_MEMORY_DETAIL = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
|
||||
/** Contract (C0, H25): the one-time loopback code exchange for `memhub ui`.
|
||||
* It is a POST because it mints a short-lived code; the CLI sends the bearer
|
||||
@@ -195,6 +198,7 @@ export function isReadOnlyPath(path: string): boolean {
|
||||
if (IMPORT_JOB.test(path)) return true;
|
||||
if (OPERATION_RECEIPT.test(path)) return true;
|
||||
if (MEMORY_MUTATION_RECEIPT.test(path)) return true;
|
||||
if (SDK_MEMORY_DETAIL.test(path)) return true;
|
||||
if (!MEMORY_DETAIL.test(path)) return false;
|
||||
const segment = path.slice("/api/v1/memories/".length);
|
||||
return !(RESERVED_MEMORY_SEGMENTS as readonly string[]).includes(segment);
|
||||
@@ -211,6 +215,43 @@ export interface MemoryWriteReceipt {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
detail?: string;
|
||||
raw_ids?: string[];
|
||||
memory_ids?: string[];
|
||||
}
|
||||
|
||||
export interface OpenAIMessage {
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseOpenAIMessages(value: unknown): OpenAIMessage[] | null {
|
||||
const candidate = Array.isArray(value)
|
||||
? value
|
||||
: isRecord(value) && Array.isArray(value.messages)
|
||||
? value.messages
|
||||
: null;
|
||||
if (candidate === null || candidate.length < 1 || candidate.length > 200)
|
||||
return null;
|
||||
const messages: OpenAIMessage[] = [];
|
||||
for (const item of candidate) {
|
||||
if (!isRecord(item)) return null;
|
||||
if (!["user", "assistant", "tool"].includes(String(item.role))) return null;
|
||||
if (
|
||||
typeof item.content !== "string" ||
|
||||
item.content.length < 1 ||
|
||||
item.content.length > 12_000
|
||||
)
|
||||
return null;
|
||||
messages.push({
|
||||
role: item.role as OpenAIMessage["role"],
|
||||
content: item.content,
|
||||
});
|
||||
}
|
||||
return messages.some((message) => message.role === "user") ? messages : null;
|
||||
}
|
||||
|
||||
/** A6: submit a write through the durable entry every entry shares.
|
||||
@@ -237,19 +278,23 @@ export async function submitMemoryWrite(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
request: {
|
||||
session: string;
|
||||
first_round: number;
|
||||
last_round: number;
|
||||
evidence_ids: readonly string[];
|
||||
source: string;
|
||||
request_id: string;
|
||||
messages: readonly OpenAIMessage[];
|
||||
memory_at?: string;
|
||||
priority?: "live" | "normal";
|
||||
},
|
||||
): Promise<MemoryWriteReceipt> {
|
||||
const response = await axios.post<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v1/memory-writes`,
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes`,
|
||||
{
|
||||
internal_session_id: request.session,
|
||||
first_round: request.first_round,
|
||||
last_round: request.last_round,
|
||||
evidence_ids: [...request.evidence_ids],
|
||||
request_id: request.request_id,
|
||||
session_id: request.session,
|
||||
source_id: request.source,
|
||||
messages: [...request.messages],
|
||||
...(request.memory_at === undefined
|
||||
? {}
|
||||
: { memory_at: request.memory_at }),
|
||||
...(request.priority === undefined ? {} : { priority: request.priority }),
|
||||
},
|
||||
{
|
||||
@@ -380,7 +425,7 @@ export async function readMemoryWrite(
|
||||
): Promise<MemoryWriteReceipt | null> {
|
||||
try {
|
||||
const response = await axios.get<MemoryWriteReceipt>(
|
||||
`${baseUrlOf(attachment)}/api/v1/memory-writes/${encodeURIComponent(requestId)}`,
|
||||
`${baseUrlOf(attachment)}/api/v2/memory-writes/${encodeURIComponent(requestId)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -6,6 +6,7 @@ const WRITE_PATHS = [
|
||||
] as const;
|
||||
const MEMORY_WRITE_PATH =
|
||||
/^\/api\/v1\/memories(?:\/[A-Za-z0-9][\w.-]{0,127}\/(?:correct|scopes|actions\/delete))?$/;
|
||||
const SDK_MEMORY_PATH = /^\/api\/v2\/memories\/[A-Za-z0-9][\w.-]{0,127}$/;
|
||||
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 =
|
||||
@@ -170,7 +171,7 @@ export function createCoreOperations(attachment: {
|
||||
body: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (path !== EXTRACTION_SETTINGS_PATH) {
|
||||
if (path !== EXTRACTION_SETTINGS_PATH && !SDK_MEMORY_PATH.test(path)) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
@@ -232,7 +233,7 @@ export function createCoreOperations(attachment: {
|
||||
};
|
||||
},
|
||||
async delete<T>(path: string, signal?: AbortSignal) {
|
||||
if (!IMPORT_FILE.test(path)) {
|
||||
if (!IMPORT_FILE.test(path) && !SDK_MEMORY_PATH.test(path)) {
|
||||
throw new Error("core_operation_path_not_allowed");
|
||||
}
|
||||
let response: { status: number; data: unknown };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, symlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
@@ -41,11 +41,15 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
|
||||
session_id: "11111111-1111-4111-8111-111111111111",
|
||||
details: {
|
||||
count: 1,
|
||||
reason: "/Users/me/private-query.txt",
|
||||
status: "sk-live-secret-token",
|
||||
source_id: "credential.private",
|
||||
prompt: "secret-token-123",
|
||||
authorization: "Bearer secret-token-123",
|
||||
} as unknown as Record<string, string | number | boolean | null>,
|
||||
safe_stack: [
|
||||
"packages/runtime-control/src/example.ts:42:7",
|
||||
"packages/runtime/sk-live-secret-token.ts:1:1",
|
||||
"/Users/me/private.py:1",
|
||||
"C:\\Users\\me\\private.py:1",
|
||||
],
|
||||
@@ -56,6 +60,8 @@ it("redacts unsafe details and absolute stack frames from the read surface", ()
|
||||
"packages/runtime-control/src/example.ts:42:7",
|
||||
]);
|
||||
expect(JSON.stringify(redacted)).not.toContain("secret-token-123");
|
||||
expect(JSON.stringify(redacted)).not.toContain("sk-live-secret-token");
|
||||
expect(JSON.stringify(redacted)).not.toContain("credential.private");
|
||||
expect(JSON.stringify(redacted)).not.toContain("/Users/me");
|
||||
expect(JSON.stringify(redacted)).not.toContain("C:\\\\Users");
|
||||
});
|
||||
@@ -132,3 +138,27 @@ it("follows a newly appended event without stopping Runtime", async () => {
|
||||
});
|
||||
expect(seen).toEqual(["service.ready", "core.failed"]);
|
||||
});
|
||||
|
||||
it("fails follow mode when the diagnostic directory is unsafe", async () => {
|
||||
const dataDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-cli-follow-unsafe-"),
|
||||
);
|
||||
const external = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-cli-follow-external-"),
|
||||
);
|
||||
roots.push(dataDir, external);
|
||||
await symlink(external, path.join(dataDir, "logs"));
|
||||
const query = parseLogQuery(["--follow"]);
|
||||
expect(query).not.toBeNull();
|
||||
if (!query) throw new Error("query_missing");
|
||||
await expect(
|
||||
followLogs({
|
||||
dataDir,
|
||||
query,
|
||||
signal: new AbortController().signal,
|
||||
maxEvents: 1,
|
||||
emit: () => undefined,
|
||||
wait: async () => undefined,
|
||||
}),
|
||||
).rejects.toThrow("log_directory_unreadable");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import {
|
||||
diagnosticLogFiles,
|
||||
projectDiagnosticEventForSafeOutput,
|
||||
readDiagnosticEvents,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
@@ -24,54 +25,6 @@ export interface LogQuery {
|
||||
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
||||
const NAME = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$/;
|
||||
const SAFE_DETAIL_KEYS = new Set([
|
||||
"action",
|
||||
"attempt",
|
||||
"count",
|
||||
"method",
|
||||
"mode",
|
||||
"phase",
|
||||
"platform",
|
||||
"reason",
|
||||
"result_count",
|
||||
"source_id",
|
||||
"spawned",
|
||||
"status",
|
||||
"version",
|
||||
"parent_session_id",
|
||||
"invalid_lines",
|
||||
"truncated",
|
||||
"runtime_state",
|
||||
"core_state",
|
||||
"broker_state",
|
||||
"instance_id",
|
||||
"open_file_count",
|
||||
"memory_bytes",
|
||||
"thread_count",
|
||||
"database_error_count",
|
||||
"file_count",
|
||||
"sqlite_file_count",
|
||||
"byte_file_count",
|
||||
"total_bytes",
|
||||
"copied_bytes",
|
||||
"bytes_per_second",
|
||||
"stored_count",
|
||||
"revised_count",
|
||||
"skipped_count",
|
||||
"error_count",
|
||||
"pending_count",
|
||||
"prepared_count",
|
||||
"queue_depth",
|
||||
"in_flight_count",
|
||||
"call_count",
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"superseded_count",
|
||||
"deleted_count",
|
||||
"operation_kind",
|
||||
"file_kind",
|
||||
]);
|
||||
|
||||
export function parseLogQuery(arguments_: readonly string[]): LogQuery | null {
|
||||
if (arguments_.filter((item) => item === "--json").length > 1) return null;
|
||||
const values: Partial<LogQuery> = { errorsOnly: false, follow: false };
|
||||
@@ -237,63 +190,13 @@ export function renderLogText(
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** Defence in depth for the CLI read surface. `DiagnosticLogger` already
|
||||
* allow-lists keys, but a future writer, a manually edited log, or a malformed
|
||||
* line must not turn `memhub logs` into an exfiltration path. */
|
||||
/** Defence in depth for the CLI read surface. Local writers accept arbitrary
|
||||
* bounded scalar keys, so every outward read surface must reuse the same
|
||||
* field-and-value projection as the support-bundle exporter. */
|
||||
export function redactDiagnosticEvent(
|
||||
event: DiagnosticEventV1,
|
||||
): DiagnosticEventV1 {
|
||||
const redacted: DiagnosticEventV1 = {
|
||||
schema_version: 1,
|
||||
timestamp: event.timestamp,
|
||||
level: event.level,
|
||||
service: event.service,
|
||||
component: event.component,
|
||||
event: event.event,
|
||||
outcome: event.outcome,
|
||||
session_id: event.session_id,
|
||||
...(event.trace_id === undefined ? {} : { trace_id: event.trace_id }),
|
||||
...(event.incident_id === undefined
|
||||
? {}
|
||||
: { incident_id: event.incident_id }),
|
||||
...(event.instance_id === undefined
|
||||
? {}
|
||||
: { instance_id: event.instance_id }),
|
||||
...(event.operation_id === undefined
|
||||
? {}
|
||||
: { operation_id: event.operation_id }),
|
||||
...(event.request_id === undefined ? {} : { request_id: event.request_id }),
|
||||
...(event.job_id === undefined ? {} : { job_id: event.job_id }),
|
||||
...(event.task_id === undefined ? {} : { task_id: event.task_id }),
|
||||
...(event.duration_ms === undefined
|
||||
? {}
|
||||
: { duration_ms: event.duration_ms }),
|
||||
...(event.state_before === undefined
|
||||
? {}
|
||||
: { state_before: event.state_before }),
|
||||
...(event.state_after === undefined
|
||||
? {}
|
||||
: { state_after: event.state_after }),
|
||||
...(event.error_code === undefined ? {} : { error_code: event.error_code }),
|
||||
...(event.error_type === undefined ? {} : { error_type: event.error_type }),
|
||||
...(event.safe_stack === undefined
|
||||
? {}
|
||||
: {
|
||||
safe_stack: event.safe_stack.filter(
|
||||
(frame) =>
|
||||
!frame.startsWith("/") &&
|
||||
!/^[A-Za-z]:\\/.test(frame) &&
|
||||
!frame.includes(".."),
|
||||
),
|
||||
}),
|
||||
};
|
||||
const details = Object.fromEntries(
|
||||
Object.entries(event.details ?? {}).filter(([key]) =>
|
||||
SAFE_DETAIL_KEYS.has(key),
|
||||
),
|
||||
);
|
||||
if (Object.keys(details).length > 0) redacted.details = details;
|
||||
return redacted;
|
||||
return projectDiagnosticEventForSafeOutput(event);
|
||||
}
|
||||
|
||||
export async function followLogs(options: {
|
||||
@@ -315,6 +218,7 @@ export async function followLogs(options: {
|
||||
...options.query,
|
||||
tail: 5_000,
|
||||
});
|
||||
if (report.failure) throw new Error(report.failure);
|
||||
for (const event of report.events) {
|
||||
const identity = JSON.stringify(event);
|
||||
if (seen.has(identity)) continue;
|
||||
|
||||
@@ -1,718 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { run } from "./cli.js";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
executeMemoryMutation,
|
||||
inspectMemoryPlan,
|
||||
memoryPlanAcceptsContent,
|
||||
planExistingMemoryMutation,
|
||||
planMemoryAdd,
|
||||
readMemoryMutationReceipt,
|
||||
} from "./memory-flows.js";
|
||||
import { readMemoryDetail } from "./memory-report.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
stopRuntimeProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const MEMORY = {
|
||||
memory_id: "memory-1",
|
||||
user_id: "local",
|
||||
kind: "working",
|
||||
status: "confirmed",
|
||||
content: "Existing synthetic memory.",
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["evidence-1"],
|
||||
origin: "extracted",
|
||||
tags: ["synthetic"],
|
||||
revision: 3,
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:01Z",
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
const DETAIL = {
|
||||
memory: MEMORY,
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
available: true,
|
||||
source_id: "codex",
|
||||
written_by: "person",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
verbatim: "Synthetic source text.",
|
||||
},
|
||||
],
|
||||
history: [
|
||||
{
|
||||
revision: 3,
|
||||
action: "create",
|
||||
status: "confirmed",
|
||||
content: "Existing synthetic memory.",
|
||||
scopes: ["all-apps"],
|
||||
created_at: "2026-09-19T00:00:01Z",
|
||||
},
|
||||
],
|
||||
index_sync: null,
|
||||
};
|
||||
|
||||
const ATTACHED = {
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1" as const,
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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(ATTACHED);
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v1/memories/memory-1"
|
||||
? { status: 200, data: DETAIL }
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
});
|
||||
|
||||
describe("memory governance plans", () => {
|
||||
it("plans a manual memory without putting private content in the plan", () => {
|
||||
const result = planMemoryAdd({
|
||||
content: "Private synthetic addition.",
|
||||
memoryKind: "working",
|
||||
scopes: ["all-apps"],
|
||||
evidenceIds: ["evidence-1"],
|
||||
});
|
||||
expect(result.state).toBe("preview");
|
||||
expect(JSON.stringify(result)).not.toContain("Private synthetic addition.");
|
||||
if (result.state !== "preview") throw new Error("preview expected");
|
||||
expect(result.plan).toMatchObject({
|
||||
operation: "add",
|
||||
memory_kind: "working",
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["evidence-1"],
|
||||
content_characters: 27,
|
||||
});
|
||||
expect(inspectMemoryPlan(result.plan)).toMatchObject({
|
||||
operationId: result.plan.request_id,
|
||||
needsContent: true,
|
||||
});
|
||||
expect(
|
||||
memoryPlanAcceptsContent(result.plan, "Private synthetic addition."),
|
||||
).toBe(true);
|
||||
expect(memoryPlanAcceptsContent(result.plan, "Different content.")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("binds correction to the current version, source, author and scopes", async () => {
|
||||
const result = await planExistingMemoryMutation(detailReader(), {
|
||||
operation: "correct",
|
||||
memoryId: "memory-1",
|
||||
content: "Corrected synthetic memory.",
|
||||
scopes: ["codex"],
|
||||
});
|
||||
expect(result.state).toBe("preview");
|
||||
if (result.state !== "preview") throw new Error("preview expected");
|
||||
expect(result.plan).toMatchObject({
|
||||
memory_id: "memory-1",
|
||||
expected_revision: 3,
|
||||
expected_status: "confirmed",
|
||||
expected_origin: "extracted",
|
||||
scopes: ["codex"],
|
||||
current: {
|
||||
revision: 3,
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
source_id: "codex",
|
||||
written_by: "person",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("Corrected synthetic memory.");
|
||||
});
|
||||
|
||||
it("posts a confirmed plan once and validates the bound receipt", async () => {
|
||||
const preview = planMemoryAdd({
|
||||
content: "Private synthetic addition.",
|
||||
memoryKind: "working",
|
||||
scopes: ["all-apps"],
|
||||
evidenceIds: ["evidence-1"],
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: {
|
||||
...MEMORY,
|
||||
memory_id: "created-1",
|
||||
content: "Private synthetic addition.",
|
||||
status: "confirmed",
|
||||
origin: "manual",
|
||||
revision: 1,
|
||||
},
|
||||
});
|
||||
const result = await executeMemoryMutation(
|
||||
preview.plan,
|
||||
"Private synthetic addition.",
|
||||
{ post } as CoreOperations,
|
||||
);
|
||||
expect(result.state).toBe("committed");
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
"/api/v1/memories",
|
||||
expect.objectContaining({
|
||||
request_id: preview.plan.request_id,
|
||||
draft: expect.objectContaining({
|
||||
origin: "manual",
|
||||
content: "Private synthetic addition.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toContain("Private synthetic addition.");
|
||||
});
|
||||
|
||||
it("does not take a created memory that is not in effect as the add", async () => {
|
||||
const preview = planMemoryAdd({
|
||||
content: "Private synthetic addition.",
|
||||
memoryKind: "working",
|
||||
scopes: ["all-apps"],
|
||||
evidenceIds: ["evidence-1"],
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const result = await executeMemoryMutation(
|
||||
preview.plan,
|
||||
"Private synthetic addition.",
|
||||
{
|
||||
post: vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: {
|
||||
...MEMORY,
|
||||
memory_id: "created-1",
|
||||
content: "Private synthetic addition.",
|
||||
status: "deleted",
|
||||
origin: "manual",
|
||||
revision: 1,
|
||||
},
|
||||
}),
|
||||
} as CoreOperations,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "memory_receipt_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([["delete", "/actions/delete", "deleted"]] as const)(
|
||||
"executes %s against the revision-bound action route",
|
||||
async (operation, suffix, status) => {
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation,
|
||||
memoryId: "memory-1",
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: { ...MEMORY, status, revision: 4 },
|
||||
});
|
||||
const result = await executeMemoryMutation(preview.plan, undefined, {
|
||||
post,
|
||||
} as CoreOperations);
|
||||
expect(result.state).toBe("committed");
|
||||
expect(post).toHaveBeenCalledWith(`/api/v1/memories/memory-1${suffix}`, {
|
||||
request_id: preview.plan.request_id,
|
||||
expected_revision: 3,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("executes correction and scope changes without changing origin", async () => {
|
||||
for (const operation of ["correct", "scope"] as const) {
|
||||
const content =
|
||||
operation === "correct" ? "Corrected synthetic memory." : undefined;
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation,
|
||||
memoryId: "memory-1",
|
||||
...(content ? { content } : {}),
|
||||
scopes: ["codex"],
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: {
|
||||
...MEMORY,
|
||||
content: content ?? MEMORY.content,
|
||||
scopes: ["codex"],
|
||||
revision: 4,
|
||||
},
|
||||
});
|
||||
const result = await executeMemoryMutation(preview.plan, content, {
|
||||
post,
|
||||
} as CoreOperations);
|
||||
expect(result.state).toBe("committed");
|
||||
expect(post.mock.calls[0][0]).toBe(
|
||||
`/api/v1/memories/memory-1/${operation === "scope" ? "scopes" : "correct"}`,
|
||||
);
|
||||
expect(post.mock.calls[0][1]).toMatchObject({
|
||||
request_id: preview.plan.request_id,
|
||||
expected_revision: 3,
|
||||
scopes: ["codex"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a misbound HTTP 200 receipt as unknown", async () => {
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation: "delete",
|
||||
memoryId: "memory-1",
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const result = await executeMemoryMutation(preview.plan, undefined, {
|
||||
post: vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: {
|
||||
...MEMORY,
|
||||
memory_id: "another-memory",
|
||||
status: "deleted",
|
||||
revision: 4,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "memory_receipt_invalid",
|
||||
operation_id: preview.plan.request_id,
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers only an exactly bound governance transaction receipt", async () => {
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation: "delete",
|
||||
memoryId: "memory-1",
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const result = await readMemoryMutationReceipt(
|
||||
receiptReader({
|
||||
schema_version: 2,
|
||||
request_id: preview.plan.request_id,
|
||||
state: "committed",
|
||||
operation: "delete",
|
||||
memory_id: "memory-1",
|
||||
result_revision: 4,
|
||||
result_status: "deleted",
|
||||
memory_state: "deleted",
|
||||
migrated_from: null,
|
||||
}),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "recovered",
|
||||
operation_id: preview.plan.request_id,
|
||||
receipt: {
|
||||
operation: "delete",
|
||||
memory_id: "memory-1",
|
||||
result_revision: 4,
|
||||
result_status: "deleted",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("content_sha256");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["operation", "correct"],
|
||||
["memory_id", "memory-2"],
|
||||
["result_revision", 5],
|
||||
["result_status", "confirmed"],
|
||||
["migrated_from", "reject"],
|
||||
] as const)("refuses a receipt with wrong %s", async (field, wrong) => {
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation: "delete",
|
||||
memoryId: "memory-1",
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const result = await readMemoryMutationReceipt(
|
||||
receiptReader({
|
||||
schema_version: 2,
|
||||
request_id: preview.plan.request_id,
|
||||
state: "committed",
|
||||
operation: "delete",
|
||||
memory_id: "memory-1",
|
||||
result_revision: 4,
|
||||
result_status: "deleted",
|
||||
memory_state: "deleted",
|
||||
migrated_from: null,
|
||||
[field]: wrong,
|
||||
}),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "memory_mutation_receipt_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps not-found, denied and unavailable lookups unresolved", async () => {
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation: "delete",
|
||||
memoryId: "memory-1",
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const notFound = await readMemoryMutationReceipt(
|
||||
receiptReader({
|
||||
schema_version: 2,
|
||||
request_id: preview.plan.request_id,
|
||||
state: "not_found",
|
||||
operation: null,
|
||||
memory_id: null,
|
||||
result_revision: null,
|
||||
result_status: null,
|
||||
memory_state: null,
|
||||
migrated_from: null,
|
||||
}),
|
||||
preview.plan,
|
||||
);
|
||||
expect(notFound).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "memory_mutation_receipt_not_found_after_attempt",
|
||||
});
|
||||
for (const state of ["denied", "unavailable"] as const) {
|
||||
const result = await readMemoryMutationReceipt(
|
||||
async () => ({ state, code: `synthetic_${state}` }),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: `memory_mutation_receipt_synthetic_${state}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("maps an add plan only to the Core create receipt", async () => {
|
||||
const preview = planMemoryAdd({
|
||||
content: "Private synthetic addition.",
|
||||
memoryKind: "working",
|
||||
scopes: ["all-apps"],
|
||||
evidenceIds: ["evidence-1"],
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const result = await readMemoryMutationReceipt(
|
||||
receiptReader({
|
||||
schema_version: 2,
|
||||
request_id: preview.plan.request_id,
|
||||
state: "committed",
|
||||
operation: "create",
|
||||
memory_id: "created-1",
|
||||
result_revision: 1,
|
||||
result_status: "confirmed",
|
||||
memory_state: "present",
|
||||
migrated_from: null,
|
||||
}),
|
||||
preview.plan,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "recovered",
|
||||
receipt: {
|
||||
operation: "create",
|
||||
memory_id: "created-1",
|
||||
result_revision: 1,
|
||||
result_status: "confirmed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows bounded history fields including scopes and status", async () => {
|
||||
const result = await readMemoryDetail(detailReader(), "memory-1");
|
||||
expect(result.history[0]).toMatchObject({
|
||||
revision: 3,
|
||||
action: "create",
|
||||
status: "confirmed",
|
||||
scopes: ["all-apps"],
|
||||
content: "Existing synthetic memory.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory pending fence", () => {
|
||||
it("rejects missing confirmation and invalid plans before any write", async () => {
|
||||
for (const argv of [
|
||||
["memories", "apply", "--plan-file", "/synthetic/plan", "--json"],
|
||||
["memories", "plan", "delete", "graph", "--json"],
|
||||
["memories", "plan", "delete", "memory-1", "--scope", "codex", "--json"],
|
||||
["memories", "plan", "confirm", "memory-1", "--json"],
|
||||
["memories", "plan", "reject", "memory-1", "--json"],
|
||||
[
|
||||
"memories",
|
||||
"plan",
|
||||
"add",
|
||||
"--content-file",
|
||||
"/synthetic/content",
|
||||
"--scope",
|
||||
"all-apps",
|
||||
"--json",
|
||||
],
|
||||
]) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(argv)).toBe(2);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
}
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generates a bounded correction preview without writing", async () => {
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-memory-preview-"),
|
||||
);
|
||||
const contentFile = path.join(directory, "content.txt");
|
||||
await writeFile(contentFile, "Corrected synthetic memory.");
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run([
|
||||
"memories",
|
||||
"plan",
|
||||
"correct",
|
||||
"memory-1",
|
||||
"--content-file",
|
||||
contentFile,
|
||||
"--scope",
|
||||
"codex",
|
||||
"--json",
|
||||
]),
|
||||
).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
const output = JSON.parse(streams.stdout());
|
||||
expect(output).toMatchObject({
|
||||
state: "preview",
|
||||
plan: {
|
||||
operation: "correct",
|
||||
memory_id: "memory-1",
|
||||
expected_revision: 3,
|
||||
scopes: ["codex"],
|
||||
},
|
||||
});
|
||||
expect(streams.stdout()).not.toContain("Corrected synthetic memory.");
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks content identity before claiming the durable fence", async () => {
|
||||
const preview = planMemoryAdd({
|
||||
content: "Private synthetic addition.",
|
||||
memoryKind: "working",
|
||||
scopes: ["all-apps"],
|
||||
evidenceIds: ["evidence-1"],
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const directory = await mkdtemp(
|
||||
path.join(os.tmpdir(), "om-memory-content-"),
|
||||
);
|
||||
const planFile = path.join(directory, "plan.json");
|
||||
const wrongFile = path.join(directory, "wrong.txt");
|
||||
const correctFile = path.join(directory, "correct.txt");
|
||||
await writeFile(planFile, JSON.stringify(preview));
|
||||
await writeFile(wrongFile, "Different content.");
|
||||
await writeFile(correctFile, "Private synthetic addition.");
|
||||
const wrong = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run([
|
||||
"memories",
|
||||
"apply",
|
||||
"--plan-file",
|
||||
planFile,
|
||||
"--content-file",
|
||||
wrongFile,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]),
|
||||
).toBe(2);
|
||||
} finally {
|
||||
wrong.restore();
|
||||
}
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...MEMORY,
|
||||
memory_id: "created-1",
|
||||
content: "Private synthetic addition.",
|
||||
status: "confirmed",
|
||||
origin: "manual",
|
||||
revision: 1,
|
||||
},
|
||||
});
|
||||
const correct = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run([
|
||||
"memories",
|
||||
"apply",
|
||||
"--plan-file",
|
||||
planFile,
|
||||
"--content-file",
|
||||
correctFile,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]),
|
||||
).toBe(0);
|
||||
} finally {
|
||||
correct.restore();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
expect(transport.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovers an unknown mutation after restart without reposting", async () => {
|
||||
const preview = await planExistingMemoryMutation(detailReader(), {
|
||||
operation: "delete",
|
||||
memoryId: "memory-1",
|
||||
});
|
||||
if (preview.state !== "preview") throw new Error("preview expected");
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "om-memory-plan-"));
|
||||
const planFile = path.join(directory, "plan.json");
|
||||
await writeFile(planFile, JSON.stringify(preview));
|
||||
transport.post.mockRejectedValue(new Error("synthetic disconnect"));
|
||||
let receiptLookups = 0;
|
||||
transport.get.mockImplementation(async (url: string) => {
|
||||
if (url === "/api/v1/memories/memory-1") {
|
||||
return { status: 200, data: DETAIL };
|
||||
}
|
||||
if (url === `/api/v1/memory-mutations/${preview.plan.request_id}`) {
|
||||
receiptLookups += 1;
|
||||
if (receiptLookups === 1) throw new Error("synthetic lookup outage");
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
schema_version: 2,
|
||||
request_id: preview.plan.request_id,
|
||||
state: "committed",
|
||||
operation: "delete",
|
||||
memory_id: "memory-1",
|
||||
result_revision: 4,
|
||||
result_status: "deleted",
|
||||
memory_state: "deleted",
|
||||
migrated_from: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: 404, data: {} };
|
||||
});
|
||||
const first = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run([
|
||||
"memories",
|
||||
"apply",
|
||||
"--plan-file",
|
||||
planFile,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]),
|
||||
).toBe(1);
|
||||
} finally {
|
||||
first.restore();
|
||||
}
|
||||
expect(JSON.parse(first.stdout())).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "memory_mutation_receipt_transport_failed",
|
||||
});
|
||||
const second = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run([
|
||||
"memories",
|
||||
"apply",
|
||||
"--plan-file",
|
||||
planFile,
|
||||
"--confirm",
|
||||
"--json",
|
||||
]),
|
||||
).toBe(0);
|
||||
} finally {
|
||||
second.restore();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
expect(JSON.parse(second.stdout())).toMatchObject({
|
||||
state: "recovered",
|
||||
receipt: {
|
||||
operation: "delete",
|
||||
memory_id: "memory-1",
|
||||
result_revision: 4,
|
||||
result_status: "deleted",
|
||||
},
|
||||
});
|
||||
expect(transport.post).toHaveBeenCalledTimes(1);
|
||||
expect(receiptLookups).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
function captureStreams() {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const out = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const error = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
restore: () => {
|
||||
out.mockRestore();
|
||||
error.mockRestore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function detailReader(): CoreReader {
|
||||
return async <T>() => ({ state: "ok", data: DETAIL as T });
|
||||
}
|
||||
|
||||
function receiptReader(receipt: unknown): CoreReader {
|
||||
return async <T>() => ({ state: "ok", data: receipt as T });
|
||||
}
|
||||
@@ -4,15 +4,11 @@ import {
|
||||
buildMemoryActionRequest,
|
||||
buildMemoryCorrectRequest,
|
||||
buildMemoryCreateRequest,
|
||||
buildMemoryScopesRequest,
|
||||
parseMemoryReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
validateGovernedMemory,
|
||||
validateMemoryDetail,
|
||||
} from "./core-responses.js";
|
||||
import { validateMemoryDetail } from "./core-responses.js";
|
||||
import { isMemoryId } from "./memory-report.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
@@ -23,7 +19,7 @@ const MEMORY_KINDS = [
|
||||
"schema",
|
||||
"intention",
|
||||
] as const;
|
||||
const OPERATIONS = ["add", "correct", "delete", "scope"] as const;
|
||||
const OPERATIONS = ["add", "correct", "delete"] as const;
|
||||
const MEMORY_ORIGINS = ["extracted", "answered", "agent", "manual"] as const;
|
||||
const MUTATION_RECEIPT_KEYS = new Set([
|
||||
"schema_version",
|
||||
@@ -145,15 +141,12 @@ export async function planExistingMemoryMutation(
|
||||
operation: Exclude<(typeof OPERATIONS)[number], "add">;
|
||||
memoryId: string;
|
||||
content?: string;
|
||||
scopes?: string[];
|
||||
},
|
||||
): Promise<MemoryFlowDocument> {
|
||||
if (
|
||||
!isMemoryId(input.memoryId) ||
|
||||
(input.operation === "correct" && input.content === undefined) ||
|
||||
(input.content !== undefined && !validContent(input.content)) ||
|
||||
((input.operation === "scope" || input.scopes !== undefined) &&
|
||||
!validScopes(input.scopes ?? []))
|
||||
(input.content !== undefined && !validContent(input.content))
|
||||
) {
|
||||
return invalid("memory_plan_input_invalid");
|
||||
}
|
||||
@@ -176,7 +169,6 @@ export async function planExistingMemoryMutation(
|
||||
expected_revision: memory.revision,
|
||||
expected_status: memory.status,
|
||||
expected_origin: memory.origin,
|
||||
...(input.scopes ? { scopes: input.scopes } : {}),
|
||||
...(input.content ? contentIdentity(input.content) : {}),
|
||||
current: {
|
||||
status: memory.status,
|
||||
@@ -407,15 +399,7 @@ function operationRequest(
|
||||
plan.memory_id as string,
|
||||
plan.expected_revision as number,
|
||||
content as string,
|
||||
plan.scopes,
|
||||
plan.request_id,
|
||||
);
|
||||
}
|
||||
if (plan.operation === "scope") {
|
||||
return buildMemoryScopesRequest(
|
||||
plan.memory_id as string,
|
||||
plan.expected_revision as number,
|
||||
plan.scopes ?? [],
|
||||
undefined,
|
||||
plan.request_id,
|
||||
);
|
||||
}
|
||||
@@ -451,10 +435,7 @@ function matchesReceipt(
|
||||
return false;
|
||||
}
|
||||
if (plan.operation === "delete" && memory.status !== "deleted") return false;
|
||||
if (
|
||||
(plan.operation === "correct" || plan.operation === "scope") &&
|
||||
memory.status !== plan.expected_status
|
||||
) {
|
||||
if (plan.operation === "correct" && memory.status !== plan.expected_status) {
|
||||
return false;
|
||||
}
|
||||
if (plan.operation === "correct" && memory.content !== content) return false;
|
||||
@@ -490,11 +471,7 @@ function validateMutationReceipt(
|
||||
: null;
|
||||
}
|
||||
const expectedOperation =
|
||||
plan.operation === "add"
|
||||
? "create"
|
||||
: plan.operation === "scope"
|
||||
? "set_scopes"
|
||||
: plan.operation;
|
||||
plan.operation === "add" ? "create" : plan.operation;
|
||||
const expectedRevision =
|
||||
plan.operation === "add" ? 1 : (plan.expected_revision as number) + 1;
|
||||
const expectedStatus = plan.operation === "delete" ? "deleted" : "confirmed";
|
||||
@@ -561,9 +538,7 @@ function validatePlan(value: unknown): MemoryMutationPlan | null {
|
||||
plan.expected_status !== "confirmed" ||
|
||||
!oneOf(plan.expected_origin, MEMORY_ORIGINS) ||
|
||||
!validCurrent(plan) ||
|
||||
(plan.operation === "correct" && !contentFields(plan)) ||
|
||||
((plan.operation === "scope" || plan.scopes !== undefined) &&
|
||||
!validScopes(plan.scopes))
|
||||
(plan.operation === "correct" && !contentFields(plan))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const FIXTURE = JSON.parse(
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
) as { receipts: Record<"add" | "correct" | "scope" | "delete", Receipt> };
|
||||
) as { receipts: Record<"add" | "correct" | "delete", Receipt> };
|
||||
|
||||
interface Receipt {
|
||||
schema_version: number;
|
||||
@@ -103,11 +103,8 @@ describe("memory mutation receipt v2 from Core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each<
|
||||
["correct" | "scope" | "delete", { content?: string; scopes?: string[] }]
|
||||
>([
|
||||
it.each<["correct" | "delete", { content?: string }]>([
|
||||
["correct", { content: "Corrected synthetic memory." }],
|
||||
["scope", { scopes: ["codex"] }],
|
||||
["delete", {}],
|
||||
])(
|
||||
"recovers %s from the receipt Core returns for it",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { GovernedMemory } from "@one-memory/contracts";
|
||||
import type { SdkMemoryPage, SdkMemoryRecord } from "@one-memory/contracts";
|
||||
import { type CoreReader, RESERVED_MEMORY_SEGMENTS } from "./core-client.js";
|
||||
import { validateMemoryDetail, validateMemoryPage } from "./core-responses.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
/** Output budget: a long memory is shortened visibly, never dropped silently. */
|
||||
@@ -47,10 +46,8 @@ export async function readMemoryPage(
|
||||
offset: query.offset,
|
||||
limit: query.limit,
|
||||
};
|
||||
if (query.query !== undefined) parameters.query = query.query;
|
||||
if (query.status !== undefined) parameters.status = query.status;
|
||||
if (query.source !== undefined) parameters.source = query.source;
|
||||
const outcome = await reader<unknown>("/api/v1/memory-library", parameters);
|
||||
if (query.status !== undefined) parameters.statuses = query.status;
|
||||
const outcome = await reader<unknown>("/api/v2/memory-library", parameters);
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -59,7 +56,7 @@ export async function readMemoryPage(
|
||||
memories: [],
|
||||
};
|
||||
}
|
||||
const page = validateMemoryPage(outcome.data, query);
|
||||
const page = sdkMemoryPage(outcome.data);
|
||||
if (page === null) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -68,7 +65,7 @@ export async function readMemoryPage(
|
||||
memories: [],
|
||||
};
|
||||
}
|
||||
const items = page.items;
|
||||
const items = page.memories;
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
@@ -88,7 +85,7 @@ export async function readMemoryDetail(
|
||||
reader: CoreReader,
|
||||
memoryId: string,
|
||||
): Promise<MemoryDetailDocument> {
|
||||
const outcome = await reader<unknown>(`/api/v1/memories/${memoryId}`);
|
||||
const outcome = await reader<unknown>(`/api/v2/memories/${memoryId}`);
|
||||
if (outcome.state !== "ok") {
|
||||
return {
|
||||
schema_version: 1,
|
||||
@@ -98,8 +95,8 @@ export async function readMemoryDetail(
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
const detail = validateMemoryDetail(outcome.data, memoryId);
|
||||
if (detail === null) {
|
||||
const memory = sdkMemory(outcome.data);
|
||||
if (memory === null || memory.memory_id !== memoryId) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
@@ -111,33 +108,9 @@ export async function readMemoryDetail(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
memory: { state: "ok", ...describeMemory(detail.memory) },
|
||||
evidence: (detail.evidence ?? []).map((item) => {
|
||||
const budgeted = budget(item.verbatim ?? "");
|
||||
return {
|
||||
evidence_id: item.evidence_id,
|
||||
available: item.available,
|
||||
source_id: item.source_id,
|
||||
written_by: item.written_by,
|
||||
created_at: item.created_at,
|
||||
excerpt: budgeted.text,
|
||||
excerpt_truncated: budgeted.truncated,
|
||||
excerpt_characters: budgeted.characters,
|
||||
};
|
||||
}),
|
||||
history: (detail.history ?? []).map((item) => {
|
||||
const content = budget(item.content);
|
||||
return {
|
||||
revision: item.revision,
|
||||
action: item.action,
|
||||
status: item.status,
|
||||
scopes: item.scopes,
|
||||
created_at: item.created_at,
|
||||
content: content.text,
|
||||
content_truncated: content.truncated,
|
||||
content_characters: content.characters,
|
||||
};
|
||||
}),
|
||||
memory: { state: "ok", ...describeMemory(memory) },
|
||||
evidence: [],
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,9 +129,7 @@ export function renderMemoryPageText(document: MemoryPageDocument): string {
|
||||
];
|
||||
for (const memory of document.memories) {
|
||||
lines.push(
|
||||
`- ${memory.memory_id} ${memory.status}/${memory.kind} rev ${memory.revision} scopes=${
|
||||
(memory.scopes as string[]).join(",") || "无"
|
||||
}`,
|
||||
`- ${memory.memory_id} ${memory.status}/${memory.layer} source=${memory.source ?? "未知"}`,
|
||||
` ${memory.content}${memory.content_truncated ? ` …(共 ${memory.content_characters} 字)` : ""}`,
|
||||
);
|
||||
}
|
||||
@@ -177,54 +148,91 @@ export function renderMemoryDetailText(document: MemoryDetailDocument): string {
|
||||
}
|
||||
const lines = [
|
||||
`记忆 ${memory.memory_id}`,
|
||||
`状态:${memory.status} 类型:${memory.kind} 来源方式:${memory.origin} 版本:${memory.revision}`,
|
||||
`授权范围:${(memory.scopes as string[]).join(",") || "无"}`,
|
||||
`证据:${(memory.evidence_ids as string[]).join(",") || "无"}`,
|
||||
`更新时间:${memory.updated_at}`,
|
||||
`状态:${memory.status} 层:${memory.layer} 来源:${memory.source ?? "未知"}`,
|
||||
`owner:${memory.owner ?? "未知"} latest:${memory.is_latest}`,
|
||||
`raw IDs:${(memory.source_raw_ids as string[]).join(",") || "无"}`,
|
||||
"",
|
||||
String(memory.content),
|
||||
memory.content_truncated ? `…(共 ${memory.content_characters} 字)` : "",
|
||||
"",
|
||||
"原文摘录:",
|
||||
"SDK memory 数据不在 MemHub 中保留 Evidence/版本副本。",
|
||||
];
|
||||
for (const item of document.evidence) {
|
||||
lines.push(
|
||||
`- ${item.evidence_id} 来源=${item.source_id ?? "未知"} 作者=${item.written_by ?? "未知"} 可读=${item.available}`,
|
||||
` ${item.excerpt}${item.excerpt_truncated ? ` …(共 ${item.excerpt_characters} 字)` : ""}`,
|
||||
);
|
||||
}
|
||||
if (document.evidence.length === 0) lines.push("(无可读原文)");
|
||||
lines.push("", "版本历史:");
|
||||
for (const item of document.history) {
|
||||
lines.push(
|
||||
`- rev ${item.revision} ${item.action} ${item.status} scopes=${(item.scopes as string[]).join(",")}`,
|
||||
` ${item.content}${item.content_truncated ? ` …(共 ${item.content_characters} 字)` : ""}`,
|
||||
);
|
||||
}
|
||||
if (document.history.length === 0) lines.push("(无)");
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function describeMemory(memory: GovernedMemory): Record<string, unknown> {
|
||||
function describeMemory(memory: SdkMemoryRecord): Record<string, unknown> {
|
||||
const budgeted = budget(memory.content ?? "");
|
||||
return {
|
||||
memory_id: memory.memory_id,
|
||||
status: memory.status,
|
||||
kind: memory.kind,
|
||||
origin: memory.origin,
|
||||
revision: memory.revision,
|
||||
scopes: memory.scopes ?? [],
|
||||
evidence_ids: memory.evidence_ids ?? [],
|
||||
layer: memory.layer,
|
||||
is_latest: memory.is_latest,
|
||||
owner: memory.owner,
|
||||
source: memory.source,
|
||||
source_raw_ids: memory.source_raw_ids,
|
||||
supersedes: memory.supersedes,
|
||||
superseded_by: memory.superseded_by,
|
||||
tags: memory.tags ?? [],
|
||||
created_at: memory.created_at,
|
||||
updated_at: memory.updated_at,
|
||||
memory_at: memory.memory_at,
|
||||
gmt_created: memory.gmt_created,
|
||||
content: budgeted.text,
|
||||
content_truncated: budgeted.truncated,
|
||||
content_characters: budgeted.characters,
|
||||
};
|
||||
}
|
||||
|
||||
function sdkMemoryPage(value: unknown): SdkMemoryPage | null {
|
||||
if (!record(value) || !Array.isArray(value.memories)) return null;
|
||||
const memories = value.memories.map(sdkMemory);
|
||||
if (memories.some((item) => item === null)) return null;
|
||||
if (!integer(value.total) || !integer(value.limit) || !integer(value.offset))
|
||||
return null;
|
||||
return {
|
||||
memories: memories as SdkMemoryRecord[],
|
||||
total: value.total,
|
||||
limit: value.limit,
|
||||
offset: value.offset,
|
||||
};
|
||||
}
|
||||
|
||||
function sdkMemory(value: unknown): SdkMemoryRecord | null {
|
||||
if (
|
||||
!record(value) ||
|
||||
!text(value.memory_id) ||
|
||||
typeof value.content !== "string" ||
|
||||
!text(value.layer) ||
|
||||
!text(value.status) ||
|
||||
typeof value.is_latest !== "boolean" ||
|
||||
!strings(value.supersedes) ||
|
||||
!strings(value.superseded_by) ||
|
||||
!strings(value.tags) ||
|
||||
!strings(value.source_raw_ids) ||
|
||||
!text(value.user_id) ||
|
||||
!text(value.agent_id)
|
||||
)
|
||||
return null;
|
||||
return value as unknown as SdkMemoryRecord;
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function strings(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function integer(value: unknown): value is number {
|
||||
return Number.isInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function budget(value: string): {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
|
||||
@@ -122,7 +122,9 @@ describe("package matrix harness", () => {
|
||||
process.execPath,
|
||||
[script, runtimeDist, "--stopped-commands", "--json"],
|
||||
{ env: isolatedEnv },
|
||||
);
|
||||
).catch((error: { stdout?: string; stderr?: string }) => {
|
||||
throw new Error(`${error.stdout ?? ""}\n${error.stderr ?? ""}`);
|
||||
});
|
||||
const document = JSON.parse(positive.stdout) as {
|
||||
state: string;
|
||||
commands: {
|
||||
@@ -137,8 +139,8 @@ describe("package matrix harness", () => {
|
||||
};
|
||||
expect(document.state).toBe("verified");
|
||||
const results = document.commands.stopped_destructive.results;
|
||||
// 11 destructive commands, each unknown/runtime_not_running with exit 3.
|
||||
expect(Object.keys(results)).toHaveLength(11);
|
||||
// Every destructive command fails closed while Runtime is stopped.
|
||||
expect(Object.keys(results)).toHaveLength(7);
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
expect(result, `${name} did not fail closed`).toEqual({
|
||||
exit: 3,
|
||||
@@ -162,7 +164,7 @@ describe("package matrix harness", () => {
|
||||
const shim = [
|
||||
"#!/usr/bin/env node",
|
||||
"const a=process.argv.slice(2);",
|
||||
"if(['calls','import-file','notes','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
|
||||
"if(['calls','notes','graph','models','memories'].includes(a[0])){",
|
||||
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
|
||||
" process.exit(0);",
|
||||
"}",
|
||||
@@ -188,7 +190,7 @@ describe("package matrix harness", () => {
|
||||
expect(failure?.state).toBe("failed");
|
||||
expect(
|
||||
failure?.failures.filter((item) => item.includes("did not fail closed")),
|
||||
).toHaveLength(11);
|
||||
).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("can require an expected bundle SHA and reports bundle metadata", {
|
||||
@@ -288,10 +290,9 @@ describe("package matrix harness", () => {
|
||||
"sources",
|
||||
"sync",
|
||||
"memories",
|
||||
"profile",
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"extraction",
|
||||
"graph",
|
||||
"models",
|
||||
"host",
|
||||
@@ -314,7 +315,6 @@ describe("package matrix harness", () => {
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"processing",
|
||||
"path",
|
||||
"adapter",
|
||||
"write",
|
||||
@@ -344,7 +344,7 @@ describe("package matrix harness", () => {
|
||||
'command "models" is missing usage "memhub models activate"',
|
||||
);
|
||||
expect(failures).toContain(
|
||||
'command "import-file" is missing usage "memhub import-file discard"',
|
||||
'command "memories" is missing usage "memhub memories update"',
|
||||
);
|
||||
expect(failures).toContain(
|
||||
'command "jobs" is missing usage "memhub jobs start"',
|
||||
|
||||
@@ -146,17 +146,16 @@ describe("memhub short-command contract", () => {
|
||||
).join("\n");
|
||||
for (const shortForm of [
|
||||
"memhub calls clear",
|
||||
"memhub import-file upload",
|
||||
"memhub import-file commit",
|
||||
"memhub import-file discard",
|
||||
"memhub notes add",
|
||||
"memhub notes process",
|
||||
"memhub models setup",
|
||||
"memhub models activate",
|
||||
"memhub models edit",
|
||||
"memhub extraction set",
|
||||
"memhub graph width",
|
||||
"memhub processing extract",
|
||||
"memhub write submit",
|
||||
"memhub write status",
|
||||
"memhub memories update",
|
||||
"memhub memories delete",
|
||||
]) {
|
||||
expect(flattened).toContain(shortForm);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseOpenAIMessages } from "./core-client.js";
|
||||
import {
|
||||
asPlanDocument,
|
||||
describe as describePlan,
|
||||
@@ -259,93 +260,92 @@ describe("unknown flags are rejected", () => {
|
||||
|
||||
describe("write", () => {
|
||||
const base = [
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--from",
|
||||
"1",
|
||||
"--to",
|
||||
"5",
|
||||
"--evidence",
|
||||
"e1",
|
||||
"--messages-file",
|
||||
"/tmp/messages.json",
|
||||
];
|
||||
|
||||
it("accepts a well-formed write", () => {
|
||||
const parsed = parseWriteCommand(base);
|
||||
expect(parsed).toEqual({
|
||||
kind: "submit",
|
||||
session: "s1",
|
||||
first_round: 1,
|
||||
last_round: 5,
|
||||
evidence_ids: ["e1"],
|
||||
source: "memhub-cli",
|
||||
messages_file: "/tmp/messages.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a write with no evidence", () => {
|
||||
expect(
|
||||
parseWriteCommand(["--session", "s1", "--from", "1", "--to", "5"]),
|
||||
).toBeNull();
|
||||
it("rejects a write with no messages file", () => {
|
||||
expect(parseWriteCommand(["submit", "--session", "s1"])).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a descending round range", () => {
|
||||
it("accepts source, request identity, and RFC3339 time", () => {
|
||||
expect(
|
||||
parseWriteCommand([
|
||||
"--session",
|
||||
"s1",
|
||||
"--from",
|
||||
"5",
|
||||
"--to",
|
||||
"1",
|
||||
"--evidence",
|
||||
"e1",
|
||||
...base,
|
||||
"--source",
|
||||
"cursor",
|
||||
"--request-id",
|
||||
"request-1",
|
||||
"--memory-at",
|
||||
"2026-10-22T00:00:00+08:00",
|
||||
]),
|
||||
).toBeNull();
|
||||
).toMatchObject({
|
||||
source: "cursor",
|
||||
request_id: "request-1",
|
||||
memory_at: "2026-10-22T00:00:00+08:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a round starting below one", () => {
|
||||
it("rejects a timestamp without timezone", () => {
|
||||
expect(
|
||||
parseWriteCommand([
|
||||
"--session",
|
||||
"s1",
|
||||
"--from",
|
||||
"0",
|
||||
"--to",
|
||||
"5",
|
||||
"--evidence",
|
||||
"e1",
|
||||
]),
|
||||
parseWriteCommand([...base, "--memory-at", "2026-10-22T00:00:00"]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a non-integer round", () => {
|
||||
expect(
|
||||
parseWriteCommand([
|
||||
"--session",
|
||||
"s1",
|
||||
"--from",
|
||||
"x",
|
||||
"--to",
|
||||
"5",
|
||||
"--evidence",
|
||||
"e1",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts several evidence ids", () => {
|
||||
const parsed = parseWriteCommand([...base, "--evidence", "e2"]);
|
||||
expect(parsed?.evidence_ids).toEqual(["e1", "e2"]);
|
||||
it("parses status under write", () => {
|
||||
expect(parseWriteCommand(["status", "--id", "request-1"])).toEqual({
|
||||
kind: "status",
|
||||
id: "request-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks --live as priority live", () => {
|
||||
expect(parseWriteCommand([...base, "--live"])?.priority).toBe("live");
|
||||
expect(parseWriteCommand([...base, "--live"])).toMatchObject({
|
||||
kind: "submit",
|
||||
priority: "live",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves priority unset by default", () => {
|
||||
expect(parseWriteCommand(base)?.priority).toBeUndefined();
|
||||
expect(parseWriteCommand(base)).not.toHaveProperty("priority");
|
||||
});
|
||||
|
||||
it("rejects a repeated --json", () => {
|
||||
expect(parseWriteCommand([...base, "--json", "--json"])).toBeNull();
|
||||
});
|
||||
|
||||
it("validates OpenAI text messages and requires a user turn", () => {
|
||||
expect(
|
||||
parseOpenAIMessages({
|
||||
messages: [
|
||||
{ role: "user", content: "Remember Tuesday." },
|
||||
{ role: "assistant", content: "Understood.", ignored: true },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: "user", content: "Remember Tuesday." },
|
||||
{ role: "assistant", content: "Understood." },
|
||||
]);
|
||||
expect(
|
||||
parseOpenAIMessages([{ role: "assistant", content: "No user turn." }]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseOpenAIMessages([{ role: "system", content: "Hidden prompt." }]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("task", () => {
|
||||
|
||||
@@ -255,52 +255,73 @@ export function restoreConfirmationMatches(
|
||||
return confirmPath === source;
|
||||
}
|
||||
|
||||
export interface WriteCommand {
|
||||
export interface WriteSubmitCommand {
|
||||
readonly kind: "submit";
|
||||
readonly session: string;
|
||||
readonly first_round: number;
|
||||
readonly last_round: number;
|
||||
readonly evidence_ids: readonly string[];
|
||||
readonly source: string;
|
||||
readonly messages_file: string;
|
||||
readonly request_id?: string;
|
||||
readonly memory_at?: string;
|
||||
readonly priority?: "live" | "normal";
|
||||
}
|
||||
|
||||
/** Parse `write`. Evidence is required: no write without something to cite. */
|
||||
export interface WriteStatusCommand {
|
||||
readonly kind: "status";
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export type WriteCommand = WriteSubmitCommand | WriteStatusCommand;
|
||||
|
||||
/** Parse SDK-native `write submit|status`. */
|
||||
export function parseWriteCommand(
|
||||
args: readonly string[],
|
||||
): WriteCommand | null {
|
||||
if (args.filter((item) => item === "--json").length > 1) return null;
|
||||
const meaningful = args.filter((item) => item !== "--json");
|
||||
if (meaningful[0] === "status") {
|
||||
const status = parseTaskCommand(meaningful.slice(1));
|
||||
return status === null ? null : { kind: "status", id: status.id };
|
||||
}
|
||||
const input = meaningful[0] === "submit" ? meaningful.slice(1) : meaningful;
|
||||
let session: string | undefined;
|
||||
let first: number | undefined;
|
||||
let last: number | undefined;
|
||||
let source = "memhub-cli";
|
||||
let messagesFile: string | undefined;
|
||||
let requestId: string | undefined;
|
||||
let memoryAt: string | undefined;
|
||||
let priority: "live" | "normal" | undefined;
|
||||
const evidence: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--json") continue;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const arg = input[index];
|
||||
if (arg === "--live") {
|
||||
priority = "live";
|
||||
continue;
|
||||
}
|
||||
const value = args[index + 1];
|
||||
const value = input[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) return null;
|
||||
index += 1;
|
||||
if (arg === "--session") session = value;
|
||||
else if (arg === "--from") first = Number(value);
|
||||
else if (arg === "--to") last = Number(value);
|
||||
else if (arg === "--evidence") evidence.push(value);
|
||||
else if (arg === "--source") source = value;
|
||||
else if (arg === "--messages-file") messagesFile = value;
|
||||
else if (arg === "--request-id") requestId = value;
|
||||
else if (arg === "--memory-at") memoryAt = value;
|
||||
else return null;
|
||||
}
|
||||
|
||||
if (session === undefined || first === undefined || last === undefined)
|
||||
if (session === undefined || messagesFile === undefined || !source)
|
||||
return null;
|
||||
if (!Number.isInteger(first) || first < 1) return null;
|
||||
if (!Number.isInteger(last) || last < first) return null;
|
||||
if (evidence.length === 0) return null;
|
||||
const command: WriteCommand = {
|
||||
if (
|
||||
memoryAt !== undefined &&
|
||||
(!Number.isFinite(Date.parse(memoryAt)) ||
|
||||
!/(?:Z|[+-]\d\d:\d\d)$/.test(memoryAt))
|
||||
)
|
||||
return null;
|
||||
const command: WriteSubmitCommand = {
|
||||
kind: "submit",
|
||||
session,
|
||||
first_round: first,
|
||||
last_round: last,
|
||||
evidence_ids: evidence,
|
||||
source,
|
||||
messages_file: messagesFile,
|
||||
...(requestId === undefined ? {} : { request_id: requestId }),
|
||||
...(memoryAt === undefined ? {} : { memory_at: memoryAt }),
|
||||
};
|
||||
return priority === undefined ? command : { ...command, priority };
|
||||
}
|
||||
|
||||
@@ -197,6 +197,9 @@ const READ_ONLY_PATHS = [
|
||||
"/api/v1/home-summary",
|
||||
"/api/v1/memory-library",
|
||||
"/api/v1/memories/",
|
||||
"/api/v2/memory-library",
|
||||
"/api/v2/memories/",
|
||||
"/api/v2/user-profile",
|
||||
];
|
||||
|
||||
const BACKGROUND_HEALTHY = {
|
||||
@@ -562,6 +565,43 @@ describe("CLI-20 status summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDK user profile", () => {
|
||||
it("prints the assembled long-term profile from the v2 endpoint", async () => {
|
||||
transport.get.mockImplementation(async (url: string) =>
|
||||
url === "/api/v2/user-profile"
|
||||
? {
|
||||
status: 200,
|
||||
data: {
|
||||
request_id: "profile-request",
|
||||
profile: {
|
||||
memory_id: "l0-head",
|
||||
content: "name is Alice",
|
||||
layer: "L0_BASIC_INFO",
|
||||
basic_info: { name: "Alice" },
|
||||
},
|
||||
elapsed_ms: 1.2,
|
||||
},
|
||||
}
|
||||
: { status: 404, data: {} },
|
||||
);
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["profile", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
"/api/v2/user-profile",
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "ok",
|
||||
profile: { memory_id: "l0-head", content: "name is Alice" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI-21 memory reads", () => {
|
||||
it("keeps memory reads open long enough for a busy Core and explains failure", async () => {
|
||||
const axios = (await import("axios")).default as unknown as {
|
||||
@@ -610,14 +650,14 @@ describe("CLI-21 memory reads", () => {
|
||||
|
||||
it("lists a page and states truthfully that more remains", async () => {
|
||||
transport.get.mockImplementation(async (url: string) => {
|
||||
if (url !== "/api/v1/memory-library") return { status: 404, data: {} };
|
||||
if (url !== "/api/v2/memory-library") return { status: 404, data: {} };
|
||||
return {
|
||||
status: 200,
|
||||
data: {
|
||||
total: 12,
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
items: [memory("m1", "第一条记忆"), memory("m2", "x".repeat(400))],
|
||||
memories: [memory("m1", "第一条记忆"), memory("m2", "x".repeat(400))],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -640,10 +680,8 @@ describe("CLI-21 memory reads", () => {
|
||||
});
|
||||
expect(report.memories[0]).toMatchObject({
|
||||
memory_id: "m1",
|
||||
status: "confirmed",
|
||||
revision: 3,
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["e1"],
|
||||
status: "active",
|
||||
layer: "L2_FACT",
|
||||
content_truncated: false,
|
||||
});
|
||||
expect(report.memories[1]).toMatchObject({
|
||||
@@ -658,12 +696,8 @@ describe("CLI-21 memory reads", () => {
|
||||
[
|
||||
"memories",
|
||||
"list",
|
||||
"--query",
|
||||
"部署",
|
||||
"--status",
|
||||
"confirmed",
|
||||
"--source",
|
||||
"codex",
|
||||
"active",
|
||||
"--offset",
|
||||
"10",
|
||||
"--limit",
|
||||
@@ -672,30 +706,23 @@ describe("CLI-21 memory reads", () => {
|
||||
],
|
||||
{},
|
||||
).catch(() => undefined);
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v1/memory-library", {
|
||||
expect(transport.get).toHaveBeenCalledWith("/api/v2/memory-library", {
|
||||
params: {
|
||||
query: "部署",
|
||||
status: "confirmed",
|
||||
source: "codex",
|
||||
statuses: "active",
|
||||
offset: 10,
|
||||
limit: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a sensitive query from a file so it stays out of shell history", async () => {
|
||||
it("rejects retired memory query flags before calling Core", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-cli-query-"));
|
||||
const file = path.join(directory, "query.txt");
|
||||
await writeFile(file, "机密查询\n", { mode: 0o600 });
|
||||
await run(["memories", "list", "--query-file", file, "--json"], {}).catch(
|
||||
() => undefined,
|
||||
);
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
"/api/v1/memory-library",
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ query: "机密查询" }),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await run(["memories", "list", "--query-file", file, "--json"], {}),
|
||||
).toBe(2);
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects contradictory or unreadable query input before calling Core", async () => {
|
||||
@@ -705,7 +732,7 @@ describe("CLI-21 memory reads", () => {
|
||||
["memories", "list", "--limit", "0"],
|
||||
["memories", "list", "--limit", "501"],
|
||||
["memories", "list", "--offset", "-1"],
|
||||
["memories", "list", "--status", "unknown-status"],
|
||||
["memories", "list", "--status", ""],
|
||||
["memories", "show"],
|
||||
["memories"],
|
||||
]) {
|
||||
@@ -723,7 +750,7 @@ describe("CLI-21 memory reads", () => {
|
||||
it("distinguishes an empty page from a denied or missing one", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { total: 0, offset: 0, limit: 100, items: [] },
|
||||
data: { total: 0, offset: 0, limit: 100, memories: [] },
|
||||
});
|
||||
const empty = captureStreams();
|
||||
try {
|
||||
@@ -763,33 +790,10 @@ describe("CLI-21 memory reads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows one memory with its source, version and evidence", async () => {
|
||||
it("shows one SDK memory without fabricated Evidence or versions", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
memory: memory("m1", "记忆正文"),
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "e1",
|
||||
available: true,
|
||||
source_id: "codex",
|
||||
written_by: "user",
|
||||
created_at: "T",
|
||||
verbatim: "y".repeat(500),
|
||||
},
|
||||
],
|
||||
history: [
|
||||
{
|
||||
memory_id: "m1",
|
||||
revision: 3,
|
||||
action: "correct",
|
||||
status: "confirmed",
|
||||
content: "记忆正文",
|
||||
scopes: ["all-apps"],
|
||||
created_at: "T",
|
||||
},
|
||||
],
|
||||
},
|
||||
data: memory("m1", "记忆正文"),
|
||||
});
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
@@ -799,28 +803,17 @@ describe("CLI-21 memory reads", () => {
|
||||
}
|
||||
const report = JSON.parse(streams.stdout());
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
"/api/v1/memories/m1",
|
||||
"/api/v2/memories/m1",
|
||||
undefined,
|
||||
);
|
||||
expect(report.memory).toMatchObject({
|
||||
state: "ok",
|
||||
memory_id: "m1",
|
||||
revision: 3,
|
||||
scopes: ["all-apps"],
|
||||
});
|
||||
expect(report.evidence[0]).toMatchObject({
|
||||
evidence_id: "e1",
|
||||
source_id: "codex",
|
||||
excerpt_truncated: true,
|
||||
excerpt_characters: 500,
|
||||
});
|
||||
expect(report.history[0]).toMatchObject({
|
||||
revision: 3,
|
||||
action: "correct",
|
||||
status: "confirmed",
|
||||
scopes: ["all-apps"],
|
||||
content: "记忆正文",
|
||||
layer: "L2_FACT",
|
||||
status: "active",
|
||||
});
|
||||
expect(report.evidence).toEqual([]);
|
||||
expect(report.history).toEqual([]);
|
||||
});
|
||||
|
||||
// Core registers /api/v1/memories/graph before /{memory_id}, and that static
|
||||
@@ -840,6 +833,8 @@ describe("CLI-21 memory reads", () => {
|
||||
expect(isReadOnlyPath("/api/v1/memories/graph")).toBe(false);
|
||||
expect(isReadOnlyPath("/api/v1/memories/ask")).toBe(false);
|
||||
expect(isReadOnlyPath("/api/v1/memories/m1")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v2/memory-library")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v2/memories/m1")).toBe(true);
|
||||
expect(isReadOnlyPath("/api/v1/model-content-permission")).toBe(false);
|
||||
expect(
|
||||
isReadOnlyPath(
|
||||
@@ -1137,17 +1132,20 @@ function memory(id: string, content: string) {
|
||||
return {
|
||||
memory_id: id,
|
||||
user_id: "local",
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
agent_id: "one-memory",
|
||||
layer: "L2_FACT",
|
||||
status: "active",
|
||||
is_latest: true,
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
content,
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["e1"],
|
||||
origin: "extracted",
|
||||
tags: [],
|
||||
revision: 3,
|
||||
created_at: "T",
|
||||
updated_at: "T",
|
||||
deleted_at: null,
|
||||
owner: "user",
|
||||
source_raw_ids: ["raw-1"],
|
||||
session_id: "s1",
|
||||
source: "codex",
|
||||
memory_at: null,
|
||||
gmt_created: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ describe("calls clear", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("import-file", () => {
|
||||
describe.skip("retired import-file", () => {
|
||||
it("never uploads without --confirm", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "om-import-"));
|
||||
const file = path.join(directory, "export.json");
|
||||
@@ -356,7 +356,7 @@ describe("notes / extraction / graph / manual extract", () => {
|
||||
expect(JSON.parse(applied.stdout).state).toBe("committed");
|
||||
});
|
||||
|
||||
it("plans then applies the extraction window", async () => {
|
||||
it.skip("plans then applies the extraction window", async () => {
|
||||
const preview = await capturedRun([
|
||||
"extraction",
|
||||
"set",
|
||||
@@ -417,7 +417,7 @@ describe("notes / extraction / graph / manual extract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("plans manual extraction and applies it only on confirm", async () => {
|
||||
it.skip("plans manual extraction and applies it only on confirm", async () => {
|
||||
const preview = await capturedRun([
|
||||
"processing",
|
||||
"extract",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "one-memory"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
description = "MemHub local-first memory core"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -12,8 +12,9 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.4",
|
||||
# Retrieval's derived vector index directly consumes these locked versions.
|
||||
"hy-memory==2.0.5",
|
||||
# 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",
|
||||
"numpy==2.5.2",
|
||||
"httpx>=0.28.1",
|
||||
|
||||
@@ -42,12 +42,10 @@ TARGETED = (
|
||||
"tests/test_database_contention.py",
|
||||
"tests/test_main_database_fairness.py",
|
||||
"tests/test_soak_probe_effectiveness.py",
|
||||
"tests/test_evidence_reconcile_primitive.py",
|
||||
"tests/test_sqlite_semantics_gate.py",
|
||||
"tests/test_store_binding.py",
|
||||
"tests/test_store_binding_ratchet.py",
|
||||
"tests/test_main_database_entry.py",
|
||||
"tests/test_memory_processing.py",
|
||||
"tests/test_extraction_window.py",
|
||||
"tests/test_extraction_defer.py",
|
||||
"tests/test_active_memory_window.py",
|
||||
|
||||
@@ -2,11 +2,5 @@
|
||||
|
||||
import os
|
||||
|
||||
from one_memory.hy_runtime_config import configure_hy_behavior_environment
|
||||
|
||||
# HY has a few reader switches evaluated at module import time. Fix the
|
||||
# product-owned, non-secret behavior before any one_memory module imports HY.
|
||||
configure_hy_behavior_environment()
|
||||
|
||||
_version = os.environ.get("ONE_MEMORY_VERSION", "").strip()
|
||||
__version__ = _version if _version else "development"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -76,8 +76,7 @@ def _memory(db: sqlite3.Connection, reference: CallReference) -> SourceRecordRef
|
||||
if row["status"] == "deleted" or row["deleted_at"] is not None:
|
||||
return _hidden(reference)
|
||||
|
||||
# Same local revocation/ownership predicate as SourceReadAccess.memories,
|
||||
# evaluated BEFORE freshness (which can read support text), in this snapshot.
|
||||
# Same local revocation/ownership predicate as SourceReadAccess.memories.
|
||||
# Its public methods open their own connections, so cannot be used here.
|
||||
access = db.execute(
|
||||
"SELECT EXISTS (SELECT 1 FROM governed_memory_evidence "
|
||||
@@ -121,8 +120,8 @@ def resolve_call_references(database_path: Path, event: CallEvent) -> list[Sourc
|
||||
|
||||
Local-owner only: event host attribution is NOT authorization. Explicit local
|
||||
source revocations deny access; absent grants retain legacy local semantics.
|
||||
A memory needs at least one accessible active citation. No proof preserves
|
||||
shared freshness compatibility, not a claim of verified historical freshness.
|
||||
A memory needs at least one accessible active citation. Evidence freshness
|
||||
proofs are retired and do not affect availability.
|
||||
|
||||
Any read/parse/budget failure raises a fixed whole-request error, never a
|
||||
partial result or per-item deletion. The route owner must map this separately
|
||||
|
||||
@@ -29,13 +29,51 @@ def _end_by(seconds: str | None) -> None:
|
||||
if __name__ == "__main__" and sys.argv[1:2] == ["capture"]:
|
||||
_end_by(os.environ.get("ONE_MEMORY_CAPTURE_DEADLINE_S"))
|
||||
|
||||
import errno # noqa: E402
|
||||
import json # noqa: E402
|
||||
from collections.abc import Sequence # noqa: E402
|
||||
import re # noqa: E402
|
||||
from collections.abc import Iterator, Sequence # noqa: E402
|
||||
from typing import TextIO # noqa: E402
|
||||
|
||||
from one_memory.config import load_settings # noqa: E402
|
||||
from one_memory.runtime_client import RuntimeStatus, inspect_runtime # noqa: E402
|
||||
|
||||
# The startup dialog can only act on the name it is given: running out of disk
|
||||
# and a damaged backup are different next steps. These are the names that reach
|
||||
# it.
|
||||
_SAFE_MIGRATION_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$")
|
||||
_OUT_OF_SPACE = frozenset({errno.ENOSPC, getattr(errno, "EDQUOT", errno.ENOSPC)})
|
||||
|
||||
|
||||
def _exception_chain(error: BaseException) -> Iterator[BaseException]:
|
||||
seen: set[int] = set()
|
||||
current: BaseException | None = error
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
yield current
|
||||
current = current.__cause__ or current.__context__
|
||||
|
||||
|
||||
def migration_failure_code(
|
||||
error: BaseException, *, fallback: str = "sdk_migration_preflight_failed"
|
||||
) -> str:
|
||||
"""Name a migration failure; never guess a code from free text.
|
||||
|
||||
Errs toward the caller's generic code: an exception this does not recognize
|
||||
keeps its message out of the document Desktop reads.
|
||||
"""
|
||||
chain = list(_exception_chain(error))
|
||||
for item in chain:
|
||||
if isinstance(item, OSError) and item.errno in _OUT_OF_SPACE:
|
||||
return "sdk_migration_disk_space_insufficient"
|
||||
for item in chain:
|
||||
code = getattr(item, "code", "")
|
||||
if not isinstance(code, str) or not _SAFE_MIGRATION_CODE.match(code):
|
||||
continue
|
||||
if code.startswith(("sdk_data_plane_", "sdk_migration_")):
|
||||
return code
|
||||
return fallback
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if sys.argv[1:] == ["--keychain-read"]:
|
||||
@@ -75,6 +113,10 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int:
|
||||
command, *options = arguments
|
||||
if command == "restore":
|
||||
return restore(options, stream)
|
||||
if command == "sdk-migrate":
|
||||
return sdk_migrate(options, stream)
|
||||
if command == "sdk-migration-status":
|
||||
return sdk_migration_status(options, stream)
|
||||
if command == "boundary" and not options:
|
||||
from one_memory.workbuddy_boundary import write_hook
|
||||
|
||||
@@ -95,6 +137,8 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int:
|
||||
stream.write(
|
||||
"Usage: one-memory [serve [--maintenance|--reader]|status [--json]|doctor [--json]"
|
||||
"|restore --source <managed-backup> --yes [--json]"
|
||||
"|sdk-migrate --runtime-build <build> --yes [--json]"
|
||||
"|sdk-migration-status [--json]"
|
||||
"|boundary [--peek|--ack TOKEN]|capture SOURCE]\n"
|
||||
)
|
||||
return 2
|
||||
@@ -139,8 +183,6 @@ def restore(options: Sequence[str], stream: TextIO) -> int:
|
||||
return 2
|
||||
|
||||
settings = load_settings()
|
||||
from one_memory.managed_restore import RestoreRejected, restore_managed_backup
|
||||
|
||||
status = inspect_runtime(settings.data_dir)
|
||||
if status.state != "stopped":
|
||||
document = {
|
||||
@@ -160,8 +202,48 @@ def restore(options: Sequence[str], stream: TextIO) -> int:
|
||||
}
|
||||
_write_restore(stream, as_json, document, "预览通过;加 --yes 才会覆盖当前数据库。")
|
||||
return 4
|
||||
from one_memory.managed_restore import RestoreRejected, restore_managed_backup
|
||||
from one_memory.sdk_migration_bundle import (
|
||||
MigrationBundleRejected,
|
||||
restore_sdk_migration_bundle,
|
||||
)
|
||||
|
||||
is_sdk_bundle = source.endswith(".sdk-bundle")
|
||||
if is_sdk_bundle:
|
||||
try:
|
||||
sdk_result = restore_sdk_migration_bundle(
|
||||
data_dir=settings.data_dir,
|
||||
source=source,
|
||||
runtime_stopped=True,
|
||||
)
|
||||
except MigrationBundleRejected as error:
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": "failed",
|
||||
"code": error.code,
|
||||
}
|
||||
_write_restore(stream, as_json, document, f"恢复失败:{error.code}")
|
||||
return 1
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": "restored",
|
||||
"bundle_id": sdk_result.bundle_id,
|
||||
"restored_files": sdk_result.restored_files,
|
||||
"restored_bytes": sdk_result.restored_bytes,
|
||||
"previous_database_path": str(sdk_result.previous_database_path),
|
||||
"previous_sdk_root": (
|
||||
str(sdk_result.previous_sdk_root) if sdk_result.previous_sdk_root else None
|
||||
),
|
||||
}
|
||||
_write_restore(
|
||||
stream,
|
||||
as_json,
|
||||
document,
|
||||
f"已恢复完整迁移备份:{sdk_result.bundle_id}。",
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
result = restore_managed_backup(data_dir=settings.data_dir, source=source)
|
||||
managed_result = restore_managed_backup(data_dir=settings.data_dir, source=source)
|
||||
except RestoreRejected as error:
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
@@ -173,15 +255,15 @@ def restore(options: Sequence[str], stream: TextIO) -> int:
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": "restored",
|
||||
"restored_bytes": result.restored_bytes,
|
||||
"previous_backup_path": str(result.previous_backup_path),
|
||||
"source_schema_version": result.source_schema_version,
|
||||
"restored_bytes": managed_result.restored_bytes,
|
||||
"previous_backup_path": str(managed_result.previous_backup_path),
|
||||
"source_schema_version": managed_result.source_schema_version,
|
||||
}
|
||||
_write_restore(
|
||||
stream,
|
||||
as_json,
|
||||
document,
|
||||
f"已恢复:{result.previous_backup_path} 为保护性备份。",
|
||||
f"已恢复:{managed_result.previous_backup_path} 为保护性备份。",
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -198,6 +280,151 @@ def _write_restore(
|
||||
stream.write(f"{text}\n")
|
||||
|
||||
|
||||
def sdk_migrate(options: Sequence[str], stream: TextIO) -> int:
|
||||
"""Bring a stopped home to SDK authority in one resumable command.
|
||||
|
||||
Backs up the home, makes queue work independent of the legacy tables,
|
||||
commits SDK authority and drops those tables. The SDK already holds every
|
||||
memory and raw turn, so nothing is copied. With `--json`, each step is one
|
||||
progress line and the last line is the result.
|
||||
"""
|
||||
runtime_build: str | None = None
|
||||
confirmed = False
|
||||
as_json = False
|
||||
index = 0
|
||||
while index < len(options):
|
||||
option = options[index]
|
||||
if option == "--runtime-build" and index + 1 < len(options):
|
||||
runtime_build = options[index + 1]
|
||||
index += 2
|
||||
continue
|
||||
if option == "--yes":
|
||||
confirmed = True
|
||||
index += 1
|
||||
continue
|
||||
if option == "--json":
|
||||
as_json = True
|
||||
index += 1
|
||||
continue
|
||||
stream.write(f"one-memory: 参数无效:{option}\n")
|
||||
return 2
|
||||
if not runtime_build:
|
||||
stream.write("one-memory: sdk-migrate 需要 --runtime-build <build>\n")
|
||||
return 2
|
||||
|
||||
settings = load_settings()
|
||||
status = inspect_runtime(settings.data_dir)
|
||||
if status.state != "stopped":
|
||||
document = {
|
||||
"schema_version": 1,
|
||||
"state": "blocked",
|
||||
"code": "sdk_migration_runtime_running",
|
||||
"runtime_state": status.state,
|
||||
}
|
||||
_write_restore(stream, as_json, document, "Runtime 仍在运行;不能迁移。")
|
||||
return 4
|
||||
if not confirmed:
|
||||
document = {"schema_version": 1, "state": "preview", "requires_confirmation": True}
|
||||
_write_restore(stream, as_json, document, "预览通过;加 --yes 后执行迁移。")
|
||||
return 4
|
||||
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE, HY_MEMORY_VERSION
|
||||
from one_memory.sdk_migration_runner import run_startup_migration
|
||||
|
||||
step_names = {
|
||||
"bundle": "正在备份记忆数据……",
|
||||
"prepare": "正在整理待处理的写入任务……",
|
||||
"cutover": "正在切换数据源……",
|
||||
"vectors": "正在接回旧版本的向量数据……",
|
||||
"verify": "正在用新版本核对记忆能否读回……",
|
||||
"cleanup": "正在删除旧表……",
|
||||
}
|
||||
|
||||
def progress(step: str) -> None:
|
||||
if as_json:
|
||||
stream.write(
|
||||
json.dumps({"schema_version": 1, "state": "running", "step": step}) + "\n"
|
||||
)
|
||||
else:
|
||||
stream.write(f"{step_names.get(step, step)}\n")
|
||||
stream.flush()
|
||||
|
||||
try:
|
||||
result = run_startup_migration(
|
||||
data_dir=settings.data_dir,
|
||||
runtime_build=runtime_build,
|
||||
sdk_version=HY_MEMORY_VERSION,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
progress=progress,
|
||||
)
|
||||
except Exception as error:
|
||||
from one_memory.sdk_migration_verify import MemoryReadbackFailed
|
||||
|
||||
code = migration_failure_code(error)
|
||||
failed: dict[str, object] = {"schema_version": 1, "state": "failed", "code": code}
|
||||
if isinstance(error, MemoryReadbackFailed):
|
||||
failed["readback"] = error.report.as_document()
|
||||
_write_restore(stream, as_json, failed, f"迁移未完成:{code}")
|
||||
return 1
|
||||
messages = {
|
||||
"not_required": "不需要迁移。",
|
||||
"complete": "迁移已完成;遗留的旧表或向量已处理。",
|
||||
"cutover": "迁移完成:记忆数据已以 SDK 为准。",
|
||||
}
|
||||
_write_restore(stream, as_json, result.as_document(), messages[result.state])
|
||||
return 0
|
||||
|
||||
|
||||
def sdk_migration_status(options: Sequence[str], stream: TextIO) -> int:
|
||||
"""Read-only probe: does this home need `sdk-migrate`?"""
|
||||
if list(options) not in ([], ["--json"]):
|
||||
stream.write("one-memory: sdk-migration-status 只接受 --json\n")
|
||||
return 2
|
||||
as_json = "--json" in options
|
||||
settings = load_settings()
|
||||
database_path = settings.data_dir / "one-memory.sqlite3"
|
||||
if not database_path.is_file():
|
||||
document: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"authority": "sdk",
|
||||
"state": "not_required",
|
||||
}
|
||||
else:
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
|
||||
from one_memory.sdk_data_plane_migration import (
|
||||
MemoryAuthority,
|
||||
SdkDataPlaneMigrationStore,
|
||||
)
|
||||
from one_memory.sdk_migration_runner import abandoned_staging_present
|
||||
from one_memory.sdk_migration_verify import read_active_profile
|
||||
from one_memory.sdk_vector_reattach import legacy_vector_collections
|
||||
from one_memory.target_schema import legacy_tables_present
|
||||
|
||||
authority = SdkDataPlaneMigrationStore(database_path).authority()
|
||||
if authority is MemoryAuthority.SDK:
|
||||
profile = read_active_profile(database_path)
|
||||
leftovers = bool(
|
||||
legacy_tables_present(database_path)
|
||||
or abandoned_staging_present(
|
||||
data_dir=settings.data_dir,
|
||||
namespace=HY_MEMORY_NAMESPACE,
|
||||
)
|
||||
or (
|
||||
profile is not None
|
||||
and legacy_vector_collections(settings.data_dir, profile)
|
||||
)
|
||||
)
|
||||
state = "required" if leftovers else "not_required"
|
||||
else:
|
||||
state = "required"
|
||||
document = {"schema_version": 1, "authority": authority.value, "state": state}
|
||||
if as_json:
|
||||
stream.write(f"{json.dumps(document, ensure_ascii=False)}\n")
|
||||
else:
|
||||
stream.write(f"SDK migration: {document['state']} (authority={document['authority']})\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _status_text(command: str, status: RuntimeStatus) -> str:
|
||||
if status.state == "running":
|
||||
location = f"{status.host}:{status.port}"
|
||||
|
||||
@@ -23,10 +23,6 @@ class Settings(BaseSettings):
|
||||
live_sync_enabled: bool = True
|
||||
automatic_memory_processing: bool = True
|
||||
imports_enabled: bool = True
|
||||
# Experimental and unreleased: ownership/error shutdown still requires acceptance.
|
||||
# Resume only explicitly initialized generations; never migrate at startup.
|
||||
retrieval_index_enabled: bool = False
|
||||
retrieval_minimum_free_bytes: int = Field(default=256 * 1024 * 1024, ge=0)
|
||||
# Bounded metadata only: query, original text and answers are never stored.
|
||||
call_event_retention_days: int | None = Field(default=30, ge=1, le=365)
|
||||
call_event_max_events: int | None = Field(default=10_000, ge=1, le=100_000)
|
||||
|
||||
@@ -89,6 +89,51 @@ class MemoryContextItem(BaseModel):
|
||||
ContextItem = Annotated[RawContextItem | MemoryContextItem, Field(discriminator="kind")]
|
||||
|
||||
|
||||
class SdkRawContextItem(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
kind: Literal["raw"] = "raw"
|
||||
raw_id: str = Field(min_length=1, max_length=512)
|
||||
content: str = Field(max_length=20000)
|
||||
source: str = Field(min_length=1, max_length=128)
|
||||
session_id: str = Field(min_length=1, max_length=512)
|
||||
memory_at: datetime | None = None
|
||||
score: float = Field(ge=0, le=1)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class SdkMemoryContextItem(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
kind: Literal["memory"] = "memory"
|
||||
memory_id: str = Field(min_length=1, max_length=512)
|
||||
content: str = Field(max_length=20000)
|
||||
layer: str = Field(max_length=64)
|
||||
status: str = Field(max_length=32)
|
||||
source: str | None = Field(default=None, max_length=128)
|
||||
updated_at: datetime | None = None
|
||||
score: float = Field(ge=0, le=1)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
SdkContextItem = Annotated[
|
||||
SdkRawContextItem | SdkMemoryContextItem,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class SdkContextSearchResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
schema_version: Literal[2] = 2
|
||||
request_id: str = Field(min_length=1, max_length=64)
|
||||
state: Literal["grounded", "empty", "partial", "unavailable"]
|
||||
items: tuple[SdkContextItem, ...] = Field(max_length=20)
|
||||
used_characters: int = Field(ge=0, le=20000)
|
||||
truncated: bool
|
||||
total_ms: float = Field(ge=0)
|
||||
|
||||
|
||||
class ContextLaneResult(BaseModel):
|
||||
"""Internal bounded adapter result; the aggregator applies shared budgets."""
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.params import Depends
|
||||
@@ -31,13 +33,23 @@ from one_memory.context_models import (
|
||||
ContextSemanticStatus,
|
||||
MemoryContextItem,
|
||||
RawContextItem,
|
||||
SdkContextItem,
|
||||
SdkContextSearchResponse,
|
||||
SdkMemoryContextItem,
|
||||
SdkRawContextItem,
|
||||
)
|
||||
from one_memory.context_raw import revalidate_raw_context
|
||||
from one_memory.context_raw import read_raw_context, revalidate_raw_context
|
||||
from one_memory.context_search import search_context
|
||||
from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception
|
||||
from one_memory.external_memory_access import external_access
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_READER
|
||||
from one_memory.hy_sdk_runtime import sdk_relevance_floor
|
||||
from one_memory.hy_sdk_types import (
|
||||
HySdkMemoryRecord,
|
||||
parse_sdk_raw_search,
|
||||
parse_sdk_search,
|
||||
)
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.retrieval.raw_lane import read_raw_indexed_context
|
||||
from one_memory.semantic_deadline import hy_wait_budget, search_within
|
||||
|
||||
CALL_EVENT_RESPONSE_BUDGET_MS = 250
|
||||
@@ -86,12 +98,11 @@ def context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
read = asyncio.create_task(
|
||||
in_read_pool(
|
||||
raw_read_pool(),
|
||||
read_raw_indexed_context,
|
||||
read_raw_context,
|
||||
database,
|
||||
value,
|
||||
source_ids=authority.raw_sources,
|
||||
deadline=lane_end,
|
||||
index=getattr(request.app.state, "retrieval_runtime", None),
|
||||
)
|
||||
)
|
||||
done, _ = await asyncio.wait({read}, timeout=max(0, lane_end - time.monotonic()))
|
||||
@@ -420,3 +431,177 @@ def context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
return final
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def sdk_context_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
"""SDK-native context: no Evidence/Governance ids or fabricated revisions."""
|
||||
router = APIRouter(prefix="/api/v2/context", dependencies=dependencies)
|
||||
|
||||
@router.post("/search", response_model=SdkContextSearchResponse)
|
||||
async def search(
|
||||
value: ContextSearchRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> SdkContextSearchResponse:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
started = time.monotonic()
|
||||
access = external_access(request.headers)
|
||||
database = cast(EvidenceStore, request.app.state.evidence_store).database_path
|
||||
try:
|
||||
authority = await asyncio.to_thread(
|
||||
resolve_context_authority,
|
||||
value,
|
||||
access,
|
||||
database,
|
||||
deadline=started + value.timeout_ms / 1000,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(503, "context_authority_unavailable") from None
|
||||
|
||||
client_handle = getattr(request.app.state, "sdk_client_handle", None)
|
||||
if client_handle is None:
|
||||
raise HTTPException(503, "sdk_context_unavailable")
|
||||
try:
|
||||
client = await client_handle.client()
|
||||
except Exception:
|
||||
raise HTTPException(503, "sdk_context_unavailable") from None
|
||||
|
||||
async def raw_lane() -> tuple[SdkRawContextItem, ...]:
|
||||
if "raw" not in value.kinds or not authority.raw_allowed:
|
||||
return ()
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
value.query,
|
||||
user_ids=["local"],
|
||||
agent_ids=[],
|
||||
include_sources=list(authority.raw_sources or ()),
|
||||
limit=value.limit,
|
||||
min_score=0.0,
|
||||
schema_min_score=0.0,
|
||||
schema_limit=0,
|
||||
intention_limit=0,
|
||||
scene="chat",
|
||||
reader=HY_MEMORY_READER,
|
||||
layers=["l1_raw"],
|
||||
)
|
||||
hits = parse_sdk_raw_search(raw)
|
||||
return tuple(
|
||||
SdkRawContextItem(
|
||||
raw_id=hit.raw_id,
|
||||
content=hit.content,
|
||||
source=hit.source,
|
||||
session_id=hit.session_id,
|
||||
memory_at=(
|
||||
datetime.fromtimestamp(hit.memory_at, tz=UTC)
|
||||
if hit.memory_at is not None
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
)
|
||||
for hit in hits
|
||||
)
|
||||
|
||||
async def memory_lane() -> tuple[SdkMemoryContextItem, ...]:
|
||||
if "memory" not in value.kinds or not authority.memory_allowed:
|
||||
return ()
|
||||
raw = await asyncio.to_thread(
|
||||
client.search,
|
||||
value.query,
|
||||
user_ids=["local"],
|
||||
agent_ids=[],
|
||||
limit=value.limit,
|
||||
min_score=sdk_relevance_floor(),
|
||||
schema_min_score=sdk_relevance_floor(),
|
||||
schema_limit=value.limit,
|
||||
intention_limit=value.limit,
|
||||
scene="normal",
|
||||
reader=HY_MEMORY_READER,
|
||||
)
|
||||
hits = parse_sdk_search(raw, error_code="hy_memory_search_shape_unknown")
|
||||
items: list[SdkMemoryContextItem] = []
|
||||
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":
|
||||
continue
|
||||
items.append(
|
||||
SdkMemoryContextItem(
|
||||
memory_id=memory.memory_id,
|
||||
content=(
|
||||
hit.formatted_content
|
||||
if hit.sdk_formatted
|
||||
and hit.formatting_complete
|
||||
and hit.formatted_content
|
||||
else memory.content
|
||||
),
|
||||
layer=memory.layer,
|
||||
status=memory.status,
|
||||
source=memory.source,
|
||||
updated_at=(
|
||||
datetime.fromtimestamp(memory.gmt_created, tz=UTC)
|
||||
if memory.gmt_created is not None
|
||||
else None
|
||||
),
|
||||
score=hit.score,
|
||||
)
|
||||
)
|
||||
return tuple(items)
|
||||
|
||||
lanes = (raw_lane(), memory_lane())
|
||||
try:
|
||||
raw_items, memory_items = await asyncio.wait_for(
|
||||
asyncio.gather(*lanes, return_exceptions=True),
|
||||
timeout=value.timeout_ms / 1000,
|
||||
)
|
||||
except TimeoutError:
|
||||
return SdkContextSearchResponse(
|
||||
request_id=str(uuid4()),
|
||||
state="unavailable",
|
||||
items=(),
|
||||
used_characters=0,
|
||||
truncated=True,
|
||||
total_ms=(time.monotonic() - started) * 1000,
|
||||
)
|
||||
partial = isinstance(raw_items, Exception) or isinstance(memory_items, Exception)
|
||||
raw_candidates = (
|
||||
() if isinstance(raw_items, Exception) else cast(tuple[SdkContextItem, ...], raw_items)
|
||||
)
|
||||
memory_candidates = (
|
||||
()
|
||||
if isinstance(memory_items, Exception)
|
||||
else cast(tuple[SdkContextItem, ...], memory_items)
|
||||
)
|
||||
candidates: list[SdkContextItem] = [
|
||||
*raw_candidates,
|
||||
*memory_candidates,
|
||||
]
|
||||
candidates.sort(key=lambda item: item.score, reverse=True)
|
||||
selected: list[SdkContextItem] = []
|
||||
used = 0
|
||||
truncated = False
|
||||
for item in candidates:
|
||||
if len(selected) >= value.limit:
|
||||
truncated = True
|
||||
break
|
||||
content = item.content
|
||||
remaining = value.character_budget - used
|
||||
if remaining <= 0:
|
||||
truncated = True
|
||||
break
|
||||
if len(content) > remaining:
|
||||
item = item.model_copy(update={"content": content[:remaining], "truncated": True})
|
||||
truncated = True
|
||||
selected.append(item)
|
||||
used += len(item.content)
|
||||
return SdkContextSearchResponse(
|
||||
request_id=str(uuid4()),
|
||||
state=("partial" if partial else "grounded" if selected else "empty"),
|
||||
items=tuple(selected),
|
||||
used_characters=used,
|
||||
truncated=truncated,
|
||||
total_ms=(time.monotonic() - started) * 1000,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -22,7 +22,7 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol, cast
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from one_memory.background_work import interactive_read_pool
|
||||
from one_memory.context_expand import ExpansionAuthority, ExpansionRefSigner
|
||||
@@ -36,23 +36,8 @@ from one_memory.retrieval.models import (
|
||||
RawEvidence,
|
||||
RetrievalPath,
|
||||
RetrievalResponse,
|
||||
TimeRange,
|
||||
)
|
||||
from one_memory.retrieval.query_plan import plan_local_query
|
||||
from one_memory.retrieval.raw_candidates import RawCandidateBatch
|
||||
from one_memory.retrieval.raw_evidence import materialize_raw_candidates
|
||||
|
||||
|
||||
class RawIndexLookup(Protocol):
|
||||
def lookup_raw(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
source_ids: frozenset[str],
|
||||
deadline: float,
|
||||
time_range: TimeRange | None = None,
|
||||
) -> tuple[RawCandidateBatch, bool]: ...
|
||||
|
||||
|
||||
ExpandRefs = tuple[ExpansionRefSigner, ExpansionAuthority]
|
||||
|
||||
@@ -106,11 +91,6 @@ class LaneResult:
|
||||
coverage_limited: bool = False
|
||||
|
||||
|
||||
def _semantic_only_raw(item: RawEvidence) -> bool:
|
||||
"""Semantic overlap alone is a candidate, not a confirmed recall item."""
|
||||
return set(item.matched_by) == {"semantic"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageTimings:
|
||||
raw_ms: float = 0.0
|
||||
@@ -191,12 +171,6 @@ def _managed(
|
||||
return selected, truncated
|
||||
|
||||
|
||||
def _group_missing_requirements(item: RawEvidence | GovernedEvidence) -> list[str]:
|
||||
if isinstance(item, RawEvidence) and _semantic_only_raw(item):
|
||||
return ["relevance_confirmation"]
|
||||
return []
|
||||
|
||||
|
||||
def _path(
|
||||
status: str,
|
||||
reason: str | None,
|
||||
@@ -296,8 +270,7 @@ def read_lexical_raw_lane(
|
||||
("lexical", "object") if plan.object_keys else ("lexical",),
|
||||
truncated,
|
||||
# Starvation means: this lane matched, then dropped the item whole. An
|
||||
# ordinary no-match must stay empty, and an unconfigured semantic lane
|
||||
# is not starvation.
|
||||
# ordinary no-match must stay empty.
|
||||
starved and not items,
|
||||
probe_saturated,
|
||||
)
|
||||
@@ -564,97 +537,6 @@ def warm_raw_read_path(
|
||||
return False
|
||||
|
||||
|
||||
def read_semantic_raw_lane(
|
||||
index: RawIndexLookup | None,
|
||||
database_path: Path,
|
||||
query: CoordinatorQuery,
|
||||
*,
|
||||
source_ids: frozenset[str],
|
||||
deadline: float,
|
||||
) -> LaneResult:
|
||||
if not source_ids:
|
||||
return LaneResult((), "unavailable", "permission_required", (), False)
|
||||
# No derived index is the no-model baseline, not a failure: the lexical lane
|
||||
# must still answer. Semantic is genuinely unsearched in that state, so it
|
||||
# must not claim a channel or a coverage result it never produced.
|
||||
if index is None:
|
||||
return LaneResult((), "not_searched", None, (), False)
|
||||
try:
|
||||
batch, unpublished = index.lookup_raw(
|
||||
query.query,
|
||||
source_ids=source_ids,
|
||||
deadline=deadline,
|
||||
time_range=plan_local_query(query.query).time_range,
|
||||
)
|
||||
except Exception:
|
||||
return LaneResult((), "unavailable", "index_unavailable", ("semantic",), False)
|
||||
if not isinstance(batch, RawCandidateBatch):
|
||||
return LaneResult((), "unavailable", "index_unavailable", ("semantic",), False)
|
||||
if batch.reason == "not_authorized":
|
||||
return LaneResult((), "unavailable", "permission_required", ("semantic",), False)
|
||||
if batch.reason == "deadline":
|
||||
return LaneResult((), "unavailable", "timeout_budget", ("semantic",), True)
|
||||
if batch.reason in {"vector_unavailable", "index_unavailable"}:
|
||||
return LaneResult((), "unavailable", batch.reason, ("semantic",), False)
|
||||
store, generation = _index_state(index, database_path)
|
||||
from one_memory.retrieval.index_store import RetrievalIndexStore
|
||||
|
||||
store = cast("RetrievalIndexStore | None", store)
|
||||
if store is None or generation is None:
|
||||
return LaneResult((), "unavailable", "index_unavailable", ("semantic",), False)
|
||||
try:
|
||||
materialized = materialize_raw_candidates(
|
||||
store,
|
||||
batch,
|
||||
generation_id=generation,
|
||||
source_ids=source_ids,
|
||||
deadline=deadline,
|
||||
character_budget=query.character_budget,
|
||||
)
|
||||
except Exception:
|
||||
return LaneResult((), "unavailable", "index_unavailable", ("semantic",), False)
|
||||
if materialized.reason == "deadline":
|
||||
return LaneResult((), "unavailable", "timeout_budget", ("semantic",), True)
|
||||
if materialized.reason in {"index_unavailable", "not_authorized"}:
|
||||
return LaneResult((), "unavailable", materialized.reason, ("semantic",), False)
|
||||
complete = materialized.complete and not unpublished
|
||||
if materialized.reason == "evidence_changed":
|
||||
status: Literal["ok", "empty", "unavailable", "not_searched"] = "unavailable"
|
||||
reason = "evidence_changed"
|
||||
elif not materialized.items and not complete:
|
||||
status, reason = "unavailable", "index_incomplete"
|
||||
elif materialized.reason == "character_budget":
|
||||
status, reason = "ok", None
|
||||
else:
|
||||
status, reason = ("ok" if materialized.items else "empty"), None
|
||||
return LaneResult(
|
||||
tuple(materialized.items),
|
||||
status,
|
||||
reason,
|
||||
("semantic",),
|
||||
materialized.reason == "character_budget",
|
||||
)
|
||||
|
||||
|
||||
def _index_state(index: RawIndexLookup, database_path: Path) -> tuple[object | None, str | None]:
|
||||
store = getattr(index, "_store", None)
|
||||
generation = None
|
||||
snapshot = getattr(index, "snapshot", None)
|
||||
if snapshot is not None:
|
||||
try:
|
||||
generation = snapshot().generation_id
|
||||
except Exception:
|
||||
generation = None
|
||||
if store is None:
|
||||
try:
|
||||
from one_memory.retrieval.index_store import RetrievalIndexStore
|
||||
|
||||
store = RetrievalIndexStore(database_path)
|
||||
except Exception:
|
||||
store = None
|
||||
return store, generation
|
||||
|
||||
|
||||
def read_memory_lane(
|
||||
database_path: Path,
|
||||
query: CoordinatorQuery,
|
||||
@@ -857,7 +739,6 @@ def coordinate_context_v2(
|
||||
query: CoordinatorQuery,
|
||||
*,
|
||||
database_path: Path,
|
||||
index: RawIndexLookup | None = None,
|
||||
deadline: float,
|
||||
expand_refs: ExpandRefs | None = None,
|
||||
) -> RetrievalResponse:
|
||||
@@ -871,62 +752,29 @@ def coordinate_context_v2(
|
||||
lexical = read_lexical_raw_lane(
|
||||
database_path, query, source_ids=authority.raw_sources, deadline=deadline
|
||||
)
|
||||
semantic = read_semantic_raw_lane(
|
||||
index, database_path, query, source_ids=authority.raw_sources, deadline=deadline
|
||||
)
|
||||
timings = StageTimings(raw_ms=(time.monotonic() - raw_started) * 1000)
|
||||
combined: dict[tuple[str, str], RawEvidence] = {}
|
||||
for item in (*lexical.items, *semantic.items):
|
||||
if not isinstance(item, RawEvidence):
|
||||
continue
|
||||
identity = _identity(item)
|
||||
previous = combined.get(identity)
|
||||
if previous is None or _semantic_only_raw(previous):
|
||||
combined[identity] = item
|
||||
# Semantic similarity is a candidate signal, not a verified recall
|
||||
# result. Keep semantic-only candidates visible in coverage, but do not
|
||||
# release their bodies for host injection unless the item is
|
||||
# independently confirmed by the lexical/object lane. A global distance
|
||||
# threshold is deliberately not used: the measured positive and
|
||||
# negative distributions overlap.
|
||||
semantic_only = {_identity(item) for item in combined.values() if _semantic_only_raw(item)}
|
||||
confirmed = {_identity(item) for item in lexical.items if isinstance(item, RawEvidence)}
|
||||
deferred_raw = semantic_only - confirmed
|
||||
if deferred_raw:
|
||||
combined = {
|
||||
identity: item
|
||||
for identity, item in combined.items()
|
||||
if identity not in deferred_raw
|
||||
}
|
||||
channels = tuple(dict.fromkeys((*lexical.channels, *semantic.channels)))
|
||||
for item in lexical.items:
|
||||
if isinstance(item, RawEvidence):
|
||||
combined.setdefault(_identity(item), item)
|
||||
if lexical.status == "unavailable":
|
||||
status: Literal["ok", "empty", "unavailable", "not_searched", "degraded"] = (
|
||||
"unavailable"
|
||||
)
|
||||
reason = lexical.reason
|
||||
elif semantic.status == "unavailable":
|
||||
status = "unavailable"
|
||||
reason = semantic.reason
|
||||
elif combined:
|
||||
status, reason = "ok", semantic.reason if semantic.status != "ok" else None
|
||||
elif deferred_raw:
|
||||
status, reason = "degraded", "relevance_confirmation"
|
||||
elif lexical.status == "empty":
|
||||
status, reason = ("empty" if semantic.status != "not_searched" else "ok"), None
|
||||
elif combined or lexical.status == "empty":
|
||||
status, reason = "ok", None
|
||||
else:
|
||||
status, reason = "empty", None
|
||||
truncated = lexical.truncated or semantic.truncated
|
||||
raw = LaneResult(
|
||||
tuple(combined.values()),
|
||||
status,
|
||||
reason,
|
||||
channels,
|
||||
truncated,
|
||||
lexical.channels,
|
||||
lexical.truncated,
|
||||
lexical.starved,
|
||||
lexical.coverage_limited,
|
||||
)
|
||||
if deferred_raw:
|
||||
object.__setattr__(raw, "coverage_limited", True)
|
||||
elif "raw" in query.kinds and not authority.raw_allowed:
|
||||
raw = LaneResult((), "unavailable", "permission_required", (), False)
|
||||
if "memory" in query.kinds:
|
||||
@@ -995,9 +843,7 @@ def coordinate_context_v2(
|
||||
return _path("unavailable", raw.reason, raw.channels, "not_searched", False, 0)
|
||||
if dropped and not final_raw:
|
||||
return _path("unavailable", "permission_required", raw.channels, "unavailable", True, 0)
|
||||
# Lexical-only is complete when the lexical lane actually answered. An
|
||||
# unconfigured semantic lane is a capability absence, not proof that
|
||||
# less than the authorized range was searched.
|
||||
# The raw lane is complete when the lexical lane actually answered.
|
||||
complete = (
|
||||
raw.status in {"ok", "empty"}
|
||||
and not dropped
|
||||
@@ -1022,9 +868,7 @@ def coordinate_context_v2(
|
||||
"count_budget"
|
||||
if truncated
|
||||
else "coverage_budget"
|
||||
if raw.coverage_limited and not deferred_raw
|
||||
else "relevance_confirmation"
|
||||
if deferred_raw
|
||||
if raw.coverage_limited
|
||||
else "character_budget"
|
||||
)
|
||||
return _path(
|
||||
@@ -1112,7 +956,7 @@ def coordinate_context_v2(
|
||||
"group_id": f"group-{index}",
|
||||
"answer_target": query.intent,
|
||||
"decision_status": "missing_confirmation",
|
||||
"missing_requirements": _group_missing_requirements(item),
|
||||
"missing_requirements": [],
|
||||
"object_refs": _object_refs(item, plan.object_keys),
|
||||
"items": [group_item.model_dump(mode="json") for group_item in (item,)],
|
||||
}
|
||||
@@ -1185,7 +1029,6 @@ async def search_context_v2(
|
||||
query: CoordinatorQuery,
|
||||
*,
|
||||
database_path: Path,
|
||||
index: RawIndexLookup | None = None,
|
||||
deadline: float,
|
||||
expand_refs: ExpandRefs | None = None,
|
||||
) -> RetrievalResponse:
|
||||
@@ -1198,7 +1041,6 @@ async def search_context_v2(
|
||||
authority,
|
||||
query,
|
||||
database_path=database_path,
|
||||
index=index,
|
||||
deadline=deadline,
|
||||
expand_refs=expand_refs,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Pure conversation-window helpers shared by capture, retrieval, and writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from one_memory.imports.models import EvidenceContentKind, EvidenceOrigin, EvidenceRecord
|
||||
from one_memory.memory_tasks import segment_evidence_record
|
||||
|
||||
MEMORY_TASK_TIMEOUT_MARGIN_SECONDS = 60.0
|
||||
MEMORY_TASK_TIMEOUT_MIN_SECONDS = 180.0
|
||||
MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES = 512
|
||||
|
||||
_HARNESS_PREFIXES = (
|
||||
"<codex_internal_context",
|
||||
"# agents.md instructions",
|
||||
"<environment_context>",
|
||||
"<permissions instructions>",
|
||||
"<system-reminder>",
|
||||
"<system_notification>",
|
||||
"<instructions>",
|
||||
)
|
||||
_HOST_ENVELOPE = re.compile(
|
||||
r"(?:"
|
||||
r"^<timestamp>.*?<system_(?:notification|reminder)>|"
|
||||
r"^<system_(?:notification|reminder)>|"
|
||||
r'^\{\s*"request_id"\s*:.*?"steps"\s*:'
|
||||
r")",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_WHOLE_TURN_CHARACTERS = 60_000
|
||||
_WINDOW_BYTE_CEILING = 2_000_000
|
||||
_HISTORY_MERGE_TURNS = 8
|
||||
_HISTORY_MERGE_CHARACTERS = 8_000
|
||||
|
||||
|
||||
def memory_task_timeout_seconds(model_timeout_seconds: float) -> float:
|
||||
"""Bound a whole write without treating one model request as its budget."""
|
||||
return max(
|
||||
MEMORY_TASK_TIMEOUT_MIN_SECONDS,
|
||||
float(model_timeout_seconds) + MEMORY_TASK_TIMEOUT_MARGIN_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _coding_tool_result_max_bytes() -> int:
|
||||
raw = os.environ.get("MEMORY_CODING_TOOL_RESULT_MAX_BYTES")
|
||||
if raw is None:
|
||||
return MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
|
||||
return value if value > 0 else MEMHUB_DEFAULT_CODING_TOOL_RESULT_MAX_BYTES
|
||||
|
||||
|
||||
_HY_TOOL_CHARACTERS = _coding_tool_result_max_bytes()
|
||||
|
||||
|
||||
def _conversation_chunks(
|
||||
records: list[EvidenceRecord],
|
||||
*,
|
||||
max_characters: int | None = None,
|
||||
max_turns: int | None = None,
|
||||
completed_only: bool = False,
|
||||
) -> list[tuple[EvidenceRecord, ...]]:
|
||||
"""Build bounded, complete user/assistant/tool windows."""
|
||||
turns: list[list[EvidenceRecord]] = []
|
||||
for record in records:
|
||||
if not _is_hy_turn_record(record):
|
||||
continue
|
||||
pending_proposal = bool(turns) and not _opens_turn(turns[-1][0])
|
||||
if not turns or (_opens_turn(record) and not pending_proposal):
|
||||
turns.append([record])
|
||||
else:
|
||||
turns[-1].append(record)
|
||||
|
||||
if completed_only and turns and _trailing_user_turn_is_open(turns[-1]):
|
||||
turns.pop()
|
||||
|
||||
whole = _whole_turns() and max_characters is None
|
||||
if whole:
|
||||
size = _hy_read_length
|
||||
max_characters = _WHOLE_TURN_CHARACTERS
|
||||
max_turns = max_turns or 1_000_000
|
||||
else:
|
||||
size = lambda record: len(record.verbatim) # noqa: E731
|
||||
max_characters = max_characters or 12_000
|
||||
max_turns = max_turns or 24
|
||||
|
||||
chunks: list[tuple[EvidenceRecord, ...]] = []
|
||||
for turn in turns:
|
||||
original_question = turn[0] if _opens_turn(turn[0]) else None
|
||||
expanded: list[EvidenceRecord] = []
|
||||
for record in turn:
|
||||
reserve = (
|
||||
size(original_question)
|
||||
if original_question is not None
|
||||
and record is not original_question
|
||||
and size(original_question) < max_characters
|
||||
else 0
|
||||
)
|
||||
segment_limit = max(1, max_characters - reserve)
|
||||
if size(record) <= segment_limit:
|
||||
expanded.append(record)
|
||||
continue
|
||||
spans = _semantic_segment_spans(record.verbatim, segment_limit)
|
||||
expanded.extend(
|
||||
segment_evidence_record(record, index=index, start=start, end=end)
|
||||
if len(spans) > 1
|
||||
else record
|
||||
for index, (start, end) in enumerate(spans)
|
||||
)
|
||||
question = expanded[0] if expanded and _opens_turn(expanded[0]) else None
|
||||
current: list[EvidenceRecord] = []
|
||||
for record in expanded:
|
||||
if current and (
|
||||
len(current) >= max_turns
|
||||
or sum(size(item) for item in current) + size(record) > max_characters
|
||||
or sum(_window_bytes(item) for item in current) + _window_bytes(record)
|
||||
> _WINDOW_BYTE_CEILING
|
||||
):
|
||||
chunks.append(tuple(current))
|
||||
current = (
|
||||
[question]
|
||||
if question is not None
|
||||
and max_turns > 1
|
||||
and size(question) + size(record) <= max_characters
|
||||
else []
|
||||
)
|
||||
current.append(record)
|
||||
chunks.append(tuple(current))
|
||||
return _merge_tool_only_chunks(chunks)
|
||||
|
||||
|
||||
def _semantic_segment_spans(text: str, limit: int) -> list[tuple[int, int]]:
|
||||
if len(text) <= limit:
|
||||
return [(0, len(text))]
|
||||
spans: list[tuple[int, int]] = []
|
||||
start = 0
|
||||
separators = ("\n\n", "\n", "。", "!", "?", ". ", "! ", "? ")
|
||||
while start < len(text):
|
||||
hard_end = min(len(text), start + limit)
|
||||
if hard_end == len(text):
|
||||
end = hard_end
|
||||
else:
|
||||
floor = start + max(1, limit // 2)
|
||||
candidates = [text.rfind(separator, floor, hard_end) for separator in separators]
|
||||
boundary = max(candidates)
|
||||
if boundary < floor:
|
||||
end = hard_end
|
||||
else:
|
||||
matched = next(
|
||||
separator for separator in separators if text.startswith(separator, boundary)
|
||||
)
|
||||
end = boundary + len(matched)
|
||||
spans.append((start, end))
|
||||
start = end
|
||||
return spans
|
||||
|
||||
|
||||
def _merge_tool_only_chunks(
|
||||
chunks: list[tuple[EvidenceRecord, ...]],
|
||||
) -> list[tuple[EvidenceRecord, ...]]:
|
||||
if not any(chunk and all(record.role == "tool" for record in chunk) for chunk in chunks):
|
||||
return chunks
|
||||
merged: list[tuple[EvidenceRecord, ...]] = []
|
||||
leading_tools: list[EvidenceRecord] = []
|
||||
for chunk in chunks:
|
||||
if not chunk:
|
||||
continue
|
||||
if all(record.role == "tool" for record in chunk):
|
||||
if merged:
|
||||
merged[-1] = (*merged[-1], *chunk)
|
||||
else:
|
||||
leading_tools.extend(chunk)
|
||||
continue
|
||||
if leading_tools:
|
||||
chunk = (*leading_tools, *chunk)
|
||||
leading_tools = []
|
||||
merged.append(chunk)
|
||||
return [] if leading_tools else merged
|
||||
|
||||
|
||||
def _merge_historical_chunks(
|
||||
chunks: list[tuple[EvidenceRecord, ...]],
|
||||
*,
|
||||
max_turns: int = _HISTORY_MERGE_TURNS,
|
||||
max_characters: int = _HISTORY_MERGE_CHARACTERS,
|
||||
) -> list[tuple[EvidenceRecord, ...]]:
|
||||
if not chunks:
|
||||
return []
|
||||
merged: list[tuple[EvidenceRecord, ...]] = []
|
||||
current: list[EvidenceRecord] = []
|
||||
current_turns = 0
|
||||
current_characters = 0
|
||||
for chunk in chunks:
|
||||
chunk_characters = sum(len(record.verbatim) for record in chunk)
|
||||
if current and (
|
||||
current_turns >= max_turns or current_characters + chunk_characters > max_characters
|
||||
):
|
||||
merged.append(tuple(current))
|
||||
current = []
|
||||
current_turns = 0
|
||||
current_characters = 0
|
||||
current.extend(chunk)
|
||||
current_turns += 1
|
||||
current_characters += chunk_characters
|
||||
if current:
|
||||
merged.append(tuple(current))
|
||||
return merged
|
||||
|
||||
|
||||
def _chunk_input_ref(chunk: tuple[EvidenceRecord, ...]) -> str:
|
||||
user_records = tuple(item for item in chunk if item.role == "user")
|
||||
return user_records[-1].evidence_id if user_records else chunk[-1].evidence_id
|
||||
|
||||
|
||||
def _opens_turn(record: EvidenceRecord) -> bool:
|
||||
return record.role == "user"
|
||||
|
||||
|
||||
def _trailing_user_turn_is_open(turn: list[EvidenceRecord]) -> bool:
|
||||
if len(turn) != 1:
|
||||
return False
|
||||
opener = turn[0]
|
||||
return (
|
||||
_opens_turn(opener)
|
||||
and opener.content_kind is EvidenceContentKind.RAW_CONVERSATION
|
||||
and not _is_memory_note(opener)
|
||||
)
|
||||
|
||||
|
||||
def _is_hy_turn_record(record: EvidenceRecord) -> bool:
|
||||
if record.role == "tool":
|
||||
return record.content_kind is EvidenceContentKind.RAW_CONVERSATION and bool(
|
||||
record.verbatim.strip()
|
||||
)
|
||||
return _is_hy_input_turn(record)
|
||||
|
||||
|
||||
def _is_hy_input_turn(record: EvidenceRecord) -> bool:
|
||||
source = record.verbatim.strip()
|
||||
if not source:
|
||||
return False
|
||||
if record.content_kind is EvidenceContentKind.MEMORY_CONTENT:
|
||||
return True
|
||||
if record.role == "assistant":
|
||||
return True
|
||||
if record.role != "user":
|
||||
return False
|
||||
if source.casefold().startswith(_HARNESS_PREFIXES):
|
||||
return False
|
||||
return _HOST_ENVELOPE.search(source[:2_000]) is None
|
||||
|
||||
|
||||
def _is_memory_note(record: EvidenceRecord) -> bool:
|
||||
meta = record.origin_meta.get(EvidenceOrigin.MANUAL_IMPORT)
|
||||
return (
|
||||
record.source_id == "one-memory-manual"
|
||||
and meta is not None
|
||||
and meta.raw_meta.get("input_mode") == "direct_memory_note"
|
||||
)
|
||||
|
||||
|
||||
def _window_bytes(record: EvidenceRecord) -> int:
|
||||
return len(record.verbatim.encode("utf-8"))
|
||||
|
||||
|
||||
def _whole_turns() -> bool:
|
||||
return os.environ.get("ONE_MEMORY_SPLIT_TURNS", "").strip() != "1"
|
||||
|
||||
|
||||
def _hy_read_length(record: EvidenceRecord) -> int:
|
||||
if record.role == "tool":
|
||||
return min(len(record.verbatim), _HY_TOOL_CHARACTERS)
|
||||
return len(record.verbatim)
|
||||
@@ -20,8 +20,10 @@ from __future__ import annotations
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -72,80 +74,30 @@ class _Refused(OSError):
|
||||
"""The path is not one we are willing to write to, and never will be."""
|
||||
|
||||
|
||||
DiagnosticLevel = Literal["info", "warn", "error"]
|
||||
DiagnosticLevel = Literal["debug", "info", "warn", "error"]
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||||
_EVENT = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+){0,15}$")
|
||||
_DETAIL_KEYS = frozenset(
|
||||
{
|
||||
"action",
|
||||
"attempt",
|
||||
"count",
|
||||
"method",
|
||||
"mode",
|
||||
"phase",
|
||||
"platform",
|
||||
"reason",
|
||||
"result_count",
|
||||
"source_id",
|
||||
"spawned",
|
||||
"status",
|
||||
"version",
|
||||
"parent_session_id",
|
||||
"invalid_lines",
|
||||
"truncated",
|
||||
"open_file_count",
|
||||
"memory_bytes",
|
||||
"thread_count",
|
||||
"database_error_count",
|
||||
"route_path",
|
||||
"sqlite_errorcode",
|
||||
"sqlite_errorname",
|
||||
"file_count",
|
||||
"sqlite_file_count",
|
||||
"byte_file_count",
|
||||
"total_bytes",
|
||||
"copied_bytes",
|
||||
"bytes_per_second",
|
||||
"stored_count",
|
||||
"revised_count",
|
||||
"skipped_count",
|
||||
"error_count",
|
||||
"pending_count",
|
||||
"prepared_count",
|
||||
"queue_depth",
|
||||
"in_flight_count",
|
||||
"call_count",
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"superseded_count",
|
||||
"deleted_count",
|
||||
"operation_kind",
|
||||
"index_complete",
|
||||
"lexical_hits",
|
||||
"semantic_hits",
|
||||
"generation",
|
||||
"timed_out",
|
||||
"failure_site",
|
||||
"error_class",
|
||||
"file_kind",
|
||||
"file_size",
|
||||
"file_mtime_ns",
|
||||
"file_inode",
|
||||
"cursor_new",
|
||||
"cursor_old",
|
||||
"cursor_read_to_end",
|
||||
"cursor_rewind_branch",
|
||||
"summed_ms",
|
||||
"model_ms",
|
||||
"accounted_ms",
|
||||
"unaccounted_ms",
|
||||
"conversation_hash",
|
||||
# The sedimentation switch: a bounded boolean, the same shape as
|
||||
# `spawned` and `index_complete`. Left out of this list it did not
|
||||
# drop a log line, it turned the switch's own request into a 500.
|
||||
"enabled",
|
||||
}
|
||||
_DIAGNOSTIC_LOG_NAME = re.compile(
|
||||
r"^diagnostic-(runtime|core|host-bridge|desktop|cli)\.jsonl(?:\.\d+)?$"
|
||||
)
|
||||
_DETAIL_KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
|
||||
_CODE_FRAME = re.compile(
|
||||
r"^(?:(?:apps|packages|scripts)/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+:\d+:\d+"
|
||||
r"|one_memory/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_.-]+:\d+:[A-Za-z_][A-Za-z0-9_]*"
|
||||
r"|[A-Za-z0-9_][A-Za-z0-9_.-]*\.py:\d+:[A-Za-z_][A-Za-z0-9_]*)$"
|
||||
)
|
||||
_SHORT_CREDENTIAL_TOKEN = re.compile(
|
||||
r"(?<![A-Za-z0-9])([A-Za-z0-9]{24,47})(?:[_-])?(?![A-Za-z0-9])"
|
||||
)
|
||||
_REFUSED_DETAIL_KEYS = frozenset({"__proto__", "prototype", "constructor"})
|
||||
_MAX_DETAIL_KEYS = 32
|
||||
_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
_EXPORT_SECRET_MARKER = re.compile(
|
||||
r"(?:^|[._:-])(?:authorization|credential|password|prompt|query|secret|token)(?:$|[._:-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TRACE_STRING_DETAIL_KEYS = frozenset({"phase", "mode", "status", "reason", "failure_site"})
|
||||
_TRACE_NUMBER_DETAIL_KEYS = frozenset({"count"})
|
||||
_trace_id: ContextVar[str | None] = ContextVar("one_memory_trace_id", default=None)
|
||||
_active: DiagnosticLog | None = None
|
||||
_database_errors: Counter[tuple[str, str]] = Counter()
|
||||
@@ -264,7 +216,12 @@ class DiagnosticLog:
|
||||
}
|
||||
value.update({key: _identifier(item) for key, item in identifiers.items() if item})
|
||||
if duration_ms is not None:
|
||||
if not isinstance(duration_ms, int) or duration_ms < 0:
|
||||
if (
|
||||
not isinstance(duration_ms, int)
|
||||
or isinstance(duration_ms, bool)
|
||||
or duration_ms < 0
|
||||
or duration_ms > _MAX_SAFE_INTEGER
|
||||
):
|
||||
raise ValueError("diagnostic_duration_invalid")
|
||||
value["duration_ms"] = duration_ms
|
||||
for field_name, item in {
|
||||
@@ -276,7 +233,9 @@ class DiagnosticLog:
|
||||
if item is not None:
|
||||
value[field_name] = _identifier(item)
|
||||
if safe_stack:
|
||||
value["safe_stack"] = [_safe_stack_line(line) for line in safe_stack[:256]]
|
||||
if len(safe_stack) > 16:
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
value["safe_stack"] = [_safe_stack_line(line) for line in safe_stack]
|
||||
if details:
|
||||
value["details"] = _details(details)
|
||||
self._submit(_serialize(value))
|
||||
@@ -289,6 +248,8 @@ class DiagnosticLog:
|
||||
event: str,
|
||||
outcome: str,
|
||||
) -> dict[str, object]:
|
||||
if level not in ("debug", "info", "warn", "error"):
|
||||
raise ValueError("diagnostic_level_invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
@@ -651,26 +612,17 @@ def _drain_diagnostics_at_exit() -> None:
|
||||
|
||||
|
||||
def diagnostic_event(**values: object) -> None:
|
||||
active = _active
|
||||
if active is None:
|
||||
return
|
||||
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(
|
||||
logging.DEBUG
|
||||
):
|
||||
return
|
||||
try:
|
||||
active = _active
|
||||
if active is None:
|
||||
return
|
||||
if values.get("level") == "debug" and not logging.getLogger().isEnabledFor(logging.DEBUG):
|
||||
return
|
||||
active.write(**values) # type: ignore[arg-type]
|
||||
except OSError:
|
||||
# Diagnostics must not become a new product outage when a disk is full
|
||||
# or the log directory is temporarily unavailable.
|
||||
return
|
||||
except ValueError:
|
||||
# Same promise, same reason, for a rejected payload instead of a failed
|
||||
# write. An unlisted `details` key once turned every external recall
|
||||
# into a 500 and then killed the process, and the sedimentation switch
|
||||
# into a permanent failure. The payload itself is still wrong and is
|
||||
# still caught in CI by tests/test_diagnostic_payload_keys.py; what
|
||||
# must not happen is the caller paying for it.
|
||||
except Exception:
|
||||
# Diagnostics are best-effort: validation, serialization, logging, and
|
||||
# filesystem failures must never become a product outage. BaseException
|
||||
# deliberately remains visible so process-control signals still work.
|
||||
return
|
||||
|
||||
|
||||
@@ -693,46 +645,95 @@ def read_trace_events(
|
||||
if (request_id is None) == (trace_id is None):
|
||||
raise ValueError("exactly_one_trace_identity_required")
|
||||
key, expected = ("request_id", request_id) if request_id is not None else ("trace_id", trace_id)
|
||||
if expected is None or not _IDENTIFIER.fullmatch(expected):
|
||||
if expected is None or not _export_identifier(expected):
|
||||
raise ValueError("invalid_trace_identity")
|
||||
selected: list[dict[str, object]] = []
|
||||
allowed_details = {"phase", "mode", "status", "reason", "failure_site", "count"}
|
||||
files = sorted(
|
||||
(data_dir / "logs").glob("diagnostic-*.jsonl*"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
)
|
||||
for path in files:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
directory = data_dir / "logs"
|
||||
try:
|
||||
directory_info = directory.lstat()
|
||||
except FileNotFoundError:
|
||||
return ()
|
||||
if stat.S_ISLNK(directory_info.st_mode) or not stat.S_ISDIR(directory_info.st_mode):
|
||||
raise ValueError("diagnostic_directory_unsafe")
|
||||
files: list[tuple[Path, float]] = []
|
||||
for path in directory.iterdir():
|
||||
if not _DIAGNOSTIC_LOG_NAME.fullmatch(path.name):
|
||||
continue
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError:
|
||||
continue
|
||||
if stat.S_ISREG(info.st_mode) and not stat.S_ISLNK(info.st_mode):
|
||||
files.append((path, info.st_mtime))
|
||||
for path, _mtime in sorted(files, key=lambda item: item[1]):
|
||||
for line in _read_regular_file_no_follow(path).splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(event, dict) or event.get(key) != expected:
|
||||
continue
|
||||
details = event.get("details")
|
||||
projected = {
|
||||
name: event[name]
|
||||
for name in (
|
||||
"timestamp",
|
||||
"component",
|
||||
"event",
|
||||
"outcome",
|
||||
"duration_ms",
|
||||
"error_code",
|
||||
"error_type",
|
||||
)
|
||||
if name in event
|
||||
timestamp = event.get("timestamp")
|
||||
if not isinstance(timestamp, str):
|
||||
continue
|
||||
try:
|
||||
datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
continue
|
||||
component = event.get("component")
|
||||
event_name = event.get("event")
|
||||
outcome = event.get("outcome")
|
||||
projected: dict[str, object] = {
|
||||
"timestamp": timestamp,
|
||||
"component": (
|
||||
component
|
||||
if isinstance(component, str) and _export_identifier(component)
|
||||
else "diagnostic.redacted"
|
||||
),
|
||||
"event": (
|
||||
event_name
|
||||
if isinstance(event_name, str) and _export_identifier(event_name)
|
||||
else "diagnostic.redacted"
|
||||
),
|
||||
"outcome": (
|
||||
outcome
|
||||
if isinstance(outcome, str) and _export_identifier(outcome)
|
||||
else "redacted"
|
||||
),
|
||||
}
|
||||
duration_ms = event.get("duration_ms")
|
||||
if (
|
||||
isinstance(duration_ms, int)
|
||||
and not isinstance(duration_ms, bool)
|
||||
and 0 <= duration_ms <= _MAX_SAFE_INTEGER
|
||||
):
|
||||
projected["duration_ms"] = duration_ms
|
||||
for name in ("error_code", "error_type"):
|
||||
value = event.get(name)
|
||||
if isinstance(value, str) and _export_identifier(value):
|
||||
projected[name] = value
|
||||
details = event.get("details")
|
||||
if isinstance(details, dict):
|
||||
projected["details"] = {
|
||||
name: value for name, value in details.items() if name in allowed_details
|
||||
}
|
||||
safe_details = _trace_details(details)
|
||||
if safe_details:
|
||||
projected["details"] = safe_details
|
||||
selected.append(projected)
|
||||
return tuple(selected[-max(1, min(limit, 500)) :])
|
||||
|
||||
|
||||
def _read_regular_file_no_follow(path: Path) -> str:
|
||||
descriptor = os.open(path, os.O_RDONLY | _O_NOFOLLOW)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
raise ValueError("diagnostic_file_unsafe")
|
||||
with os.fdopen(descriptor, encoding="utf-8", errors="replace") as handle:
|
||||
descriptor = -1
|
||||
return handle.read()
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def current_trace_id() -> str | None:
|
||||
return _trace_id.get()
|
||||
|
||||
@@ -839,8 +840,77 @@ def diagnostic_exception(error: BaseException) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _contains_credential_material(value: str) -> bool:
|
||||
return bool(
|
||||
re.search(
|
||||
r"(?:^|[^A-Za-z0-9])(?:sk-|sk_|omhg_|ghp_|github_pat_)[A-Za-z0-9._-]+",
|
||||
value,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]+", value, re.IGNORECASE)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])", value)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])AKID[A-Za-z0-9]{12,}", value, re.IGNORECASE)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}", value)
|
||||
or re.search(r"(?:^|[^A-Za-z0-9])GOCSPX-[A-Za-z0-9_-]+", value)
|
||||
or any(
|
||||
_is_conservative_short_credential(match.group(1))
|
||||
for match in _SHORT_CREDENTIAL_TOKEN.finditer(value)
|
||||
)
|
||||
or re.search(
|
||||
r"\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
|
||||
value,
|
||||
)
|
||||
or any(
|
||||
len(candidate) >= 48
|
||||
and any(character.isalpha() for character in candidate)
|
||||
and any(character.isdigit() for character in candidate)
|
||||
for candidate in re.findall(r"\b[A-Za-z0-9._-]{48,}\b", value)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _is_conservative_short_credential(value: str) -> bool:
|
||||
return bool(
|
||||
re.search(r"[A-Z]", value)
|
||||
and re.search(r"[a-z]", value)
|
||||
and re.search(r"\d", value)
|
||||
and not re.fullmatch(r"[A-Fa-f0-9]+", value)
|
||||
)
|
||||
|
||||
|
||||
def _export_identifier(value: str) -> bool:
|
||||
return bool(
|
||||
_IDENTIFIER.fullmatch(value)
|
||||
and not _EXPORT_SECRET_MARKER.search(value)
|
||||
and not _contains_credential_material(value)
|
||||
)
|
||||
|
||||
|
||||
def _trace_details(values: Mapping[object, object]) -> dict[str, str | int | float]:
|
||||
result: dict[str, str | int | float] = {}
|
||||
for key, value in values.items():
|
||||
if (
|
||||
key in _TRACE_STRING_DETAIL_KEYS
|
||||
and isinstance(value, str)
|
||||
and _export_identifier(value)
|
||||
):
|
||||
result[str(key)] = value
|
||||
elif key in _TRACE_NUMBER_DETAIL_KEYS and isinstance(value, (int, float)):
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
safe_integer = isinstance(value, int) and abs(value) <= _MAX_SAFE_INTEGER
|
||||
safe_float = (
|
||||
isinstance(value, float)
|
||||
and math.isfinite(value)
|
||||
and (not value.is_integer() or abs(value) <= _MAX_SAFE_INTEGER)
|
||||
)
|
||||
if safe_integer or safe_float:
|
||||
result[str(key)] = value
|
||||
return result
|
||||
|
||||
|
||||
def _serialize(value: Mapping[str, object]) -> str:
|
||||
return json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n"
|
||||
return json.dumps(value, ensure_ascii=True, allow_nan=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _uuid(value: str | None) -> str | None:
|
||||
@@ -866,24 +936,47 @@ def _event_name(value: str) -> str:
|
||||
|
||||
|
||||
def _safe_stack_line(value: str) -> str:
|
||||
if not value or len(value) > 256 or "\n" in value or "\r" in value:
|
||||
if (
|
||||
not value
|
||||
or len(value) > 256
|
||||
or any(character in value for character in "\0\r\n")
|
||||
or _contains_credential_material(value)
|
||||
):
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
if Path(value.split(":", 1)[0]).is_absolute() or "file://" in value:
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
return value
|
||||
if _CODE_FRAME.fullmatch(value):
|
||||
return value
|
||||
if value.startswith("thread|"):
|
||||
frames = value.removeprefix("thread|").split(" > ")
|
||||
if frames and all(_CODE_FRAME.fullmatch(frame) for frame in frames):
|
||||
return value
|
||||
raise ValueError("diagnostic_stack_invalid")
|
||||
|
||||
|
||||
def _details(
|
||||
values: Mapping[str, str | int | float | bool | None],
|
||||
) -> dict[str, str | int | float | bool | None]:
|
||||
if len(values) > _MAX_DETAIL_KEYS:
|
||||
raise ValueError("diagnostic_detail_count_invalid")
|
||||
result: dict[str, str | int | float | bool | None] = {}
|
||||
for key, value in values.items():
|
||||
if key not in _DETAIL_KEYS:
|
||||
if (
|
||||
not isinstance(key, str)
|
||||
or not _DETAIL_KEY.fullmatch(key)
|
||||
or key in _REFUSED_DETAIL_KEYS
|
||||
):
|
||||
raise ValueError("diagnostic_detail_key_invalid")
|
||||
if isinstance(value, str):
|
||||
if not value or len(value) > 256 or "\n" in value or "\r" in value:
|
||||
if not value or len(value) > 256 or any(character in value for character in "\0\r\n"):
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
elif value is not None and not isinstance(value, (int, float, bool)):
|
||||
elif isinstance(value, float):
|
||||
if not math.isfinite(value) or (value.is_integer() and abs(value) > _MAX_SAFE_INTEGER):
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
elif isinstance(value, bool) or value is None:
|
||||
pass
|
||||
elif isinstance(value, int):
|
||||
if abs(value) > _MAX_SAFE_INTEGER:
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
else:
|
||||
raise ValueError("diagnostic_detail_value_invalid")
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -3,8 +3,8 @@
|
||||
Every site that catches `Exception` broadly must call `report_exception`
|
||||
before (or instead of) continuing, so a failure never looks like success
|
||||
and a stack is always on disk. Content (conversation text, keys, SQL
|
||||
parameters) never reaches the log: only exception type, a truncated
|
||||
message, and file:line:function frames.
|
||||
parameters, and exception messages) never reaches the diagnostic log: only
|
||||
exception type, a repeat count, and file:line:function frames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -67,7 +67,7 @@ def report_exception(
|
||||
event=event,
|
||||
outcome="failed",
|
||||
**payload,
|
||||
details={"reason": _safe_message(error), "count": _RECENT[key]},
|
||||
details={"count": _RECENT[key]},
|
||||
)
|
||||
if full:
|
||||
_logger.error(
|
||||
|
||||
@@ -10,11 +10,8 @@ from uuid import UUID
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.matters.models import Matter, MatterContext
|
||||
from one_memory.matters.store import MatterStore
|
||||
from one_memory.memory_governance import MemoryGovernanceStore
|
||||
|
||||
TOOLS = frozenset({"om_recall", "om_ask", "om_conversations", "om_matters", "om_diagnostic_trace"})
|
||||
TOOLS = frozenset({"om_recall", "om_ask", "om_conversations", "om_diagnostic_trace"})
|
||||
_ENVELOPE_HEADERS = frozenset(
|
||||
{
|
||||
"x-one-memory-read-context",
|
||||
@@ -27,8 +24,9 @@ _ENVELOPE_HEADERS = frozenset(
|
||||
_BROKER_ROUTES = {
|
||||
("POST", "/api/v1/recall"): "om_recall",
|
||||
("POST", "/api/v1/memories/ask"): "om_ask",
|
||||
("POST", "/api/v2/memories/ask"): "om_ask",
|
||||
("POST", "/api/v2/memories/ask/stream"): "om_ask",
|
||||
("GET", "/api/v1/conversations/search"): "om_conversations",
|
||||
("POST", "/api/v1/matters/context/search"): "om_matters",
|
||||
("POST", "/api/v1/diagnostics/trace"): "om_diagnostic_trace",
|
||||
("POST", "/api/v1/boundary/peek"): "om_recall",
|
||||
("POST", "/api/v1/boundary/ack"): "om_recall",
|
||||
@@ -68,50 +66,6 @@ class ExternalMemoryAccess:
|
||||
}
|
||||
return sources - revoked
|
||||
|
||||
def allows_matter(self, matter: Matter, governance: MemoryGovernanceStore) -> bool:
|
||||
if (
|
||||
not matter.agent_access
|
||||
or matter.availability != "ready"
|
||||
or matter.source_engine != "hy-memory"
|
||||
):
|
||||
return False
|
||||
support_ids: set[str] = set()
|
||||
for claim in matter.claims:
|
||||
if not claim.memory_refs:
|
||||
return False
|
||||
for reference in claim.memory_refs:
|
||||
memory = governance.get(reference.memory_id, user_id="local")
|
||||
if (
|
||||
memory is None
|
||||
or memory.revision != reference.revision
|
||||
or memory.status != reference.status
|
||||
or self.scopes.isdisjoint(memory.scopes)
|
||||
):
|
||||
return False
|
||||
support_ids.update(memory.evidence_ids)
|
||||
# Titles, boundaries and uncertainties can depend on uncited anchors.
|
||||
# Referenced memories can also have support not quoted by this matter.
|
||||
with main_database(governance.database_path).read() as db:
|
||||
row = db.execute(
|
||||
"SELECT dependencies FROM matter_versions WHERE matter_id=? AND version=?",
|
||||
(matter.matter_id, matter.version),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
dependencies = json.loads(row[0])
|
||||
if not dependencies:
|
||||
return False
|
||||
for evidence_id in set(dependencies) | support_ids:
|
||||
source = db.execute(
|
||||
"SELECT e.source_id FROM evidence e JOIN source_grants g "
|
||||
"USING(user_id,source_id) WHERE e.user_id='local' AND e.evidence_id=? "
|
||||
"AND g.revoked_at IS NULL",
|
||||
(evidence_id,),
|
||||
).fetchone()
|
||||
if source is None or f"source:{source[0]}" not in self.scopes:
|
||||
return False
|
||||
return bool(matter.claims)
|
||||
|
||||
|
||||
def external_access(headers: Mapping[str, str]) -> ExternalMemoryAccess | None:
|
||||
# Starlette Headers.items() preserves duplicate HTTP fields; .get() does not.
|
||||
@@ -174,7 +128,10 @@ def require_external_endpoint(request: Request) -> None:
|
||||
access = external_access(request.headers)
|
||||
if access is None:
|
||||
return
|
||||
if (request.method, request.url.path) == ("POST", "/api/v1/context/search"):
|
||||
if request.method == "POST" and request.url.path in {
|
||||
"/api/v1/context/search",
|
||||
"/api/v2/context/search",
|
||||
}:
|
||||
# The route resolves each requested lane against its original grant.
|
||||
# Passing this envelope gate does not authorize either lane by itself.
|
||||
if access.tools.isdisjoint({"om_recall", "om_conversations"}):
|
||||
@@ -189,46 +146,7 @@ def require_external_endpoint(request: Request) -> None:
|
||||
raise HTTPException(403, "external_endpoint_denied")
|
||||
if tool not in access.tools:
|
||||
raise HTTPException(403, "external_recall_scope_denied")
|
||||
if tool in {"om_conversations", "om_matters"}:
|
||||
if tool == "om_conversations":
|
||||
access.require(tool, frozenset({"all-apps"}))
|
||||
if not any(scope.startswith("source:") for scope in access.scopes):
|
||||
raise HTTPException(403, "external_source_permission_required")
|
||||
|
||||
|
||||
def search_external_matter_contexts(
|
||||
access: ExternalMemoryAccess,
|
||||
query: str,
|
||||
*,
|
||||
matters: MatterStore,
|
||||
governance: MemoryGovernanceStore,
|
||||
) -> list[MatterContext]:
|
||||
"""Apply installation bounds before rendering quotes or consuming the result limit."""
|
||||
access.require("om_matters", frozenset({"all-apps"}))
|
||||
access.readable_source_ids(governance.database_path)
|
||||
terms = query.casefold().split()
|
||||
found: list[MatterContext] = []
|
||||
for matter in matters.list():
|
||||
if not access.allows_matter(matter, governance):
|
||||
continue
|
||||
searchable = f"{matter.title} {matter.purpose} {' '.join(matter.tags)}".casefold()
|
||||
if not all(term in searchable for term in terms):
|
||||
continue
|
||||
context = matters.context(matter.matter_id, character_budget=20_000)
|
||||
current = matters.get(matter.matter_id)
|
||||
if (
|
||||
context.availability != "ready"
|
||||
or current.version != matter.version
|
||||
or context.version != matter.version
|
||||
or not access.allows_matter(current, governance)
|
||||
):
|
||||
continue
|
||||
found.append(context)
|
||||
if len(found) == 3:
|
||||
break
|
||||
# Rendering a later item may race a revocation affecting an earlier one.
|
||||
released = []
|
||||
for context in found:
|
||||
current = matters.get(context.matter_id)
|
||||
if current.version == context.version and access.allows_matter(current, governance):
|
||||
released.append(context)
|
||||
return released
|
||||
@@ -1,75 +1,15 @@
|
||||
"""Persisted extraction-window choice.
|
||||
|
||||
Kept apart from onboarding: the window can change long after the first run,
|
||||
and it has to survive a restart so a widened window does not silently snap
|
||||
back to ninety days.
|
||||
"""
|
||||
"""Persisted extraction-window choice stored in Core-owned config.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from one_memory.database import (
|
||||
take_the_write_lock_first,
|
||||
)
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.memory_queue import ExtractionWindowPreset
|
||||
|
||||
#: Schema owned by `memory_server.migrations` (migration 17). No local copy.
|
||||
from one_memory.memory_server.migrations import extraction_settings_ddl
|
||||
from one_memory.memory_server.store_binding import store_for
|
||||
|
||||
|
||||
def _is_mysql(store: Any) -> bool:
|
||||
"""Whether the bound store is the MySQL one.
|
||||
|
||||
Asked of the store rather than guessed from a global, because two engines
|
||||
can exist in one process during the migration and a global would make the
|
||||
answer depend on whichever was resolved last.
|
||||
"""
|
||||
from one_memory.memory_server.sync_facade import SyncStoreFacade
|
||||
|
||||
return isinstance(store, SyncStoreFacade)
|
||||
|
||||
|
||||
def _value(row: object, index: int) -> object:
|
||||
"""Read a column from either a sqlite3.Row or a plain tuple.
|
||||
|
||||
`sqlite3.Row` supports indexing by name and by position; a MySQL tuple only
|
||||
by position. Reading by position works on both, which is why the SELECT
|
||||
names its column explicitly.
|
||||
"""
|
||||
try:
|
||||
return row[index] # type: ignore[index]
|
||||
except (TypeError, KeyError):
|
||||
return row[index] # type: ignore[index]
|
||||
|
||||
|
||||
SCHEMA = extraction_settings_ddl("sqlite")
|
||||
|
||||
#: The upsert, per engine.
|
||||
#:
|
||||
#: SQLite has `ON CONFLICT ... DO UPDATE` and MySQL does not; MySQL spells the
|
||||
#: same thing `ON DUPLICATE KEY UPDATE`. They are not interchangeable, and
|
||||
#: getting this wrong does not raise — it either fails to update the existing
|
||||
#: row or refuses to insert, so the setting silently keeps its old value.
|
||||
UPSERT_SQLITE = """
|
||||
INSERT INTO extraction_settings (singleton, window_preset)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET window_preset = excluded.window_preset
|
||||
"""
|
||||
UPSERT_MYSQL = """
|
||||
INSERT INTO extraction_settings (singleton, window_preset)
|
||||
VALUES (1, %s)
|
||||
ON DUPLICATE KEY UPDATE window_preset = VALUES(window_preset)
|
||||
"""
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
|
||||
|
||||
class ExtractionSettings(BaseModel):
|
||||
@@ -81,111 +21,34 @@ class ExtractionSettings(BaseModel):
|
||||
window_preset: ExtractionWindowPreset = ExtractionWindowPreset.THREE_MONTHS
|
||||
|
||||
|
||||
class _SyncCursor:
|
||||
"""A synchronous `.execute()` over an async MySQL connection.
|
||||
|
||||
Exists so the calling code does not have to know which engine it is on:
|
||||
both engines expose `execute(sql, params)` returning something with
|
||||
`fetchone()`/`fetchall()`. Without it every call site would need two
|
||||
branches, which is the duplication this migration is trying to remove.
|
||||
|
||||
Statements run on the facade's loop via `run_coroutine_threadsafe`, so the
|
||||
caller stays synchronous while the MySQL I/O happens off the main loop.
|
||||
"""
|
||||
|
||||
def __init__(self, connection: Any, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._connection = connection
|
||||
self._loop = loop
|
||||
|
||||
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> _SyncResult:
|
||||
async def _run() -> Any:
|
||||
async with self._connection.cursor() as cursor:
|
||||
await cursor.execute(sql, params)
|
||||
try:
|
||||
return await cursor.fetchall()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
rows = asyncio.run_coroutine_threadsafe(_run(), self._loop).result(timeout=30)
|
||||
return _SyncResult(rows)
|
||||
|
||||
|
||||
class _SyncResult:
|
||||
"""The small surface the call sites use: `fetchone` / `fetchall`."""
|
||||
|
||||
def __init__(self, rows: Any) -> None:
|
||||
self._rows = rows or []
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self) -> Any:
|
||||
return self._rows
|
||||
|
||||
|
||||
class ExtractionSettingsStore:
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self._store: Any = store_for(database_path)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
"""Create the table through the store, not a raw `executescript`.
|
||||
|
||||
`executescript` is a sqlite3-only convenience; the MySQL path has no
|
||||
equivalent, so the schema goes through the ordinary checkout instead.
|
||||
"""
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute(SCHEMA)
|
||||
|
||||
def _upsert_sql(self) -> str:
|
||||
return UPSERT_MYSQL if _is_mysql(self._store) else UPSERT_SQLITE
|
||||
self._config = ConfigStore(database_path.parent)
|
||||
self._adopt_legacy_value()
|
||||
|
||||
def get(self) -> ExtractionSettings:
|
||||
with self._connect(initialize=True) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT window_preset FROM extraction_settings WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return ExtractionSettings()
|
||||
raw = _value(row, 0)
|
||||
return ExtractionSettings(window_preset=ExtractionWindowPreset(str(raw)))
|
||||
return ExtractionSettings(window_preset=self._config.extraction_window_preset())
|
||||
|
||||
def set_window_preset(self, preset: ExtractionWindowPreset) -> ExtractionSettings:
|
||||
with self._connect(writing=True) as connection:
|
||||
connection.execute(self._upsert_sql(), (preset.value,))
|
||||
self._config.set_extraction_window_preset(preset)
|
||||
return self.get()
|
||||
|
||||
@contextmanager
|
||||
def _connect(
|
||||
self,
|
||||
*,
|
||||
initialize: bool = False,
|
||||
writing: bool = False,
|
||||
) -> Iterator[Any]:
|
||||
"""A checkout that yields something with `.execute()` on both engines.
|
||||
|
||||
On SQLite this is the existing behaviour, including the write lock and
|
||||
`sqlite3.Row`. On MySQL the facade hands back a connection whose
|
||||
`execute` is async, so statements are routed to the facade's loop and
|
||||
the caller still sees a synchronous `.execute(...)`.
|
||||
"""
|
||||
if _is_mysql(self._store):
|
||||
checkout = self._store.write() if (initialize or writing) else self._store.read()
|
||||
with checkout as connection:
|
||||
yield _SyncCursor(connection, self._store._loop)
|
||||
def _adopt_legacy_value(self) -> None:
|
||||
"""Move the old singleton into config without creating its table."""
|
||||
if not self.database_path.exists():
|
||||
return
|
||||
|
||||
database = main_database(self.database_path)
|
||||
checkout = (
|
||||
database.write(autocommit=True, row_factory=sqlite3.Row)
|
||||
if initialize or writing
|
||||
else database.read(row_factory=sqlite3.Row)
|
||||
)
|
||||
with checkout as connection, connection:
|
||||
if writing:
|
||||
take_the_write_lock_first(connection)
|
||||
else:
|
||||
connection.autocommit = False
|
||||
yield connection
|
||||
try:
|
||||
with main_database(self.database_path).read() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT window_preset FROM extraction_settings WHERE singleton=1"
|
||||
).fetchone()
|
||||
except sqlite3.DatabaseError:
|
||||
return
|
||||
if row is None:
|
||||
return
|
||||
try:
|
||||
preset = ExtractionWindowPreset(str(row[0]))
|
||||
except ValueError:
|
||||
return
|
||||
self._config.migrate_extraction_window_preset(preset)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Kuzu graph-width inspection and explicit user-directed administration.
|
||||
|
||||
This module is the second graph architecture exception. It may open Kuzu
|
||||
directly to inspect an existing graph's schema, but owns no prompts, memory
|
||||
reconciliation, coding data, vector database, or legacy governance behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from one_memory.hy_runtime_config import HY_MEMORY_NAMESPACE
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
|
||||
|
||||
|
||||
class GraphWidthError(RuntimeError):
|
||||
"""Base error for graph-width inspection and administration."""
|
||||
|
||||
|
||||
class GraphWidthDecision(StrEnum):
|
||||
"""What to do when an existing graph disagrees with the embedding width."""
|
||||
|
||||
KEEP_EXISTING = "keep_existing"
|
||||
REBUILD_WITH_NEW = "rebuild_with_new"
|
||||
EXPORT_FIRST = "export_first"
|
||||
|
||||
|
||||
class GraphWidthMismatch(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
stored_width: int
|
||||
expected_width: int
|
||||
graph_path: str
|
||||
|
||||
|
||||
class GraphWidthOutcome(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
archived_path: str | None = None
|
||||
exported_path: str | None = None
|
||||
|
||||
|
||||
class GraphWidthMismatchError(GraphWidthError):
|
||||
"""Raised instead of silently archiving a live graph."""
|
||||
|
||||
def __init__(self, mismatch: GraphWidthMismatch) -> None:
|
||||
super().__init__(
|
||||
f"graph_width_mismatch:stored={mismatch.stored_width}"
|
||||
f":expected={mismatch.expected_width}"
|
||||
)
|
||||
self.mismatch = mismatch
|
||||
|
||||
|
||||
def graph_store_enabled() -> bool:
|
||||
"""Whether HY should open the Kuzu graph store."""
|
||||
raw = os.environ.get("ONE_MEMORY_GRAPH_STORE", "").strip().lower()
|
||||
if not raw:
|
||||
return False
|
||||
return raw != "none"
|
||||
|
||||
|
||||
def inspect_graph_width_mismatch(config: Any) -> GraphWidthMismatch | None:
|
||||
"""Return a mismatch without renaming or deleting anything."""
|
||||
db_path = Path(config.graph_store.db_path)
|
||||
expected = getattr(getattr(config, "embedder", None), "embedding_dims", None)
|
||||
return _inspect_graph_width(db_path, expected)
|
||||
|
||||
|
||||
def inspect_profile_graph_width(
|
||||
profile: ModelProfileSnapshot,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
) -> GraphWidthMismatch | None:
|
||||
"""Inspect the graph path and width selected by the active profile."""
|
||||
del api_key
|
||||
return _inspect_graph_width(
|
||||
_profile_graph_path(data_dir),
|
||||
_profile_embedding_width(profile),
|
||||
)
|
||||
|
||||
|
||||
def decide_profile_graph_width(
|
||||
profile: ModelProfileSnapshot,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
decision: GraphWidthDecision,
|
||||
) -> GraphWidthOutcome:
|
||||
"""Apply the user's decision to the active profile's graph."""
|
||||
del api_key
|
||||
config = _GraphAdminConfig(
|
||||
graph_store=_GraphStoreConfig(str(_profile_graph_path(data_dir))),
|
||||
embedder=_EmbeddingWidthConfig(_profile_embedding_width(profile)),
|
||||
)
|
||||
return apply_graph_width_decision(
|
||||
config,
|
||||
decision,
|
||||
export_root=data_dir.parent,
|
||||
)
|
||||
|
||||
|
||||
def apply_graph_width_decision(
|
||||
config: Any,
|
||||
decision: GraphWidthDecision,
|
||||
*,
|
||||
export_root: Path | None = None,
|
||||
) -> GraphWidthOutcome:
|
||||
"""Act only on a current mismatch and only as explicitly requested."""
|
||||
mismatch = inspect_graph_width_mismatch(config)
|
||||
if mismatch is None:
|
||||
return GraphWidthOutcome()
|
||||
if decision is GraphWidthDecision.REBUILD_WITH_NEW:
|
||||
archive = retire_mismatched_graph_store(config)
|
||||
return GraphWidthOutcome(archived_path=str(archive) if archive is not None else None)
|
||||
if decision is GraphWidthDecision.EXPORT_FIRST:
|
||||
if export_root is None:
|
||||
raise GraphWidthError("export_root_required")
|
||||
return GraphWidthOutcome(
|
||||
exported_path=str(export_graph_store(config, export_root=export_root))
|
||||
)
|
||||
return GraphWidthOutcome()
|
||||
|
||||
|
||||
def export_graph_store(config: Any, *, export_root: Path) -> Path:
|
||||
"""Copy the graph outside its data directory, atomically."""
|
||||
db_path = Path(config.graph_store.db_path)
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
||||
destination = export_root / f"one-memory-graph-{stamp}"
|
||||
staging = destination.with_name(f"{destination.name}.partial")
|
||||
sources = [db_path] + [
|
||||
sibling
|
||||
for suffix in (".wal", ".shadow", ".tmp")
|
||||
if (sibling := db_path.with_name(f"{db_path.name}{suffix}")).exists()
|
||||
]
|
||||
try:
|
||||
staging.mkdir(parents=True)
|
||||
for source in sources:
|
||||
shutil.copy2(source, staging / source.name)
|
||||
staging.rename(destination)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
logging.getLogger(__name__).info(
|
||||
"exported hy graph store to %s (%d files)",
|
||||
destination,
|
||||
len(sources),
|
||||
)
|
||||
return destination
|
||||
|
||||
|
||||
def retire_mismatched_graph_store(config: Any) -> Path | None:
|
||||
"""Archive a mismatched Kuzu graph only after the user chooses rebuild."""
|
||||
db_path = Path(config.graph_store.db_path)
|
||||
if not db_path.exists():
|
||||
return None
|
||||
expected = getattr(getattr(config, "embedder", None), "embedding_dims", None)
|
||||
if not expected:
|
||||
return None
|
||||
stored = _graph_embedding_width(db_path)
|
||||
if stored is None or stored == int(expected):
|
||||
return None
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
archive = db_path.with_name(f"{db_path.name}.dim{stored}.{stamp}.bak")
|
||||
db_path.rename(archive)
|
||||
for suffix in (".wal", ".shadow", ".tmp"):
|
||||
sibling = db_path.with_name(f"{db_path.name}{suffix}")
|
||||
if sibling.exists():
|
||||
sibling.rename(archive.with_name(f"{archive.name}{suffix}"))
|
||||
logging.getLogger(__name__).warning(
|
||||
"retired hy graph store with embedding width %s (model expects %s): %s",
|
||||
stored,
|
||||
expected,
|
||||
archive.name,
|
||||
)
|
||||
return archive
|
||||
|
||||
|
||||
_GRAPH_WIDTH_CACHE: dict[str, int | None] = {}
|
||||
_GRAPH_WIDTH_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _graph_embedding_width(db_path: Path) -> int | None:
|
||||
"""Read the Memory embedding width directly from Kuzu's schema."""
|
||||
try:
|
||||
kuzu = import_module("kuzu")
|
||||
except ModuleNotFoundError:
|
||||
return None
|
||||
key = str(db_path.resolve())
|
||||
with _GRAPH_WIDTH_LOCK:
|
||||
if key in _GRAPH_WIDTH_CACHE:
|
||||
return _GRAPH_WIDTH_CACHE[key]
|
||||
try:
|
||||
database = kuzu.Database(str(db_path), read_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
width: int | None = None
|
||||
try:
|
||||
connection = kuzu.Connection(database)
|
||||
result = connection.execute("CALL TABLE_INFO('Memory') RETURN *")
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
if len(row) < 3 or row[1] != "embedding":
|
||||
continue
|
||||
match = re.search(r"\[(\d+)\]", str(row[2]))
|
||||
width = int(match.group(1)) if match else None
|
||||
break
|
||||
except Exception:
|
||||
width = None
|
||||
with _GRAPH_WIDTH_LOCK:
|
||||
_GRAPH_WIDTH_CACHE[key] = width
|
||||
return width
|
||||
|
||||
|
||||
def _inspect_graph_width(
|
||||
db_path: Path,
|
||||
expected_width: object,
|
||||
) -> GraphWidthMismatch | None:
|
||||
if not db_path.exists() or not expected_width:
|
||||
return None
|
||||
stored = _graph_embedding_width(db_path)
|
||||
if stored is None or stored == int(expected_width):
|
||||
return None
|
||||
return GraphWidthMismatch(
|
||||
stored_width=int(stored),
|
||||
expected_width=int(expected_width),
|
||||
graph_path=str(db_path),
|
||||
)
|
||||
|
||||
|
||||
def _profile_graph_path(data_dir: Path) -> Path:
|
||||
name = "graph" if graph_store_enabled() else "graph-disabled"
|
||||
return data_dir / "hy-memory" / HY_MEMORY_NAMESPACE / name
|
||||
|
||||
|
||||
def _profile_embedding_width(profile: ModelProfileSnapshot) -> int | None:
|
||||
if profile.embedding_dimensions is not None:
|
||||
return profile.embedding_dimensions
|
||||
if (profile.embedding_provider_kind or profile.provider_kind) is ProviderKind.LOCAL_ONNX:
|
||||
from one_memory.embedding_assets import BUILTIN_EMBEDDING_DIMENSIONS
|
||||
|
||||
return BUILTIN_EMBEDDING_DIMENSIONS
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GraphStoreConfig:
|
||||
db_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmbeddingWidthConfig:
|
||||
embedding_dims: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GraphAdminConfig:
|
||||
graph_store: _GraphStoreConfig
|
||||
embedder: _EmbeddingWidthConfig
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Read-only projection of HY's Kuzu graph for MemHub's existing graph UI.
|
||||
|
||||
This module is one of the two explicit graph architecture exceptions. The
|
||||
public SDK does not expose graph projection yet, so it may use the client's
|
||||
private graph handle and loop thread. It must remain read-only and must not
|
||||
grow memory, vector, coding, prompt, or governance responsibilities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class HyGraphNode(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
node_id: str
|
||||
content: str
|
||||
layer: str | None = None
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
class HyGraphEdge(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
source_node_id: str
|
||||
target_node_id: str
|
||||
relation: str
|
||||
weight: float | None = None
|
||||
|
||||
|
||||
class HyGraphSnapshot(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
nodes: tuple[HyGraphNode, ...] = ()
|
||||
edges: tuple[HyGraphEdge, ...] = ()
|
||||
# False when HY was never consulted, which is not the same as HY having
|
||||
# induced no relations.
|
||||
consulted: bool = True
|
||||
|
||||
|
||||
def read_graph_snapshot(
|
||||
client: object,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
limit: int,
|
||||
) -> HyGraphSnapshot:
|
||||
raw_nodes, raw_edges = _read_graph_snapshot(client, user_id, agent_id, limit)
|
||||
return HyGraphSnapshot(
|
||||
nodes=tuple(
|
||||
HyGraphNode(
|
||||
node_id=str(row[0]),
|
||||
content=str(row[1]),
|
||||
layer=str(row[2]) if row[2] is not None else None,
|
||||
confidence=_safe_float(row[3]),
|
||||
)
|
||||
for row in raw_nodes
|
||||
if len(row) >= 4 and row[0] and row[1]
|
||||
),
|
||||
edges=tuple(
|
||||
HyGraphEdge(
|
||||
source_node_id=str(row[0]),
|
||||
target_node_id=str(row[1]),
|
||||
relation=str(row[2]),
|
||||
weight=_safe_float(row[3]),
|
||||
)
|
||||
for row in raw_edges
|
||||
if len(row) >= 4 and row[0] and row[1] and row[2]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_graph_snapshot(
|
||||
client: object,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
limit: int,
|
||||
) -> tuple[list[list[object]], list[list[object]]]:
|
||||
"""Read the graph through the SDK handles until a public API exists."""
|
||||
concrete = cast(Any, client)
|
||||
# Keep the existing architecture gate unchanged; this module is the named
|
||||
# graph-only exception while the rest of MemHub remains on public handles.
|
||||
graph_store = getattr(concrete, "_graph_store") # noqa: B009
|
||||
if graph_store is None:
|
||||
return ([], [])
|
||||
loop_thread = getattr(concrete, "_loop_thread") # noqa: B009
|
||||
# Kuzu rejects a prepared statement that is handed a parameter its text
|
||||
# never references, so a wildcard read must not bind agent_id at all.
|
||||
params: dict[str, object] = {"user_id": user_id}
|
||||
if agent_id != "*":
|
||||
params["agent_id"] = agent_id
|
||||
safe_limit = max(1, min(int(limit), 1_000))
|
||||
memory_agent = "" if agent_id == "*" else " AND m.agent_id = $agent_id"
|
||||
related_agent = (
|
||||
"" if agent_id == "*" else " AND a.agent_id = $agent_id AND b.agent_id = $agent_id"
|
||||
)
|
||||
source_agent = "" if agent_id == "*" else " AND a.agent_id = $agent_id"
|
||||
nodes = (
|
||||
loop_thread.run(
|
||||
graph_store._run(
|
||||
f"""
|
||||
MATCH (m:Memory)
|
||||
WHERE m.user_id = $user_id{memory_agent}
|
||||
AND m.status = 'active'
|
||||
RETURN m.node_id, m.content, m.layer, m.confidence
|
||||
LIMIT {safe_limit}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
or []
|
||||
)
|
||||
related_edges = (
|
||||
loop_thread.run(
|
||||
graph_store._run(
|
||||
f"""
|
||||
MATCH (a:Memory)-[r:RELATED_TO]->(b:Memory)
|
||||
WHERE a.user_id = $user_id{related_agent}
|
||||
AND b.user_id = $user_id
|
||||
AND a.status = 'active' AND b.status = 'active'
|
||||
RETURN a.node_id, b.node_id, r.relation_type, r.weight
|
||||
LIMIT {safe_limit * 4}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
or []
|
||||
)
|
||||
derived_edges = (
|
||||
loop_thread.run(
|
||||
graph_store._run(
|
||||
f"""
|
||||
MATCH (a:Memory)-[:DERIVED_FROM]->(v:VdbRef)
|
||||
WHERE a.user_id = $user_id{source_agent}
|
||||
AND a.status = 'active'
|
||||
RETURN a.node_id, v.node_id, 'derived_from', 1.0
|
||||
LIMIT {safe_limit * 4}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
or []
|
||||
)
|
||||
topic_nodes = (
|
||||
loop_thread.run(
|
||||
graph_store._run(
|
||||
f"""
|
||||
MATCH (m:Memory)-[:TAGGED_WITH]->(t:Topic)
|
||||
WHERE m.user_id = $user_id{memory_agent}
|
||||
AND m.status = 'active'
|
||||
RETURN DISTINCT t.topic_id, t.name, 'TOPIC', 1.0
|
||||
LIMIT {safe_limit}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
or []
|
||||
)
|
||||
tagged_edges = (
|
||||
loop_thread.run(
|
||||
graph_store._run(
|
||||
f"""
|
||||
MATCH (m:Memory)-[:TAGGED_WITH]->(t:Topic)
|
||||
WHERE m.user_id = $user_id{memory_agent}
|
||||
AND m.status = 'active'
|
||||
RETURN m.node_id, t.topic_id, 'tagged_with', 1.0
|
||||
LIMIT {safe_limit * 4}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
or []
|
||||
)
|
||||
return (
|
||||
[*nodes, *topic_nodes],
|
||||
[*related_edges, *derived_edges, *tagged_edges],
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(value: object) -> float | None:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return None
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Which memories HY no longer counts as current, read from HY itself.
|
||||
|
||||
HY owns a memory's lifecycle: it supersedes, deduplicates, shadows and
|
||||
restores on its own schedule — during a search, while deleting the head of a
|
||||
chain — and most of that never passes through a write the ledger sees. So
|
||||
the ledger does not keep a copy of any of it; readers ask HY, once per read,
|
||||
for the nodes it has set aside.
|
||||
|
||||
Only the vector store is read. HY writes a node's status there on every
|
||||
transition, and the graph holds no status the vector store lacks.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Collection, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import import_module
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
#: HY's one status for a node that is current. Everything else it writes —
|
||||
#: superseded, shadow, archived, negated, conflicted — takes it out of recall.
|
||||
_ACTIVE = "active"
|
||||
#: HY's verbatim chunks. Stored as shadow from the start and never a memory,
|
||||
#: so never something a reader could have shown.
|
||||
_RAW_LAYER = "l1_raw"
|
||||
|
||||
|
||||
class HyLifecycleUnreadable(RuntimeError):
|
||||
"""HY's store could not be read; never to be taken for "nothing is hidden"."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class HyLifecycle:
|
||||
"""HY's set-aside nodes for one user, each with what HY says replaced it."""
|
||||
|
||||
hidden: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(other.hidden)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(tuple(sorted(self.hidden.items())))
|
||||
|
||||
def replaced_by(self, memory_id: str) -> tuple[str, ...]:
|
||||
return self.hidden.get(memory_id, ())
|
||||
|
||||
def replacing(self, memory_id: str) -> tuple[str, ...]:
|
||||
"""The set-aside memories HY names this one as the replacement of."""
|
||||
return tuple(sorted(old for old, by in self.hidden.items() if memory_id in by))
|
||||
|
||||
|
||||
def read_hy_lifecycle(
|
||||
client: object, user_id: str, ids: Collection[str] | None = None
|
||||
) -> HyLifecycle:
|
||||
"""Every node of `user_id` HY holds as anything but active, in one read.
|
||||
|
||||
With `ids`, only those nodes are looked up — what a single-memory read
|
||||
needs, and a lookup by key rather than a scan; `replacing` is then only
|
||||
known among them.
|
||||
|
||||
Read one level below HY's own listing, which drops a failed read and
|
||||
answers empty: a hidden memory would then read as current.
|
||||
"""
|
||||
native: Any = client
|
||||
try:
|
||||
chroma = import_module("hy_memory.data.vector_store_chroma")
|
||||
store = native._vector_store
|
||||
collection = store._collection
|
||||
if not isinstance(store, chroma.ChromaVectorStore) or collection is None:
|
||||
raise HyLifecycleUnreadable("hy_lifecycle_store_unsupported")
|
||||
found = collection.get(
|
||||
ids=None if ids is None else list(ids),
|
||||
where={
|
||||
"$and": [
|
||||
{"user_id": {"$eq": user_id}},
|
||||
{"status": {"$ne": _ACTIVE}},
|
||||
{"layer": {"$ne": _RAW_LAYER}},
|
||||
]
|
||||
},
|
||||
include=["metadatas"],
|
||||
)
|
||||
metadatas = found["metadatas"]
|
||||
point_ids = found["ids"]
|
||||
if (
|
||||
not isinstance(point_ids, list)
|
||||
or not isinstance(metadatas, list)
|
||||
or len(point_ids) != len(metadatas)
|
||||
):
|
||||
raise HyLifecycleUnreadable("hy_lifecycle_store_mismatch")
|
||||
except HyLifecycleUnreadable:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise HyLifecycleUnreadable("hy_lifecycle_unreadable") from error
|
||||
hidden: dict[str, tuple[str, ...]] = {}
|
||||
for point_id, metadata in zip(point_ids, metadatas, strict=True):
|
||||
if not isinstance(point_id, str) or not point_id or not isinstance(metadata, Mapping):
|
||||
raise HyLifecycleUnreadable("hy_lifecycle_store_mismatch")
|
||||
payload = chroma.ChromaVectorStore._chroma_metadata_to_payload(dict(metadata))
|
||||
node_id = payload.get("node_id") or point_id
|
||||
if not isinstance(node_id, str) or not node_id or node_id != point_id:
|
||||
raise HyLifecycleUnreadable("hy_lifecycle_store_mismatch")
|
||||
hidden[node_id] = _replacements(payload)
|
||||
return HyLifecycle(hidden=hidden)
|
||||
|
||||
|
||||
def _replacements(payload: Mapping[str, object]) -> tuple[str, ...]:
|
||||
"""What HY names as taking this node's place: its chain successor or the kept duplicate."""
|
||||
names: list[str] = []
|
||||
superseded_by = payload.get("superseded_by")
|
||||
if isinstance(superseded_by, list):
|
||||
names.extend(value for value in superseded_by if isinstance(value, str) and value)
|
||||
kept = payload.get("dedup_kept_id")
|
||||
custom = payload.get("custom")
|
||||
if isinstance(custom, str):
|
||||
try:
|
||||
custom = json.loads(custom)
|
||||
except ValueError:
|
||||
custom = None
|
||||
if not isinstance(kept, str) and isinstance(custom, Mapping):
|
||||
audit = custom.get("hy_dedup")
|
||||
if isinstance(audit, Mapping):
|
||||
kept = audit.get("kept_id")
|
||||
if isinstance(kept, str) and kept:
|
||||
names.append(kept)
|
||||
return tuple(dict.fromkeys(names))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,72 +0,0 @@
|
||||
"""Whether HY still holds a memory, read so that a failed read is never taken for an absence.
|
||||
|
||||
The pinned HY SDK turns read failures into "not found" at every level a
|
||||
caller would normally use: `get` drops a failed graph query, Chroma's
|
||||
`get_by_id` drops any error, and Kuzu's `get_node` answers None while the
|
||||
graph is unavailable. A deletion confirmed through them can be confirmed by a
|
||||
store that was never read. So each store is asked here one level lower, where
|
||||
a failure still raises, without changing the SDK.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HyPresence(StrEnum):
|
||||
PRESENT = "present"
|
||||
ABSENT = "absent"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def confirm_deleted(client: object, memory_id: str) -> HyPresence:
|
||||
"""PRESENT if either store still has it; ABSENT only if both were read and have none."""
|
||||
native: Any = client
|
||||
answers = (_in_vectors(native, memory_id), _in_graph(native, memory_id))
|
||||
if HyPresence.PRESENT in answers:
|
||||
return HyPresence.PRESENT
|
||||
if HyPresence.UNKNOWN in answers:
|
||||
return HyPresence.UNKNOWN
|
||||
return HyPresence.ABSENT
|
||||
|
||||
|
||||
def _in_vectors(native: Any, memory_id: str) -> HyPresence:
|
||||
chroma = import_module("hy_memory.data.vector_store_chroma")
|
||||
store = native._vector_store
|
||||
if not isinstance(store, chroma.ChromaVectorStore) or store._collection is None:
|
||||
return HyPresence.UNKNOWN
|
||||
point_id = store._node_id_to_point_id(memory_id)
|
||||
|
||||
async def read() -> Any:
|
||||
return await chroma._run_in_vdb_pool(store._collection.get, ids=[point_id], include=[])
|
||||
|
||||
try:
|
||||
found = native._loop_thread.run(read())
|
||||
except Exception:
|
||||
return HyPresence.UNKNOWN
|
||||
return HyPresence.PRESENT if found["ids"] else HyPresence.ABSENT
|
||||
|
||||
|
||||
def _in_graph(native: Any, memory_id: str) -> HyPresence:
|
||||
store = native._graph_store
|
||||
if store is None:
|
||||
# HY was set up without a graph: there is nowhere else for it to hold one.
|
||||
return HyPresence.ABSENT
|
||||
kuzu = import_module("hy_memory.data.graph_store_kuzu")
|
||||
if not isinstance(store, kuzu.KuzuGraphStore) or not store._available or store._conn is None:
|
||||
return HyPresence.UNKNOWN
|
||||
|
||||
def count() -> int:
|
||||
result = store._conn.execute(
|
||||
"MATCH (m:Memory {node_id: $nid}) RETURN count(m);", {"nid": memory_id}
|
||||
)
|
||||
return int(result.get_next()[0])
|
||||
|
||||
async def read() -> int:
|
||||
return int(await kuzu._run_in_kuzu_pool(count))
|
||||
|
||||
try:
|
||||
held = native._loop_thread.run(read())
|
||||
except Exception:
|
||||
return HyPresence.UNKNOWN
|
||||
return HyPresence.PRESENT if held else HyPresence.ABSENT
|
||||
@@ -1,321 +0,0 @@
|
||||
"""Transport validation for the pinned HY SDK; HY's prompts and decisions pass through unchanged."""
|
||||
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms
|
||||
from one_memory.hy_source_context import HyWriteBindings
|
||||
from one_memory.hy_step_timing import HyStepTimer
|
||||
|
||||
|
||||
def _http_status(value: Any) -> int | None:
|
||||
return value if type(value) is int and 100 <= value <= 599 else None
|
||||
|
||||
|
||||
def _exception_status(error: Exception) -> int | None:
|
||||
try:
|
||||
status = _http_status(getattr(error, "status_code", None))
|
||||
if status is not None:
|
||||
return status
|
||||
status = _http_status(getattr(getattr(error, "response", None), "status_code", None))
|
||||
if status is not None:
|
||||
return status
|
||||
# Native LLMRetryError retains sanitized attempt dictionaries. Only the
|
||||
# final attempt describes the terminal failure; never parse error text.
|
||||
attempts = getattr(error, "attempts", None)
|
||||
if isinstance(attempts, (list, tuple)) and attempts and isinstance(attempts[-1], dict):
|
||||
return _http_status(attempts[-1].get("status_code"))
|
||||
except Exception:
|
||||
# Diagnostic attribute access must not replace the provider exception.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _model_failure_code(status: int | None) -> str:
|
||||
specific = {
|
||||
401: "hy_memory_model_authentication_failed",
|
||||
402: "hy_memory_model_payment_required",
|
||||
403: "hy_memory_model_access_denied",
|
||||
408: "hy_memory_model_timeout",
|
||||
429: "hy_memory_model_rate_limited",
|
||||
}
|
||||
if status in specific:
|
||||
return specific[status]
|
||||
if status is not None and 500 <= status <= 599:
|
||||
return "hy_memory_model_service_unavailable"
|
||||
if status is not None and 400 <= status <= 422:
|
||||
return "hy_memory_model_request_incompatible"
|
||||
return "hy_memory_model_request_failed"
|
||||
|
||||
|
||||
def _terminal_client_timeout(error: Exception) -> bool:
|
||||
try:
|
||||
attempts = getattr(error, "attempts", None)
|
||||
if not isinstance(attempts, (list, tuple)) or not attempts:
|
||||
return False
|
||||
final = attempts[-1]
|
||||
if type(final) is not dict:
|
||||
return False
|
||||
kind = final.get("failure_kind")
|
||||
return type(kind) is str and kind == "timeout"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class HyResponseGuard:
|
||||
def __init__(self, provider: Any, *, phase: str = "extract") -> None:
|
||||
self.provider = provider
|
||||
#: Diagnostic label only; the request passes through unchanged.
|
||||
self.phase = phase
|
||||
self.failures: list[str] = []
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.provider, name)
|
||||
|
||||
async def complete(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._complete("complete", args, kwargs)
|
||||
|
||||
async def complete_messages(self, *args: Any, **kwargs: Any) -> Any:
|
||||
return await self._complete("complete_messages", args, kwargs)
|
||||
|
||||
async def _complete(self, method: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
||||
for attempt in range(2):
|
||||
started = monotonic()
|
||||
try:
|
||||
response = await getattr(self.provider, method)(*args, **kwargs)
|
||||
except Exception as error:
|
||||
status = _exception_status(error)
|
||||
code = (
|
||||
"hy_memory_model_timeout"
|
||||
if status is None and _terminal_client_timeout(error)
|
||||
else _model_failure_code(status)
|
||||
)
|
||||
self.failures.append(code)
|
||||
logging.getLogger(__name__).warning(
|
||||
"%s: status_code=%s", code, status if status is not None else "unknown"
|
||||
)
|
||||
diagnostic_event(
|
||||
level="error",
|
||||
component="hy.model",
|
||||
event="hy.model_call.failed",
|
||||
outcome="failed",
|
||||
duration_ms=elapsed_ms(started),
|
||||
error_code=code,
|
||||
**diagnostic_exception(error),
|
||||
details={"phase": self.phase, "attempt": attempt + 1, "call_count": 1},
|
||||
)
|
||||
raise
|
||||
truncated = getattr(response, "finish_reason", "") == "length"
|
||||
empty = not (response.content or "").strip() and not getattr(
|
||||
response, "tool_calls", None
|
||||
)
|
||||
if not truncated and not empty:
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="hy.model",
|
||||
event="hy.model_call.completed",
|
||||
outcome="succeeded",
|
||||
duration_ms=elapsed_ms(started),
|
||||
details={"phase": self.phase, "attempt": attempt + 1, "call_count": 1},
|
||||
)
|
||||
return response
|
||||
code = (
|
||||
"hy_memory_model_output_truncated"
|
||||
if truncated
|
||||
else "hy_memory_model_empty_response"
|
||||
)
|
||||
if attempt:
|
||||
self.failures.append(code)
|
||||
raise ValueError(code)
|
||||
logging.getLogger(__name__).warning("%s: retrying the same HY model request", code)
|
||||
if truncated and "max_tokens" in kwargs:
|
||||
kwargs = {**kwargs, "max_tokens": min(16000, kwargs["max_tokens"] * 2)}
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
class HyReconcileGuard:
|
||||
#: Set only for the span of one leased write.
|
||||
step_timer: HyStepTimer | None = None
|
||||
|
||||
def __init__(self, reconciler: Any) -> None:
|
||||
self.reconciler = reconciler
|
||||
self.failures: list[str] = []
|
||||
self.bindings: HyWriteBindings | None = None
|
||||
self.imported_contents: frozenset[str] | None = None
|
||||
self.mutation_target_ids: set[str] = set()
|
||||
|
||||
def begin_write(self) -> None:
|
||||
self.mutation_target_ids.clear()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.reconciler, name)
|
||||
|
||||
async def reconcile(self, *args: Any, **kwargs: Any) -> Any:
|
||||
timer = self.step_timer
|
||||
with timer.step("reconcile") if timer is not None else nullcontext():
|
||||
return await self._reconcile(args, kwargs)
|
||||
|
||||
async def _reconcile(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
||||
started = monotonic()
|
||||
if self.imported_contents is not None:
|
||||
metadata = kwargs.get("new_memories_with_meta") or []
|
||||
texts = kwargs.get("new_memories") or []
|
||||
if (
|
||||
not metadata
|
||||
or not texts
|
||||
or any(
|
||||
not isinstance(item, dict) or item.get("content") not in self.imported_contents
|
||||
for item in metadata
|
||||
)
|
||||
or any(text not in self.imported_contents for text in texts)
|
||||
or {item["content"] for item in metadata} != set(texts)
|
||||
):
|
||||
code = "hy_memory_write_verification_import_input_mismatch"
|
||||
self.failures.append(code)
|
||||
raise ValueError(code)
|
||||
try:
|
||||
result = await self.reconciler.reconcile(*args, **kwargs)
|
||||
except Exception as error:
|
||||
diagnostic_event(
|
||||
level="error",
|
||||
component="hy.reconcile",
|
||||
event="hy.reconcile.failed",
|
||||
outcome="failed",
|
||||
duration_ms=elapsed_ms(started),
|
||||
error_code="hy_memory_reconcile_failed",
|
||||
**diagnostic_exception(error),
|
||||
)
|
||||
raise
|
||||
if not result.success:
|
||||
self.failures.append("hy_memory_reconcile_failed")
|
||||
diagnostic_event(
|
||||
level="error",
|
||||
component="hy.reconcile",
|
||||
event="hy.reconcile.failed",
|
||||
outcome="failed",
|
||||
duration_ms=elapsed_ms(started),
|
||||
error_code="hy_memory_reconcile_failed",
|
||||
details={"result_count": len(result.ops)},
|
||||
)
|
||||
return result
|
||||
owners: dict[str, set[str]] = {}
|
||||
for item in kwargs.get("new_memories_with_meta") or []:
|
||||
if item.get("owner") in {"user", "agent"}:
|
||||
owners.setdefault(item["content"], set()).add(item["owner"])
|
||||
# HY's no-candidate ADD path copies content verbatim but omits owner.
|
||||
# Restore only an unambiguous exact match, never infer from wording.
|
||||
for operation in result.ops:
|
||||
source_owners = owners.get(operation.content, set())
|
||||
if operation.op == "ADD" and operation.owner is None and len(source_owners) == 1:
|
||||
operation.owner = next(iter(source_owners))
|
||||
operation_counts = {"ADD": 0, "UPDATE": 0, "SUPERSEDE": 0, "DELETE": 0}
|
||||
for operation in result.ops:
|
||||
raw_name = getattr(operation, "op", "")
|
||||
name = str(getattr(raw_name, "value", raw_name)).upper()
|
||||
if name in {"UPDATE", "SUPERSEDE", "DELETE", "SHADOW"}:
|
||||
target = getattr(operation, "memory_id", None)
|
||||
if isinstance(target, str) and target:
|
||||
self.mutation_target_ids.add(target)
|
||||
self.mutation_target_ids.update(
|
||||
key
|
||||
for key in getattr(operation, "memory_ids", ())
|
||||
if isinstance(key, str) and key
|
||||
)
|
||||
if name == "ADD":
|
||||
operation_counts["ADD"] += 1
|
||||
elif name in {"UPDATE", "MERGE", "NOOP"}:
|
||||
operation_counts["UPDATE"] += 1
|
||||
elif name == "SUPERSEDE":
|
||||
operation_counts["SUPERSEDE"] += 1
|
||||
elif name in {"DELETE", "SHADOW"}:
|
||||
operation_counts["DELETE"] += 1
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="hy.reconcile",
|
||||
event="hy.reconcile.completed",
|
||||
outcome="succeeded",
|
||||
duration_ms=elapsed_ms(started),
|
||||
details={
|
||||
"created_count": operation_counts["ADD"],
|
||||
"updated_count": operation_counts["UPDATE"],
|
||||
"superseded_count": operation_counts["SUPERSEDE"],
|
||||
"deleted_count": operation_counts["DELETE"],
|
||||
"result_count": len(result.ops),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
async def _check_targets(self, result: Any, kwargs: dict[str, Any]) -> None:
|
||||
"""HY may only touch nodes of this user and scope that this write was allowed to see."""
|
||||
targets = {op.memory_id for op in result.ops if op.memory_id}
|
||||
targets.update(key for op in result.ops for key in getattr(op, "memory_ids", []))
|
||||
for key in targets:
|
||||
node = await kwargs["vector_store"].get_by_id(key)
|
||||
if (
|
||||
node is None
|
||||
or node.user_id != kwargs["user_id"]
|
||||
or node.agent_id != kwargs["agent_id"]
|
||||
):
|
||||
raise ValueError("hy_memory_write_verification_target_invalid")
|
||||
if self.bindings is not None:
|
||||
known = self.bindings.known.get(key)
|
||||
if known is None or (known.content, known.owner) != (node.content, node.owner):
|
||||
raise ValueError("hy_memory_write_verification_target_unbound")
|
||||
if self.bindings is None:
|
||||
return
|
||||
for op in result.ops:
|
||||
if not op.content:
|
||||
continue
|
||||
linked = {key for key in (op.memory_id, *getattr(op, "memory_ids", [])) if key}
|
||||
self.bindings.record_operation_support(op.content, op.owner, linked)
|
||||
if op.memory_id:
|
||||
self.bindings.verified_operations[op.memory_id] = (op.content, op.owner)
|
||||
|
||||
|
||||
def install_hy_write_guards(client: Any) -> None:
|
||||
"""The pinned HY SDK treats an empty reconcile response as a successful empty op list.
|
||||
|
||||
Bind this client's real write paths. Pro/lite use the ordinary writer;
|
||||
ultra uses the System2 writer's inner writer. Keep HY prompts, extraction,
|
||||
retrieval, reconciliation and storage intact; never patch the shared SDK
|
||||
installation.
|
||||
"""
|
||||
|
||||
async def install() -> None:
|
||||
from one_memory.hy_write_verification import HyExtractionGuard
|
||||
|
||||
ordinary = await client._registry.get_writer()
|
||||
system2 = await client._registry.get_system2_writer()
|
||||
guards: list[Any] = []
|
||||
seen_writers: set[int] = set()
|
||||
seen_guards: set[int] = set()
|
||||
for writer in (ordinary, system2._writer):
|
||||
if id(writer) in seen_writers:
|
||||
continue
|
||||
seen_writers.add(id(writer))
|
||||
agent = writer.mem_agent
|
||||
if not isinstance(agent.llm_provider, HyResponseGuard):
|
||||
agent.llm_provider = HyResponseGuard(agent.llm_provider)
|
||||
agent.extractor.llm = agent.llm_provider
|
||||
if not isinstance(agent.extractor, HyExtractionGuard):
|
||||
agent.extractor = HyExtractionGuard(agent.extractor)
|
||||
reconciler = writer.reconciler
|
||||
provider = reconciler._get_llm()
|
||||
if not isinstance(provider, HyResponseGuard):
|
||||
reconciler._llm = HyResponseGuard(provider, phase="reconcile")
|
||||
if not isinstance(reconciler, HyReconcileGuard):
|
||||
writer._reconciler = HyReconcileGuard(reconciler)
|
||||
for guard in (
|
||||
agent.llm_provider,
|
||||
reconciler._llm,
|
||||
writer._reconciler,
|
||||
agent.extractor,
|
||||
):
|
||||
if id(guard) not in seen_guards:
|
||||
seen_guards.add(id(guard))
|
||||
guards.append(guard)
|
||||
client._one_memory_write_guards = tuple(guards)
|
||||
|
||||
client._loop_thread.run(install())
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.4"
|
||||
HY_MEMORY_VERSION = "2.0.5"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
@@ -33,49 +33,8 @@ _COLLECTION_LOGGERS = (
|
||||
)
|
||||
_NOISY_LIBRARY_LOGGERS = ("httpx", "httpcore", "watchfiles", "jieba")
|
||||
|
||||
# One Memory deliberately stays on ultra because its existing, user-visible
|
||||
# profile/graph flow calls digest(), which the pinned public SDK exposes only
|
||||
# for ultra clients. Reader and non-reranker behavior remain pinned for
|
||||
# compatibility with public SDK 2.0.4.
|
||||
HY_MEMORY_MODE = "ultra"
|
||||
# The SDK only recognises `legacy` and `hybrid` (see
|
||||
# hy_memory/pipelines/_retrieval/config.py: `ALL_READERS = (READER_LEGACY,
|
||||
# READER_HYBRID)`). The old spelling `hybrid_v2` was silently rejected and
|
||||
# fell back to legacy, so the hybrid-v2 recall strategy never ran at all.
|
||||
HY_MEMORY_READER = "hybrid"
|
||||
|
||||
HY_MEMORY_BEHAVIOR_ENV: dict[str, str] = {
|
||||
"MEMORY_MODE": HY_MEMORY_MODE,
|
||||
"MEMORY_ENABLE_GRAPH": "true",
|
||||
"MEMORY_GRAPH_RERANK_ENABLED": "false",
|
||||
"MEMORY_GRAPH_TEMPORAL_SPINE": "true",
|
||||
"MEMORY_TOPIC_SUMMARY_ENABLED": "false",
|
||||
"MEMORY_EPISODE_SUMMARY_ENABLED": "false",
|
||||
"MEMORY_EPISODE_RECALL_ENABLED": "true",
|
||||
"MEMORY_DYNAMIC_EPISODE_ENABLED": "true",
|
||||
"MEMORY_SESSION_EPISODE_ENABLED": "false",
|
||||
"MEMORY_SESSION_EPISODE_CONTEXT_ENABLED": "false",
|
||||
"MEMORY_EPISODE_CAPSULE_CONTEXT_ENABLED": "true",
|
||||
"MEMORY_EPISODE_CAPSULE_QUERY_MIN": "0.65",
|
||||
"MEMORY_EPISODE_CAPSULE_ASSIGNMENT_MIN": "0.60",
|
||||
"MEMORY_EPISODE_CAPSULE_FIRST_RANK_MAX": "3",
|
||||
"MEMORY_EPISODE_CAPSULE_MEMBER_LIMIT": "3",
|
||||
"MEMORY_ENABLE_INTENTION": "false",
|
||||
"MEMORY_ENABLE_QUERY_REWRITE": "false",
|
||||
"MEMORY_FEW_SHOT_ENABLED": "true",
|
||||
"MEMORY_HYBRID_V2_QUERY_EXPAND": "1",
|
||||
"MEMORY_HYBRID_V2_GEO_INTENT_PRIOR": "0.6",
|
||||
"MEMORY_HYBRID_V2_DIVERSITY": "1",
|
||||
"MEMORY_HYBRID_V2_TEMPORAL_ADMIT_PRIOR": "0.55",
|
||||
"MEMORY_HYBRID_V2_TEMPORAL_BOOST": "0.18",
|
||||
"MEMORY_CROSS_DOMAIN_CON_THRESHOLD": "0.5",
|
||||
"MEMORY_READER_ENABLE_SUMMARY": "false",
|
||||
"MEMORY_SUMMARY_ENABLED_IN_SYS2": "false",
|
||||
"MEMORY_LOCAL_RERANK_ENABLED": "false",
|
||||
"RECONCILE_ENABLED": "true",
|
||||
"HY_MEMORY_READER": HY_MEMORY_READER,
|
||||
}
|
||||
|
||||
|
||||
def detailed_logging_enabled(data_dir: Path) -> bool:
|
||||
return diagnostic_log_level(data_dir) == "debug"
|
||||
@@ -147,10 +106,7 @@ def apply_diagnostic_log_level(level: DiagnosticLogLevel) -> None:
|
||||
for root in _NOISY_LIBRARY_LOGGERS:
|
||||
logging.getLogger(root).setLevel(noisy_level)
|
||||
for name in tuple(logging.Logger.manager.loggerDict):
|
||||
if any(
|
||||
name == root or name.startswith(f"{root}.")
|
||||
for root in _NOISY_LIBRARY_LOGGERS
|
||||
):
|
||||
if any(name == root or name.startswith(f"{root}.") for root in _NOISY_LIBRARY_LOGGERS):
|
||||
logging.getLogger(name).setLevel(noisy_level)
|
||||
|
||||
os.environ["MEMORY_LOG_LEVEL"] = logging.getLevelName(selected)
|
||||
@@ -171,7 +127,6 @@ def configure_hy_runtime_environment(data_dir: Path) -> None:
|
||||
log_dir = memory_root / "logs"
|
||||
memory_root.mkdir(parents=True, exist_ok=True)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.environ.update(HY_MEMORY_BEHAVIOR_ENV)
|
||||
os.environ["MEMORY_DATA_DIR"] = str(memory_root)
|
||||
os.environ["MEMORY_LOG_DIR"] = str(log_dir)
|
||||
level = diagnostic_log_level(data_dir)
|
||||
@@ -181,9 +136,3 @@ def configure_hy_runtime_environment(data_dir: Path) -> None:
|
||||
os.environ["MEMORY_MEMORY_OPERATIONS_ENABLED"] = "false"
|
||||
os.environ["ANONYMIZED_TELEMETRY"] = "False"
|
||||
os.environ.pop("CHROMA_OTEL_COLLECTION_ENDPOINT", None)
|
||||
|
||||
|
||||
def configure_hy_behavior_environment() -> None:
|
||||
"""Set import-time HY behavior flags without touching paths or secrets."""
|
||||
|
||||
os.environ.update(HY_MEMORY_BEHAVIOR_ENV)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Offline snapshot and restore of the HY SDK data plane for the App migration.
|
||||
|
||||
Runtime product code must use public ``HyMemoryClient`` methods. The startup
|
||||
migration bundle is the isolated exception: all writers are stopped, so it may
|
||||
copy and restore the SDK-owned local stores as files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
DataPlaneRestore,
|
||||
DataPlaneSnapshot,
|
||||
restore_local_data_plane,
|
||||
snapshot_local_data_plane,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_data_plane(
|
||||
*,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
writers_stopped: bool,
|
||||
) -> DataPlaneSnapshot:
|
||||
"""Snapshot an explicitly quiesced SDK-owned local data plane."""
|
||||
return snapshot_local_data_plane(
|
||||
source_root=source_root,
|
||||
target_root=target_root,
|
||||
writers_stopped=writers_stopped,
|
||||
)
|
||||
|
||||
|
||||
def restore_data_plane(
|
||||
*,
|
||||
snapshot_root: Path,
|
||||
target_root: Path,
|
||||
writers_stopped: bool,
|
||||
protective_root: Path | None = None,
|
||||
) -> DataPlaneRestore:
|
||||
"""Restore an explicitly quiesced SDK-owned local data plane."""
|
||||
return restore_local_data_plane(
|
||||
snapshot_root=snapshot_root,
|
||||
target_root=target_root,
|
||||
writers_stopped=writers_stopped,
|
||||
protective_root=protective_root,
|
||||
)
|
||||
@@ -0,0 +1,443 @@
|
||||
"""HY SDK client construction and shared-runtime leasing.
|
||||
|
||||
This module deliberately depends only on the SDK's package-root exports. A
|
||||
lease owns one client while the process shares one ``SharedRuntime`` per
|
||||
physical MemHub data directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from hy_memory import ( # type: ignore[import-untyped]
|
||||
HyMemoryClient,
|
||||
MemoryConfig,
|
||||
SharedRuntime,
|
||||
)
|
||||
|
||||
from one_memory.embedding_assets import (
|
||||
BUILTIN_EMBEDDING_DIMENSIONS,
|
||||
BUILTIN_EMBEDDING_MODEL,
|
||||
)
|
||||
from one_memory.hy_graph_admin import (
|
||||
GraphWidthMismatchError,
|
||||
graph_store_enabled,
|
||||
inspect_graph_width_mismatch,
|
||||
)
|
||||
from one_memory.hy_sdk_types import HySdkRuntimeError
|
||||
from one_memory.local_embedding import LocalEmbedder
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind
|
||||
|
||||
_RUNTIME_LOCK = threading.RLock()
|
||||
BUILTIN_RETRIEVAL_PROFILE_ID = "builtin-retrieval"
|
||||
_LOCAL_ONNX_PLACEHOLDER_URL = "http://127.0.0.1/local-onnx"
|
||||
DEFAULT_RELEVANCE_FLOOR = 0.35
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeEntry:
|
||||
runtime: SharedRuntime
|
||||
references: int
|
||||
|
||||
|
||||
_RUNTIMES: dict[Path, _RuntimeEntry] = {}
|
||||
|
||||
|
||||
class LocalOnnxEmbeddingService:
|
||||
"""Public ``EmbeddingService`` implementation backed by MemHub's ONNX model."""
|
||||
|
||||
_embedder: ClassVar[LocalEmbedder | None] = None
|
||||
_embedder_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
|
||||
def __init__(self, embedder: LocalEmbedder | None = None) -> None:
|
||||
if embedder is None:
|
||||
with self._embedder_lock:
|
||||
if self._embedder is None:
|
||||
self.__class__._embedder = LocalEmbedder()
|
||||
embedder = self._embedder
|
||||
if embedder is None: # pragma: no cover - guarded by the assignment above
|
||||
raise HySdkRuntimeError("hy_sdk_local_embedding_unavailable")
|
||||
self._local_embedder = embedder
|
||||
|
||||
async def embed(self, text: str, use_cache: bool = True) -> list[float]:
|
||||
del use_cache
|
||||
vectors = await asyncio.to_thread(self._local_embedder.encode, [text])
|
||||
return vectors[0]
|
||||
|
||||
async def embed_with_meta(
|
||||
self,
|
||||
text: str,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[list[float], dict[str, Any]]:
|
||||
return await self.embed(text, use_cache), {"provider": "local-onnx"}
|
||||
|
||||
async def embed_batch(
|
||||
self,
|
||||
texts: list[str],
|
||||
use_cache: bool = True,
|
||||
) -> list[list[float]]:
|
||||
del use_cache
|
||||
if not texts:
|
||||
return []
|
||||
return await asyncio.to_thread(self._local_embedder.encode, list(texts))
|
||||
|
||||
async def embed_queued(self, text: str, use_cache: bool = True) -> list[float]:
|
||||
return await self.embed(text, use_cache)
|
||||
|
||||
async def embed_queued_with_meta(
|
||||
self,
|
||||
text: str,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[list[float], dict[str, Any]]:
|
||||
return await self.embed_with_meta(text, use_cache)
|
||||
|
||||
def get_embedding_dims(self) -> int:
|
||||
return BUILTIN_EMBEDDING_DIMENSIONS
|
||||
|
||||
|
||||
class ClientLease:
|
||||
"""Own a client and release its shared runtime exactly once."""
|
||||
|
||||
def __init__(self, client: HyMemoryClient, data_dir: Path) -> None:
|
||||
self.client = client
|
||||
self.data_dir = data_dir.resolve()
|
||||
self._closed = False
|
||||
self._close_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
def close(self) -> None:
|
||||
with self._close_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self.client.close()
|
||||
finally:
|
||||
_release_runtime(self.data_dir)
|
||||
|
||||
def __enter__(self) -> ClientLease:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class SdkClientHandle:
|
||||
"""Lazy async owner for one profile-bound public SDK client lease."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile: ModelProfileSnapshot,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
) -> None:
|
||||
self._profile = profile
|
||||
self._api_key = api_key
|
||||
self._data_dir = data_dir
|
||||
self._http_transport_factory = http_transport_factory
|
||||
self._lease: ClientLease | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def client(self) -> HyMemoryClient:
|
||||
if self._lease is not None:
|
||||
return self._lease.client
|
||||
async with self._lock:
|
||||
if self._lease is None:
|
||||
self._lease = await asyncio.to_thread(
|
||||
acquire_client,
|
||||
self._profile,
|
||||
api_key=self._api_key,
|
||||
data_dir=self._data_dir,
|
||||
http_transport_factory=self._http_transport_factory,
|
||||
)
|
||||
return self._lease.client
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._lock:
|
||||
lease, self._lease = self._lease, None
|
||||
if lease is not None:
|
||||
await asyncio.to_thread(lease.close)
|
||||
|
||||
|
||||
def builtin_retrieval_snapshot() -> ModelProfileSnapshot:
|
||||
"""Return the model-free profile used by SDK-native read operations."""
|
||||
return ModelProfileSnapshot(
|
||||
profile_id=BUILTIN_RETRIEVAL_PROFILE_ID,
|
||||
revision=0,
|
||||
provider_kind=ProviderKind.LOCAL_ONNX,
|
||||
base_url=_LOCAL_ONNX_PLACEHOLDER_URL,
|
||||
model=BUILTIN_EMBEDDING_MODEL,
|
||||
embedding_provider_kind=ProviderKind.LOCAL_ONNX,
|
||||
embedding_model=BUILTIN_EMBEDDING_MODEL,
|
||||
embedding_dimensions=BUILTIN_EMBEDDING_DIMENSIONS,
|
||||
timeout_seconds=60,
|
||||
max_concurrency=1,
|
||||
context_window=None,
|
||||
has_api_key=False,
|
||||
)
|
||||
|
||||
|
||||
def sdk_relevance_floor() -> float:
|
||||
raw = os.environ.get("ONE_MEMORY_HY_RELEVANCE_FLOOR", "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_RELEVANCE_FLOOR
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return DEFAULT_RELEVANCE_FLOOR
|
||||
return min(1.0, max(0.0, value))
|
||||
|
||||
|
||||
def build_sdk_config(
|
||||
profile: ModelProfileSnapshot,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
) -> MemoryConfig:
|
||||
"""Map a model snapshot to SDK models, endpoints, backends, and local paths."""
|
||||
generation_endpoint, generation_key = _generation_access(profile, api_key)
|
||||
embedding_provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
embedding_service_is_local = embedding_provider is ProviderKind.LOCAL_ONNX
|
||||
embedding_model = profile.embedding_model or (
|
||||
BUILTIN_EMBEDDING_MODEL if embedding_service_is_local else None
|
||||
)
|
||||
if not embedding_model:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_model_required")
|
||||
dimensions: int | None
|
||||
if embedding_service_is_local:
|
||||
embedding_endpoint = None
|
||||
embedding_key = None
|
||||
dimensions = profile.embedding_dimensions or BUILTIN_EMBEDDING_DIMENSIONS
|
||||
else:
|
||||
embedding_endpoint = _openai_endpoint(
|
||||
embedding_provider,
|
||||
profile.embedding_base_url or profile.base_url,
|
||||
)
|
||||
embedding_key = "ollama" if embedding_provider is ProviderKind.OLLAMA else api_key
|
||||
if embedding_provider is ProviderKind.OPENAI_COMPATIBLE and not embedding_key:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_api_key_missing")
|
||||
if embedding_provider not in {
|
||||
ProviderKind.OPENAI_COMPATIBLE,
|
||||
ProviderKind.OLLAMA,
|
||||
}:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_provider_unsupported")
|
||||
dimensions = profile.embedding_dimensions
|
||||
|
||||
root = data_dir.resolve() / "hy-memory" / "governed-v2"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
collection = _collection_base(
|
||||
embedding_provider, embedding_endpoint, embedding_model, dimensions
|
||||
)
|
||||
|
||||
return MemoryConfig.from_dict(
|
||||
{
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"model": profile.model,
|
||||
"api_key": generation_key,
|
||||
"base_url": generation_endpoint,
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "openai",
|
||||
"model": (
|
||||
BUILTIN_EMBEDDING_MODEL if embedding_service_is_local else embedding_model
|
||||
),
|
||||
"api_key": embedding_key,
|
||||
"base_url": embedding_endpoint,
|
||||
"embedding_dims": dimensions,
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "chroma",
|
||||
"collection_name": collection,
|
||||
"persist_directory": str(root / "vector"),
|
||||
"embedding_dims": dimensions,
|
||||
"host": None,
|
||||
"port": None,
|
||||
"api_key": None,
|
||||
},
|
||||
"graph_store": {
|
||||
"provider": "kuzu",
|
||||
"db_path": str(root / "graph"),
|
||||
"url": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"database": None,
|
||||
},
|
||||
"cache": {
|
||||
"backend": "sqlite",
|
||||
"db_path": str(root / "cache.sqlite3"),
|
||||
},
|
||||
"history": {"db_path": str(root / "history.sqlite3")},
|
||||
"coding": {"db_path": str(root / "coding.sqlite3")},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def acquire_client(
|
||||
profile: ModelProfileSnapshot,
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
) -> ClientLease:
|
||||
"""Construct a public SDK client and return its explicit runtime lease."""
|
||||
config = build_sdk_config(profile, api_key=api_key, data_dir=data_dir)
|
||||
config.http_transport_factory = http_transport_factory
|
||||
mismatch = inspect_graph_width_mismatch(config)
|
||||
if mismatch is not None:
|
||||
raise GraphWidthMismatchError(mismatch)
|
||||
key = data_dir.resolve()
|
||||
runtime = _acquire_runtime(config, key)
|
||||
embedding_service: LocalOnnxEmbeddingService | None = None
|
||||
if (profile.embedding_provider_kind or profile.provider_kind) is ProviderKind.LOCAL_ONNX:
|
||||
embedding_service = LocalOnnxEmbeddingService()
|
||||
try:
|
||||
client = HyMemoryClient(
|
||||
config=config,
|
||||
mode="ultra" if graph_store_enabled() else "pro",
|
||||
runtime=runtime,
|
||||
embedding_service=embedding_service,
|
||||
)
|
||||
except BaseException:
|
||||
_release_runtime(key)
|
||||
raise
|
||||
return ClientLease(client, key)
|
||||
|
||||
|
||||
def close_all_shared_runtimes() -> None:
|
||||
"""Close process runtimes only after every client lease has been released."""
|
||||
with _RUNTIME_LOCK:
|
||||
if _RUNTIMES:
|
||||
raise HySdkRuntimeError("hy_sdk_runtime_leases_active")
|
||||
|
||||
|
||||
def _generation_access(
|
||||
profile: ModelProfileSnapshot,
|
||||
api_key: str | None,
|
||||
) -> tuple[str, str]:
|
||||
if profile.provider_kind is ProviderKind.LOCAL_ONNX:
|
||||
return _LOCAL_ONNX_PLACEHOLDER_URL, "local-read-only"
|
||||
if profile.provider_kind not in {
|
||||
ProviderKind.OPENAI_COMPATIBLE,
|
||||
ProviderKind.OLLAMA,
|
||||
}:
|
||||
raise HySdkRuntimeError("hy_sdk_generation_provider_unsupported")
|
||||
endpoint = _openai_endpoint(profile.provider_kind, profile.base_url)
|
||||
if profile.provider_kind is ProviderKind.OLLAMA:
|
||||
return endpoint, "ollama"
|
||||
if not api_key:
|
||||
raise HySdkRuntimeError("hy_sdk_model_api_key_missing")
|
||||
return endpoint, api_key
|
||||
|
||||
|
||||
def _collection_base(
|
||||
provider: ProviderKind,
|
||||
endpoint: str | None,
|
||||
model: str,
|
||||
dimensions: int | None,
|
||||
) -> str:
|
||||
identity = "\0".join((provider.value, endpoint or "local", model, str(dimensions or "auto")))
|
||||
return "memhub_" + hashlib.sha256(identity.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def vector_collection_bases(profile: ModelProfileSnapshot) -> tuple[str, tuple[str, ...]]:
|
||||
"""The SDK vector collection base for this embedding space, and the older ones.
|
||||
|
||||
The SDK derives every vector collection (chat, Coding keys, Working facts,
|
||||
each variant) by suffixing this base. Builds before #145 named the same
|
||||
embedding space `one_memory_<12 hex>`, with or without a declared width;
|
||||
the offline migration renames those so their vectors stay readable.
|
||||
"""
|
||||
provider = profile.embedding_provider_kind or profile.provider_kind
|
||||
local = provider is ProviderKind.LOCAL_ONNX
|
||||
model = profile.embedding_model or (BUILTIN_EMBEDDING_MODEL if local else None)
|
||||
if not model:
|
||||
raise HySdkRuntimeError("hy_sdk_embedding_model_required")
|
||||
endpoint: str | None
|
||||
dimensions: int | None
|
||||
if local:
|
||||
endpoint, dimensions = None, profile.embedding_dimensions or BUILTIN_EMBEDDING_DIMENSIONS
|
||||
legacy_endpoint = "local-onnx"
|
||||
else:
|
||||
endpoint = _openai_endpoint(provider, profile.embedding_base_url or profile.base_url)
|
||||
dimensions = profile.embedding_dimensions
|
||||
legacy_endpoint = endpoint
|
||||
legacy_model = profile.embedding_model or "bge-small-zh-v1.5"
|
||||
legacy = tuple(
|
||||
"one_memory_"
|
||||
+ hashlib.sha256(
|
||||
":".join((provider.value, legacy_endpoint, legacy_model, *declared)).encode()
|
||||
).hexdigest()[:12]
|
||||
for declared in ((), ("auto",), (str(profile.embedding_dimensions or "auto"),))
|
||||
)
|
||||
return _collection_base(provider, endpoint, model, dimensions), tuple(dict.fromkeys(legacy))
|
||||
|
||||
|
||||
def _openai_endpoint(provider: ProviderKind, endpoint: str | None) -> str:
|
||||
value = (endpoint or "").rstrip("/")
|
||||
if not value:
|
||||
raise HySdkRuntimeError("hy_sdk_model_endpoint_missing")
|
||||
if provider is ProviderKind.OLLAMA and not value.endswith("/v1"):
|
||||
return f"{value}/v1"
|
||||
return value
|
||||
|
||||
|
||||
def _acquire_runtime(config: MemoryConfig, key: Path) -> SharedRuntime:
|
||||
with _RUNTIME_LOCK:
|
||||
entry = _RUNTIMES.get(key)
|
||||
if entry is None:
|
||||
runtime = _run_sync(SharedRuntime.create(config))
|
||||
_RUNTIMES[key] = _RuntimeEntry(runtime=runtime, references=1)
|
||||
return runtime
|
||||
entry.references += 1
|
||||
return entry.runtime
|
||||
|
||||
|
||||
def _release_runtime(key: Path) -> None:
|
||||
runtime: SharedRuntime | None = None
|
||||
with _RUNTIME_LOCK:
|
||||
entry = _RUNTIMES.get(key)
|
||||
if entry is None:
|
||||
return
|
||||
entry.references -= 1
|
||||
if entry.references == 0:
|
||||
runtime = entry.runtime
|
||||
del _RUNTIMES[key]
|
||||
if runtime is not None:
|
||||
runtime.close()
|
||||
|
||||
|
||||
def _run_sync[T](coroutine: Coroutine[Any, Any, T]) -> T:
|
||||
"""Run SDK async construction from either sync or async caller threads."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
result: list[T] = []
|
||||
failure: list[BaseException] = []
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
result.append(asyncio.run(coroutine))
|
||||
except BaseException as error:
|
||||
failure.append(error)
|
||||
|
||||
thread = threading.Thread(target=run, name="hy-sdk-runtime-create")
|
||||
thread.start()
|
||||
thread.join()
|
||||
if failure:
|
||||
raise failure[0]
|
||||
return result[0]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Validated records and response parsers for public HY SDK operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
|
||||
|
||||
class HySdkError(RuntimeError):
|
||||
"""Base error carrying a stable, product-facing code."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
class HySdkRuntimeError(HySdkError):
|
||||
"""The public SDK runtime could not be configured or leased."""
|
||||
|
||||
|
||||
class HySdkMigrationError(HySdkError):
|
||||
"""The privileged offline SDK data-plane migration failed."""
|
||||
|
||||
|
||||
class HySdkReadError(HySdkError):
|
||||
"""A public SDK read operation failed or returned an invalid shape."""
|
||||
|
||||
|
||||
class HySdkMemoryRecord(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
memory_id: str
|
||||
content: str
|
||||
layer: str
|
||||
status: str
|
||||
is_latest: bool
|
||||
supersedes: tuple[str, ...] = ()
|
||||
superseded_by: tuple[str, ...] = ()
|
||||
tags: tuple[str, ...] = ()
|
||||
owner: str | None = None
|
||||
source_raw_ids: tuple[str, ...] = ()
|
||||
user_id: str
|
||||
agent_id: str
|
||||
session_id: str | None = None
|
||||
source: str | None = None
|
||||
memory_at: int | None = None
|
||||
gmt_created: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object) -> HySdkMemoryRecord:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_record_shape_unknown")
|
||||
if any(
|
||||
not isinstance(raw.get(key), str) or not str(raw[key]).strip()
|
||||
for key in ("memory_id", "content")
|
||||
):
|
||||
raise HySdkReadError("hy_memory_record_shape_unknown")
|
||||
try:
|
||||
return cls(
|
||||
memory_id=str(raw["memory_id"]),
|
||||
content=str(raw["content"]),
|
||||
layer=str(raw.get("layer") or ""),
|
||||
status=str(raw.get("status") or ""),
|
||||
is_latest=bool(raw.get("is_latest", True)),
|
||||
supersedes=_string_tuple(raw.get("supersedes")),
|
||||
superseded_by=_string_tuple(raw.get("superseded_by")),
|
||||
tags=_string_tuple(raw.get("tags")),
|
||||
owner=(str(raw["owner"]) if raw.get("owner") in {"user", "agent"} else None),
|
||||
source_raw_ids=_source_raw_ids(raw),
|
||||
user_id=str(raw.get("user_id") or ""),
|
||||
agent_id=str(raw.get("agent_id") or ""),
|
||||
session_id=(
|
||||
str(raw["session_id"]) if isinstance(raw.get("session_id"), str) else None
|
||||
),
|
||||
source=str(raw["source"]) if isinstance(raw.get("source"), str) else None,
|
||||
memory_at=raw.get("memory_at") if isinstance(raw.get("memory_at"), int) else None,
|
||||
gmt_created=(
|
||||
raw.get("gmt_created") if isinstance(raw.get("gmt_created"), int) else None
|
||||
),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise HySdkReadError("hy_memory_record_shape_unknown") from error
|
||||
|
||||
|
||||
class HySdkMemoryPage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
memories: tuple[HySdkMemoryRecord, ...]
|
||||
total: int = Field(ge=0)
|
||||
limit: int = Field(ge=1)
|
||||
offset: int = Field(ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object, *, limit: int, offset: int) -> HySdkMemoryPage:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
rows: list[object] = []
|
||||
total = 0
|
||||
for bucket_name, rows_name in (("vdb", "memories"), ("graph", "nodes")):
|
||||
bucket = raw.get(bucket_name)
|
||||
if bucket is None and bucket_name == "graph":
|
||||
continue
|
||||
if not isinstance(bucket, Mapping) or not isinstance(bucket.get(rows_name), list):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
bucket_total = bucket.get("total")
|
||||
if (
|
||||
not isinstance(bucket_total, int)
|
||||
or isinstance(bucket_total, bool)
|
||||
or bucket_total < 0
|
||||
):
|
||||
raise HySdkReadError("hy_memory_list_shape_unknown")
|
||||
total += bucket_total
|
||||
rows.extend(bucket[rows_name])
|
||||
deduplicated: dict[str, HySdkMemoryRecord] = {}
|
||||
for row in rows:
|
||||
record = HySdkMemoryRecord.from_sdk(row)
|
||||
deduplicated.setdefault(record.memory_id, record)
|
||||
return cls(
|
||||
memories=tuple(deduplicated.values()),
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
class HySdkRawRecord(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
raw_id: str
|
||||
content: str
|
||||
user_id: str
|
||||
agent_id: str
|
||||
session_id: str
|
||||
source: str
|
||||
memory_at: int | None = None
|
||||
gmt_created: int | None = None
|
||||
custom: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object) -> HySdkRawRecord:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_raw_shape_unknown")
|
||||
if any(
|
||||
not isinstance(raw.get(key), str) or not str(raw[key]).strip()
|
||||
for key in ("raw_id", "content", "user_id", "agent_id", "session_id", "source")
|
||||
):
|
||||
raise HySdkReadError("hy_memory_raw_shape_unknown")
|
||||
custom = raw.get("custom")
|
||||
if not isinstance(custom, Mapping):
|
||||
custom = {}
|
||||
try:
|
||||
return cls(
|
||||
raw_id=str(raw["raw_id"]),
|
||||
content=str(raw["content"]),
|
||||
user_id=str(raw["user_id"]),
|
||||
agent_id=str(raw["agent_id"]),
|
||||
session_id=str(raw["session_id"]),
|
||||
source=str(raw["source"]),
|
||||
memory_at=raw.get("memory_at") if isinstance(raw.get("memory_at"), int) else None,
|
||||
gmt_created=(
|
||||
raw.get("gmt_created") if isinstance(raw.get("gmt_created"), int) else None
|
||||
),
|
||||
custom=dict(custom),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise HySdkReadError("hy_memory_raw_shape_unknown") from error
|
||||
|
||||
|
||||
class HySdkRawHit(HySdkRawRecord):
|
||||
score: float = Field(ge=0, le=1)
|
||||
|
||||
@classmethod
|
||||
def from_sdk_search(cls, raw: object) -> HySdkRawHit:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_raw_search_shape_unknown")
|
||||
raw_id = raw.get("memory_id")
|
||||
score = raw.get("score")
|
||||
if (
|
||||
not isinstance(raw_id, str)
|
||||
or not isinstance(score, int | float)
|
||||
or isinstance(score, bool)
|
||||
):
|
||||
raise HySdkReadError("hy_memory_raw_search_shape_unknown")
|
||||
base = HySdkRawRecord.from_sdk({**raw, "raw_id": raw_id})
|
||||
return cls(
|
||||
**base.model_dump(),
|
||||
score=max(0.0, min(1.0, float(score))),
|
||||
)
|
||||
|
||||
|
||||
class HySdkRawPage(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
raw: tuple[HySdkRawRecord, ...]
|
||||
total: int = Field(ge=0)
|
||||
limit: int = Field(ge=1)
|
||||
offset: int = Field(ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_memory_list(
|
||||
cls,
|
||||
raw: object,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> HySdkRawPage:
|
||||
"""Parse ``list_memories(layers=['l1_raw'])`` into the App raw DTO."""
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
bucket = raw.get("vdb")
|
||||
if not isinstance(bucket, Mapping):
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
rows = bucket.get("memories")
|
||||
total = bucket.get("total")
|
||||
if (
|
||||
not isinstance(rows, list)
|
||||
or not isinstance(total, int)
|
||||
or isinstance(total, bool)
|
||||
or total < 0
|
||||
):
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
converted = []
|
||||
for row in rows:
|
||||
if not isinstance(row, Mapping) or row.get("layer") != "l1_raw":
|
||||
raise HySdkReadError("hy_memory_raw_list_shape_unknown")
|
||||
converted.append({**row, "raw_id": row.get("memory_id")})
|
||||
return cls(
|
||||
raw=tuple(HySdkRawRecord.from_sdk(row) for row in converted),
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
class HySdkUserProfile(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
request_id: str
|
||||
profile: dict[str, object] | None
|
||||
elapsed_ms: float = Field(ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_sdk(cls, raw: object) -> HySdkUserProfile:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError("hy_memory_profile_shape_unknown")
|
||||
request_id = raw.get("request_id")
|
||||
profile = raw.get("profile")
|
||||
elapsed_ms = raw.get("elapsed_ms")
|
||||
if (
|
||||
not isinstance(request_id, str)
|
||||
or (profile is not None and not isinstance(profile, Mapping))
|
||||
or not isinstance(elapsed_ms, int | float)
|
||||
or isinstance(elapsed_ms, bool)
|
||||
):
|
||||
raise HySdkReadError("hy_memory_profile_shape_unknown")
|
||||
return cls(
|
||||
request_id=request_id,
|
||||
profile=None if profile is None else dict(profile),
|
||||
elapsed_ms=float(elapsed_ms),
|
||||
)
|
||||
|
||||
|
||||
def parse_sdk_search(raw: object, *, error_code: str) -> tuple[SemanticMemoryHit, ...]:
|
||||
rows = _reply_rows(raw, error_code)
|
||||
found: list[SemanticMemoryHit] = []
|
||||
seen: set[str] = set()
|
||||
for item in sorted(rows, key=_score, reverse=True):
|
||||
memory_id = item.get("memory_id") or item.get("id")
|
||||
if not isinstance(memory_id, str) or not memory_id or memory_id in seen:
|
||||
continue
|
||||
seen.add(memory_id)
|
||||
content = item.get("content")
|
||||
member_ids, formatting_complete = _formatted_member_ids(item)
|
||||
found.append(
|
||||
SemanticMemoryHit(
|
||||
memory_id=memory_id,
|
||||
score=_score(item),
|
||||
formatted_content=(
|
||||
content.strip() if isinstance(content, str) and content.strip() else None
|
||||
),
|
||||
member_ids=member_ids,
|
||||
sdk_formatted=True,
|
||||
formatting_complete=formatting_complete,
|
||||
)
|
||||
)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def parse_sdk_raw_search(raw: object) -> tuple[HySdkRawHit, ...]:
|
||||
return tuple(
|
||||
HySdkRawHit.from_sdk_search(row)
|
||||
for row in _reply_rows(raw, "hy_memory_raw_search_shape_unknown")
|
||||
)
|
||||
|
||||
|
||||
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(
|
||||
dict.fromkeys(item.strip() for item in value if isinstance(item, str) and item.strip())
|
||||
)
|
||||
|
||||
|
||||
def _source_raw_ids(raw: Mapping[str, object]) -> tuple[str, ...]:
|
||||
custom = raw.get("custom")
|
||||
values: list[object] = []
|
||||
if isinstance(custom, Mapping):
|
||||
listed = custom.get("source_raw_memory_ids")
|
||||
if isinstance(listed, list):
|
||||
values.extend(listed)
|
||||
singular = raw.get("source_raw_memory_id")
|
||||
if isinstance(singular, str):
|
||||
values.append(singular)
|
||||
return tuple(
|
||||
dict.fromkeys(value.strip() for value in values if isinstance(value, str) and value.strip())
|
||||
)
|
||||
|
||||
|
||||
def _reply_rows(raw: object, error_code: str) -> tuple[Mapping[str, object], ...]:
|
||||
if not isinstance(raw, Mapping):
|
||||
raise HySdkReadError(error_code)
|
||||
keys = tuple(key for key in ("memories", "coding_memories") if key in raw)
|
||||
if "scene" in raw:
|
||||
scene = raw["scene"]
|
||||
if not isinstance(scene, str):
|
||||
raise HySdkReadError(error_code)
|
||||
if scene in ("normal", "chat"):
|
||||
selected = "memories"
|
||||
elif scene in ("productivity", "working"):
|
||||
selected = "coding_memories"
|
||||
else:
|
||||
raise HySdkReadError(error_code)
|
||||
if selected not in keys:
|
||||
raise HySdkReadError(error_code)
|
||||
elif len(keys) == 1:
|
||||
selected = keys[0]
|
||||
else:
|
||||
raise HySdkReadError(error_code)
|
||||
|
||||
result: list[Mapping[str, object]] = []
|
||||
for key in keys:
|
||||
value = raw[key]
|
||||
if key == "memories" and isinstance(value, Mapping):
|
||||
if set(value) != {"schema", "proactive", "normal"}:
|
||||
raise HySdkReadError(error_code)
|
||||
channels = tuple(value.values())
|
||||
else:
|
||||
channels = (value,)
|
||||
for channel in channels:
|
||||
if not isinstance(channel, list) or any(
|
||||
not isinstance(row, Mapping) for row in channel
|
||||
):
|
||||
raise HySdkReadError(error_code)
|
||||
if key == selected:
|
||||
result.extend(channel)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _score(memory: Mapping[str, object]) -> float:
|
||||
score = memory.get("score")
|
||||
return float(score) if isinstance(score, int | float) and not isinstance(score, bool) else 0.0
|
||||
|
||||
|
||||
def _formatted_member_ids(memory: Mapping[str, object]) -> tuple[tuple[str, ...], bool]:
|
||||
represented: dict[str, None] = {}
|
||||
composite = False
|
||||
raw_members = memory.get("member_ids")
|
||||
if raw_members is not None:
|
||||
composite = True
|
||||
if not isinstance(raw_members, list | tuple) or any(
|
||||
not isinstance(member_id, str) or not member_id for member_id in raw_members
|
||||
):
|
||||
return (), False
|
||||
for member_id in raw_members:
|
||||
represented.setdefault(member_id, None)
|
||||
chain = memory.get("evolution_chain")
|
||||
if chain is not None:
|
||||
composite = True
|
||||
if not isinstance(chain, list | tuple):
|
||||
return (), False
|
||||
for entry in chain:
|
||||
if not isinstance(entry, Mapping):
|
||||
return (), False
|
||||
member_id = entry.get("node_id") or entry.get("memory_id")
|
||||
if not isinstance(member_id, str) or not member_id:
|
||||
return (), False
|
||||
represented.setdefault(member_id, None)
|
||||
if memory.get("capsule_memories") is not None or memory.get("timeline") is not None:
|
||||
composite = True
|
||||
if raw_members is None:
|
||||
return (), False
|
||||
content = memory.get("content")
|
||||
if isinstance(content, str) and content.lstrip().startswith(
|
||||
("Related memories:", "Matched memories:", "Memory timeline:")
|
||||
):
|
||||
composite = True
|
||||
if not represented:
|
||||
return (), False
|
||||
return tuple(represented), not composite or bool(represented)
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Per-write original Evidence and where each HY memory came from; never a memory source."""
|
||||
|
||||
import json
|
||||
from collections.abc import Set as AbstractSet
|
||||
from importlib import import_module
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from one_memory.imports.models import EvidenceContentKind, EvidenceRecord
|
||||
from one_memory.input_sanitization import sanitize_model_input
|
||||
|
||||
|
||||
class HySupportedMemory(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
hy_memory_id: str
|
||||
governed_memory_id: str
|
||||
revision: int
|
||||
content: str
|
||||
owner: Literal["user", "agent"]
|
||||
evidence_ids: tuple[str, ...]
|
||||
|
||||
|
||||
class HySourceContext(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
current: tuple[EvidenceRecord, ...]
|
||||
history: tuple[EvidenceRecord, ...] = ()
|
||||
supporting: tuple[EvidenceRecord, ...] = ()
|
||||
known_memories: tuple[HySupportedMemory, ...] = ()
|
||||
|
||||
|
||||
class HyWriteBindings:
|
||||
def __init__(self, context: HySourceContext) -> None:
|
||||
if not context.current:
|
||||
raise ValueError("hy_memory_write_verification_context_scope")
|
||||
self.context = context
|
||||
seed = context.current[0]
|
||||
records = (*context.current, *context.history, *context.supporting)
|
||||
workspaces = {record.workspace for record in records}
|
||||
# A missing workspace is a legacy identity, not a wildcard that may
|
||||
# absorb a named project. Keep NULL and named workspaces disjoint.
|
||||
if None in workspaces and len(workspaces) != 1:
|
||||
raise ValueError("hy_memory_write_verification_context_scope")
|
||||
ids = [record.evidence_id for record in records]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("hy_memory_write_verification_context_scope")
|
||||
for record in records:
|
||||
if (
|
||||
(
|
||||
record.user_id,
|
||||
record.source_id,
|
||||
record.conversation_id,
|
||||
record.workspace,
|
||||
)
|
||||
!= (
|
||||
seed.user_id,
|
||||
seed.source_id,
|
||||
seed.conversation_id,
|
||||
seed.workspace,
|
||||
)
|
||||
or record.content_kind is not EvidenceContentKind.RAW_CONVERSATION
|
||||
or record.role not in {"user", "assistant", "tool"}
|
||||
):
|
||||
raise ValueError("hy_memory_write_verification_context_scope")
|
||||
self.all_sources: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": record.evidence_id,
|
||||
"role": record.role,
|
||||
"content": sanitize_model_input(record.verbatim),
|
||||
"new": index < len(context.current),
|
||||
"revision": record.revision,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
}
|
||||
for index, record in enumerate(records)
|
||||
]
|
||||
self.sources = self.all_sources[: len(context.current) + len(context.history)]
|
||||
self._supports: dict[tuple[str, str], tuple[str, ...]] = {}
|
||||
self.profile_order: list[str] | None = None
|
||||
self.verified_operations: dict[str, tuple[str, str]] = {}
|
||||
self.known = {memory.hy_memory_id: memory for memory in context.known_memories}
|
||||
for memory in context.known_memories:
|
||||
if not memory.evidence_ids or not set(memory.evidence_ids) <= set(ids):
|
||||
raise ValueError("hy_memory_write_verification_context_scope")
|
||||
self._supports[(memory.content, memory.owner)] = memory.evidence_ids
|
||||
|
||||
def check_content(self, content: str) -> None:
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except ValueError as error:
|
||||
raise ValueError("hy_memory_write_verification_input_mismatch") from error
|
||||
expected = [
|
||||
{"role": record.role, "content": sanitize_model_input(record.verbatim)}
|
||||
for record in self.context.current
|
||||
]
|
||||
# HY strips tool messages itself before chat extraction when it judges a turn chat.
|
||||
without_tools = [item for item in expected if item["role"] != "tool"]
|
||||
if parsed != expected and (without_tools == expected or parsed != without_tools):
|
||||
raise ValueError("hy_memory_write_verification_input_mismatch")
|
||||
|
||||
def history_json(self) -> str:
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"evidence_id": source["id"],
|
||||
**{k: source[k] for k in ("role", "content", "revision", "created_at")},
|
||||
}
|
||||
for source in self.sources
|
||||
if not source["new"]
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def turn_support(
|
||||
self, owner: str | None, targets: AbstractSet[str] = frozenset()
|
||||
) -> tuple[str, ...]:
|
||||
"""This turn's words by the memory's author, plus what a merged memory already cited."""
|
||||
roles = {"user": {"user"}, "agent": {"assistant"}}.get(owner or "")
|
||||
current = [source for source in self.all_sources if source["new"]]
|
||||
ids = [source["id"] for source in current if roles is None or source["role"] in roles]
|
||||
if not ids:
|
||||
ids = [source["id"] for source in current]
|
||||
for target in sorted(targets):
|
||||
if target in self.known:
|
||||
ids.extend(self.known[target].evidence_ids)
|
||||
return tuple(dict.fromkeys(ids))
|
||||
|
||||
def record_turn_supports(self, items: list[dict[str, Any]]) -> None:
|
||||
for item in items:
|
||||
key = (item["content"], item["owner"] or "")
|
||||
self._supports.setdefault(key, self.turn_support(item["owner"]))
|
||||
|
||||
def record_operation_support(self, content: str, owner: str | None, targets: set[str]) -> None:
|
||||
key = (content, owner or "")
|
||||
merged = (*self._supports.get(key, ()), *self.turn_support(owner, targets))
|
||||
self._supports[key] = tuple(dict.fromkeys(merged))
|
||||
|
||||
def support_for(self, content: str, owner: str | None) -> tuple[str, ...]:
|
||||
return self._supports.get((content, owner or ""), ())
|
||||
|
||||
def profile_support(self, content: str, values: dict[str, Any]) -> tuple[str, ...]:
|
||||
render = import_module("hy_memory.agent.tools.basic_profile")._render_content
|
||||
|
||||
if not values or render(values, field_order=self.profile_order) != content:
|
||||
return ()
|
||||
ids: dict[str, None] = {}
|
||||
for key, value in values.items():
|
||||
support = self.support_for(f"{key}: {value}", "user")
|
||||
if not support:
|
||||
return ()
|
||||
ids.update(dict.fromkeys(support))
|
||||
return tuple(ids)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Input checks around HY's native extractor; HY's output is published as it returns it."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from one_memory.hy_source_context import HyWriteBindings
|
||||
|
||||
|
||||
def _extracted_items(info: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for section in ("memory", "intentions"):
|
||||
for item in info.get(section) or []:
|
||||
if isinstance(item, dict) and isinstance(item.get("content"), str):
|
||||
items.append({"content": item["content"], "owner": item.get("owner")})
|
||||
for name, value in (info.get("basic_info") or {}).items():
|
||||
items.append({"content": f"{name}: {value}", "owner": "user"})
|
||||
return items
|
||||
|
||||
|
||||
class HyExtractionGuard:
|
||||
def __init__(self, extractor: Any) -> None:
|
||||
self.extractor = extractor
|
||||
self.failures: list[str] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self.bindings: HyWriteBindings | None = None
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self.extractor, name)
|
||||
|
||||
async def extract(self, *args: Any, **kwargs: Any) -> Any:
|
||||
async with self._lock:
|
||||
try:
|
||||
return await self._extract(args, kwargs)
|
||||
except ValueError as error:
|
||||
if str(error).startswith("hy_memory_write_verification"):
|
||||
self.failures.append(str(error))
|
||||
raise
|
||||
|
||||
async def _extract(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
||||
content = kwargs.get("content", args[0] if args else "")
|
||||
bindings = self.bindings
|
||||
if bindings is not None:
|
||||
bindings.check_content(content)
|
||||
fields = kwargs.get("basic_profile_fields")
|
||||
bindings.profile_order = list(fields) if fields else None
|
||||
kwargs = {**kwargs, "history_context": bindings.history_json()}
|
||||
else:
|
||||
# No raw-history identity was supplied; do not trust HY's text prefixes.
|
||||
kwargs = {**kwargs, "history_context": ""}
|
||||
result = await self.extractor.extract(*args, **kwargs)
|
||||
if bindings is not None and result.success:
|
||||
bindings.record_turn_supports(_extracted_items(result.info))
|
||||
return result
|
||||
@@ -449,8 +449,11 @@ def safe_project_label(value: str | None) -> str | None:
|
||||
def opaque_workspace_id(source_id: str, workspace: str | None) -> str | None:
|
||||
if not workspace:
|
||||
return None
|
||||
normalized = str(Path(workspace).expanduser())
|
||||
digest = hashlib.sha256(f"{source_id}\0{normalized}".encode()).hexdigest()
|
||||
if len(workspace) > 4_096 or any(ord(character) < 32 for character in workspace):
|
||||
return None
|
||||
# Workspace is an opaque identity, not a filesystem target. Hash the
|
||||
# caller's exact value without expansion, resolution, stat, open, or join.
|
||||
digest = hashlib.sha256(f"{source_id}\0{workspace}".encode()).hexdigest()
|
||||
return f"workspace:v1:{digest}"
|
||||
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ class ScanIssueCode(StrEnum):
|
||||
SOURCE_BUSY = "source_busy"
|
||||
UNSUPPORTED_SCHEMA = "unsupported_schema"
|
||||
BUDGET_EXCEEDED = "budget_exceeded"
|
||||
#: The file is bigger than the import's own per-file ceiling. Skipped with
|
||||
#: a reason rather than read, because one oversized history file can hold
|
||||
#: the import open long enough to take the process with it. Distinct from
|
||||
#: the access layer's `FILE_TOO_LARGE`, which is a per-root limit.
|
||||
#: The file is bigger than a per-file ceiling: either the import's own
|
||||
#: ceiling or the access gate's per-root `max_file_bytes`. Both are skipped
|
||||
#: with this reason rather than read, because one oversized history file
|
||||
#: can hold the import open long enough to take the process with it, and
|
||||
#: the user-facing cause is the same size limit.
|
||||
FILE_TOO_LARGE = "file_too_large"
|
||||
HISTORY_WINDOW_EXCLUDED = "history_window_excluded"
|
||||
|
||||
|
||||
@@ -557,12 +557,10 @@ class PollingLiveSyncSupervisor:
|
||||
)
|
||||
|
||||
def _note_internal_error(self, source_id: str, error: Exception) -> None:
|
||||
# Type alone used to be all this said. It is not enough to act on:
|
||||
# `OperationalError` covers a locked database, a missing table and a
|
||||
# corrupt page alike, and without the message and the frames two dozen
|
||||
# of these in a log could only be guessed at. The stack is the
|
||||
# redacted, `one_memory`-relative one, so it names the call site
|
||||
# without printing anyone's paths or query text.
|
||||
# The ordinary local log keeps the exception message for debugging.
|
||||
# The structured diagnostic event deliberately keeps only the stable
|
||||
# error code, source id, type, and relative stack: exception messages
|
||||
# can contain paths, query text, or credentials and are exportable data.
|
||||
logging.getLogger(__name__).warning(
|
||||
"live_sync_internal_error %s: %s", type(error).__name__, error
|
||||
)
|
||||
@@ -572,10 +570,7 @@ class PollingLiveSyncSupervisor:
|
||||
event="live_sync.internal_error",
|
||||
outcome="failed",
|
||||
error_code="live_sync_internal_error",
|
||||
# `reason` is the listed key that can carry the message; see
|
||||
# `diagnostic_log._DETAIL_KEYS`. Truncated because a detail value
|
||||
# longer than 256 characters is rejected.
|
||||
details={"source_id": source_id, "reason": str(error)[:200]},
|
||||
details={"source_id": source_id},
|
||||
**diagnostic_exception(error),
|
||||
)
|
||||
if isinstance(error, sqlite3.Error):
|
||||
|
||||
@@ -2,7 +2,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Annotated
|
||||
from uuid import UUID, uuid5
|
||||
@@ -343,3 +343,33 @@ class EvidenceRecord(BaseModel):
|
||||
@property
|
||||
def origins(self) -> frozenset[EvidenceOrigin]:
|
||||
return frozenset(self.origin_meta)
|
||||
|
||||
|
||||
def materialize_write_input(
|
||||
value: EvidenceInput,
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
) -> EvidenceRecord:
|
||||
"""Freeze source input directly into a write-job payload, without Evidence SQL."""
|
||||
now = observed_at or datetime.now(UTC)
|
||||
return EvidenceRecord(
|
||||
evidence_id=value.evidence_id,
|
||||
user_id=value.user_id,
|
||||
source_id=value.source_id,
|
||||
workspace=value.workspace,
|
||||
project=value.project,
|
||||
conversation_id=value.conversation_id,
|
||||
message_id=value.message_id,
|
||||
correlation_id=value.correlation_id,
|
||||
source_ordinal=value.source_ordinal,
|
||||
content_kind=value.content_kind,
|
||||
role=value.role,
|
||||
written_by=value.written_by,
|
||||
created_at=value.created_at,
|
||||
verbatim=value.verbatim,
|
||||
origin_meta=value.origin_meta,
|
||||
content_hash=value.content_hash,
|
||||
revision=1,
|
||||
imported_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -37,8 +37,9 @@ from one_memory.imports.jobs import (
|
||||
ImportStatus,
|
||||
InvalidJobTransitionError,
|
||||
)
|
||||
from one_memory.imports.models import EvidenceRecord
|
||||
from one_memory.imports.models import EvidenceRecord, materialize_write_input
|
||||
from one_memory.imports.storage import EvidenceStore, PendingMemoryDispatch
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.service_levels import ServiceLevel, service_level_context
|
||||
|
||||
_SKIP_BATCH_SECONDS = 0.1
|
||||
@@ -166,6 +167,8 @@ class ImportRunner:
|
||||
on_evidence: Callable[[EvidenceRecord], object] | None = None,
|
||||
on_stable_evidence: Callable[[EvidenceRecord], object] | None = None,
|
||||
heartbeat_interval_seconds: float = 10.0,
|
||||
sdk_queue: DurableWriteQueue | None = None,
|
||||
sdk_authority: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
if evidence_store.database_path.resolve() != job_store.database_path.resolve():
|
||||
raise ValueError("Evidence and import jobs must share one database")
|
||||
@@ -175,6 +178,8 @@ class ImportRunner:
|
||||
self.on_evidence = on_evidence
|
||||
self.on_stable_evidence = on_stable_evidence
|
||||
self._live_import = False
|
||||
self.sdk_queue = sdk_queue
|
||||
self.sdk_authority = sdk_authority or (lambda: False)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -652,6 +657,18 @@ class ImportRunner:
|
||||
count_skips()
|
||||
if isinstance(event, ScanCursorTouch):
|
||||
self.job_store._upsert_cursor(connection, row, event.cursor, now)
|
||||
if (
|
||||
self.sdk_authority()
|
||||
and self.sdk_queue is not None
|
||||
and event.cursor.read_to_end
|
||||
):
|
||||
self.sdk_queue.finalize_sdk_file(
|
||||
user_id=current.user_id,
|
||||
source_id=current.source_id,
|
||||
file_key=event.cursor.file_key,
|
||||
connection=connection,
|
||||
)
|
||||
continue
|
||||
if self.on_stable_evidence is not None and event.cursor.read_to_end:
|
||||
# Read without consuming: the durable submit runs after
|
||||
# this transaction closes, so the pending marker must
|
||||
@@ -669,6 +686,23 @@ class ImportRunner:
|
||||
(current.user_id, current.source_id, event.cursor.file_key)
|
||||
)
|
||||
continue
|
||||
if self.sdk_authority() and self.sdk_queue is not None:
|
||||
record = materialize_write_input(event.evidence)
|
||||
self.sdk_queue.stage_sdk_input(
|
||||
record,
|
||||
file_key=event.cursor.file_key,
|
||||
connection=connection,
|
||||
priority="live" if self._live_import else "normal",
|
||||
)
|
||||
self.job_store.record_evidence(
|
||||
job_id,
|
||||
runner_token=runner_token,
|
||||
cursor=event.cursor,
|
||||
was_created=True,
|
||||
was_revised=False,
|
||||
connection=connection,
|
||||
)
|
||||
continue
|
||||
result = self.evidence_store.put(event.evidence, connection=connection)
|
||||
self.job_store.record_evidence(
|
||||
job_id,
|
||||
@@ -833,10 +867,7 @@ class ImportRunner:
|
||||
outcome="failed",
|
||||
job_id=job_id,
|
||||
error_code="import_heartbeat_failed",
|
||||
# `reason` is the listed key for this; see
|
||||
# `diagnostic_log._DETAIL_KEYS`. Truncated: a detail value
|
||||
# over 256 characters is rejected.
|
||||
details={"reason": str(error)[:200]},
|
||||
details={"phase": "heartbeat"},
|
||||
**diagnostic_exception(error),
|
||||
)
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ def _clamp_verbatim(text: str) -> str:
|
||||
keep = max(0, MAX_VERBATIM_CHARACTERS - len(marker))
|
||||
return text[:keep] + marker
|
||||
|
||||
|
||||
#: At most one read heartbeat per second. Faster would be more writes than the
|
||||
#: counts are worth; slower leaves the job's visible state stale.
|
||||
_READ_PROGRESS_INTERVAL_SECONDS = 1.0
|
||||
@@ -1093,8 +1094,7 @@ def to_evidence(
|
||||
created_at=parsed.created_at,
|
||||
verbatim=_clamp_verbatim(parsed.verbatim),
|
||||
raw_verbatim=(
|
||||
None if parsed.raw_verbatim is None
|
||||
else _clamp_verbatim(parsed.raw_verbatim)
|
||||
None if parsed.raw_verbatim is None else _clamp_verbatim(parsed.raw_verbatim)
|
||||
),
|
||||
origin_meta={
|
||||
EvidenceOrigin.FILE: OriginMetadata(
|
||||
@@ -1120,6 +1120,11 @@ def discovery_failure_code(
|
||||
) -> ScanIssueCode:
|
||||
if failure.reason_code is SourceAccessErrorCode.BUDGET_EXCEEDED:
|
||||
return ScanIssueCode.BUDGET_EXCEEDED
|
||||
if failure.reason_code is SourceAccessErrorCode.FILE_TOO_LARGE:
|
||||
# The file is readable and its format is fine; only its size is over
|
||||
# the access gate. Reporting that as `unsupported_schema` sends the
|
||||
# user looking for a parser problem that does not exist.
|
||||
return ScanIssueCode.FILE_TOO_LARGE
|
||||
return ScanIssueCode.UNSUPPORTED_SCHEMA
|
||||
|
||||
|
||||
@@ -1127,6 +1132,8 @@ def _scan_failure_code(code: SourceAccessErrorCode) -> ScanIssueCode:
|
||||
"""Map a per-file open failure onto a machine code, never onto free text."""
|
||||
if code is SourceAccessErrorCode.BUDGET_EXCEEDED:
|
||||
return ScanIssueCode.BUDGET_EXCEEDED
|
||||
if code is SourceAccessErrorCode.FILE_TOO_LARGE:
|
||||
return ScanIssueCode.FILE_TOO_LARGE
|
||||
if code is SourceAccessErrorCode.SYMLINK_NOT_ALLOWED:
|
||||
return ScanIssueCode.UNSUPPORTED_SCHEMA
|
||||
if code in {
|
||||
|
||||
@@ -846,9 +846,7 @@ def _read_store_messages(
|
||||
reason_code=ScanIssueCode.UNKNOWN_RECORD,
|
||||
)
|
||||
continue
|
||||
host_timestamp = (
|
||||
_cursor_host_timestamp(content) if role == "user" else None
|
||||
)
|
||||
host_timestamp = _cursor_host_timestamp(content) if role == "user" else None
|
||||
fallback_timestamp = datetime.fromtimestamp(
|
||||
source_file.modified_ns / 1_000_000_000,
|
||||
tz=UTC,
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import PurePosixPath
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from one_memory.derived_echo import strip_injected_memory
|
||||
from one_memory.imports.access import (
|
||||
AuthorizedSourceFile,
|
||||
AuthorizedSourceRoot,
|
||||
@@ -213,6 +214,7 @@ def parse_hermes_rollout_record(
|
||||
message.get("content"),
|
||||
allowed_block_types=frozenset({"text", "input_text", "output_text"}),
|
||||
)
|
||||
text = strip_injected_memory(text)
|
||||
if not text:
|
||||
return ParsedSkip(
|
||||
reason_code=ScanIssueCode.NO_TEXT,
|
||||
@@ -386,7 +388,8 @@ def _read_state_db(
|
||||
# The assistant's call sits on its own row as `tool_calls`; the
|
||||
# `tool` rows after it hold only what came back.
|
||||
calls = _tool_calls_text(row["tool_calls"]) if role == "assistant" else None
|
||||
has_text = bool(str(row["content"] or "").strip())
|
||||
content = strip_injected_memory(str(row["content"] or ""))
|
||||
has_text = bool(content.strip())
|
||||
if role not in {"user", "assistant"} or (not has_text and calls is None):
|
||||
yield ScanSkip(
|
||||
cursor=cursor,
|
||||
@@ -401,7 +404,7 @@ def _read_state_db(
|
||||
role=str(role) if has_text else "tool",
|
||||
written_by=_written_by(role, row["session_source"]) if has_text else WrittenBy.TOOL,
|
||||
created_at=parse_timestamp(row["timestamp"]),
|
||||
verbatim=str(row["content"]) if has_text else str(calls),
|
||||
verbatim=content if has_text else str(calls),
|
||||
workspace=workspace,
|
||||
project=safe_project_label(workspace),
|
||||
record_type="state_db_message" if has_text else "state_db_message.tool",
|
||||
|
||||
@@ -1796,6 +1796,67 @@ class EvidenceStore:
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
def release_sdk_staging(self, evidence_ids: tuple[str, ...]) -> int:
|
||||
"""Remove post-cutover queue staging after SDK success.
|
||||
|
||||
Historical rows referenced by legacy Governance remain for the
|
||||
compatibility release. SDK-authority imports create no such links and
|
||||
stop being MemHub raw storage once the job has SDK receipts.
|
||||
"""
|
||||
ids = tuple(dict.fromkeys(evidence_ids))
|
||||
if not ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with self._connect(writing=True) as connection:
|
||||
has_governance = (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM sqlite_master "
|
||||
"WHERE type='table' AND name='governed_memory_evidence'"
|
||||
).fetchone()
|
||||
is not None
|
||||
)
|
||||
legacy_guard = (
|
||||
"AND NOT EXISTS ("
|
||||
"SELECT 1 FROM governed_memory_evidence governed "
|
||||
"WHERE governed.evidence_id=evidence.evidence_id"
|
||||
")"
|
||||
if has_governance
|
||||
else ""
|
||||
)
|
||||
changed = connection.execute(
|
||||
f"DELETE FROM evidence WHERE evidence_id IN ({placeholders}) {legacy_guard}",
|
||||
ids,
|
||||
)
|
||||
return changed.rowcount
|
||||
|
||||
def release_completed_sdk_staging(self, *, limit: int = 2_048) -> int:
|
||||
"""Resume cleanup if Core stopped after job settlement."""
|
||||
if not 1 <= limit <= 100_000:
|
||||
raise ValueError("limit must be between 1 and 100000")
|
||||
with self._connect(writing=True) as connection:
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
if (
|
||||
not {
|
||||
"memory_write_jobs",
|
||||
"memory_write_job_evidence",
|
||||
}
|
||||
<= tables
|
||||
):
|
||||
return 0
|
||||
rows = connection.execute(
|
||||
"SELECT DISTINCT linked.evidence_id "
|
||||
"FROM memory_write_jobs job "
|
||||
"JOIN memory_write_job_evidence linked USING(job_id) "
|
||||
"WHERE job.state='completed' "
|
||||
"AND (job.sdk_raw_ids_json!='[]' OR job.sdk_memory_ids_json!='[]') "
|
||||
"ORDER BY linked.evidence_id LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return self.release_sdk_staging(tuple(str(row[0]) for row in rows))
|
||||
|
||||
def stage_memory_dispatch(
|
||||
self,
|
||||
record: EvidenceRecord,
|
||||
|
||||
@@ -17,6 +17,7 @@ from one_memory.imports.jobs import (
|
||||
from one_memory.imports.models import EvidenceRecord
|
||||
from one_memory.imports.runner import ImportRunner
|
||||
from one_memory.imports.storage import EvidenceStore
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.service_levels import (
|
||||
ResourceLimit,
|
||||
ServiceLevel,
|
||||
@@ -47,6 +48,8 @@ class ImportSupervisor:
|
||||
source_enabled: Callable[[str], bool] | None = None,
|
||||
prepare_adapter: Callable[[ImportJob, SourceAdapter], Awaitable[SourceAdapter]]
|
||||
| None = None,
|
||||
sdk_queue: DurableWriteQueue | None = None,
|
||||
sdk_authority: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
if max_work_events_per_slice < 1:
|
||||
raise ValueError("max_work_events_per_slice must be positive")
|
||||
@@ -61,6 +64,8 @@ class ImportSupervisor:
|
||||
self.on_stable_evidence = on_stable_evidence
|
||||
self.source_enabled = source_enabled
|
||||
self.prepare_adapter = prepare_adapter
|
||||
self.sdk_queue = sdk_queue
|
||||
self.sdk_authority = sdk_authority or (lambda: False)
|
||||
self.max_work_events_per_slice = max_work_events_per_slice
|
||||
self.max_work_seconds_per_slice = max_work_seconds_per_slice
|
||||
self.retry_base_seconds = retry_base_seconds
|
||||
@@ -230,6 +235,8 @@ class ImportSupervisor:
|
||||
job_store=self.job_store,
|
||||
on_evidence=self.on_evidence,
|
||||
on_stable_evidence=self.on_stable_evidence,
|
||||
sdk_queue=self.sdk_queue,
|
||||
sdk_authority=self.sdk_authority,
|
||||
)
|
||||
adapter = self.adapters[job.source_id]
|
||||
if self.prepare_adapter is not None:
|
||||
|
||||
@@ -91,8 +91,7 @@ def _state(connection: sqlite3.Connection, evidence_id: str) -> dict[str, str |
|
||||
{
|
||||
key: item
|
||||
for key, item in value.items()
|
||||
if key
|
||||
not in {"verbatim", "written_by", "origin_meta", "imported_at", "updated_at"}
|
||||
if key not in {"verbatim", "written_by", "origin_meta", "imported_at", "updated_at"}
|
||||
}
|
||||
)
|
||||
return dict(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Passive lifecycle projection used only by retired Governance readers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class HyLifecycle:
|
||||
hidden: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "hidden", MappingProxyType(dict(self.hidden)))
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, HyLifecycle) and dict(self.hidden) == dict(
|
||||
other.hidden
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(tuple(sorted(self.hidden.items())))
|
||||
|
||||
def replaced_by(self, memory_id: str) -> tuple[str, ...]:
|
||||
return self.hidden.get(memory_id, ())
|
||||
|
||||
def replacing(self, memory_id: str) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
old
|
||||
for old, replacements in self.hidden.items()
|
||||
if memory_id in replacements
|
||||
)
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Write visible hook text into Evidence. Tool output stays out."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
@@ -74,6 +75,7 @@ _FILE_FALLBACK = {
|
||||
}
|
||||
_FALLBACK_EVENTS = frozenset({"Stop", "stop", "SubagentStop", "StopFailure"})
|
||||
_TEXT_BLOCKS = frozenset({"text"})
|
||||
SDK_MIGRATION_CAPTURE_FENCE = ".sdk-migration-capture-fence.json"
|
||||
|
||||
|
||||
def database_path() -> Path:
|
||||
@@ -88,17 +90,42 @@ def capture_stdio(source_id: str, stdin: TextIO, output: TextIO) -> int:
|
||||
del output
|
||||
if source_id not in CAPTURE_SOURCES:
|
||||
return 2
|
||||
database = database_path()
|
||||
if sdk_migration_capture_fenced(database.parent):
|
||||
return 0
|
||||
try:
|
||||
payload = json.loads(stdin.read() or "")
|
||||
except json.JSONDecodeError:
|
||||
return 0
|
||||
try:
|
||||
capture_hook(source_id, payload, database_path())
|
||||
capture_hook(source_id, payload, database)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def sdk_migration_capture_fenced(data_dir: Path) -> bool:
|
||||
"""Ignore stale fences; only a live Desktop migration pauses hook writes."""
|
||||
marker = data_dir / SDK_MIGRATION_CAPTURE_FENCE
|
||||
try:
|
||||
document = json.loads(marker.read_text(encoding="utf-8"))
|
||||
pid = int(document["pid"])
|
||||
if pid < 1:
|
||||
raise ValueError("invalid migration fence pid")
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError, OSError):
|
||||
marker.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def capture_hook(source_id: str, payload: object, database: Path) -> bool:
|
||||
if source_id not in CAPTURE_SOURCES or not isinstance(payload, dict):
|
||||
return False
|
||||
@@ -224,7 +251,7 @@ def record_capture_rejection(
|
||||
def _submit_live_memory(store: EvidenceStore, record: EvidenceRecord, *, changed: bool) -> None:
|
||||
if not changed or not _sync_open(store.database_path, record.source_id):
|
||||
return
|
||||
from one_memory.memory_processing import _conversation_chunks
|
||||
from one_memory.conversation_processing import _conversation_chunks
|
||||
from one_memory.memory_server.durable_queue import DurableWriteQueue
|
||||
from one_memory.memory_server.unified_entry import UnifiedWriteIntake
|
||||
|
||||
|
||||
@@ -10,16 +10,12 @@ weights alone.
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from one_memory.embedding_assets import (
|
||||
BUILTIN_EMBEDDING_DIMENSIONS,
|
||||
BUILTIN_EMBEDDING_MAX_TOKENS,
|
||||
EMBEDDING_ASSETS,
|
||||
AssetVerificationError,
|
||||
MissingEmbeddingAssetsError,
|
||||
assets_present,
|
||||
resolve_asset_dir,
|
||||
@@ -34,28 +30,6 @@ _FETCH_HINT = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenOffset:
|
||||
"""Half-open Python Unicode code point offsets; synthetic specials use [0,0)."""
|
||||
|
||||
start_cp: int
|
||||
end_cp: int
|
||||
special: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenizedText:
|
||||
"""Untruncated, unpadded encoding, including tokenizer postprocessor specials."""
|
||||
|
||||
token_ids: tuple[int, ...]
|
||||
offsets: tuple[TokenOffset, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Defensive copies also keep runtime callers from retaining mutable lists.
|
||||
object.__setattr__(self, "token_ids", tuple(self.token_ids))
|
||||
object.__setattr__(self, "offsets", tuple(self.offsets))
|
||||
|
||||
|
||||
class LocalEmbedder:
|
||||
"""Encodes text with the built-in model, loading it on first use."""
|
||||
|
||||
@@ -68,43 +42,6 @@ class LocalEmbedder:
|
||||
self._session: Any | None = None
|
||||
self._tokenizer: Any | None = None
|
||||
self._input_names: frozenset[str] = frozenset()
|
||||
self._offset_lock = threading.Lock()
|
||||
self._offset_tokenizer: Any | None = None
|
||||
|
||||
def tokenize_with_offsets(self, text: str) -> TokenizedText:
|
||||
"""Tokenize original text without encode()'s truncation or batch padding.
|
||||
|
||||
The Python tokenizers binding returns code point offsets into the input,
|
||||
even when its model normalizer changes spelling. This separate lazy
|
||||
tokenizer uses the exact locked asset; it never loads an ONNX session or
|
||||
mutates the tokenizer shared by encode(). Offsets are not UTF-16 indices.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("embedding_offsets_require_text")
|
||||
with self._offset_lock:
|
||||
if self._offset_tokenizer is None:
|
||||
directory = resolve_asset_dir()
|
||||
path = directory / "tokenizer.json"
|
||||
if not path.is_file():
|
||||
raise MissingEmbeddingAssetsError(f"{_FETCH_HINT} Looked in {directory}.")
|
||||
data = path.read_bytes()
|
||||
expected = next(a.sha256 for a in EMBEDDING_ASSETS if a.filename == path.name)
|
||||
if sha256(data).hexdigest() != expected:
|
||||
raise AssetVerificationError("tokenizer.json: locked digest mismatch")
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
tokenizer = Tokenizer.from_str(data.decode("utf-8"))
|
||||
tokenizer.no_truncation()
|
||||
tokenizer.no_padding()
|
||||
self._offset_tokenizer = tokenizer
|
||||
encoding = self._offset_tokenizer.encode(text, add_special_tokens=True)
|
||||
offsets = tuple(
|
||||
TokenOffset(start, end, bool(special))
|
||||
for (start, end), special in zip(
|
||||
encoding.offsets, encoding.special_tokens_mask, strict=True
|
||||
)
|
||||
)
|
||||
return TokenizedText(tuple(encoding.ids), offsets)
|
||||
|
||||
def _load(self) -> tuple[Any, Any]:
|
||||
# Double-checked under a lock: memory processing encodes from worker
|
||||
@@ -121,7 +58,7 @@ class LocalEmbedder:
|
||||
raise MissingEmbeddingAssetsError(f"{_FETCH_HINT} Looked in {directory}.")
|
||||
|
||||
# Imported by name for the same reason kuzu and hy_memory are in
|
||||
# hy_memory_bridge: onnxruntime ships no py.typed, and a dynamic
|
||||
# onnxruntime ships no py.typed, and a dynamic
|
||||
# import keeps the strict checker on everything around it.
|
||||
ort = import_module("onnxruntime")
|
||||
from tokenizers import Tokenizer
|
||||
|
||||
@@ -14,6 +14,7 @@ stuck in it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -23,6 +24,7 @@ from types import FrameType
|
||||
logger = logging.getLogger("one_memory.loop_watchdog")
|
||||
|
||||
DEFAULT_STALL_SECONDS = 2.0
|
||||
_SAFE_FALLBACK_FILE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*\.py$")
|
||||
|
||||
|
||||
class LoopWatchdog:
|
||||
@@ -120,22 +122,28 @@ def _safe_stack_for_main_thread() -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _safe_stacks_for_all_threads() -> tuple[str, ...]:
|
||||
"""Return one content-free, bounded stack summary for every live thread."""
|
||||
names = {thread.ident: thread.name for thread in threading.enumerate()}
|
||||
"""Return bounded stack summaries without recording thread names."""
|
||||
lines: list[str] = []
|
||||
for ident, frame in sorted(sys._current_frames().items()):
|
||||
for _ident, frame in sorted(sys._current_frames().items()):
|
||||
stack: list[str] = []
|
||||
# Most specific frame first so the 256-character privacy bound keeps
|
||||
# the likely culprit rather than only the thread bootstrap frames.
|
||||
# Most specific frame first; append only complete frames so truncation
|
||||
# can never turn a valid location into free text.
|
||||
for entry in reversed(traceback.extract_stack(frame)[-8:]):
|
||||
parts = entry.filename.replace("\\", "/").split("/")
|
||||
try:
|
||||
root = parts.index("one_memory")
|
||||
relative = "/".join(parts[root:])
|
||||
except ValueError:
|
||||
relative = parts[-1]
|
||||
fallback = parts[-1]
|
||||
relative = fallback if _SAFE_FALLBACK_FILE.fullmatch(fallback) else "unknown.py"
|
||||
name = entry.name if entry.name.isidentifier() else "call"
|
||||
stack.append(f"{relative}:{entry.lineno}:{name}")
|
||||
thread_name = names.get(ident, "unknown").replace("\n", " ").replace("\r", " ")
|
||||
lines.append(f"thread={thread_name[:48]}|{' > '.join(stack)}"[:256])
|
||||
candidate = f"{relative}:{entry.lineno}:{name}"
|
||||
separator = "" if not stack else " > "
|
||||
if len("thread|") + len(" > ".join(stack)) + len(separator) + len(candidate) > 256:
|
||||
break
|
||||
stack.append(candidate)
|
||||
if stack:
|
||||
lines.append(f"thread|{' > '.join(stack)}")
|
||||
if len(lines) == 16:
|
||||
break
|
||||
return tuple(lines)
|
||||
Loaded 100 of 576 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user