Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
625f877b43 | ||
|
|
1608a4cfc2 | ||
|
|
20b92d8a89 | ||
|
|
2a4c5e38e7 | ||
|
|
c891a1fd1d | ||
|
|
7741e17f09 | ||
|
|
2a4dc2ffa2 | ||
|
|
61b5ef30a9 | ||
|
|
f79770cdb0 | ||
|
|
9ba602ffd2 | ||
|
|
842fbe6044 | ||
|
|
268ad16d26 | ||
|
|
8903872b47 | ||
|
|
1aed8d7507 | ||
|
|
1c31295e13 | ||
|
|
55edf051e5 | ||
|
|
210e24f8b0 |
No files matched your search
@@ -59,4 +59,30 @@ jobs:
|
||||
- run: corepack enable
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: uv sync --directory apps/core --all-groups --locked
|
||||
- run: pnpm check
|
||||
- name: Verify public source structure and privacy boundaries
|
||||
run: |
|
||||
python3 scripts/verify_structure.py
|
||||
python3 scripts/generate_core_test_index.py --check
|
||||
python3 scripts/verify_zero_collection.py
|
||||
python3 scripts/verify_zero_collection.py --self-test
|
||||
- name: Lint Node workspaces
|
||||
run: pnpm lint
|
||||
- name: Typecheck Node workspaces
|
||||
run: pnpm typecheck
|
||||
- name: Test Node workspaces
|
||||
run: pnpm test
|
||||
- name: Test release tooling
|
||||
run: pnpm test:release-scripts
|
||||
- name: Fetch and verify the pinned embedding model
|
||||
run: |
|
||||
pnpm fetch:embedding-model
|
||||
uv run --directory apps/core python -c \
|
||||
"from one_memory.embedding_assets import assets_present, verify_assets; assert assets_present(); verify_assets()"
|
||||
- name: Test public Core smoke suite
|
||||
run: >-
|
||||
bash scripts/run-tests-isolated.sh core-pr
|
||||
tests/test_core_test_reporting.py
|
||||
tests/test_import_api.py
|
||||
tests/test_recent_first_import.py
|
||||
tests/test_source_session_count_api.py
|
||||
tests/test_schema_migrations.py
|
||||
@@ -102,3 +102,65 @@ jobs:
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 14
|
||||
|
||||
publish-draft-release:
|
||||
if: ${{ inputs.run_lifecycle && startsWith(github.ref, 'refs/tags/v') }}
|
||||
needs: matrix
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
pattern: memhub-runtime-linux-*
|
||||
path: memhub/release-assets
|
||||
merge-multiple: true
|
||||
- name: Verify tag and Linux release assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
for arch in x64 arm64; do
|
||||
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
|
||||
test -f "$archive"
|
||||
test -f "$archive.sha256"
|
||||
(
|
||||
cd release-assets
|
||||
sha256sum -c "$(basename "$archive.sha256")"
|
||||
)
|
||||
done
|
||||
- name: Upload Linux assets to a draft GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
test "$(
|
||||
gh release view "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--json isDraft \
|
||||
--jq .isDraft
|
||||
)" = "true"
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--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 \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
+57
-8
@@ -2,6 +2,32 @@
|
||||
|
||||
Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Hy Memory 2.0.3 — 2026-10-17
|
||||
|
||||
- Added an optional runtime HTTP transport factory so embedded products can
|
||||
enforce request budgets without wrapping or restricting SDK local-store
|
||||
access.
|
||||
- Published
|
||||
[`hy-memory==2.0.3`](https://pypi.org/project/hy-memory/2.0.3/) with
|
||||
unchanged memory extraction, reconciliation, storage, and Coding/Working
|
||||
behavior.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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
|
||||
[`hy-memory==2.0.2`](https://pypi.org/project/hy-memory/2.0.2/) with the
|
||||
current SDK fixes and Apache-2.0 package metadata.
|
||||
|
||||
## Hy Memory 2.0.1 — 2026-10-16
|
||||
|
||||
- Published the Python distribution as
|
||||
@@ -10,13 +36,36 @@ 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
|
||||
## MemHub 1.0.3 — Unreleased
|
||||
|
||||
MemHub has no public release yet. The source version is `1.0.0` (build 4), the
|
||||
selected npm package is `memhub-cli`, and both npm publication and GitHub
|
||||
Release artifacts are still pending. Internal release-candidate builds are not
|
||||
public releases.
|
||||
- Updated the source version to `1.0.3` (build 7).
|
||||
- Pinned the public SDK to `hy-memory==2.0.3`.
|
||||
- Removed the HyPermission store wrappers and global model-content consent
|
||||
switch; the trusted embedded SDK now uses native local stores.
|
||||
- Prevented uncertain writes from blocking later source work and added one
|
||||
reconciled automatic retry after ten seconds.
|
||||
- Added product-facing memory pipeline counters and the log troubleshooting
|
||||
runbook.
|
||||
- Allowed a verified packaged Desktop Runtime from a different commit to take
|
||||
over an equal-build install atomically while preserving downgrade and
|
||||
tamper refusal.
|
||||
|
||||
No compatibility or deprecation policy has been adopted beyond the explicit
|
||||
CLI/Runtime protocol ranges stored in the release manifests. Future public
|
||||
releases must record user-visible changes and supported platforms here.
|
||||
## MemHub 1.0.2 — 2026-10-17
|
||||
|
||||
- Updated the source version to `1.0.2` (build 6).
|
||||
- Pinned the public SDK to `hy-memory==2.0.2`.
|
||||
- Pinned `onnxruntime==1.23.2` to retain macOS x64 packaging support.
|
||||
- Fixed relaunch and upgrade failures when a prior managed Runtime exists.
|
||||
- Embedded canonical build identity when an external packaging pipeline omits
|
||||
release environment fields.
|
||||
- Added automatic Linux Runtime upload to tagged Draft Releases.
|
||||
|
||||
## MemHub 1.0.1 — Superseded before release
|
||||
|
||||
- The candidate tag was not published as a GitHub Release; `1.0.2` replaces it
|
||||
after the managed-Runtime relaunch fix.
|
||||
|
||||
## MemHub 1.0.0
|
||||
|
||||
- Published the initial GitHub Release with macOS arm64 and Linux Runtime
|
||||
artifacts.
|
||||
@@ -32,7 +32,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.1`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.1/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.3`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.3/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
|
||||
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **Released** **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)** (submitted to **ICLR 2027**).
|
||||
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **Accepted** at **EMNLP 2026**: **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)**!
|
||||
@@ -63,13 +63,13 @@ Welcome to the **Hy Memory Research Family**, covering cognitive chat memory, lo
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.1 # core (includes ChromaDB and Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.1" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.1" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.1" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.1" # + MySQL cache
|
||||
pip install "hy-memory[rerank]==2.0.1" # + local multilingual cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.1" # everything
|
||||
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
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
+8
-8
@@ -32,7 +32,7 @@
|
||||
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.1`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.1/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.3`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.3/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
|
||||
* *<mark>[new]</mark>* 🔥🔥 **[2026-09]**: 📊 **基准与方法发布** 推出长周期与交错式编程记忆评测基准 **[CMB: A Coding Memory Benchmark for Long-Horizon and Interleaved Coding Agents](docs/papers/CMB_A_Coding_Memory_Benchmark_for_Long_Horizon_and_Interleaved_Coding_Agents.pdf)**(已投稿 **ICLR 2027**)。
|
||||
* *<mark>[new]</mark>* 🔥🔥 **[2026-08]**: 🎉 **论文录用** 双系统认知记忆架构论文 **[Memory Beyond Recall: A Dual-Process Cognitive Memory System for Self-Evolving LLM Agents](https://arxiv.org/abs/2606.09483)** 被 **EMNLP 2026** 接收!
|
||||
@@ -63,13 +63,13 @@
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.1 # 核心(含 ChromaDB 与 Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.1" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.1" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.1" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.1" # + MySQL 缓存
|
||||
pip install "hy-memory[rerank]==2.0.1" # + 本地多语言 cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.1" # 全部
|
||||
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" # 全部
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
|
||||
+1
-1
@@ -37,7 +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.1`. MemHub pins that public
|
||||
- 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 source uses Apache-2.0 at the repository root. Third-party
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
## 安装与快速开始
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.1
|
||||
pip install hy-memory==2.0.3
|
||||
|
||||
# 可选后端
|
||||
pip install 'hy-memory[qdrant]==2.0.1'
|
||||
|
||||
@@ -496,11 +496,21 @@ class LLMProvider:
|
||||
clients = self._clients.setdefault(loop, {})
|
||||
client = clients.get(client_key)
|
||||
if client is None:
|
||||
client_options: Dict[str, Any] = {
|
||||
"api_key": base_api_key,
|
||||
"base_url": base_url,
|
||||
"timeout": self._llm_config.timeout,
|
||||
"max_retries": 0,
|
||||
}
|
||||
transport_factory = getattr(self.config, "http_transport_factory", None)
|
||||
if callable(transport_factory):
|
||||
import httpx
|
||||
|
||||
client_options["http_client"] = httpx.AsyncClient(
|
||||
transport=transport_factory()
|
||||
)
|
||||
client = AsyncOpenAI(
|
||||
api_key=base_api_key,
|
||||
base_url=base_url,
|
||||
timeout=self._llm_config.timeout,
|
||||
max_retries=0,
|
||||
**client_options,
|
||||
)
|
||||
clients[client_key] = client
|
||||
|
||||
|
||||
+8
-1
@@ -6,7 +6,7 @@ Agent Memory - 配置管理
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1252,6 +1252,13 @@ class MemoryConfig:
|
||||
enable_agent: bool = False
|
||||
debug: bool = False
|
||||
metrics_enabled: bool = True
|
||||
# Runtime-only HTTP transport factory. MemHub uses this for request-cost
|
||||
# budgeting without restricting the embedded SDK's local store access.
|
||||
http_transport_factory: Optional[Callable[[], Any]] = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
compare=False,
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
# enable_graph 跟随 mode 走:mode=ultra 时自动启用,无需独立环境变量
|
||||
|
||||
@@ -356,11 +356,21 @@ class EmbedService:
|
||||
key = self._client_cache_key(base_url, api_key, timeout)
|
||||
client = self._client_cache.get(key)
|
||||
if client is None:
|
||||
client_options = {
|
||||
"api_key": api_key,
|
||||
"base_url": base_url or None,
|
||||
"timeout": timeout,
|
||||
"max_retries": 0,
|
||||
}
|
||||
transport_factory = getattr(self.config, "http_transport_factory", None)
|
||||
if callable(transport_factory):
|
||||
import httpx
|
||||
|
||||
client_options["http_client"] = httpx.AsyncClient(
|
||||
transport=transport_factory()
|
||||
)
|
||||
client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url or None,
|
||||
timeout=timeout,
|
||||
max_retries=0,
|
||||
**client_options,
|
||||
)
|
||||
self._client_cache[key] = client
|
||||
return client
|
||||
|
||||
@@ -16,11 +16,10 @@ Agent Memory V2 - VectorStore (Qdrant)
|
||||
- 简化运维,后续可按需拆分
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from typing import Optional, List, Dict, Any, Tuple, Callable
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
@@ -30,12 +29,10 @@ from ..models.memory import (
|
||||
from ..config import MemoryConfig
|
||||
from ..utils.async_executor import run_in_executor_guarded
|
||||
from .vector_store_base import VectorStoreBase
|
||||
from .vector_store_chroma import _vdb_executor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# VDB 独立线程池(与 Chroma 共享同一策略)
|
||||
from .vector_store_chroma import _vdb_executor
|
||||
|
||||
|
||||
def _run_in_vdb_pool(func, *args, **kwargs):
|
||||
"""在 VDB 独立线程池中执行同步函数"""
|
||||
@@ -44,6 +41,70 @@ def _run_in_vdb_pool(func, *args, **kwargs):
|
||||
return run_in_executor_guarded(_vdb_executor, call)
|
||||
|
||||
|
||||
class _LocalQdrantOwner:
|
||||
"""Keep one local Qdrant client and its SQLite state on one owner thread.
|
||||
|
||||
Local Qdrant may enable SQLite's ``check_same_thread`` on environments
|
||||
compiled with ``THREADSAFE=2``. The public async surface still uses the
|
||||
shared VDB pool, but every actual client access is forwarded to this
|
||||
instance's single owner thread.
|
||||
"""
|
||||
|
||||
def __init__(self, factory: Callable[[], Any]):
|
||||
self._executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="qdrant-local-owner",
|
||||
)
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._closed = False
|
||||
try:
|
||||
self._client = self._executor.submit(factory).result()
|
||||
except BaseException:
|
||||
self._closed = True
|
||||
self._executor.shutdown(wait=True, cancel_futures=True)
|
||||
raise
|
||||
|
||||
def _submit(self, callback: Callable[[], Any]) -> Any:
|
||||
with self._lifecycle_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("Local Qdrant client is closed")
|
||||
future = self._executor.submit(callback)
|
||||
return future.result()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
is_callable, value = self._submit(
|
||||
lambda: (
|
||||
callable(attribute := getattr(self._client, name)),
|
||||
None if callable(attribute) else attribute,
|
||||
)
|
||||
)
|
||||
if not is_callable:
|
||||
return value
|
||||
|
||||
def invoke(*args: Any, **kwargs: Any) -> Any:
|
||||
return self._submit(
|
||||
lambda: getattr(self._client, name)(*args, **kwargs)
|
||||
)
|
||||
|
||||
return invoke
|
||||
|
||||
def close(self) -> None:
|
||||
def close_owned_client() -> None:
|
||||
client, self._client = self._client, None
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
with self._lifecycle_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
future = self._executor.submit(close_owned_client)
|
||||
try:
|
||||
future.result()
|
||||
finally:
|
||||
self._executor.shutdown(wait=True, cancel_futures=True)
|
||||
|
||||
|
||||
class QdrantVectorStore(VectorStoreBase):
|
||||
"""
|
||||
Qdrant 向量存储实现
|
||||
@@ -113,8 +174,8 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
self._is_local = True
|
||||
# 兼容性处理: 清理高版本 qdrant-client 写入的 meta.json 中的未知字段
|
||||
self._patch_local_meta(vs_config.persist_directory)
|
||||
self._client = QdrantClient(
|
||||
path=vs_config.persist_directory,
|
||||
self._client = _LocalQdrantOwner(
|
||||
lambda: QdrantClient(path=vs_config.persist_directory)
|
||||
)
|
||||
|
||||
# 确保集合存在(同步调用,在 to_thread 中执行)
|
||||
@@ -217,7 +278,13 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
has_sparse = False
|
||||
return has_sparse
|
||||
|
||||
has_sparse = await _run_in_vdb_pool(_ensure_collection)
|
||||
try:
|
||||
has_sparse = await _run_in_vdb_pool(_ensure_collection)
|
||||
except BaseException:
|
||||
client, self._client = self._client, None
|
||||
if client is not None:
|
||||
await _run_in_vdb_pool(client.close)
|
||||
raise
|
||||
self._supports_fulltext = bool(has_sparse and bm25_fastembed.is_available())
|
||||
logger.info(
|
||||
f"[qdrant] fulltext(BM25 sparse)={'on' if self._supports_fulltext else 'off'}"
|
||||
@@ -1043,7 +1110,10 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
FieldCondition(key="status", match=MatchAny(any=status_values))
|
||||
)
|
||||
if layers:
|
||||
layer_values = [l.value if hasattr(l, 'value') else str(l) for l in layers]
|
||||
layer_values = [
|
||||
layer.value if hasattr(layer, "value") else str(layer)
|
||||
for layer in layers
|
||||
]
|
||||
if not allow_raw_layer:
|
||||
layer_values = [v for v in layer_values if v != MemoryLayer.L1_RAW.value]
|
||||
if layer_values:
|
||||
@@ -1372,9 +1442,9 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭客户端"""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
client, self._client = self._client, None
|
||||
if client is not None:
|
||||
await _run_in_vdb_pool(client.close)
|
||||
|
||||
# ================================================================
|
||||
# Keyword Search (Full-text)
|
||||
@@ -1412,8 +1482,6 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
except Exception as e:
|
||||
logger.debug(f"[qdrant] inspect search_text index failed (will try create anyway): {e}")
|
||||
|
||||
target_tokenizer_value = getattr(TokenizerType.WHITESPACE, "value", "whitespace")
|
||||
|
||||
if existing_tokenizer is not None:
|
||||
if str(existing_tokenizer).lower().endswith("whitespace") or str(existing_tokenizer).lower() == "whitespace":
|
||||
return # 已经是新格式,啥都不做
|
||||
@@ -1436,7 +1504,9 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
max_token_len=20,
|
||||
),
|
||||
)
|
||||
logger.info(f"[qdrant] Created search_text text index (whitespace) on existing collection")
|
||||
logger.info(
|
||||
"[qdrant] Created search_text text index (whitespace) on existing collection"
|
||||
)
|
||||
except Exception as e:
|
||||
# 兜底:未支持或并发竞争,静默
|
||||
logger.debug(f"[qdrant] _ensure_text_index swallowed: {e}")
|
||||
@@ -1510,7 +1580,10 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
)
|
||||
if layers:
|
||||
must_conditions.append(
|
||||
FieldCondition(key="layer", match=MatchAny(any=[l.value for l in layers]))
|
||||
FieldCondition(
|
||||
key="layer",
|
||||
match=MatchAny(any=[layer.value for layer in layers]),
|
||||
)
|
||||
)
|
||||
if status_filter:
|
||||
must_conditions.append(
|
||||
@@ -1729,7 +1802,7 @@ class QdrantVectorStore(VectorStoreBase):
|
||||
|
||||
if changed:
|
||||
meta_path.write_text(_json.dumps(meta), encoding="utf-8")
|
||||
logger.info(f"Patched Qdrant meta.json: removed unsupported fields")
|
||||
logger.info("Patched Qdrant meta.json: removed unsupported fields")
|
||||
|
||||
# ================================================================
|
||||
# Tag Index(per-user tag embedding,供 reader_hybrid_tag 使用)
|
||||
|
||||
@@ -54,6 +54,8 @@ def concurrent_result_guarded(
|
||||
try:
|
||||
return future.result(timeout=interval)
|
||||
except concurrent.futures.TimeoutError:
|
||||
if future.done():
|
||||
return future.result()
|
||||
target_loop.call_soon_threadsafe(lambda: None)
|
||||
|
||||
|
||||
@@ -70,4 +72,6 @@ async def await_concurrent_future_guarded(
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(wrapped), timeout=interval)
|
||||
except asyncio.TimeoutError:
|
||||
if wrapped.done():
|
||||
return wrapped.result()
|
||||
target_loop.call_soon_threadsafe(lambda: None)
|
||||
+44
-7
@@ -15,12 +15,12 @@ write、search,并把搜索结果格式化为可交给模型的 memory text ch
|
||||
|
||||
```text
|
||||
distribution: hy-memory
|
||||
version: 2.0.1
|
||||
version: 2.0.3
|
||||
mode: 按运行时配置选择
|
||||
reader: hybrid
|
||||
```
|
||||
|
||||
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.1`。
|
||||
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.3`。
|
||||
MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、
|
||||
Governance 和撤权边界。
|
||||
|
||||
@@ -37,6 +37,40 @@ MemHub 对 SDK 使用以下公开、可复现的本地策略:
|
||||
- API Key 进入系统安全存储,不写入仓库、安装包、日志或 HY metadata;
|
||||
- 服务端数据库、租户队列及宿主 shell 中的 ambient 凭据不会进入 SDK config。
|
||||
|
||||
发布包固定应用以下 SDK 行为配置:
|
||||
|
||||
| 环境变量 | 值 |
|
||||
|---|---|
|
||||
| `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,应自行显式配置存储目录和模型端点。
|
||||
@@ -271,9 +305,9 @@ Runtime 应长期复用 client,不为每次请求重新初始化数据库和
|
||||
|
||||
```text
|
||||
Evidence
|
||||
-> ModelProcessingPermissionLease
|
||||
-> HyPermissionTransport
|
||||
-> HyMemoryBridge.ingest / ingest_extracted / search_ids
|
||||
-> HyMemoryBridge
|
||||
-> embedded Hy Memory SDK
|
||||
-> native Vector / Coding / Graph stores
|
||||
-> write result verification
|
||||
-> Governance
|
||||
-> Recall Broker / UI
|
||||
@@ -282,8 +316,11 @@ Evidence
|
||||
不要在新业务模块里直接创建 `HyMemoryClient`,否则会绕过:
|
||||
|
||||
- 来源读取授权;
|
||||
- 模型内容处理授权;
|
||||
- 发送前与响应后的撤权复核;
|
||||
- 共享 Runtime、模型配置、API Key 安全存储与请求预算;
|
||||
- Evidence 引用与作者归属;
|
||||
- 写后回读验证;
|
||||
- Governance 状态、版本、更正和删除级联。
|
||||
|
||||
Hy Memory SDK 是 MemHub 的内置可信组件,可自由访问和管理本机的全部
|
||||
Hy Memory 数据。来源授权仍决定哪些宿主数据可以进入本机;宿主 Recall Grant
|
||||
仍决定哪些外部 AI 应用可以读取治理后的记忆。这两类边界不限制内置 SDK 的本地 CRUD。
|
||||
+22
-8
@@ -1,18 +1,30 @@
|
||||
# MemHub / One Memory
|
||||
# MemHub
|
||||
|
||||
MemHub 是一个本地优先的共享记忆产品:把用户授权的多个 AI 产品中的历史对话和新增内容
|
||||
汇总到同一份记忆,并让这些 AI 在独立授权后复用它。Hy Memory 是 MemHub 使用的 Python
|
||||
SDK 和记忆引擎。One Memory / `one-memory` 是历史模块、数据目录、服务和兼容命令名称。
|
||||
MemHub是一个本地优先的共享记忆产品。它把用户明确授权的AI对话和编码记录汇总到一份本地记忆中,再由用户分别授权给支持的AI工具调用。你可以继续使用熟悉的客户端,同时保留跨工具延续的上下文。Hy Memory是MemHub使用的Python SDK和记忆引擎。
|
||||
|
||||
# 💡❤️ MemHub 刚刚开源,欢迎一起优化共建! ❤️💡
|
||||
|
||||
**# 💭 MemHub处于preview阶段,当前公开版本可以下载和体验,部分平台制品、安装路径与宿主体验仍在持续优化验证。安装前请先查看Release里的实际制品和版本说明。**
|
||||
|
||||
> 🙋 如果你遇到问题/有建议:欢迎提交Issue,并附上MemHub版本、操作系统与芯片架构、复现步骤、预期结果、实际结果和经过脱敏的日志。请不要上传APIKey、真实对话、个人路径或其他敏感信息。
|
||||
|
||||
> 我们会公开跟进可复现的问题,持续发布修复,并根据真实反馈完善安装、同步和召回体验。
|
||||
|
||||
**# 感谢每一条反馈,期待你和我们一起打磨MemHub的体验 :)**
|
||||
|
||||
> 💗 MemHub has just been open-sourced and is under active development. The current public release is available for download andevaluation. Some platform packages, installation paths, and host integrations are still being completed and verified. Check the assets
|
||||
and release notes before installing.
|
||||
If you run into a problem, please open an Issue with the version, platform,reproduction steps, and sanitized logs, We wilreviewreports, publish fixes, and keep the support matrix up to date.
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
公共 Python SDK 为 `hy-memory==2.0.1`。MemHub 源码版本为 `1.0.0`
|
||||
(build 4),GitHub 仓库为
|
||||
公共 Python SDK 为 `hy-memory==2.0.3`。MemHub 源码版本为 `1.0.3`
|
||||
(build 7),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.0` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
`v1.0.3` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -42,6 +54,8 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
|
||||
|
||||
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
|
||||
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
|
||||
产品、测试和一线支持排查导入与记忆沉淀时,使用
|
||||
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
|
||||
|
||||
## 开发环境
|
||||
|
||||
@@ -99,7 +113,7 @@ MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime
|
||||
|
||||
## Hy Memory
|
||||
|
||||
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.1`。SDK 配置、权限边界及 search
|
||||
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.3`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
+96
-8
@@ -3,9 +3,9 @@
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "one-memory",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-15T17:26:47Z",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-17T11:07:57Z",
|
||||
"creationInfo": {
|
||||
"created": "2026-10-15T17:26:47Z",
|
||||
"created": "2026-10-17T11:07:57Z",
|
||||
"creators": [
|
||||
"Tool: one-memory-generate-sbom"
|
||||
]
|
||||
@@ -479,6 +479,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-coloredlogs",
|
||||
"name": "coloredlogs",
|
||||
"versionInfo": "15.0.1",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:pypi/coloredlogs@15.0.1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-distro",
|
||||
"name": "distro",
|
||||
@@ -751,6 +768,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-humanfriendly",
|
||||
"name": "humanfriendly",
|
||||
"versionInfo": "10.0",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:pypi/humanfriendly@10.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-idna",
|
||||
"name": "idna",
|
||||
@@ -1125,6 +1159,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-mpmath",
|
||||
"name": "mpmath",
|
||||
"versionInfo": "1.3.0",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:pypi/mpmath@1.3.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-multidict",
|
||||
"name": "multidict",
|
||||
@@ -1230,7 +1281,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-onnxruntime",
|
||||
"name": "onnxruntime",
|
||||
"versionInfo": "1.29.0",
|
||||
"versionInfo": "1.23.2",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
@@ -1240,7 +1291,7 @@
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:pypi/onnxruntime@1.29.0"
|
||||
"referenceLocator": "pkg:pypi/onnxruntime@1.23.2"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1992,6 +2043,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-sympy",
|
||||
"name": "sympy",
|
||||
"versionInfo": "1.14.0",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:pypi/sympy@1.14.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-pypi-tenacity",
|
||||
"name": "tenacity",
|
||||
@@ -2267,12 +2335,12 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.1",
|
||||
"versionInfo": "2.0.3",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "MIT",
|
||||
"licenseDeclared": "MIT",
|
||||
"copyrightText": "Copyright (c) 2025 HY Memory Contributors"
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
"licenseDeclared": "Apache-2.0",
|
||||
"copyrightText": "Copyright (C) 2026 Tencent. All rights reserved."
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-memmy-agent-derived",
|
||||
@@ -2427,6 +2495,11 @@
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-cloudpickle"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-coloredlogs"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
@@ -2507,6 +2580,11 @@
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-huggingface-hub"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-humanfriendly"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
@@ -2617,6 +2695,11 @@
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-more-itertools"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-mpmath"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
@@ -2872,6 +2955,11 @@
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-starlette"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-pypi-sympy"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
|
||||
@@ -2,39 +2,79 @@
|
||||
|
||||
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-15T17:26:47Z
|
||||
Generated from the installed dependency tree on 2026-10-17T11:07:57Z
|
||||
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.1
|
||||
## hy-memory 2.0.3
|
||||
|
||||
License: MIT
|
||||
License: Apache-2.0
|
||||
|
||||
```text
|
||||
MIT License
|
||||
Tencent is pleased to support the community by making Hy-Memory available.
|
||||
|
||||
Copyright (c) 2025 HY Memory Contributors
|
||||
Copyright (C) 2026 Tencent. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
Hy-Memory is licensed under Apache-2.0. Hy-Memory does not impose any additional restrictions beyond those specified in the license.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
For the avoidance of doubt, Hy-Memory refers solely to code, parameters, and weights made publicly available by Tencent in accordance with Apache-2.0.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Terms of the Apache-2.0 License:
|
||||
--------------------------------------------------------------------
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
```
|
||||
|
||||
## MemTensor memmy-agent (adapted)
|
||||
@@ -85,7 +125,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
|
||||
- tar 7.5.22
|
||||
- three 0.185.1
|
||||
|
||||
## Python (PyPI) — 122 packages
|
||||
## Python (PyPI) — 126 packages
|
||||
|
||||
- aiohappyeyeballs 2.7.1
|
||||
- aiohttp 3.14.3
|
||||
@@ -104,6 +144,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
|
||||
- chromadb 1.5.9
|
||||
- click 8.5.0
|
||||
- cloudpickle 3.1.2
|
||||
- coloredlogs 15.0.1
|
||||
- distro 1.9.0
|
||||
- durationpy 0.11
|
||||
- execnet 2.1.2
|
||||
@@ -120,6 +161,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
|
||||
- httptools 0.8.0
|
||||
- httpx 0.28.1
|
||||
- huggingface-hub 1.29.0
|
||||
- humanfriendly 10.0
|
||||
- idna 3.19
|
||||
- importlib-resources 7.1.0
|
||||
- iniconfig 2.3.0
|
||||
@@ -142,13 +184,14 @@ No Memmy brand, icon, font or mascot assets are distributed.
|
||||
- mdurl 0.1.2
|
||||
- mmh3 5.3.0
|
||||
- more-itertools 11.1.0
|
||||
- mpmath 1.3.0
|
||||
- multidict 6.7.1
|
||||
- mypy 2.3.1
|
||||
- mypy-extensions 1.1.0
|
||||
- narwhals 2.25.0
|
||||
- numpy 2.5.2
|
||||
- oauthlib 3.3.1
|
||||
- onnxruntime 1.29.0
|
||||
- onnxruntime 1.23.2
|
||||
- openai 2.54.0
|
||||
- opentelemetry-api 1.44.0
|
||||
- opentelemetry-exporter-otlp-proto-common 1.44.0
|
||||
@@ -193,6 +236,7 @@ No Memmy brand, icon, font or mascot assets are distributed.
|
||||
- six 1.17.0
|
||||
- sniffio 1.3.1
|
||||
- starlette 1.6.0
|
||||
- sympy 1.14.0
|
||||
- tenacity 9.1.4
|
||||
- threadpoolctl 3.6.0
|
||||
- tiktoken 0.14.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
* the real package.json and only strips what is provably not needed at runtime.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -27,10 +27,7 @@ const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
|
||||
const pkgPath = resolve(root, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
|
||||
if (
|
||||
!publishName ||
|
||||
!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)
|
||||
) {
|
||||
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
|
||||
throw new Error(
|
||||
"MEMHUB_NPM_PACKAGE must name the explicit npm publish identity",
|
||||
);
|
||||
@@ -38,8 +35,12 @@ if (
|
||||
|
||||
const bundle = resolve(root, "dist/one-memory.cjs");
|
||||
const source = readFileSync(bundle, "utf8");
|
||||
const runtimeRequires = [...source.matchAll(/require\(["']([^"']+)["']\)/g)].map((m) => m[1]);
|
||||
const workspaceLeaked = runtimeRequires.filter((name) => name.startsWith("@one-memory/"));
|
||||
const runtimeRequires = [
|
||||
...source.matchAll(/require\(["']([^"']+)["']\)/g),
|
||||
].map((m) => m[1]);
|
||||
const workspaceLeaked = runtimeRequires.filter((name) =>
|
||||
name.startsWith("@one-memory/"),
|
||||
);
|
||||
if (workspaceLeaked.length > 0) {
|
||||
throw new Error(
|
||||
`bundle still requires workspace packages at runtime: ${workspaceLeaked.join(", ")}. ` +
|
||||
@@ -61,9 +62,7 @@ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
// else is build-time only. The bundle is inlined (tsup noExternal), so
|
||||
// in practice only Node builtins remain — but compute it rather than
|
||||
// assume, so a future non-inlined dep is not silently dropped.
|
||||
runtimeRequires.some(
|
||||
(req) => req === name || req.startsWith(`${name}/`),
|
||||
),
|
||||
runtimeRequires.some((req) => req === name || req.startsWith(`${name}/`)),
|
||||
),
|
||||
);
|
||||
if (Object.keys(published[section]).length === 0) delete published[section];
|
||||
@@ -77,8 +76,15 @@ published.files = ["dist", "scripts", "package.json"];
|
||||
// in the staging dir overrides that and makes the manifest authoritative.
|
||||
writeFileSync(
|
||||
resolve(outDir, ".npmignore"),
|
||||
["# Ship the built bundle: the repo root .gitignore excludes dist/.", "!dist/", ""].join("\n"),
|
||||
[
|
||||
"# Ship the built bundle: the repo root .gitignore excludes dist/.",
|
||||
"!dist/",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
writeFileSync(resolve(outDir, "package.json"), `${JSON.stringify(published, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
resolve(outDir, "package.json"),
|
||||
`${JSON.stringify(published, null, 2)}\n`,
|
||||
);
|
||||
console.log("wrote", resolve(outDir, "package.json"));
|
||||
@@ -20,11 +20,7 @@ import { pathToFileURL } from "node:url";
|
||||
|
||||
const REQUIRED_ALIASES = {
|
||||
daemon: ["start", "stop", "restart", "status"],
|
||||
"startup-service": [
|
||||
"service-plan",
|
||||
"service-install",
|
||||
"service-uninstall",
|
||||
],
|
||||
"startup-service": ["service-plan", "service-install", "service-uninstall"],
|
||||
};
|
||||
|
||||
const REQUIRED_COMMAND_SURFACE = [
|
||||
@@ -36,7 +32,6 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"permission",
|
||||
"extraction",
|
||||
"graph",
|
||||
"models",
|
||||
@@ -81,7 +76,7 @@ const REQUIRED_MARKERS = [
|
||||
|
||||
/**
|
||||
* Sub-commands the UI-write parity work added. A package can declare the
|
||||
* parent command (`permission`, `models`) while still missing the new verb, so
|
||||
* parent command (`models`) while still missing the new verb, so
|
||||
* checking command names alone would pass a stale bundle. The marker is the
|
||||
* exact literal the catalog usage string contains.
|
||||
*/
|
||||
@@ -92,9 +87,6 @@ const REQUIRED_SUBCOMMANDS = [
|
||||
{ command: "import-file", marker: "memhub import-file discard" },
|
||||
{ command: "notes", marker: "memhub notes add" },
|
||||
{ command: "notes", marker: "memhub notes process" },
|
||||
{ command: "permission", marker: "memhub permission status" },
|
||||
{ command: "permission", marker: "memhub permission allow" },
|
||||
{ command: "permission", marker: "memhub permission deny" },
|
||||
{ command: "extraction", marker: "memhub extraction set" },
|
||||
{ command: "graph", marker: "memhub graph width" },
|
||||
{ command: "models", marker: "memhub models edit" },
|
||||
@@ -112,19 +104,83 @@ const REQUIRED_SUBCOMMANDS = [
|
||||
* These run without `--lifecycle`, so they are safe against any package.
|
||||
*/
|
||||
const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
{ name: "calls_clear_preview", args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111"] },
|
||||
{ name: "calls_clear_apply", args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111", "--confirm"] },
|
||||
{ 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: ["notes", "process", "33333333-3333-4333-8333-333333333333", "--confirm"] },
|
||||
{ name: "permission_allow", args: ["permission", "allow"] },
|
||||
{ name: "permission_deny", args: ["permission", "deny"] },
|
||||
{ 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: ["models", "activate", "44444444-4444-4444-8444-444444444444", "--confirm"] },
|
||||
{ name: "models_edit_apply", args: ["models", "edit", "44444444-4444-4444-8444-444444444444", "--config-file", "/nonexistent/patch.json", "--confirm"] },
|
||||
{
|
||||
name: "calls_clear_preview",
|
||||
args: ["calls", "clear", "--event", "11111111-1111-4111-8111-111111111111"],
|
||||
},
|
||||
{
|
||||
name: "calls_clear_apply",
|
||||
args: [
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
"notes",
|
||||
"process",
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
"--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: [
|
||||
"models",
|
||||
"activate",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "models_edit_apply",
|
||||
args: [
|
||||
"models",
|
||||
"edit",
|
||||
"44444444-4444-4444-8444-444444444444",
|
||||
"--config-file",
|
||||
"/nonexistent/patch.json",
|
||||
"--confirm",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function validateCatalog(document) {
|
||||
@@ -201,7 +257,9 @@ export async function runPackageMatrix(runtimeDist, options = {}) {
|
||||
const markers = REQUIRED_MARKERS.filter(
|
||||
(marker) => !bundle.toString("utf8").includes(marker),
|
||||
);
|
||||
const isolatedRoot = await mkdtemp(path.join(tmpdir(), "memhub-package-matrix-"));
|
||||
const isolatedRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "memhub-package-matrix-"),
|
||||
);
|
||||
const home = path.join(isolatedRoot, "home");
|
||||
const dataDir = path.join(isolatedRoot, "data");
|
||||
const hostHome = path.join(isolatedRoot, "host-home");
|
||||
@@ -343,7 +401,10 @@ async function runStoppedDestructive(node, cli, environment) {
|
||||
`${probe.name} exit ${result.code}, expected 3 while Runtime is stopped`,
|
||||
);
|
||||
}
|
||||
if (document?.state !== "unknown" || document?.code !== "runtime_not_running") {
|
||||
if (
|
||||
document?.state !== "unknown" ||
|
||||
document?.code !== "runtime_not_running"
|
||||
) {
|
||||
failures.push(
|
||||
`${probe.name} did not fail closed with unknown/runtime_not_running`,
|
||||
);
|
||||
@@ -451,8 +512,7 @@ async function main() {
|
||||
}
|
||||
const asJson = rest.includes("--json");
|
||||
const expectShaIndex = rest.indexOf("--expect-sha");
|
||||
const expectSha =
|
||||
expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
|
||||
const expectSha = expectShaIndex >= 0 ? rest[expectShaIndex + 1] : undefined;
|
||||
if (expectSha !== undefined && !/^[0-9a-fA-F]{64}$/.test(expectSha)) {
|
||||
process.stderr.write("--expect-sha must be a 64-character SHA-256\n");
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -30,12 +30,16 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
|
||||
}
|
||||
if (request.headers.authorization === "Bearer r409") {
|
||||
response.statusCode = 409;
|
||||
response.end(JSON.stringify({ detail: "backup_restore_runtime_running" }));
|
||||
response.end(
|
||||
JSON.stringify({ detail: "backup_restore_runtime_running" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.headers.authorization === "Bearer r422") {
|
||||
response.statusCode = 422;
|
||||
response.end(JSON.stringify({ detail: "backup_restore_source_outside_home" }));
|
||||
response.end(
|
||||
JSON.stringify({ detail: "backup_restore_source_outside_home" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.end(
|
||||
@@ -48,9 +52,12 @@ describe("managed restore over HTTP", { timeout: 15_000 }, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("no port");
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("no port");
|
||||
port = address.port;
|
||||
});
|
||||
|
||||
@@ -137,8 +144,18 @@ describe("maintenance Core identity", { timeout: 15_000 }, () => {
|
||||
|
||||
it("rejects a squatter, wrong service, wrong instance, and non-200", async () => {
|
||||
const cases = [
|
||||
{ status: 200, data: { status: "ok", service: "other", instance_id: "x" } },
|
||||
{ status: 200, data: { status: "ok", service: "one-memory-core", instance_id: "wrong" } },
|
||||
{
|
||||
status: 200,
|
||||
data: { status: "ok", service: "other", instance_id: "x" },
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
data: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
instance_id: "wrong",
|
||||
},
|
||||
},
|
||||
{ status: 503, data: {} },
|
||||
{ status: 200, data: null },
|
||||
];
|
||||
|
||||
@@ -91,7 +91,8 @@ export async function callRestore(
|
||||
source: string,
|
||||
request: typeof defaultPost = defaultPost,
|
||||
): Promise<
|
||||
{ state: "restored"; result: RestoreResult } | { state: "rejected"; code: string }
|
||||
| { state: "restored"; result: RestoreResult }
|
||||
| { state: "rejected"; code: string }
|
||||
> {
|
||||
const response = await request(
|
||||
`http://${options.host}:${options.port}/api/v1/backup/restore`,
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
batchScanExitCode,
|
||||
executeBatchImport,
|
||||
planBatchImport,
|
||||
renderBatchScanText,
|
||||
} from "./batch-import-flow.js";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
|
||||
function readerFor(catalog: unknown): CoreReader {
|
||||
return (async (path: string) =>
|
||||
path === "/api/v1/sources"
|
||||
? ({ state: "ok", data: catalog } as ReadOutcome<unknown>)
|
||||
: ({ state: "not_found", code: "http_404" } as ReadOutcome<unknown>)) as CoreReader;
|
||||
: ({
|
||||
state: "not_found",
|
||||
code: "http_404",
|
||||
} as ReadOutcome<unknown>)) as CoreReader;
|
||||
}
|
||||
|
||||
function found(source_id: string, extra: Record<string, unknown> = {}) {
|
||||
@@ -81,18 +83,36 @@ describe("A19 batch execution", () => {
|
||||
readerFor([found("codex"), found("cursor")]),
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn(async (_path: string, body: Record<string, unknown>) => ({
|
||||
state: "committed" as const,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
accepted_source_ids: ["codex", "cursor"],
|
||||
failed_sources: {},
|
||||
jobs: [
|
||||
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 3, stored: 3, errors: 0, operation: "history" },
|
||||
{ job_id: "j2", source_id: "cursor", status: "pending", discovered: 5, stored: 5, errors: 0, operation: "history" },
|
||||
],
|
||||
},
|
||||
}));
|
||||
const post = vi.fn(
|
||||
async (_path: string, body: Record<string, unknown>) => ({
|
||||
state: "committed" as const,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
accepted_source_ids: ["codex", "cursor"],
|
||||
failed_sources: {},
|
||||
jobs: [
|
||||
{
|
||||
job_id: "j1",
|
||||
source_id: "codex",
|
||||
status: "pending",
|
||||
discovered: 3,
|
||||
stored: 3,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
{
|
||||
job_id: "j2",
|
||||
source_id: "cursor",
|
||||
status: "pending",
|
||||
discovered: 5,
|
||||
stored: 5,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const operations = { post } as unknown as CoreOperations;
|
||||
const document = await executeBatchImport(
|
||||
planned.candidates,
|
||||
@@ -127,7 +147,15 @@ describe("A19 batch execution", () => {
|
||||
accepted_source_ids: ["codex"],
|
||||
failed_sources: { cursor: "source_identity_changed" },
|
||||
jobs: [
|
||||
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
|
||||
{
|
||||
job_id: "j1",
|
||||
source_id: "codex",
|
||||
status: "pending",
|
||||
discovered: 1,
|
||||
stored: 1,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
@@ -242,7 +270,15 @@ describe("A19 batch execution", () => {
|
||||
accepted_source_ids: ["codex"],
|
||||
failed_sources: {},
|
||||
jobs: [
|
||||
{ job_id: "j1", source_id: "codex", status: "pending", discovered: 1, stored: 1, errors: 0, operation: "history" },
|
||||
{
|
||||
job_id: "j1",
|
||||
source_id: "codex",
|
||||
status: "pending",
|
||||
discovered: 1,
|
||||
stored: 1,
|
||||
errors: 0,
|
||||
operation: "history",
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildSourceImportRequest } from "@one-memory/operations";
|
||||
|
||||
import type { ImportJob } from "@one-memory/contracts";
|
||||
import { buildSourceImportRequest } from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
type SourceConnectionCandidate,
|
||||
validateSourceConnectionCandidates,
|
||||
} from "./core-responses.js";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export interface BatchSourceRow {
|
||||
@@ -57,19 +56,34 @@ export function detectedSourceIds(
|
||||
return candidates.map((candidate) => candidate.source_id);
|
||||
}
|
||||
|
||||
export async function planBatchImport(
|
||||
reader: CoreReader,
|
||||
): Promise<
|
||||
| { state: "preview"; candidates: SourceConnectionCandidate[]; rows: BatchSourceRow[]; notFound: string[] }
|
||||
| { state: "rejected" | "unknown" | "no_sources"; code: string; notFound: string[] }
|
||||
export async function planBatchImport(reader: CoreReader): Promise<
|
||||
| {
|
||||
state: "preview";
|
||||
candidates: SourceConnectionCandidate[];
|
||||
rows: BatchSourceRow[];
|
||||
notFound: string[];
|
||||
}
|
||||
| {
|
||||
state: "rejected" | "unknown" | "no_sources";
|
||||
code: string;
|
||||
notFound: string[];
|
||||
}
|
||||
> {
|
||||
const outcome = await reader<unknown>("/api/v1/sources");
|
||||
if (outcome.state !== "ok") {
|
||||
return { state: outcome.state === "unavailable" ? "unknown" : "rejected", code: outcome.code, notFound: [] };
|
||||
return {
|
||||
state: outcome.state === "unavailable" ? "unknown" : "rejected",
|
||||
code: outcome.code,
|
||||
notFound: [],
|
||||
};
|
||||
}
|
||||
const catalog = outcome.data;
|
||||
if (!Array.isArray(catalog) || catalog.length > SOURCE_LIST_LIMIT) {
|
||||
return { state: "rejected", code: "sources_response_invalid", notFound: [] };
|
||||
return {
|
||||
state: "rejected",
|
||||
code: "sources_response_invalid",
|
||||
notFound: [],
|
||||
};
|
||||
}
|
||||
const candidates = validateSourceConnectionCandidates(catalog);
|
||||
if (!candidates) {
|
||||
@@ -165,9 +179,7 @@ export async function executeBatchImport(
|
||||
receipt.failed_sources && typeof receipt.failed_sources === "object"
|
||||
? (receipt.failed_sources as Record<string, string>)
|
||||
: {};
|
||||
const jobs = Array.isArray(receipt.jobs)
|
||||
? (receipt.jobs as ImportJob[])
|
||||
: [];
|
||||
const jobs = Array.isArray(receipt.jobs) ? (receipt.jobs as ImportJob[]) : [];
|
||||
const firstSeen = jobs.map((job) => ({
|
||||
source_id: job.source_id,
|
||||
discovered: job.discovered,
|
||||
@@ -262,7 +274,8 @@ export async function executeBatchImport(
|
||||
}
|
||||
|
||||
export function batchScanExitCode(document: BatchScanDocument): number {
|
||||
if (document.state === "committed" || document.state === "no_sources") return 0;
|
||||
if (document.state === "committed" || document.state === "no_sources")
|
||||
return 0;
|
||||
if (document.state === "partial") return 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
+31
-71
@@ -81,6 +81,7 @@ import {
|
||||
readMemoryWrite,
|
||||
readScanRoots,
|
||||
removeScanRoot,
|
||||
resetOnboarding,
|
||||
submitBackup,
|
||||
submitMemoryWrite,
|
||||
submitScan,
|
||||
@@ -190,13 +191,6 @@ import {
|
||||
renderMemoryDetailText,
|
||||
renderMemoryPageText,
|
||||
} from "./memory-report.js";
|
||||
import {
|
||||
type ModelContentPermissionDocument,
|
||||
modelContentPermissionExitCode,
|
||||
readModelContentPermission,
|
||||
renderModelContentPermissionText,
|
||||
setModelContentPermission,
|
||||
} from "./model-content-permission-flow.js";
|
||||
import {
|
||||
executeModelProbe,
|
||||
inspectModelProbePlan,
|
||||
@@ -217,12 +211,10 @@ import {
|
||||
executeModelProfileActivate,
|
||||
executeModelProfileEdit,
|
||||
type ModelWriteDocument,
|
||||
type ModelWritePlan,
|
||||
modelWriteExitCode,
|
||||
planModelProfileActivate,
|
||||
planModelProfileEdit,
|
||||
renderModelWriteText,
|
||||
validateModelWritePlan,
|
||||
} from "./model-write-flows.js";
|
||||
import { claimPlanAttempt } from "./operation-journal.js";
|
||||
import {
|
||||
@@ -697,6 +689,36 @@ async function runCommand(
|
||||
// `path list` and `adapter list` are read-only: they have no plan to confirm,
|
||||
// so they are answered here rather than falling into the two-phase branch
|
||||
// (which would reject them for having nothing to apply).
|
||||
// Back to the cold-start screens on purpose. Deleting the app cannot do this
|
||||
// (the completion flag lives with the memories, by design), and deleting the
|
||||
// data to get there would cost the user everything they have stored.
|
||||
if (named === "onboarding" && arguments_.includes("reset")) {
|
||||
if (
|
||||
arguments_.filter((item) => item === "--json").length > 1 ||
|
||||
arguments_.filter((item) => item !== "--json").join(" ") !==
|
||||
"onboarding reset"
|
||||
) {
|
||||
return usage();
|
||||
}
|
||||
const attachment = await attachToRuntime(
|
||||
resolveRuntimeCliConfig(environment, process.argv[1]).dataDir,
|
||||
);
|
||||
if (attachment === null) {
|
||||
return runtimeUnavailable(descriptor, asJson);
|
||||
}
|
||||
const state = await resetOnboarding(attachment);
|
||||
if (state === null) {
|
||||
return reject(descriptor, asJson, "引导状态端点不可用。");
|
||||
}
|
||||
if (asJson) {
|
||||
process.stdout.write(`${JSON.stringify(state)}\n`);
|
||||
return EXIT_OK;
|
||||
}
|
||||
process.stdout.write(
|
||||
`引导流程已回到首次运行(status=${state.status}),记忆数据未改动。\n`,
|
||||
);
|
||||
return EXIT_OK;
|
||||
}
|
||||
if (named === "path" && arguments_.includes("list")) {
|
||||
if (
|
||||
arguments_.filter((item) => item === "--json").length > 1 ||
|
||||
@@ -2462,45 +2484,6 @@ async function runCommand(
|
||||
);
|
||||
return uiWriteExitCode(document);
|
||||
}
|
||||
if (named === "permission") {
|
||||
const parsed = parsePermissionCommand(arguments_.slice(1));
|
||||
if (!parsed) return usage();
|
||||
const config = resolveRuntimeCliConfig(environment, process.argv[1]);
|
||||
const runtime = await inspectRuntimeStatus({
|
||||
dataDir: config.dataDir,
|
||||
probeIdentity,
|
||||
requireServiceReceipt: true,
|
||||
});
|
||||
const attached =
|
||||
runtime.state === "running"
|
||||
? await attachClients(config.dataDir)
|
||||
: { reader: null, operations: null, failure: runtime.code };
|
||||
if (
|
||||
runtime.state !== "running" ||
|
||||
(parsed === "status" ? !attached.reader : !attached.operations)
|
||||
) {
|
||||
const document: ModelContentPermissionDocument = {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "unknown",
|
||||
code:
|
||||
runtime.state !== "running"
|
||||
? runtime.code
|
||||
: (attached.failure ?? "runtime_attach_failed"),
|
||||
};
|
||||
emitModelContentPermissionDocument(document, asJson);
|
||||
return runtime.state === "stopped" ? 3 : 1;
|
||||
}
|
||||
const document =
|
||||
parsed === "status"
|
||||
? await readModelContentPermission(attached.reader!)
|
||||
: await setModelContentPermission(
|
||||
attached.operations!,
|
||||
parsed === "allow",
|
||||
);
|
||||
emitModelContentPermissionDocument(document, asJson);
|
||||
return modelContentPermissionExitCode(document);
|
||||
}
|
||||
if (named === "notes" || named === "extraction" || named === "graph") {
|
||||
const parsed = parseExtendedWriteCommand(
|
||||
named as "notes" | "extraction" | "graph",
|
||||
@@ -5124,18 +5107,6 @@ function parseHeadlessCommand(
|
||||
: null;
|
||||
}
|
||||
|
||||
function parsePermissionCommand(
|
||||
rest: readonly string[],
|
||||
): "status" | "allow" | "deny" | null {
|
||||
if (rest.filter((item) => item === "--json").length > 1) return null;
|
||||
const tokens = rest.filter((item) => item !== "--json");
|
||||
const action = tokens[0];
|
||||
return tokens.length === 1 &&
|
||||
(action === "status" || action === "allow" || action === "deny")
|
||||
? action
|
||||
: null;
|
||||
}
|
||||
|
||||
function unwrapPlan(value: unknown): unknown {
|
||||
if (
|
||||
value &&
|
||||
@@ -5323,17 +5294,6 @@ function emitModelReportDocument(
|
||||
);
|
||||
}
|
||||
|
||||
function emitModelContentPermissionDocument(
|
||||
document: ModelContentPermissionDocument,
|
||||
asJson: boolean,
|
||||
): void {
|
||||
process.stdout.write(
|
||||
asJson
|
||||
? `${JSON.stringify(document)}\n`
|
||||
: renderModelContentPermissionText(document),
|
||||
);
|
||||
}
|
||||
|
||||
function emitSourceSyncDocument(
|
||||
document: SourceSyncFlowDocument,
|
||||
asJson: boolean,
|
||||
|
||||
@@ -282,11 +282,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
summary: "列出来源、执行已确认的导入计划。",
|
||||
usage:
|
||||
"memhub sources list [--json]\n memhub sources import --plan-file <path> --confirm [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
options: [
|
||||
PLAN_FILE_OPTION,
|
||||
EXPLICIT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
options: [PLAN_FILE_OPTION, EXPLICIT_CONFIRM_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
@@ -497,14 +493,6 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "permission",
|
||||
summary: "读取或直接设置全局模型内容外发许可。",
|
||||
usage:
|
||||
"memhub permission status [--json]\n memhub permission allow [--json]\n memhub permission deny [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "extraction",
|
||||
summary: "设置提取窗口;默认只出计划,--confirm 后才写入。",
|
||||
@@ -663,6 +651,14 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "onboarding",
|
||||
summary:
|
||||
"onboarding reset 回到首次运行的引导流程。只清除引导完成状态:不删记忆数据,也不需要卸载重装。",
|
||||
usage: "memhub onboarding reset [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
summary: "显示总帮助或单个命令的用法。",
|
||||
|
||||
@@ -81,7 +81,6 @@ const COMMANDS = [
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"permission",
|
||||
"extraction",
|
||||
"graph",
|
||||
"host",
|
||||
@@ -93,6 +92,7 @@ const COMMANDS = [
|
||||
"uninstall",
|
||||
"upgrade",
|
||||
"delete-data",
|
||||
"onboarding",
|
||||
"help",
|
||||
"version",
|
||||
// A6 product commands. Appended, not inserted: the catalog is a stable
|
||||
@@ -259,7 +259,14 @@ describe("CLI-22 shared output contract", () => {
|
||||
|
||||
describe("argument validation", () => {
|
||||
it("fails unknown commands without printing data or running anything", async () => {
|
||||
for (const argv of [["unknown"], ["unknown", "--help"], ["--typo"]]) {
|
||||
for (const argv of [
|
||||
["unknown"],
|
||||
["unknown", "--help"],
|
||||
["--typo"],
|
||||
["permission", "status"],
|
||||
["permission", "allow"],
|
||||
["permission", "deny"],
|
||||
]) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(argv, {})).toBe(2);
|
||||
@@ -300,11 +307,7 @@ describe("CLI-22 shared output contract", () => {
|
||||
`${PRODUCT} sync`,
|
||||
],
|
||||
[
|
||||
[
|
||||
"sync",
|
||||
"receipt",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
],
|
||||
["sync", "receipt", "123e4567-e89b-12d3-a456-426614174000"],
|
||||
`${PRODUCT} sync`,
|
||||
],
|
||||
[["sync", "pause", "--source"], `${PRODUCT} sync`],
|
||||
@@ -555,12 +558,7 @@ describe("documented commands", () => {
|
||||
// Source processing is one Freeze/Thaw switch under `sync`; the obsolete
|
||||
// independent read/write `sources access` command must not return here.
|
||||
sources: ["list", "import", "receipt"],
|
||||
jobs: [
|
||||
"list",
|
||||
"show",
|
||||
"start",
|
||||
"<pause|resume|cancel>",
|
||||
],
|
||||
jobs: ["list", "show", "start", "<pause|resume|cancel>"],
|
||||
// `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>"],
|
||||
|
||||
@@ -342,17 +342,20 @@ describe("unified context search", () => {
|
||||
["--limit", "1", "2"],
|
||||
["--character-budget", "200", "300"],
|
||||
["--timeout-ms", "100", "200"],
|
||||
])("rejects a repeated %s before Runtime attach", async (flag, first, second) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
|
||||
).toBe(2);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toBe("");
|
||||
});
|
||||
])(
|
||||
"rejects a repeated %s before Runtime attach",
|
||||
async (flag, first, second) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
await run(["recall", "q", flag, first, flag, second, "--json"], {}),
|
||||
).toBe(2);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses malformed success bodies instead of leaking nested values", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { classifyModelRemove } from "@one-memory/operations";
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Only paths proven side-effect free by reading the Core handlers belong here.
|
||||
@@ -8,7 +8,6 @@ import { classifyModelRemove } from "@one-memory/operations";
|
||||
*/
|
||||
const READ_ONLY_PATHS = [
|
||||
"/api/v1/call-events",
|
||||
"/api/v1/model-content-permission",
|
||||
"/api/v1/home-summary",
|
||||
"/api/v1/import-jobs",
|
||||
"/api/v1/live-sync",
|
||||
@@ -56,7 +55,10 @@ export function createUiSessionRequest(attachment: {
|
||||
return { state: "ok" as const, data: response.data };
|
||||
}
|
||||
if (response.status === 503) {
|
||||
return { state: "unavailable" as const, code: "ui_web_assets_unavailable" };
|
||||
return {
|
||||
state: "unavailable" as const,
|
||||
code: "ui_web_assets_unavailable",
|
||||
};
|
||||
}
|
||||
return { state: "unavailable" as const, code: `http_${response.status}` };
|
||||
};
|
||||
@@ -395,15 +397,30 @@ export async function readMemoryWrite(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readScanRoots(
|
||||
export interface OnboardingState {
|
||||
status: string;
|
||||
selected_source_ids: string[];
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
/** Send onboarding back to its first-run state.
|
||||
*
|
||||
* Returns null when the endpoint is not there, so an older runtime degrades to
|
||||
* "not available" instead of failing the command. Only the completion flag
|
||||
* moves: memories, sources and settings live in other tables, which is what
|
||||
* makes this the honest way to see the cold-start screens again.
|
||||
*/
|
||||
export async function resetOnboarding(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
): Promise<{ schema_version: number; revision: number; roots: unknown[] } | null> {
|
||||
): Promise<OnboardingState | null> {
|
||||
try {
|
||||
const response = await axios.get<{ schema_version: number; revision: number; roots: unknown[] }>(
|
||||
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
|
||||
const response = await axios.post<OnboardingState>(
|
||||
`${baseUrlOf(attachment)}/api/v1/onboarding/reset`,
|
||||
undefined,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
timeout: 15_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
@@ -415,12 +432,19 @@ export async function readScanRoots(
|
||||
}
|
||||
}
|
||||
|
||||
export async function addScanRoot(
|
||||
export async function readScanRoots(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
body: { root: string; source?: string; recursive?: boolean },
|
||||
): Promise<unknown | null> {
|
||||
): Promise<{
|
||||
schema_version: number;
|
||||
revision: number;
|
||||
roots: unknown[];
|
||||
} | null> {
|
||||
try {
|
||||
const response = await axios.post<unknown>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, body, {
|
||||
const response = await axios.get<{
|
||||
schema_version: number;
|
||||
revision: number;
|
||||
roots: unknown[];
|
||||
}>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, {
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
@@ -428,6 +452,28 @@ export async function addScanRoot(
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
});
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addScanRoot(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
body: { root: string; source?: string; recursive?: boolean },
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const response = await axios.post<unknown>(
|
||||
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
|
||||
body,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
@@ -438,14 +484,17 @@ export async function removeScanRoot(
|
||||
body: { root: string },
|
||||
): Promise<unknown | null> {
|
||||
try {
|
||||
const response = await axios.delete<unknown>(`${baseUrlOf(attachment)}/api/v1/scan-roots`, {
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
data: body,
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
});
|
||||
const response = await axios.delete<unknown>(
|
||||
`${baseUrlOf(attachment)}/api/v1/scan-roots`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
data: body,
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { _error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -456,16 +505,16 @@ export async function readAdapters(
|
||||
attachment: RuntimeAttachmentLike,
|
||||
): Promise<{ schema_version: number; adapters: unknown[] } | null> {
|
||||
try {
|
||||
const response = await axios.get<{ schema_version: number; adapters: unknown[] }>(
|
||||
`${baseUrlOf(attachment)}/api/v1/adapters`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
},
|
||||
);
|
||||
const response = await axios.get<{
|
||||
schema_version: number;
|
||||
adapters: unknown[];
|
||||
}>(`${baseUrlOf(attachment)}/api/v1/adapters`, {
|
||||
headers: { Authorization: `Bearer ${attachment.apiToken}` },
|
||||
timeout: 10_000,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxContentLength: MAX_RESPONSE_BYTES,
|
||||
});
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -30,7 +30,6 @@ const IMPORT_FILE =
|
||||
const MEMORY_NOTES_PATH = "/api/v1/memory-notes";
|
||||
const MEMORY_NOTE_PROCESS =
|
||||
/^\/api\/v1\/memory-notes\/[A-Za-z0-9][\w.:-]{0,127}\/process$/;
|
||||
const MODEL_CONTENT_PERMISSION_PATH = "/api/v1/model-content-permission";
|
||||
const EXTRACTION_SETTINGS_PATH = "/api/v1/extraction-settings";
|
||||
const GRAPH_WIDTH_PATH = "/api/v1/graph-width";
|
||||
const MANUAL_EXTRACT_PATH = "/api/v1/memory-processing/manual-extract";
|
||||
@@ -134,7 +133,6 @@ export function createCoreOperations(attachment: {
|
||||
!IMPORT_FILE_COMMIT.test(path) &&
|
||||
path !== MEMORY_NOTES_PATH &&
|
||||
!MEMORY_NOTE_PROCESS.test(path) &&
|
||||
path !== MODEL_CONTENT_PERMISSION_PATH &&
|
||||
path !== GRAPH_WIDTH_PATH &&
|
||||
path !== MANUAL_EXTRACT_PATH &&
|
||||
path !== ONBOARDING_COMPLETE_PATH &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -23,14 +23,22 @@ describe("explicit data deletion", () => {
|
||||
"one-memory.sqlite3": "db",
|
||||
"runtime-service.json": "{}",
|
||||
});
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
expect(plan.state).toBe("preview");
|
||||
expect(plan.entries).toContain(path.join(dataDir, "one-memory.sqlite3"));
|
||||
});
|
||||
|
||||
it("refuses while the Runtime is still running", async () => {
|
||||
const { home, dataDir } = await homeWith();
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: true });
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: true,
|
||||
});
|
||||
expect(plan).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "runtime_still_running",
|
||||
@@ -66,16 +74,23 @@ describe("explicit data deletion", () => {
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
expect(plan).toMatchObject({ state: "blocked", code: "data_dir_refused" });
|
||||
expect(plan).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "data_dir_refused",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("requires the second confirmation to name the exact directory", async () => {
|
||||
const { home, dataDir } = await homeWith();
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
|
||||
await expect(applyDataDeletion(plan, path.join(home, "other"))).rejects.toThrow(
|
||||
"data_deletion_confirmation_mismatch",
|
||||
);
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
await expect(
|
||||
applyDataDeletion(plan, path.join(home, "other")),
|
||||
).rejects.toThrow("data_deletion_confirmation_mismatch");
|
||||
// The data survived a mismatched confirmation.
|
||||
await expect(
|
||||
stat(path.join(dataDir, "one-memory.sqlite3")),
|
||||
@@ -84,7 +99,11 @@ describe("explicit data deletion", () => {
|
||||
|
||||
it("deletes an owned Home only when both confirmations match", async () => {
|
||||
const { home, dataDir } = await homeWith({ "one-memory.sqlite3": "db" });
|
||||
const plan = await planDataDeletion({ dataDir, home, runtimeRunning: false });
|
||||
const plan = await planDataDeletion({
|
||||
dataDir,
|
||||
home,
|
||||
runtimeRunning: false,
|
||||
});
|
||||
const receipt = await applyDataDeletion(plan, dataDir);
|
||||
expect(receipt.state).toBe("deleted");
|
||||
await expect(stat(dataDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
@@ -68,7 +68,8 @@ export function renderDoctorText(document: DoctorDocument): string {
|
||||
return [
|
||||
`MemHub 检查:${doctorState(document.state)}`,
|
||||
...document.checks.map(
|
||||
(check) => `${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
|
||||
(check) =>
|
||||
`${symbol(check.state)} ${labels[check.id]}:${checkState(check.state)}`,
|
||||
),
|
||||
...(document.state === "healthy"
|
||||
? []
|
||||
|
||||
@@ -28,11 +28,7 @@ export function resolveEntryDiagnostics(input: {
|
||||
// comparison would misread that as isolated and silently drop diagnostics.
|
||||
const home = resolve(input.home);
|
||||
const accountHome = resolve(input.accountHome);
|
||||
if (
|
||||
home === undefined ||
|
||||
accountHome === undefined ||
|
||||
home === accountHome
|
||||
) {
|
||||
if (home === undefined || accountHome === undefined || home === accountHome) {
|
||||
return { kind: "log" };
|
||||
}
|
||||
// Isolated HOME with no explicit data dir: the fallback path would write
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
resolveRuntimeCliConfig,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { run } from "./cli.js";
|
||||
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
|
||||
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
|
||||
import { resolveDiagnosticsDataDir } from "./install-receipt.js";
|
||||
|
||||
function accountHomeDirectory(): string | undefined {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
ModelProfileInputError,
|
||||
SourceImportInputError,
|
||||
buildModelProfileCreateRequest,
|
||||
buildSourceImportRequest,
|
||||
ModelProfileInputError,
|
||||
parseModelProfileReceipt,
|
||||
SourceImportInputError,
|
||||
} from "@one-memory/operations";
|
||||
import type { ImportJob, ModelProfile } from "@one-memory/contracts";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
@@ -117,9 +117,7 @@ function classifySourceImport(
|
||||
product: PRODUCT,
|
||||
state: "rejected",
|
||||
operation_id: operationId,
|
||||
code: onlyBusy
|
||||
? "source_import_all_busy"
|
||||
: "source_import_all_rejected",
|
||||
code: onlyBusy ? "source_import_all_busy" : "source_import_all_rejected",
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,7 +266,9 @@ export function renderHostText(document: HostFlowDocument): string {
|
||||
}
|
||||
if (review.length > 12) lines.push(` … 另有 ${review.length - 12} 处`);
|
||||
lines.push("");
|
||||
lines.push("这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。");
|
||||
lines.push(
|
||||
"这些不是我们安装的内容,卸载不会替你删除;需自行处理后才能卸载。",
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
|
||||
@@ -259,7 +259,7 @@ describe("A2 dual-mode init", () => {
|
||||
expect(streams.value()).toContain("处理:已开启");
|
||||
});
|
||||
|
||||
it("continues the TTY wizard through model probe and content permission", async () => {
|
||||
it("continues the TTY wizard through model setup and probe", async () => {
|
||||
const profileId = "22222222-2222-4222-8222-222222222222";
|
||||
const profile = {
|
||||
profile_id: profileId,
|
||||
@@ -361,12 +361,6 @@ describe("A2 dual-mode init", () => {
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/activate")) return { status: 200, data: profile };
|
||||
if (url === "/api/v1/model-content-permission") {
|
||||
return {
|
||||
status: 200,
|
||||
data: { allowed: body.allowed },
|
||||
};
|
||||
}
|
||||
if (url === "/api/v1/onboarding/complete") {
|
||||
return {
|
||||
status: 200,
|
||||
@@ -383,7 +377,7 @@ describe("A2 dual-mode init", () => {
|
||||
},
|
||||
);
|
||||
const prompt = extendedPrompter(
|
||||
["", "", "", "", "", "", "", "", "n"],
|
||||
["", "", "", "", "", "", "", "n"],
|
||||
"sk-never-print-this",
|
||||
);
|
||||
const streams = captureStdout();
|
||||
@@ -402,7 +396,6 @@ describe("A2 dual-mode init", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.value()).toContain("已接入来源:codex");
|
||||
expect(prompt.written.join("\n")).toContain("模型内容外发:已允许");
|
||||
expect(streams.value()).toContain("首次设置完成。");
|
||||
expect(`${streams.value()}\n${prompt.written.join("\n")}`).not.toContain(
|
||||
"sk-never-print-this",
|
||||
@@ -412,10 +405,10 @@ describe("A2 dual-mode init", () => {
|
||||
expect.objectContaining({ api_key: "sk-never-print-this" }),
|
||||
undefined,
|
||||
);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
expect(transport.post).not.toHaveBeenCalledWith(
|
||||
"/api/v1/model-content-permission",
|
||||
{ allowed: true },
|
||||
undefined,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/onboarding/complete",
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "./init-wizard.js";
|
||||
|
||||
function scripted(lines: string[]): WizardPrompter & { asked: string[]; wrote: string[] } {
|
||||
function scripted(
|
||||
lines: string[],
|
||||
): WizardPrompter & { asked: string[]; wrote: string[] } {
|
||||
let index = 0;
|
||||
const asked: string[] = [];
|
||||
const wrote: string[] = [];
|
||||
|
||||
@@ -99,8 +99,7 @@ export async function runInitWizard(
|
||||
"开启处理?开启后会处理历史未完成会话并持续接收新会话;输入 n 保持冻结。[Y/n]: ",
|
||||
);
|
||||
const processing = parseYesNo(processingAnswer, true);
|
||||
if (processing === null)
|
||||
return { state: "invalid", code: "invalid_answer" };
|
||||
if (processing === null) return { state: "invalid", code: "invalid_answer" };
|
||||
|
||||
return {
|
||||
state: "answered",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
|
||||
import {
|
||||
defaultInstallReceiptPath,
|
||||
readInstallReceipt,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resolveInstalledDataDir,
|
||||
writeInstallReceipt,
|
||||
} from "./install-receipt.js";
|
||||
import { resolveEntryDiagnostics } from "./entry-diagnostics.js";
|
||||
|
||||
async function root(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "memhub-install-receipt-"));
|
||||
@@ -230,9 +229,9 @@ describe("managed install receipt", () => {
|
||||
}),
|
||||
).toBe("/tmp/env data");
|
||||
// Receipt beats default.
|
||||
expect(
|
||||
await resolveDiagnosticsDataDir(["install"], { HOME: home }),
|
||||
).toBe(path.join(home, "receipt data"));
|
||||
expect(await resolveDiagnosticsDataDir(["install"], { HOME: home })).toBe(
|
||||
path.join(home, "receipt data"),
|
||||
);
|
||||
// Invalid explicit flag must not silently fall back to the receipt.
|
||||
expect(
|
||||
await resolveDiagnosticsDataDir(
|
||||
|
||||
@@ -132,12 +132,6 @@ function clients(options: { failedProbes?: number } = {}) {
|
||||
if (path.endsWith("/activate")) {
|
||||
return { state: "committed" as const, data: profile(true) };
|
||||
}
|
||||
if (path === "/api/v1/model-content-permission") {
|
||||
return {
|
||||
state: "committed" as const,
|
||||
data: { allowed: body.allowed },
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "rejected" as const,
|
||||
code: "unexpected_path",
|
||||
@@ -160,8 +154,8 @@ function clients(options: { failedProbes?: number } = {}) {
|
||||
}
|
||||
|
||||
describe("interactive model setup", () => {
|
||||
it("uses App defaults, keeps the key out of output, and allows model content", async () => {
|
||||
const prompt = prompter(["", "", "", "", "", ""]);
|
||||
it("uses App defaults and keeps the key out of output", async () => {
|
||||
const prompt = prompter(["", "", ""]);
|
||||
const api = clients();
|
||||
|
||||
const result = await runInteractiveModelSetup(prompt.value, api, {
|
||||
@@ -170,10 +164,7 @@ describe("interactive model setup", () => {
|
||||
installHosts: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: "configured",
|
||||
model_content_allowed: true,
|
||||
});
|
||||
expect(result).toMatchObject({ state: "configured" });
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/api/v1/model-profiles",
|
||||
expect.objectContaining({
|
||||
@@ -184,9 +175,9 @@ describe("interactive model setup", () => {
|
||||
embedding_provider_kind: "local_onnx",
|
||||
}),
|
||||
);
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
expect(api.post).not.toHaveBeenCalledWith(
|
||||
"/api/v1/model-content-permission",
|
||||
{ allowed: true },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(prompt.written.join("\n")).not.toContain("sk-do-not-print");
|
||||
});
|
||||
@@ -206,7 +197,7 @@ describe("interactive model setup", () => {
|
||||
});
|
||||
|
||||
it("removes an unverified profile before skipping after a failed probe", async () => {
|
||||
const prompt = prompter(["", "", "", "n", "0"]);
|
||||
const prompt = prompter(["", "", "", "0"]);
|
||||
const api = clients();
|
||||
api.post.mockImplementation(
|
||||
async (path: string, body: Record<string, unknown>) => {
|
||||
@@ -281,7 +272,7 @@ describe("interactive model setup", () => {
|
||||
});
|
||||
|
||||
it("cleans a failed probe and lets the user re-enter the model settings", async () => {
|
||||
const prompt = prompter(["", "", "", "", "", "", "", "", "", "", ""]);
|
||||
const prompt = prompter(["", "", "", "", "", "", ""]);
|
||||
const api = clients({ failedProbes: 1 });
|
||||
|
||||
const result = await runInteractiveModelSetup(prompt.value, api, {
|
||||
@@ -290,10 +281,7 @@ describe("interactive model setup", () => {
|
||||
installHosts: false,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: "configured",
|
||||
model_content_allowed: true,
|
||||
});
|
||||
expect(result).toMatchObject({ state: "configured" });
|
||||
expect(api.remove).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
api.post.mock.calls.filter(([path]) => path === "/api/v1/model-profiles"),
|
||||
@@ -306,7 +294,7 @@ describe("interactive model setup", () => {
|
||||
const prompt = prompter(["", "", ""]);
|
||||
prompt.value.ask = async () => {
|
||||
asked += 1;
|
||||
if (asked === 4) throw new Error("interrupted");
|
||||
if (asked === 3) throw new Error("interrupted");
|
||||
return "";
|
||||
};
|
||||
const api = clients();
|
||||
@@ -323,7 +311,7 @@ describe("interactive model setup", () => {
|
||||
});
|
||||
|
||||
it("installs selected detected hosts through preview and fingerprint apply", async () => {
|
||||
const prompt = prompter(["", "", "", "n", "", ""]);
|
||||
const prompt = prompter(["", "", "", "", ""]);
|
||||
const api = clients();
|
||||
const read = vi
|
||||
.fn()
|
||||
@@ -361,7 +349,6 @@ describe("interactive model setup", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: "configured",
|
||||
model_content_allowed: false,
|
||||
installed_hosts: ["codex"],
|
||||
});
|
||||
expect(read).toHaveBeenNthCalledWith(
|
||||
@@ -374,7 +361,7 @@ describe("interactive model setup", () => {
|
||||
});
|
||||
|
||||
it("lists and installs only hosts that are actually present", async () => {
|
||||
const prompt = prompter(["", "", "", "n", "", ""]);
|
||||
const prompt = prompter(["", "", "", "", ""]);
|
||||
const api = clients();
|
||||
const read = vi
|
||||
.fn()
|
||||
@@ -421,7 +408,7 @@ describe("interactive model setup", () => {
|
||||
});
|
||||
|
||||
it("completes the wizard and keeps onboarding reachable when one host install fails", async () => {
|
||||
const prompt = prompter(["", "", "", "n", "", ""]);
|
||||
const prompt = prompter(["", "", "", "", ""]);
|
||||
const api = clients();
|
||||
const read = vi
|
||||
.fn()
|
||||
@@ -470,7 +457,6 @@ describe("interactive model setup", () => {
|
||||
// calling the install a failure.
|
||||
expect(result).toMatchObject({
|
||||
state: "configured",
|
||||
model_content_allowed: false,
|
||||
installed_hosts: ["codex"],
|
||||
});
|
||||
const summary = prompt.written.join("\n");
|
||||
@@ -480,7 +466,7 @@ describe("interactive model setup", () => {
|
||||
});
|
||||
|
||||
it("tells the user to install App-only hosts in the App instead of calling them failed", async () => {
|
||||
const prompt = prompter(["", "", "", "n", "", ""]);
|
||||
const prompt = prompter(["", "", "", "", ""]);
|
||||
const api = clients();
|
||||
const read = vi
|
||||
.fn()
|
||||
@@ -507,7 +493,6 @@ describe("interactive model setup", () => {
|
||||
// only the App can install, so nothing counts as "installed".
|
||||
expect(result).toMatchObject({
|
||||
state: "configured",
|
||||
model_content_allowed: false,
|
||||
installed_hosts: [],
|
||||
});
|
||||
const summary = prompt.written.join("\n");
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
executeModelProfileActivate,
|
||||
planModelProfileActivate,
|
||||
} from "./model-write-flows.js";
|
||||
import { setModelContentPermission } from "./model-content-permission-flow.js";
|
||||
|
||||
type ProviderPreset = {
|
||||
id: string;
|
||||
@@ -122,7 +121,6 @@ export type InteractiveModelSetupState =
|
||||
| {
|
||||
state: "configured";
|
||||
profile: ModelProfile;
|
||||
model_content_allowed: boolean;
|
||||
installed_hosts: string[];
|
||||
}
|
||||
| { state: "skipped" }
|
||||
@@ -289,15 +287,11 @@ async function installHosts(
|
||||
function writeSummary(
|
||||
prompter: WizardPrompter,
|
||||
profile: ModelProfile,
|
||||
modelContentAllowed: boolean,
|
||||
installedHosts: readonly string[],
|
||||
failedHosts: readonly { hostId: string; label: string }[],
|
||||
): void {
|
||||
prompter.write("配置摘要:");
|
||||
prompter.write(` 模型:${profile.name} / ${profile.model}`);
|
||||
prompter.write(
|
||||
` 模型内容外发:${modelContentAllowed ? "已允许" : "已禁止"}`,
|
||||
);
|
||||
prompter.write(
|
||||
` 应用接入:${installedHosts.length > 0 ? installedHosts.join("、") : "已跳过"}`,
|
||||
);
|
||||
@@ -353,14 +347,6 @@ export async function runInteractiveModelSetup(
|
||||
prompter.write("未填写 API Key。输入 back 返回,或重新选择模型服务。");
|
||||
continue;
|
||||
}
|
||||
const permissionAnswer = (
|
||||
await prompter.ask("允许将对话内容发送给模型以整理记忆?[Y/n]: ")
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const modelContentAllowed = !["n", "no", "否", "0"].includes(
|
||||
permissionAnswer,
|
||||
);
|
||||
const hostApi = options.hostApi ?? { list: listHosts, read: readHost };
|
||||
const selectedHosts = options.installHosts
|
||||
? await chooseDetectedHosts(prompter, options.hostHome, hostApi)
|
||||
@@ -452,13 +438,6 @@ export async function runInteractiveModelSetup(
|
||||
return { state: "failed", code: "model_activate_failed" };
|
||||
}
|
||||
|
||||
const permission = await setModelContentPermission(
|
||||
clients.operations,
|
||||
modelContentAllowed,
|
||||
);
|
||||
if (permission.state !== "ok") {
|
||||
return { state: "failed", code: "model_content_permission_set_failed" };
|
||||
}
|
||||
const { installed, failed } = await installHosts(
|
||||
options.dataDir,
|
||||
selectedHosts,
|
||||
@@ -467,7 +446,6 @@ export async function runInteractiveModelSetup(
|
||||
writeSummary(
|
||||
prompter,
|
||||
profile,
|
||||
modelContentAllowed,
|
||||
installed,
|
||||
failed.map((hostId) => ({
|
||||
hostId,
|
||||
@@ -477,7 +455,6 @@ export async function runInteractiveModelSetup(
|
||||
return {
|
||||
state: "configured",
|
||||
profile,
|
||||
model_content_allowed: modelContentAllowed,
|
||||
installed_hosts: installed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { createCoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
executeJobAction,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
planJobAction,
|
||||
planJobStart,
|
||||
} from "./job-flow.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
|
||||
function operations(
|
||||
post: (path: string, body: Record<string, unknown>) => Promise<unknown>,
|
||||
@@ -180,5 +180,8 @@ describe("import-job write allow-list", () => {
|
||||
await expect(
|
||||
operations.post("/api/v1/import-jobs/job-1/delete", {}),
|
||||
).rejects.toThrow("core_operation_path_not_allowed");
|
||||
await expect(
|
||||
operations.post("/api/v1/model-content-permission", { allowed: true }),
|
||||
).rejects.toThrow("core_operation_path_not_allowed");
|
||||
});
|
||||
});
|
||||
@@ -145,9 +145,7 @@ function jobMutationResult(
|
||||
export function jobExitCode(document: JobFlowDocument): number {
|
||||
if (document.state === "preview" || document.state === "committed") return 0;
|
||||
if (document.state === "recovered") return 0;
|
||||
return document.state === "rejected" || document.state === "unknown"
|
||||
? 1
|
||||
: 2;
|
||||
return document.state === "rejected" || document.state === "unknown" ? 1 : 2;
|
||||
}
|
||||
|
||||
function invalid(code: string): JobFlowDocument {
|
||||
|
||||
@@ -105,10 +105,7 @@ describe("memhub jobs", () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["jobs", "start", "--source", "codex", "--json"],
|
||||
{},
|
||||
);
|
||||
code = await run(["jobs", "start", "--source", "codex", "--json"], {});
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
@@ -154,10 +151,7 @@ describe("memhub jobs", () => {
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["jobs", action, "--job", "job-1", "--json"],
|
||||
{},
|
||||
);
|
||||
code = await run(["jobs", action, "--job", "job-1", "--json"], {});
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import { diagnosticLogFiles, readDiagnosticEvents } from "@one-memory/runtime-control";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DiagnosticEventV1 } from "@one-memory/runtime-control";
|
||||
import {
|
||||
diagnosticLogFiles,
|
||||
readDiagnosticEvents,
|
||||
} from "@one-memory/runtime-control";
|
||||
|
||||
export interface LogQuery {
|
||||
since?: Date;
|
||||
@@ -160,10 +163,21 @@ export async function readLogReport(
|
||||
const logsDirectory = path.join(path.resolve(dataDir), "logs");
|
||||
const info = await lstat(logsDirectory).catch(() => null);
|
||||
if (info !== null && (!info.isDirectory() || info.isSymbolicLink())) {
|
||||
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
if (info !== null && (await diagnosticLogFiles(dataDir).catch(() => null)) === null) {
|
||||
return { events: [], invalid_lines: 0, failure: "log_directory_unreadable" };
|
||||
if (
|
||||
info !== null &&
|
||||
(await diagnosticLogFiles(dataDir).catch(() => null)) === null
|
||||
) {
|
||||
return {
|
||||
events: [],
|
||||
invalid_lines: 0,
|
||||
failure: "log_directory_unreadable",
|
||||
};
|
||||
}
|
||||
const explicit =
|
||||
query.since ||
|
||||
@@ -239,15 +253,27 @@ export function redactDiagnosticEvent(
|
||||
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.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.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
|
||||
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CliLinkPlan } from "@one-memory/runtime-control";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
installManagedCliAlias,
|
||||
installManagedCliLink,
|
||||
@@ -107,7 +106,9 @@ describe("managed memhub CLI alias", () => {
|
||||
await installManagedCliAlias(plan);
|
||||
|
||||
await expect(removeManagedCliAlias(plan)).resolves.toBe("removed");
|
||||
await expect(lstat(plan.link_path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(plan.link_path)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
// C8 P1 (2026-09-29): `runtime-control.applyLocalUninstall` deletes
|
||||
@@ -156,7 +157,10 @@ describe("managed memhub CLI alias", () => {
|
||||
it("manages the one-memory companion when memhub is already present", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const f = await fixture();
|
||||
const primary = { ...f.base, link_path: path.join(f.base.bin_directory, "memhub") };
|
||||
const primary = {
|
||||
...f.base,
|
||||
link_path: path.join(f.base.bin_directory, "memhub"),
|
||||
};
|
||||
await mkdir(f.base.bin_directory, { recursive: true, mode: 0o700 });
|
||||
await (await import("node:fs/promises")).chmod(f.base.bin_directory, 0o700);
|
||||
await (await import("node:fs/promises")).symlink(
|
||||
@@ -168,7 +172,10 @@ describe("managed memhub CLI alias", () => {
|
||||
const plan = await planManagedCliLink(f.base, "one-memory");
|
||||
// The companion does not exist yet, so it is ours to create; crucially it
|
||||
// targets the compat name, not the package's `memhub`.
|
||||
expect(plan).toMatchObject({ status: "create", link_path: f.base.link_path });
|
||||
expect(plan).toMatchObject({
|
||||
status: "create",
|
||||
link_path: f.base.link_path,
|
||||
});
|
||||
|
||||
// The package's memhub link is untouched.
|
||||
expect(await readlink(primary.link_path)).toBe(f.target);
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { access, chmod, lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
readlink,
|
||||
rm,
|
||||
symlink,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { CliLinkPlan, CliLinkReceipt } from "@one-memory/runtime-control";
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: { create: vi.fn(() => transport), get: vi.fn() },
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
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({
|
||||
instanceId: "instance-1",
|
||||
apiToken: "synthetic-runtime-token",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
servicePid: 4242,
|
||||
health: {
|
||||
status: "ok",
|
||||
service: "one-memory-core",
|
||||
version: "1.2.3",
|
||||
instance_id: "instance-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("model content permission CLI", () => {
|
||||
it("documents only final-state permission and manual extraction commands", async () => {
|
||||
const permission = await capturedRun(["help", "permission"]);
|
||||
expect(permission.code).toBe(0);
|
||||
expect(permission.stdout).toContain("memhub permission allow");
|
||||
expect(permission.stdout).toContain("memhub permission deny");
|
||||
expect(permission.stdout).not.toMatch(/\b(preview|confirm|process|revoke)\b/);
|
||||
expect(permission.stdout).not.toContain("revision");
|
||||
expect(permission.stdout).not.toContain("receipt");
|
||||
|
||||
const processing = await capturedRun(["help", "processing"]);
|
||||
expect(processing.code).toBe(0);
|
||||
expect(processing.stdout).toContain("memhub processing extract");
|
||||
expect(processing.stdout).not.toMatch(/\b(plan|apply|receipt)\b/);
|
||||
expect(processing.stdout).not.toContain("backlog");
|
||||
});
|
||||
|
||||
it("reads the final state", async () => {
|
||||
transport.get.mockResolvedValue({ status: 200, data: { allowed: true } });
|
||||
|
||||
const result = await capturedRun(["permission", "status", "--json"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
state: "ok",
|
||||
allowed: true,
|
||||
});
|
||||
expect(transport.get).toHaveBeenCalledWith(
|
||||
"/api/v1/model-content-permission",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["allow", true],
|
||||
["deny", false],
|
||||
] as const)("sets %s directly with only allowed", async (verb, allowed) => {
|
||||
transport.post.mockResolvedValue({ status: 200, data: { allowed } });
|
||||
|
||||
const result = await capturedRun(["permission", verb, "--json"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ state: "ok", allowed });
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/model-content-permission",
|
||||
{ allowed },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["preview", "confirm", "process", "revoke"])(
|
||||
"rejects removed %s commands before runtime access",
|
||||
async (verb) => {
|
||||
const result = await capturedRun(["permission", verb, "--json"]);
|
||||
|
||||
expect(result.code).toBe(2);
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["plan", "apply", "receipt"])(
|
||||
"rejects removed processing %s commands before runtime access",
|
||||
async (verb) => {
|
||||
const result = await capturedRun(["processing", verb, "--json"]);
|
||||
|
||||
expect(result.code).toBe(2);
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
expect(transport.post).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function capturedRun(arguments_: string[]) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
return {
|
||||
code: await run(arguments_, {
|
||||
ONE_MEMORY_DATA_DIR: "/synthetic/data",
|
||||
}),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import {
|
||||
readModelContentPermission,
|
||||
setModelContentPermission,
|
||||
} from "./model-content-permission-flow.js";
|
||||
|
||||
describe("model content permission flow", () => {
|
||||
it("reads the single final-state boolean", async () => {
|
||||
const reader = vi.fn().mockResolvedValue({
|
||||
state: "ok",
|
||||
data: { allowed: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
readModelContentPermission(reader as CoreReader),
|
||||
).resolves.toMatchObject({ state: "ok", allowed: true });
|
||||
expect(reader).toHaveBeenCalledWith("/api/v1/model-content-permission");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["allow", true],
|
||||
["deny", false],
|
||||
] as const)("sets %s directly without revision or receipt fields", async (_, allowed) => {
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: { allowed },
|
||||
});
|
||||
|
||||
await expect(
|
||||
setModelContentPermission({ post } as CoreOperations, allowed),
|
||||
).resolves.toMatchObject({ state: "ok", allowed });
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/model-content-permission", {
|
||||
allowed,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not accept a malformed or mismatched success response", async () => {
|
||||
const malformed = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: { allowed: true, revision: 1 },
|
||||
});
|
||||
await expect(
|
||||
setModelContentPermission({ post: malformed } as CoreOperations, true),
|
||||
).resolves.toMatchObject({
|
||||
state: "unknown",
|
||||
code: "model_content_permission_response_invalid",
|
||||
});
|
||||
|
||||
const mismatched = vi.fn().mockResolvedValue({
|
||||
state: "committed",
|
||||
data: { allowed: false },
|
||||
});
|
||||
await expect(
|
||||
setModelContentPermission({ post: mismatched } as CoreOperations, true),
|
||||
).resolves.toMatchObject({
|
||||
state: "unknown",
|
||||
code: "model_content_permission_state_mismatch",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
export type ModelContentPermissionDocument =
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "ok";
|
||||
allowed: boolean;
|
||||
}
|
||||
| {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
state: "rejected" | "unknown";
|
||||
code: string;
|
||||
};
|
||||
|
||||
export async function readModelContentPermission(
|
||||
reader: CoreReader,
|
||||
): Promise<ModelContentPermissionDocument> {
|
||||
const outcome = await reader<unknown>("/api/v1/model-content-permission");
|
||||
if (outcome.state !== "ok") {
|
||||
return failure("unknown", outcome.code);
|
||||
}
|
||||
return permissionState(outcome.data, "model_content_permission_response_invalid");
|
||||
}
|
||||
|
||||
export async function setModelContentPermission(
|
||||
operations: CoreOperations,
|
||||
allowed: boolean,
|
||||
): Promise<ModelContentPermissionDocument> {
|
||||
const outcome = await operations.post<unknown>(
|
||||
"/api/v1/model-content-permission",
|
||||
{ allowed },
|
||||
);
|
||||
if (outcome.state === "rejected") {
|
||||
return failure("rejected", outcome.code);
|
||||
}
|
||||
if (outcome.state === "unknown") {
|
||||
return failure("unknown", outcome.code);
|
||||
}
|
||||
const document = permissionState(
|
||||
outcome.data,
|
||||
"model_content_permission_response_invalid",
|
||||
);
|
||||
return document.state === "ok" && document.allowed !== allowed
|
||||
? failure("unknown", "model_content_permission_state_mismatch")
|
||||
: document;
|
||||
}
|
||||
|
||||
export function modelContentPermissionExitCode(
|
||||
document: ModelContentPermissionDocument,
|
||||
): number {
|
||||
return document.state === "ok" ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderModelContentPermissionText(
|
||||
document: ModelContentPermissionDocument,
|
||||
): string {
|
||||
if (document.state === "ok") {
|
||||
return `模型内容外发:${document.allowed ? "允许" : "禁止"}\n`;
|
||||
}
|
||||
return `模型内容权限未更新:${document.state}(${document.code})\n`;
|
||||
}
|
||||
|
||||
function permissionState(
|
||||
value: unknown,
|
||||
invalidCode: string,
|
||||
): ModelContentPermissionDocument {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
typeof (value as Record<string, unknown>).allowed !== "boolean"
|
||||
) {
|
||||
return failure("unknown", invalidCode);
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
state: "ok",
|
||||
allowed: (value as { allowed: boolean }).allowed,
|
||||
};
|
||||
}
|
||||
|
||||
function failure(
|
||||
state: "rejected" | "unknown",
|
||||
code: string,
|
||||
): ModelContentPermissionDocument {
|
||||
return { schema_version: 1, product: PRODUCT, state, code };
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { ModelProfile } from "@one-memory/contracts";
|
||||
import {
|
||||
ModelProfileInputError,
|
||||
buildModelProfileUpdateRequest,
|
||||
ModelProfileInputError,
|
||||
parseModelProfileReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreOperations, CorePatchOperations } from "./core-operations.js";
|
||||
@@ -78,7 +78,10 @@ function digestOf(value: unknown): string {
|
||||
|
||||
function preview(
|
||||
plan:
|
||||
| Omit<Extract<ModelWritePlan, { kind: "model_profile_activate" }>, "digest">
|
||||
| Omit<
|
||||
Extract<ModelWritePlan, { kind: "model_profile_activate" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ModelWritePlan, { kind: "model_profile_edit" }>, "digest">,
|
||||
): ModelWriteDocument {
|
||||
return {
|
||||
@@ -198,9 +201,9 @@ export async function executeModelProfileEdit(
|
||||
let request: ReturnType<typeof buildModelProfileUpdateRequest>;
|
||||
try {
|
||||
request = buildModelProfileUpdateRequest(plan.profile_id, {
|
||||
...plan.patch,
|
||||
expected_revision: plan.expected_revision,
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
...plan.patch,
|
||||
expected_revision: plan.expected_revision,
|
||||
...(apiKey === undefined ? {} : { api_key: apiKey }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ModelProfileInputError) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
@@ -137,8 +137,8 @@ describe("package matrix harness", () => {
|
||||
};
|
||||
expect(document.state).toBe("verified");
|
||||
const results = document.commands.stopped_destructive.results;
|
||||
// 13 destructive commands, each unknown/runtime_not_running with exit 3.
|
||||
expect(Object.keys(results)).toHaveLength(13);
|
||||
// 11 destructive commands, each unknown/runtime_not_running with exit 3.
|
||||
expect(Object.keys(results)).toHaveLength(11);
|
||||
for (const [name, result] of Object.entries(results)) {
|
||||
expect(result, `${name} did not fail closed`).toEqual({
|
||||
exit: 3,
|
||||
@@ -162,7 +162,7 @@ describe("package matrix harness", () => {
|
||||
const shim = [
|
||||
"#!/usr/bin/env node",
|
||||
"const a=process.argv.slice(2);",
|
||||
"if(['calls','import-file','notes','permission','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
|
||||
"if(['calls','import-file','notes','extraction','graph','models'].includes(a[0])||(a[0]==='processing'&&a[1]==='extract')){",
|
||||
' process.stdout.write(\'{"schema_version":1,"product":"memhub","state":"committed","operation_id":"x","receipt":{}}\\n\');',
|
||||
" process.exit(0);",
|
||||
"}",
|
||||
@@ -188,7 +188,7 @@ describe("package matrix harness", () => {
|
||||
expect(failure?.state).toBe("failed");
|
||||
expect(
|
||||
failure?.failures.filter((item) => item.includes("did not fail closed")),
|
||||
).toHaveLength(13);
|
||||
).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("can require an expected bundle SHA and reports bundle metadata", {
|
||||
@@ -280,9 +280,8 @@ describe("package matrix harness", () => {
|
||||
});
|
||||
|
||||
it("rejects a catalog that declares the parent command but not the new verb", async () => {
|
||||
// A bundle can still declare `permission`/`models` while missing
|
||||
// `permission allow` or `models activate`. Names alone would pass it; the
|
||||
// usage marker must not.
|
||||
// A bundle can still declare `models` while missing `models activate`.
|
||||
// Names alone would pass it; the usage marker must not.
|
||||
const declared = [
|
||||
"install",
|
||||
"init",
|
||||
@@ -292,7 +291,6 @@ describe("package matrix harness", () => {
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"permission",
|
||||
"extraction",
|
||||
"graph",
|
||||
"models",
|
||||
@@ -342,9 +340,6 @@ describe("package matrix harness", () => {
|
||||
},
|
||||
};
|
||||
const failures = harness.validateCatalog(catalog);
|
||||
expect(failures).toContain(
|
||||
'command "permission" is missing usage "memhub permission allow"',
|
||||
);
|
||||
expect(failures).toContain(
|
||||
'command "models" is missing usage "memhub models activate"',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -151,9 +151,6 @@ describe("memhub short-command contract", () => {
|
||||
"memhub import-file discard",
|
||||
"memhub notes add",
|
||||
"memhub notes process",
|
||||
"memhub permission status",
|
||||
"memhub permission allow",
|
||||
"memhub permission deny",
|
||||
"memhub models setup",
|
||||
"memhub models activate",
|
||||
"memhub models edit",
|
||||
@@ -163,6 +160,9 @@ describe("memhub short-command contract", () => {
|
||||
]) {
|
||||
expect(flattened).toContain(shortForm);
|
||||
}
|
||||
expect(COMMANDS.some((command) => command.name === "permission")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ships a memhub bin entry pointing at a real bundle path", () => {
|
||||
|
||||
@@ -103,26 +103,45 @@ describe("unknown flags are rejected", () => {
|
||||
});
|
||||
|
||||
it("still accepts the documented verbless scan", () => {
|
||||
expect(parseProductCommand("scan", ["--all", "--yes"])?.plan.all).toBe(true);
|
||||
expect(parseProductCommand("scan", ["--source", "codex"])?.plan.sourceId).toBe(
|
||||
"codex",
|
||||
expect(parseProductCommand("scan", ["--all", "--yes"])?.plan.all).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
parseProductCommand("scan", ["--source", "codex"])?.plan.sourceId,
|
||||
).toBe("codex");
|
||||
});
|
||||
|
||||
it("rejects flags that the named verb does not consume", () => {
|
||||
// These were accepted and then silently dropped. An operator who typed
|
||||
// `path remove --source codex` would believe the source filter applied.
|
||||
expect(
|
||||
parseProductCommand("path", ["remove", "--root", "/tmp/x", "--source", "codex"]),
|
||||
parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--source",
|
||||
"codex",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", ["remove", "--root", "/tmp/x", "--no-recursive"]),
|
||||
parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/x",
|
||||
"--no-recursive",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("path", ["add", "--root", "/tmp/x", "--full"]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", ["create", "--target", "/tmp/b", "--source", "/tmp/s"]),
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/b",
|
||||
"--source",
|
||||
"/tmp/s",
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", [
|
||||
@@ -163,9 +182,7 @@ describe("unknown flags are rejected", () => {
|
||||
});
|
||||
|
||||
it("rejects repeated boolean flags instead of treating them as idempotent", () => {
|
||||
expect(
|
||||
parseProductCommand("scan", ["--all", "--all", "--yes"]),
|
||||
).toBeNull();
|
||||
expect(parseProductCommand("scan", ["--all", "--all", "--yes"])).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("scan", ["--full", "--full", "--yes"]),
|
||||
).toBeNull();
|
||||
@@ -179,7 +196,13 @@ describe("unknown flags are rejected", () => {
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseProductCommand("backup", ["create", "--target", "/tmp/a", "--yes", "--yes"]),
|
||||
parseProductCommand("backup", [
|
||||
"create",
|
||||
"--target",
|
||||
"/tmp/a",
|
||||
"--yes",
|
||||
"--yes",
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -215,12 +238,12 @@ describe("unknown flags are rejected", () => {
|
||||
]);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.confirmPath).toBe("/tmp/backups/a.sqlite3");
|
||||
expect(restoreConfirmationMatches("/tmp/backups/a.sqlite3", parsed?.confirmPath)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
restoreConfirmationMatches("/tmp/backups/a.sqlite3", parsed?.confirmPath),
|
||||
).toBe(true);
|
||||
expect(
|
||||
restoreConfirmationMatches("/tmp/backups/a.sqlite3", undefined),
|
||||
).toBe(false);
|
||||
expect(
|
||||
restoreConfirmationMatches(
|
||||
"/tmp/backups/a.sqlite3",
|
||||
@@ -365,7 +388,11 @@ describe("path add and remove carry what the endpoint needs", () => {
|
||||
});
|
||||
|
||||
it("distinguishes remove from add", () => {
|
||||
const parsed = parseProductCommand("path", ["remove", "--root", "/tmp/scanme"]);
|
||||
const parsed = parseProductCommand("path", [
|
||||
"remove",
|
||||
"--root",
|
||||
"/tmp/scanme",
|
||||
]);
|
||||
if (parsed === null) {
|
||||
throw new Error("path remove must parse");
|
||||
}
|
||||
@@ -375,7 +402,12 @@ describe("path add and remove carry what the endpoint needs", () => {
|
||||
});
|
||||
|
||||
it("does not silently drop --yes", () => {
|
||||
const parsed = parseProductCommand("path", ["add", "--root", "/tmp/a", "--yes"]);
|
||||
const parsed = parseProductCommand("path", [
|
||||
"add",
|
||||
"--root",
|
||||
"/tmp/a",
|
||||
"--yes",
|
||||
]);
|
||||
expect(parsed?.confirmed).toBe(true);
|
||||
// Without this, a typo'd `--yes` would fall through to a plan and the user
|
||||
// would believe the change had been made.
|
||||
|
||||
@@ -6,10 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** The app root (one level above this test's `src/`). */
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bundle = path.join(root, "dist", "one-memory.cjs");
|
||||
|
||||
// The published npm package must be self-contained. The bundle inlines
|
||||
@@ -20,9 +17,10 @@ const bundle = path.join(root, "dist", "one-memory.cjs");
|
||||
describe("publish manifest is production-safe", () => {
|
||||
it("declares no npm runtime dependency once dependencies are inlined", () => {
|
||||
// `test` builds with tsup first, so a missing bundle is a real failure.
|
||||
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
existsSync(bundle),
|
||||
"run `pnpm --filter @one-memory/cli build`",
|
||||
).toBe(true);
|
||||
const source = readFileSync(bundle, "utf8");
|
||||
const external = [
|
||||
...new Set(
|
||||
@@ -37,16 +35,15 @@ describe("publish manifest is production-safe", () => {
|
||||
),
|
||||
];
|
||||
// Every one of these must be a Node builtin, i.e. no npm package.
|
||||
const npmDeps = external.filter(
|
||||
(name) => !builtinModules.includes(name),
|
||||
);
|
||||
const npmDeps = external.filter((name) => !builtinModules.includes(name));
|
||||
expect(npmDeps).toEqual([]);
|
||||
});
|
||||
|
||||
it("strip-out rule keeps only what the bundle requires", () => {
|
||||
expect(existsSync(bundle), "run `pnpm --filter @one-memory/cli build`").toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
existsSync(bundle),
|
||||
"run `pnpm --filter @one-memory/cli build`",
|
||||
).toBe(true);
|
||||
// Run the real generator, then inspect its output manifest.
|
||||
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
|
||||
cwd: root,
|
||||
|
||||
@@ -840,6 +840,7 @@ 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/v1/model-content-permission")).toBe(false);
|
||||
expect(
|
||||
isReadOnlyPath(
|
||||
"/api/v1/memory-mutations/123e4567-e89b-12d3-a456-426614174000",
|
||||
|
||||
@@ -49,9 +49,9 @@ describe("runtime service lock guard", () => {
|
||||
JSON.stringify({ pid: 4321 }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await expect(
|
||||
inspectRuntimeServiceLock(dataDir, () => false),
|
||||
).resolves.toBe("clear");
|
||||
await expect(inspectRuntimeServiceLock(dataDir, () => false)).resolves.toBe(
|
||||
"clear",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on a malformed lease", async () => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type CommandResult,
|
||||
inspectLaunchdService,
|
||||
restartManagedService,
|
||||
type ServiceCommandRunner,
|
||||
startManagedService,
|
||||
stopManagedService,
|
||||
type CommandResult,
|
||||
type ServiceCommandRunner,
|
||||
} from "./service-session.js";
|
||||
|
||||
const SERVICE_ID = "com.tencent.onememory.runtime";
|
||||
@@ -35,7 +35,11 @@ describe("service-aware launchd session", () => {
|
||||
it("reports running with the launchd pid and plist", async () => {
|
||||
const run = vi.fn<ServiceCommandRunner>(async () => running(84237));
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
state: "running",
|
||||
pid: 84237,
|
||||
@@ -46,7 +50,11 @@ describe("service-aware launchd session", () => {
|
||||
it("treats 'could not find service' as absent", async () => {
|
||||
const run = vi.fn<ServiceCommandRunner>(async () => absent());
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({ state: "absent" });
|
||||
});
|
||||
|
||||
@@ -57,7 +65,11 @@ describe("service-aware launchd session", () => {
|
||||
stderr: "Input/output error",
|
||||
}));
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({ state: "unknown", code: "launchd_inspection_failed" });
|
||||
});
|
||||
|
||||
@@ -146,13 +158,21 @@ describe("service-aware launchd session", () => {
|
||||
if (command[1] === "print") {
|
||||
printed += 1;
|
||||
return printed === 1
|
||||
? { code: 0, stdout: `path = /tmp/x.plist\nstate = not running\n`, stderr: "" }
|
||||
? {
|
||||
code: 0,
|
||||
stdout: `path = /tmp/x.plist\nstate = not running\n`,
|
||||
stderr: "",
|
||||
}
|
||||
: running(84237);
|
||||
}
|
||||
return running(84237);
|
||||
});
|
||||
await expect(
|
||||
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
startManagedService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toBe("started");
|
||||
expect(calls).toContainEqual([
|
||||
"launchctl",
|
||||
@@ -169,7 +189,11 @@ describe("service-aware launchd session", () => {
|
||||
return running(84237);
|
||||
});
|
||||
await expect(
|
||||
startManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
startManagedService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toBe("started");
|
||||
// Already running -> no kickstart needed.
|
||||
expect(calls.some((c) => c[1] === "kickstart")).toBe(false);
|
||||
@@ -214,7 +238,11 @@ describe("service-aware launchd session", () => {
|
||||
stderr: "",
|
||||
}));
|
||||
await expect(
|
||||
inspectLaunchdService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
inspectLaunchdService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toEqual({ state: "loaded", plistPath: "/tmp/x.plist" });
|
||||
});
|
||||
|
||||
@@ -225,7 +253,11 @@ describe("service-aware launchd session", () => {
|
||||
return running(101);
|
||||
});
|
||||
await expect(
|
||||
restartManagedService({ serviceId: SERVICE_ID, uid: UID, runCaptured: run }),
|
||||
restartManagedService({
|
||||
serviceId: SERVICE_ID,
|
||||
uid: UID,
|
||||
runCaptured: run,
|
||||
}),
|
||||
).resolves.toBe("restarted");
|
||||
expect(calls).toContainEqual([
|
||||
"launchctl",
|
||||
@@ -286,7 +318,9 @@ describe("service-aware launchd session", () => {
|
||||
stdout: JSON.stringify({
|
||||
Label: SERVICE_ID,
|
||||
ProgramArguments: ["/tmp/node", "/tmp/runtime.cjs", "serve"],
|
||||
EnvironmentVariables: { ONE_MEMORY_DATA_DIR: "/tmp/this-install/data" },
|
||||
EnvironmentVariables: {
|
||||
ONE_MEMORY_DATA_DIR: "/tmp/this-install/data",
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
@@ -49,7 +49,10 @@ function launchdDomain(uid: number): string {
|
||||
/** Deterministic location of the managed LaunchAgent plist. Needed because
|
||||
* after `bootout` launchd no longer reports a path, yet `start` must be able to
|
||||
* bootstrap the very same service back. */
|
||||
export function defaultLaunchAgentPath(home: string, serviceId: string): string {
|
||||
export function defaultLaunchAgentPath(
|
||||
home: string,
|
||||
serviceId: string,
|
||||
): string {
|
||||
return path.posix.join(home, "Library", "LaunchAgents", `${serviceId}.plist`);
|
||||
}
|
||||
|
||||
@@ -162,7 +165,10 @@ export async function stopManagedService(options: {
|
||||
? (["launchctl", "bootout", domain, before.plistPath] as const)
|
||||
: (["launchctl", "bootout", `${domain}/${options.serviceId}`] as const);
|
||||
const result = await options.runCaptured([...target]);
|
||||
if (result.code !== 0 && !/could not find service|service not found/i.test(result.stderr)) {
|
||||
if (
|
||||
result.code !== 0 &&
|
||||
!/could not find service|service not found/i.test(result.stderr)
|
||||
) {
|
||||
throw new Error("launchd_bootout_failed");
|
||||
}
|
||||
// "Stopped" must mean the label stays gone, not that we saw it gone once.
|
||||
@@ -295,9 +301,7 @@ export async function runLaunchctlCaptured(
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.once("error", () => reject(new Error("launchd_command_failed")));
|
||||
child.once("close", (code) =>
|
||||
resolve({ code: code ?? 1, stdout, stderr }),
|
||||
);
|
||||
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyShellPath, planShellPath, removeShellPath } from "./shell-path.js";
|
||||
import {
|
||||
applyShellPath,
|
||||
planShellPath,
|
||||
removeShellPath,
|
||||
} from "./shell-path.js";
|
||||
|
||||
async function home(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "memhub-shell-path-"));
|
||||
|
||||
@@ -72,9 +72,7 @@ function blockFor(binDirectory: string): string {
|
||||
* original file byte-for-byte regardless of the original's trailing newline. */
|
||||
function ownedBlock(existing: string | null, binDirectory: string): string {
|
||||
const separator =
|
||||
existing === null || existing === "" || existing.endsWith("\n")
|
||||
? ""
|
||||
: "\n";
|
||||
existing === null || existing === "" || existing.endsWith("\n") ? "" : "\n";
|
||||
return `${separator}${blockFor(binDirectory)}`;
|
||||
}
|
||||
|
||||
@@ -104,10 +102,7 @@ export async function planShellPath(options: {
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
const blocked = (
|
||||
reason: string,
|
||||
target: string,
|
||||
): ShellPathPlan => ({
|
||||
const blocked = (reason: string, target: string): ShellPathPlan => ({
|
||||
schema_version: 1,
|
||||
status: "blocked",
|
||||
blocked_reason: reason,
|
||||
@@ -229,11 +224,12 @@ async function writeProfileAtomically(
|
||||
// A fresh `--home` may not exist yet; create it (owner-only) before writing
|
||||
// the temp profile, otherwise the write fails with a raw ENOENT. Surface a
|
||||
// structured code for any other failure.
|
||||
await mkdir(path.dirname(profilePath), { recursive: true, mode: 0o700 }).catch(
|
||||
() => {
|
||||
throw new Error("shell_path_home_unwritable");
|
||||
},
|
||||
);
|
||||
await mkdir(path.dirname(profilePath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
}).catch(() => {
|
||||
throw new Error("shell_path_home_unwritable");
|
||||
});
|
||||
const temporary = `${profilePath}.memhub.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, content, { encoding: "utf8", mode: 0o644 });
|
||||
|
||||
@@ -468,7 +468,9 @@ describe("CLI-19 progress and issue reads", () => {
|
||||
);
|
||||
expect(output).toContain("[15:04:05.067] progress #1");
|
||||
expect(output).toContain("[15:04:05.067] progress #2");
|
||||
expect(output.match(/\x1b\[2J\x1b\[H/g)).toHaveLength(2);
|
||||
expect(output.match(new RegExp("\\x1b\\[2J\\x1b\\[H", "g"))).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-terminal watch output append-only", async () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreReader, ReadOutcome } from "./core-client.js";
|
||||
import type {
|
||||
CoreOperations,
|
||||
OperationOutcome,
|
||||
} from "./core-operations.js";
|
||||
import type { CoreOperations, OperationOutcome } from "./core-operations.js";
|
||||
import {
|
||||
renderSourceSyncText,
|
||||
setSourceSync,
|
||||
@@ -320,4 +317,3 @@ describe("setSourceSync", () => {
|
||||
expect(renderSourceSyncText(invalid)).toBe("未变更:invalid_source_id\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
SourceSyncInputError,
|
||||
buildSourceSyncRequest,
|
||||
hasSourceSyncTarget,
|
||||
SourceSyncInputError,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreReader } from "./core-client.js";
|
||||
import type { CoreOperations } from "./core-operations.js";
|
||||
@@ -36,7 +36,10 @@ async function current(
|
||||
reader: CoreReader,
|
||||
sourceId: string,
|
||||
): Promise<
|
||||
| { state: "ok"; source: NonNullable<ReturnType<typeof validateSources>>[number] }
|
||||
| {
|
||||
state: "ok";
|
||||
source: NonNullable<ReturnType<typeof validateSources>>[number];
|
||||
}
|
||||
| { state: "invalid" | "unknown"; code: string }
|
||||
> {
|
||||
const outcome = await reader<unknown>("/api/v1/sources");
|
||||
|
||||
@@ -30,6 +30,8 @@ describe("status default output", () => {
|
||||
expect(text).not.toContain("[object Object]");
|
||||
expect(text).toContain("卡住 类型 整理对话/原因 等待模型/数量 2");
|
||||
expect(text).not.toMatch(/background_verified|additions_only|error_codes=/);
|
||||
expect(text).not.toMatch(/provider_kind|has_api_key|credential_state|observed_at/);
|
||||
expect(text).not.toMatch(
|
||||
/provider_kind|has_api_key|credential_state|observed_at/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -85,12 +85,16 @@ describe("memhub ui command wiring", () => {
|
||||
const output = capture();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], {}, {
|
||||
openBrowser: async (url) => {
|
||||
opened.push(url);
|
||||
return true;
|
||||
code = await run(
|
||||
["ui", "--json"],
|
||||
{},
|
||||
{
|
||||
openBrowser: async (url) => {
|
||||
opened.push(url);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
@@ -102,7 +106,9 @@ describe("memhub ui command wiring", () => {
|
||||
state: "opened",
|
||||
url: "http://127.0.0.1:8765/ui/login?code=one-time-code",
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:8765/ui/login?code=one-time-code"]);
|
||||
expect(opened).toEqual([
|
||||
"http://127.0.0.1:8765/ui/login?code=one-time-code",
|
||||
]);
|
||||
// The bearer token was sent to Core in the request header, never in output.
|
||||
expect(output.value()).not.toMatch(/synthetic-runtime-token/);
|
||||
});
|
||||
@@ -159,7 +165,11 @@ describe("memhub ui command wiring", () => {
|
||||
const output = capture();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], {}, { openBrowser: async () => false });
|
||||
code = await run(
|
||||
["ui", "--json"],
|
||||
{},
|
||||
{ openBrowser: async () => false },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
@@ -185,7 +195,11 @@ describe("memhub ui command wiring", () => {
|
||||
const output = capture();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], {}, { openBrowser: async () => true });
|
||||
code = await run(
|
||||
["ui", "--json"],
|
||||
{},
|
||||
{ openBrowser: async () => true },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { parseUiSessionExchange } from "./ui-session.js";
|
||||
describe("ui session exchange over HTTP", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
const seen: Array<{ authorization?: string; method?: string; url?: string }> = [];
|
||||
const seen: Array<{ authorization?: string; method?: string; url?: string }> =
|
||||
[];
|
||||
let status = 200;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
@@ -45,11 +45,31 @@ describe("memhub ui session wiring", () => {
|
||||
for (const doc of [
|
||||
null,
|
||||
{},
|
||||
{ schema_version: 2, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
|
||||
{
|
||||
schema_version: 2,
|
||||
exchange_code: "x",
|
||||
expires_in_seconds: 60,
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
},
|
||||
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60 },
|
||||
{ schema_version: 1, exchange_code: "", expires_in_seconds: 60, url: "http://127.0.0.1:1/ui/login?code=x" },
|
||||
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 0, url: "http://127.0.0.1:1/ui/login?code=x" },
|
||||
{ schema_version: 1, exchange_code: "x", expires_in_seconds: 60, url: "http://127.0.0.1:1/" },
|
||||
{
|
||||
schema_version: 1,
|
||||
exchange_code: "",
|
||||
expires_in_seconds: 60,
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
},
|
||||
{
|
||||
schema_version: 1,
|
||||
exchange_code: "x",
|
||||
expires_in_seconds: 0,
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
},
|
||||
{
|
||||
schema_version: 1,
|
||||
exchange_code: "x",
|
||||
expires_in_seconds: 60,
|
||||
url: "http://127.0.0.1:1/",
|
||||
},
|
||||
]) {
|
||||
expect(parseUiSessionExchange(doc)).toBeNull();
|
||||
}
|
||||
@@ -59,9 +79,18 @@ describe("memhub ui session wiring", () => {
|
||||
expect(uiSessionFailure("ui_web_assets_unavailable")).toMatchObject({
|
||||
code: "ui_web_assets_unavailable",
|
||||
});
|
||||
expect(uiExitCode({ state: "opened", url: "http://127.0.0.1:1/ui/login?code=x" })).toBe(0);
|
||||
expect(uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" })).toBe(4);
|
||||
expect(uiExitCode({ state: "failed", code: "ui_session_code_invalid" })).toBe(1);
|
||||
expect(
|
||||
uiExitCode({
|
||||
state: "opened",
|
||||
url: "http://127.0.0.1:1/ui/login?code=x",
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
uiExitCode({ state: "blocked", code: "ui_session_contract_unavailable" }),
|
||||
).toBe(4);
|
||||
expect(
|
||||
uiExitCode({ state: "failed", code: "ui_session_code_invalid" }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a platform-appropriate opener and reports missing openers", async () => {
|
||||
|
||||
@@ -42,7 +42,12 @@ function isLoopbackHttpUrl(value: string): boolean {
|
||||
}
|
||||
if (parsed.protocol !== "http:") return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
||||
return (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "[::1]" ||
|
||||
host === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate the Core exchange document; return null when it does not match the
|
||||
|
||||
@@ -80,7 +80,13 @@ describe("calls clear", () => {
|
||||
});
|
||||
|
||||
it("previews without contacting Core", async () => {
|
||||
const result = await capturedRun(["calls", "clear", "--event", EVENT, "--json"]);
|
||||
const result = await capturedRun([
|
||||
"calls",
|
||||
"clear",
|
||||
"--event",
|
||||
EVENT,
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const document = JSON.parse(result.stdout);
|
||||
expect(document.state).toBe("preview");
|
||||
@@ -341,7 +347,10 @@ describe("notes / extraction / graph / manual extract", () => {
|
||||
"--json",
|
||||
]);
|
||||
expect(applied.code).toBe(0);
|
||||
const body = transport.post.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
const body = transport.post.mock.calls.at(-1)?.[1] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(body.text).toBe("记住这个决定");
|
||||
expect(body.scope).toBe("all-apps");
|
||||
expect(JSON.parse(applied.stdout).state).toBe("committed");
|
||||
|
||||
@@ -10,18 +10,17 @@
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
MemoryNoteInputError,
|
||||
buildMemoryNoteSaveRequest,
|
||||
parseMemoryNoteReceipt,
|
||||
MemoryNoteInputError,
|
||||
parseExtractionSettingsReceipt,
|
||||
parseGraphWidthDecisionReceipt,
|
||||
parseManualExtractReceipt,
|
||||
parseMemoryNoteReceipt,
|
||||
} from "@one-memory/operations";
|
||||
import type { CoreOperations, CorePutOperations } from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const NOTE_MAX_CHARACTERS = 12_000;
|
||||
const EXTRACTION_PRESETS = ["3m", "6m", "1y", "all"] as const;
|
||||
@@ -117,7 +116,10 @@ function digestOf(value: unknown): string {
|
||||
type Draft =
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_add" }>, "digest">
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "memory_note_process" }>, "digest">
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>, "digest">
|
||||
| Omit<
|
||||
Extract<ExtendedWritePlan, { kind: "extraction_window_set" }>,
|
||||
"digest"
|
||||
>
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "graph_width_decide" }>, "digest">
|
||||
| Omit<Extract<ExtendedWritePlan, { kind: "manual_extract" }>, "digest">;
|
||||
|
||||
@@ -148,7 +150,10 @@ function committed(
|
||||
};
|
||||
}
|
||||
|
||||
function rejected(operationId: string | undefined, code: string): ExtendedWriteOutcome {
|
||||
function rejected(
|
||||
operationId: string | undefined,
|
||||
code: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
@@ -158,7 +163,10 @@ function rejected(operationId: string | undefined, code: string): ExtendedWriteO
|
||||
};
|
||||
}
|
||||
|
||||
function unknown(operationId: string | undefined, code: string): ExtendedWriteOutcome {
|
||||
function unknown(
|
||||
operationId: string | undefined,
|
||||
code: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
@@ -197,7 +205,9 @@ export function validNoteText(text: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function planMemoryNoteProcess(evidenceId: string): ExtendedWriteOutcome {
|
||||
export function planMemoryNoteProcess(
|
||||
evidenceId: string,
|
||||
): ExtendedWriteOutcome {
|
||||
return UUID.test(evidenceId)
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
@@ -313,8 +323,7 @@ export function planGraphWidthDecide(decision: string): ExtendedWriteOutcome {
|
||||
|
||||
export function planManualExtract(evidenceIds: string[]): ExtendedWriteOutcome {
|
||||
const ids = [...new Set(evidenceIds)];
|
||||
return ids.length === evidenceIds.length &&
|
||||
ids.every((id) => UUID.test(id))
|
||||
return ids.length === evidenceIds.length && ids.every((id) => UUID.test(id))
|
||||
? preview({
|
||||
schema_version: 1,
|
||||
kind: "manual_extract",
|
||||
@@ -455,12 +464,12 @@ export function validateExtendedWritePlan(
|
||||
}
|
||||
|
||||
export function extendedWriteExitCode(document: ExtendedWriteOutcome): number {
|
||||
return ["preview", "committed", "recovered"].includes(document.state)
|
||||
? 0
|
||||
: 1;
|
||||
return ["preview", "committed", "recovered"].includes(document.state) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function renderExtendedWriteText(document: ExtendedWriteOutcome): string {
|
||||
export function renderExtendedWriteText(
|
||||
document: ExtendedWriteOutcome,
|
||||
): string {
|
||||
if (document.state === "preview") {
|
||||
const lines = ["以下操作尚未执行;加 --confirm 后才会写入:"];
|
||||
const plan = document.plan;
|
||||
|
||||
@@ -61,10 +61,9 @@ describe("UI write execution", () => {
|
||||
}));
|
||||
const plan = planOf(planCallEventsClear([EVENT]));
|
||||
if (plan.kind !== "call_events_clear") throw new Error("kind");
|
||||
const result = await executeCallEventsClear(
|
||||
plan,
|
||||
{ post } as unknown as CoreOperations,
|
||||
);
|
||||
const result = await executeCallEventsClear(plan, {
|
||||
post,
|
||||
} as unknown as CoreOperations);
|
||||
expect(result.state).toBe("committed");
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear", {
|
||||
event_ids: [EVENT],
|
||||
@@ -79,10 +78,9 @@ describe("UI write execution", () => {
|
||||
}));
|
||||
const plan = planOf(planCallEventsClearAll());
|
||||
if (plan.kind !== "call_events_clear_all") throw new Error("kind");
|
||||
await executeCallEventsClearAll(
|
||||
plan,
|
||||
{ post } as unknown as CoreOperations,
|
||||
);
|
||||
await executeCallEventsClearAll(plan, {
|
||||
post,
|
||||
} as unknown as CoreOperations);
|
||||
expect(post).toHaveBeenCalledWith("/api/v1/call-events/clear-all", {
|
||||
user_confirmed: true,
|
||||
});
|
||||
@@ -107,7 +105,7 @@ describe("UI write execution", () => {
|
||||
committed: true,
|
||||
},
|
||||
}));
|
||||
const del = vi.fn(async <T,>() => ({
|
||||
const del = vi.fn(async <T>() => ({
|
||||
state: "committed" as const,
|
||||
data: {} as T,
|
||||
}));
|
||||
@@ -118,10 +116,9 @@ describe("UI write execution", () => {
|
||||
const commit = planOf(planImportFileCommit(UPLOAD));
|
||||
if (commit.kind !== "import_file_commit") throw new Error("kind");
|
||||
await executeImportFileCommit(commit, operations);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
`/api/v1/import-files/${UPLOAD}/commit`,
|
||||
{ confirmed: true },
|
||||
);
|
||||
expect(post).toHaveBeenCalledWith(`/api/v1/import-files/${UPLOAD}/commit`, {
|
||||
confirmed: true,
|
||||
});
|
||||
const discard = planOf(planImportFileDiscard(UPLOAD));
|
||||
if (discard.kind !== "import_file_discard") throw new Error("kind");
|
||||
expect((await executeImportFileDiscard(discard, operations)).state).toBe(
|
||||
@@ -133,15 +130,12 @@ describe("UI write execution", () => {
|
||||
it("treats a malformed success receipt as unknown", async () => {
|
||||
const plan = planOf(planCallEventsClear([EVENT]));
|
||||
if (plan.kind !== "call_events_clear") throw new Error("kind");
|
||||
const result = await executeCallEventsClear(
|
||||
plan,
|
||||
{
|
||||
post: vi.fn(async () => ({
|
||||
state: "committed" as const,
|
||||
data: { wrong: true },
|
||||
})),
|
||||
} as unknown as CoreOperations,
|
||||
);
|
||||
const result = await executeCallEventsClear(plan, {
|
||||
post: vi.fn(async () => ({
|
||||
state: "committed" as const,
|
||||
data: { wrong: true },
|
||||
})),
|
||||
} as unknown as CoreOperations);
|
||||
expect(result).toMatchObject({
|
||||
state: "unknown",
|
||||
code: "ui_write_receipt_invalid",
|
||||
|
||||
@@ -14,8 +14,7 @@ import type {
|
||||
} from "./core-operations.js";
|
||||
import { PRODUCT } from "./version.js";
|
||||
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const INPUT_KINDS = ["raw_conversation", "memory_content"] as const;
|
||||
const MAX_UPLOAD_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
@@ -139,7 +138,11 @@ export function planCallEventsClear(
|
||||
) {
|
||||
return invalid("call_event_ids_invalid");
|
||||
}
|
||||
return preview({ schema_version: 1, kind: "call_events_clear", event_ids: [...eventIds] });
|
||||
return preview({
|
||||
schema_version: 1,
|
||||
kind: "call_events_clear",
|
||||
event_ids: [...eventIds],
|
||||
});
|
||||
}
|
||||
|
||||
export function planCallEventsClearAll(): UiWriteOutcome {
|
||||
@@ -224,11 +227,7 @@ export async function readImportFileUpload(
|
||||
}
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (
|
||||
!stats.isFile() ||
|
||||
stats.nlink !== 1 ||
|
||||
stats.size > MAX_UPLOAD_BYTES
|
||||
) {
|
||||
if (!stats.isFile() || stats.nlink !== 1 || stats.size > MAX_UPLOAD_BYTES) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.alloc(Number(stats.size));
|
||||
@@ -273,9 +272,7 @@ function fileUploadOperationId(
|
||||
checksum: string,
|
||||
): string {
|
||||
const hex = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([filename, inputKind, checksum]),
|
||||
)
|
||||
.update(JSON.stringify([filename, inputKind, checksum]))
|
||||
.digest("hex");
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
@@ -325,8 +322,7 @@ export function validateUiWritePlan(value: unknown): UiWritePlan | null {
|
||||
UUID.test(candidate.operation_id) &&
|
||||
typeof candidate.path === "string" &&
|
||||
typeof candidate.filename === "string" &&
|
||||
candidate.filename ===
|
||||
candidate.path.split(/[/\\]/).pop() &&
|
||||
candidate.filename === candidate.path.split(/[/\\]/).pop() &&
|
||||
/^[0-9a-f]{64}$/.test(String(candidate.sha256)) &&
|
||||
INPUT_KINDS.includes(
|
||||
candidate.input_kind as (typeof INPUT_KINDS)[number],
|
||||
@@ -364,9 +360,7 @@ export async function executeCallEventsClear(
|
||||
event_ids: plan.event_ids,
|
||||
user_confirmed: true,
|
||||
});
|
||||
return outcome(result, plan.event_ids.join(","), [
|
||||
"removed",
|
||||
]);
|
||||
return outcome(result, plan.event_ids.join(","), ["removed"]);
|
||||
}
|
||||
|
||||
export async function executeCallEventsClearAll(
|
||||
@@ -543,7 +537,9 @@ function outcome(
|
||||
return committed(operationId, {});
|
||||
}
|
||||
const data =
|
||||
result.data !== null && typeof result.data === "object" && !Array.isArray(result.data)
|
||||
result.data !== null &&
|
||||
typeof result.data === "object" &&
|
||||
!Array.isArray(result.data)
|
||||
? (result.data as Record<string, unknown>)
|
||||
: null;
|
||||
if (data === null || required.some((key) => !Object.hasOwn(data, key))) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { createRequire } from "node:module";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveBuildVersion } = require("../../scripts/release-version.cjs") as {
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const { resolveBuildVersion } =
|
||||
require("../../scripts/release-version.cjs") as {
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
|
||||
export default defineConfig({
|
||||
@@ -18,7 +19,9 @@ export default defineConfig({
|
||||
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
|
||||
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
|
||||
),
|
||||
__ONE_MEMORY_COMMIT__: JSON.stringify(process.env.ONE_MEMORY_COMMIT ?? "unknown"),
|
||||
__ONE_MEMORY_COMMIT__: JSON.stringify(
|
||||
process.env.ONE_MEMORY_COMMIT ?? "unknown",
|
||||
),
|
||||
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
|
||||
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
|
||||
),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const version = JSON.parse(readFileSync(new URL("package.json", import.meta.url), "utf8"))
|
||||
.version as string;
|
||||
const version = JSON.parse(
|
||||
readFileSync(new URL("package.json", import.meta.url), "utf8"),
|
||||
).version as string;
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
|
||||
@@ -11,7 +11,7 @@ folder_layout = layout == 'folder'
|
||||
datas = [('src/one_memory/embedding_assets', 'one_memory/embedding_assets')]
|
||||
binaries = []
|
||||
# Loaded lazily by the API; static dependency analysis cannot discover it.
|
||||
hiddenimports = ['one_memory.hy_permission_context']
|
||||
hiddenimports = []
|
||||
tmp_ret = collect_all('hy_memory')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('chromadb')
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "one-memory"
|
||||
version = "1.0.0"
|
||||
version = "1.0.3"
|
||||
description = "MemHub local-first memory core"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"fastapi>=0.141.1",
|
||||
"filelock>=3.32.4",
|
||||
# Public SDK version validated against MemHub's permission and recall contracts.
|
||||
"hy-memory==2.0.1",
|
||||
"hy-memory==2.0.3",
|
||||
# Retrieval's derived vector index directly consumes these locked versions.
|
||||
"chromadb==1.5.9",
|
||||
"numpy==2.5.2",
|
||||
@@ -24,7 +24,7 @@ dependencies = [
|
||||
# transitive version we do not ask for is one an unrelated upgrade could
|
||||
# drop. Deliberately NOT sentence-transformers or torch: those would take
|
||||
# the installer past 2GB, and onnxruntime alone runs the exported model.
|
||||
"onnxruntime>=1.22.0",
|
||||
"onnxruntime==1.23.2",
|
||||
"tokenizers>=0.21.0",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"uvicorn[standard]>=0.52.4",
|
||||
|
||||
@@ -20,7 +20,6 @@ from copy import copy
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
from uuid import UUID
|
||||
@@ -164,7 +163,6 @@ from one_memory.memory_library import (
|
||||
)
|
||||
from one_memory.memory_notes import MemoryNoteStore, memory_note_router
|
||||
from one_memory.memory_processing import (
|
||||
MemoryBridgeProtocol,
|
||||
MemoryProcessingSupervisor,
|
||||
_conversation_chunks,
|
||||
memory_task_timeout_seconds,
|
||||
@@ -178,10 +176,8 @@ from one_memory.memory_queue import (
|
||||
)
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus, MemoryTaskStore
|
||||
from one_memory.model_content_routes import model_content_router
|
||||
from one_memory.model_presets import seed_preset_profile
|
||||
from one_memory.model_probe import ModelProber, ModelProbeResult
|
||||
from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease
|
||||
from one_memory.model_profiles import (
|
||||
ActiveModelProfileError,
|
||||
ModelCreationOperationConflictError,
|
||||
@@ -818,6 +814,13 @@ def create_app(
|
||||
evidence=app.state.evidence_store,
|
||||
)
|
||||
app.state.memory_write_queue = memory_write_queue
|
||||
# Builds through 1.0.2 parked pre-invocation jobs behind the
|
||||
# retired model-content consent bit. The embedded SDK is trusted
|
||||
# now, so make those durable jobs claimable on the first startup.
|
||||
await asyncio.to_thread(
|
||||
memory_write_queue.readmit_parked,
|
||||
states=("waiting_for_permission",),
|
||||
)
|
||||
repaired_identity_failures = await asyncio.to_thread(
|
||||
memory_write_queue.repair_pre_send_identity_failures
|
||||
)
|
||||
@@ -960,7 +963,7 @@ def create_app(
|
||||
# Not fatal: without the tables every read checks exactly, as
|
||||
# it does today.
|
||||
app.state.memory_visibility_error = str(error)
|
||||
app.state.model_request_budget = (
|
||||
model_request_budget = (
|
||||
ModelRequestBudget(
|
||||
database_path,
|
||||
request_limit=runtime_settings.model_request_limit,
|
||||
@@ -970,6 +973,7 @@ def create_app(
|
||||
if runtime_settings.model_request_limit is not None
|
||||
else None
|
||||
)
|
||||
app.state.model_request_budget = model_request_budget
|
||||
app.state.memory_answerer = MemoryAnswerer(
|
||||
governance=app.state.memory_governance_store,
|
||||
filter_memories=app.state.source_read_access.memories,
|
||||
@@ -1008,6 +1012,16 @@ def create_app(
|
||||
governance=app.state.memory_governance_store,
|
||||
data_dir=runtime_settings.data_dir,
|
||||
extraction_settings=app.state.extraction_settings_store,
|
||||
bridge_factory=lambda profile, api_key, data_dir: HyMemoryBridge(
|
||||
profile=profile,
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
http_transport_factory=(
|
||||
(lambda: BudgetedModelTransport(model_request_budget))
|
||||
if model_request_budget is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
# `None` means "whatever the active profile allows". Pinned to
|
||||
# 1 in `a59f4b5` as temporary load-shedding for an acceptance
|
||||
# package, and left pinned after it: a hardcoded 1 is rule 9's
|
||||
@@ -1016,10 +1030,6 @@ def create_app(
|
||||
# profile concurrency 4, recall latency is unchanged while
|
||||
# precipitation goes 4x.
|
||||
max_concurrent_tasks=runtime_settings.precipitation_max_concurrent_tasks,
|
||||
model_content_config=app.state.config_store,
|
||||
permission_bridge_factory=lambda lease, secret, directory: _permission_context(
|
||||
app, lease, secret, directory
|
||||
),
|
||||
source_writes_permitted=lambda source_id: _source_writes_on(app, source_id),
|
||||
service_levels=app.state.service_levels,
|
||||
)
|
||||
@@ -1039,10 +1049,15 @@ def create_app(
|
||||
job.job_id,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
memory_write_queue.retry_timed_out_unknown_after_reconcile,
|
||||
older_than=datetime.now(UTC) - timedelta(minutes=5),
|
||||
memory_write_queue.retry_first_unknown_after_reconcile,
|
||||
older_than=datetime.now(UTC) - timedelta(seconds=10),
|
||||
limit=32,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
app.state.memory_task_store.retry_first_unknown_after_reconcile,
|
||||
older_than=datetime.now(UTC) - timedelta(seconds=10),
|
||||
limit=128,
|
||||
)
|
||||
|
||||
async def admit_hy_invocation(
|
||||
source_id: str,
|
||||
@@ -1134,7 +1149,6 @@ def create_app(
|
||||
app.state.matter_discovery.processor = app.state.memory_processing_supervisor
|
||||
app.state.matter_discovery.write_intake = app.state.memory_write_intake
|
||||
app.state.matter_discovery.write_queue = memory_write_queue
|
||||
app.state.processing_coordinator = None
|
||||
app.state.memory_note_store = MemoryNoteStore(
|
||||
app.state.evidence_store,
|
||||
app.state.memory_processing_supervisor,
|
||||
@@ -1446,7 +1460,6 @@ def create_app(
|
||||
app.include_router(matter_router(protected))
|
||||
app.include_router(memory_note_router(protected))
|
||||
app.include_router(file_import_router(protected))
|
||||
app.include_router(model_content_router(protected))
|
||||
app.include_router(context_router(protected))
|
||||
|
||||
def require_workbuddy_boundary(request: Request) -> None:
|
||||
@@ -1873,6 +1886,20 @@ def create_app(
|
||||
"""Record UI completion only; finishing or skipping is never model consent."""
|
||||
return await asyncio.to_thread(_onboarding_store(app).complete)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/onboarding/reset",
|
||||
response_model=OnboardingState,
|
||||
dependencies=protected,
|
||||
)
|
||||
async def reset_onboarding() -> OnboardingState:
|
||||
"""Go back to the first-run screens. Only the completion flag moves.
|
||||
|
||||
Memories, sources and settings live in other tables and are untouched,
|
||||
which is the point: someone who wants to see the cold-start flow again
|
||||
should not have to delete anything to do it.
|
||||
"""
|
||||
return await asyncio.to_thread(_onboarding_store(app).reset)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/sources",
|
||||
response_model=list[SourceResponse],
|
||||
@@ -2328,7 +2355,7 @@ def create_app(
|
||||
await asyncio.to_thread(
|
||||
app.state.memory_task_store.park_source_for_permission,
|
||||
source_id=source_id,
|
||||
error_code="model_content_permission_required",
|
||||
error_code="hy_memory_source_revoked",
|
||||
)
|
||||
await _live_sync(app).stop_source(source_id)
|
||||
_supervisor(app).unregister_adapter(source_id)
|
||||
@@ -3894,12 +3921,7 @@ async def _authorize_answer_generation(
|
||||
# The explicit question is this operation's input, not a standing permission for history.
|
||||
if not question.strip() or not grounding:
|
||||
raise MemoryAnswerError("memory_answer_generation_not_authorized")
|
||||
if not await asyncio.to_thread(_config_store(app).model_content_allowed):
|
||||
raise MemoryAnswerError("memory_answer_generation_not_authorized")
|
||||
|
||||
def check() -> None:
|
||||
if not _config_store(app).model_content_allowed():
|
||||
raise MemoryAnswerError("memory_answer_generation_not_authorized")
|
||||
if _model_profile_store(app).active_identity() != (
|
||||
profile.profile_id,
|
||||
profile.revision,
|
||||
@@ -3922,30 +3944,6 @@ async def _authorize_answer_generation(
|
||||
await recheck()
|
||||
return recheck
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _permission_context(
|
||||
app: FastAPI,
|
||||
lease: ModelProcessingPermissionLease,
|
||||
secret: str | None,
|
||||
directory: Path,
|
||||
) -> AsyncIterator[MemoryBridgeProtocol]:
|
||||
module = await asyncio.to_thread(import_module, "one_memory.hy_permission_context")
|
||||
budget = cast(ModelRequestBudget | None, app.state.model_request_budget)
|
||||
source_id = lease.records[0].source_id if lease.records else "manual"
|
||||
factory = module.permission_bridge_factory(
|
||||
lambda profile, api_key, data_dir: _memory_processing_supervisor(app)._bridge(
|
||||
profile,
|
||||
api_key,
|
||||
data_dir,
|
||||
scope_key=source_id,
|
||||
),
|
||||
**({"http_transport": BudgetedModelTransport(budget)} if budget else {}),
|
||||
)
|
||||
async with factory(lease, secret, directory) as bridge:
|
||||
yield cast(MemoryBridgeProtocol, bridge)
|
||||
|
||||
|
||||
class _LazyASGIApp:
|
||||
"""Compatibility ASGI app that is built only when a server imports it.
|
||||
|
||||
@@ -4723,8 +4721,6 @@ async def _run_backfill(app: FastAPI) -> None:
|
||||
app.state.memory_backfill_error = None
|
||||
if not app.state.settings.automatic_memory_processing:
|
||||
return
|
||||
if not await asyncio.to_thread(_config_store(app).model_content_allowed):
|
||||
return
|
||||
try:
|
||||
queue = app.state.memory_write_queue
|
||||
intake = app.state.memory_write_intake
|
||||
|
||||
@@ -21,7 +21,6 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from one_memory.diagnostic_log import diagnostic_event, diagnostic_exception, elapsed_ms
|
||||
from one_memory.hy_lifecycle import HyLifecycle, HyLifecycleUnreadable, read_hy_lifecycle
|
||||
from one_memory.hy_permission_store import native_store_read_slot
|
||||
from one_memory.hy_presence import HyPresence, confirm_deleted
|
||||
from one_memory.hy_runtime_config import (
|
||||
HY_MEMORY_MODE,
|
||||
@@ -32,6 +31,7 @@ from one_memory.hy_runtime_config import (
|
||||
diagnostic_log_level,
|
||||
)
|
||||
from one_memory.hy_source_context import HySourceContext, HyWriteBindings
|
||||
from one_memory.hy_store_concurrency import native_store_read_slot
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ProviderKind, attach_disable_thinking
|
||||
from one_memory.semantic_deadline import SemanticMemoryHit
|
||||
|
||||
@@ -376,11 +376,13 @@ class HyMemoryBridge:
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
client: HyMemoryClientProtocol | None = None,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
) -> None:
|
||||
self.profile = profile
|
||||
self.api_key = api_key
|
||||
self.data_dir = data_dir
|
||||
self._client = client
|
||||
self._http_transport_factory = http_transport_factory
|
||||
self._client_lock = asyncio.Lock()
|
||||
self._write_lock = asyncio.Lock()
|
||||
self._last_bindings: HyWriteBindings | None = None
|
||||
@@ -427,6 +429,13 @@ class HyMemoryBridge:
|
||||
)
|
||||
client = await self._get_client()
|
||||
guards = getattr(client, "_one_memory_write_guards", ())
|
||||
from one_memory.hy_response_guard import HyReconcileGuard
|
||||
|
||||
reconcile_guards = tuple(
|
||||
guard for guard in guards if isinstance(guard, HyReconcileGuard)
|
||||
)
|
||||
for guard in reconcile_guards:
|
||||
guard.begin_write()
|
||||
before = [len(guard.failures) for guard in guards]
|
||||
bindings = HyWriteBindings(request.source_context) if request.source_context else None
|
||||
if bindings:
|
||||
@@ -500,6 +509,12 @@ class HyMemoryBridge:
|
||||
if len(guard.failures) > count:
|
||||
raise HyMemoryBridgeError(guard.failures[count])
|
||||
result = await self._result(client, request, raw, expected_contents=None, bindings=bindings)
|
||||
result = await _with_native_mutation_delta(
|
||||
client,
|
||||
result,
|
||||
raw,
|
||||
reconcile_guards,
|
||||
)
|
||||
if bindings:
|
||||
outputs = []
|
||||
for memory in result.memories:
|
||||
@@ -553,6 +568,8 @@ class HyMemoryBridge:
|
||||
from one_memory.hy_response_guard import HyReconcileGuard
|
||||
|
||||
report_guards = tuple(guard for guard in guards if isinstance(guard, HyReconcileGuard))
|
||||
for guard in report_guards:
|
||||
guard.begin_write()
|
||||
expected_contents = frozenset(content.strip() for content in contents if content.strip())
|
||||
for guard in report_guards:
|
||||
guard.imported_contents = expected_contents
|
||||
@@ -599,6 +616,12 @@ class HyMemoryBridge:
|
||||
raw,
|
||||
expected_contents=expected_contents,
|
||||
)
|
||||
result = await _with_native_mutation_delta(
|
||||
client,
|
||||
result,
|
||||
raw,
|
||||
report_guards,
|
||||
)
|
||||
diagnostic_event(
|
||||
level="info",
|
||||
component="hy.bridge",
|
||||
@@ -1087,6 +1110,7 @@ class HyMemoryBridge:
|
||||
self.profile,
|
||||
api_key=self.api_key,
|
||||
data_dir=self.data_dir,
|
||||
http_transport_factory=self._http_transport_factory,
|
||||
)
|
||||
|
||||
await _joined_thread(initialize)
|
||||
@@ -1136,6 +1160,7 @@ def create_hy_memory_client(
|
||||
*,
|
||||
api_key: str | None,
|
||||
data_dir: Path,
|
||||
http_transport_factory: Callable[[], Any] | None = None,
|
||||
) -> HyMemoryClientProtocol:
|
||||
configure_hy_memory_environment(data_dir)
|
||||
config = build_hy_memory_config(
|
||||
@@ -1143,6 +1168,7 @@ def create_hy_memory_client(
|
||||
api_key=api_key,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
config.http_transport_factory = http_transport_factory
|
||||
reattach_legacy_vector_collection(profile, config)
|
||||
mismatch = inspect_graph_width_mismatch(config)
|
||||
if mismatch is not None:
|
||||
@@ -1287,6 +1313,74 @@ async def _coding_result(
|
||||
return HyMemoryIngestResult(success=True, memories=tuple(outputs))
|
||||
|
||||
|
||||
async def _with_native_mutation_delta(
|
||||
client: HyMemoryClientProtocol,
|
||||
result: HyMemoryIngestResult,
|
||||
raw: Mapping[str, object],
|
||||
reconcile_guards: Sequence[Any],
|
||||
) -> HyMemoryIngestResult:
|
||||
"""Map native SDK mutations back into MemHub governance.
|
||||
|
||||
Store permission wrappers used to discover this by intercepting every
|
||||
native CRUD call. The embedded SDK now owns unrestricted local CRUD, so
|
||||
chat targets come from the reconcile observer and coding targets come from
|
||||
the SDK's public result document.
|
||||
"""
|
||||
|
||||
retired = set(result.retired_memory_ids)
|
||||
superseded = {
|
||||
key: tuple(values) for key, values in result.superseded_memory_ids.items()
|
||||
}
|
||||
chat_targets = {
|
||||
key
|
||||
for guard in reconcile_guards
|
||||
for key in getattr(guard, "mutation_target_ids", ())
|
||||
if isinstance(key, str) and key
|
||||
}
|
||||
|
||||
for item in raw.get("ops", ()) if isinstance(raw.get("ops"), list) else ():
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
if item.get("action") == "DELETE":
|
||||
key = item.get("target_experience_id")
|
||||
if isinstance(key, str) and key:
|
||||
retired.add(key)
|
||||
|
||||
for item in raw.get("fact_ops", ()) if isinstance(raw.get("fact_ops"), list) else ():
|
||||
if not isinstance(item, Mapping) or item.get("action") != "SUPERSEDE":
|
||||
continue
|
||||
old, new = item.get("fact_id"), item.get("superseded_by")
|
||||
if isinstance(old, str) and old:
|
||||
retired.add(old)
|
||||
if isinstance(new, str) and new:
|
||||
superseded[old] = (new,)
|
||||
|
||||
for key in chat_targets:
|
||||
record = await _joined_thread(_read_hy_record, client, key)
|
||||
if record is None:
|
||||
retired.add(key)
|
||||
continue
|
||||
status = str(record.get("status", "")).lower()
|
||||
if status in {"deleted", "shadow", "superseded"}:
|
||||
retired.add(key)
|
||||
replacement = record.get("superseded_by")
|
||||
if isinstance(replacement, str) and replacement:
|
||||
superseded[key] = (replacement,)
|
||||
elif isinstance(replacement, list):
|
||||
values = tuple(
|
||||
value for value in replacement if isinstance(value, str) and value
|
||||
)
|
||||
if values:
|
||||
superseded[key] = values
|
||||
|
||||
return result.model_copy(
|
||||
update={
|
||||
"retired_memory_ids": tuple(sorted(retired)),
|
||||
"superseded_memory_ids": superseded,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _read_hy_record(
|
||||
client: HyMemoryClientProtocol,
|
||||
memory_id: str,
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
"""Operation-bound read/write capability for the pinned native HY coding store.
|
||||
|
||||
HY's coding route writes two layers, experiences and working facts. Rows this
|
||||
lease cannot attest stay in the same backend but are invisible here, so they
|
||||
never reach a coding prompt and cannot be updated, superseded or linked.
|
||||
|
||||
HY's reconcilers catch store exceptions and carry on, so a denial latches and
|
||||
recheck() raises it again at finish; a swallowed denial still fails the task.
|
||||
Any native method not implemented here is denied rather than passed through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
from types import MappingProxyType
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from hy_memory.coding.types import CodingMemory, WorkingFact # type: ignore[import-untyped]
|
||||
|
||||
from one_memory.hy_permission_store import HyNodePermission
|
||||
from one_memory.model_processing_permission_guard import (
|
||||
ModelProcessingPermissionLease,
|
||||
permission_error,
|
||||
)
|
||||
|
||||
_NATIVE_WRITES = frozenset(
|
||||
{
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"insert_fact",
|
||||
"supersede_fact",
|
||||
"set_fact_derived_experiences",
|
||||
"set_experience_source_facts",
|
||||
# Trace rows are raw conversation: writes must be gated like any other.
|
||||
"append_trace",
|
||||
}
|
||||
)
|
||||
|
||||
_TIMESTAMPS = frozenset({"created_at", "updated_at"})
|
||||
|
||||
# SDK 2.0.1 reads `config` off the coding store while routing a coding
|
||||
# turn. The wrapper must answer it, but the native `MemoryConfig` carries
|
||||
# credentials (llm/embedder api keys, graph and cache passwords) and is a live
|
||||
# handle into the backend, so it is never handed out. Only this redacted
|
||||
# projection is exposed: credential-bearing keys collapse to None.
|
||||
_CONFIG_REDACTED_KEYS = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"password",
|
||||
"username",
|
||||
"mysql_password",
|
||||
"mysql_user",
|
||||
"extra_headers",
|
||||
"extra_body",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _RedactedConfig(Mapping[str, Any]):
|
||||
"""Read-only config view: readable by key and attribute, never the native one.
|
||||
|
||||
The SDK reaches config values both ways, so this keeps attribute access
|
||||
working. It is deliberately not the native MemoryConfig: it exposes only
|
||||
plain copied values, so no backend handle, connection or callable escapes.
|
||||
"""
|
||||
|
||||
__slots__ = ("_values",)
|
||||
|
||||
def __init__(self, values: Mapping[str, Any]) -> None:
|
||||
object.__setattr__(self, "_values", MappingProxyType(dict(values)))
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._values[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._values)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._values)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
values = object.__getattribute__(self, "_values")
|
||||
try:
|
||||
return values[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name) from None
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> NoReturn:
|
||||
raise AttributeError("redacted config is read-only")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"_RedactedConfig({sorted(self._values)})"
|
||||
|
||||
|
||||
def _redact_config(value: Any, depth: int = 0) -> Any:
|
||||
"""Copy config data into read-only views, collapsing credentials to None.
|
||||
|
||||
The SDK reads this both by attribute and by key, so each config section
|
||||
becomes a mapping-like view rather than a plain dict: attribute access stays
|
||||
available while the native object itself is never handed out.
|
||||
"""
|
||||
if depth > 4:
|
||||
return None
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return _RedactedConfig(
|
||||
{
|
||||
key: None if key in _CONFIG_REDACTED_KEYS else _redact_config(item, depth + 1)
|
||||
for key, item in value.items()
|
||||
}
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_redact_config(item, depth + 1) for item in value]
|
||||
if hasattr(value, "__dict__") and type(value).__module__.startswith("hy_memory"):
|
||||
return _redact_config(vars(value), depth + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _config_projection(native: Any) -> Any:
|
||||
try:
|
||||
return _redact_config(native.config)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def hy_coding_fingerprint(row: CodingMemory | WorkingFact) -> str:
|
||||
"""Hash the complete native row as read back, including links and timestamps."""
|
||||
if type(row) is CodingMemory:
|
||||
kind = "experience"
|
||||
elif type(row) is WorkingFact:
|
||||
kind = "fact"
|
||||
else:
|
||||
raise permission_error("hy_coding_store_row_shape")
|
||||
payload = {"kind": kind, **row.to_dict()}
|
||||
return sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _row_id(row: CodingMemory | WorkingFact) -> str:
|
||||
return str(row.experience_id if type(row) is CodingMemory else row.fact_id)
|
||||
|
||||
|
||||
class HyPermissionCodingStore:
|
||||
"""Closed, task-exclusive adapter; initialize/close never own the native store.
|
||||
|
||||
node_permissions is the same attested ledger the chat vector store uses;
|
||||
coding rows are keyed by experience or fact id. written_nodes carries the
|
||||
whole lease footprint for each row written here, as the chat store does.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
native: Any,
|
||||
lease: ModelProcessingPermissionLease,
|
||||
*,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
node_permissions: Mapping[str, HyNodePermission] | None = None,
|
||||
) -> None:
|
||||
self._native = native
|
||||
self._lease = lease
|
||||
self._agent_id = agent_id
|
||||
self._session_id = session_id
|
||||
self._user_id = lease.user_id
|
||||
self._failure: str | None = None
|
||||
self._closed = False
|
||||
self._lock = asyncio.Lock()
|
||||
self._deleted: set[str] = set()
|
||||
self._written: dict[str, HyNodePermission] = {}
|
||||
self._superseded: dict[str, str] = {}
|
||||
self._evidence_ids = tuple(record.evidence_id for record in lease.records)
|
||||
self._permissions = {
|
||||
key: proof
|
||||
for key, proof in (node_permissions or {}).items()
|
||||
if set(proof.evidence_ids) <= set(self._evidence_ids)
|
||||
}
|
||||
self.recheck()
|
||||
if not agent_id or agent_id == "*" or not session_id or not self._evidence_ids:
|
||||
self._deny("scope")
|
||||
|
||||
def __getattr__(self, name: str) -> NoReturn:
|
||||
if name.startswith("__"):
|
||||
raise AttributeError(name)
|
||||
self._deny("unsupported_operation")
|
||||
|
||||
@property
|
||||
def written_nodes(self) -> Mapping[str, HyNodePermission]:
|
||||
self.recheck()
|
||||
return MappingProxyType(dict(self._written))
|
||||
|
||||
@property
|
||||
def config(self) -> Any:
|
||||
"""Read-only, redacted view of the native coding config.
|
||||
|
||||
SDK 2.0.1 reads `config` when it routes a coding turn. Answering
|
||||
it is safe only as a projection: the native MemoryConfig is a live
|
||||
backend handle and carries credentials, so callers get plain values
|
||||
with every credential-bearing key collapsed to None. This carries no
|
||||
identity, permission or tenant state and is not the native object.
|
||||
"""
|
||||
self._assert_open()
|
||||
return _config_projection(self._native)
|
||||
|
||||
@property
|
||||
def retired_ids(self) -> frozenset[str]:
|
||||
"""Experiences HY deleted in this operation."""
|
||||
self.recheck()
|
||||
return frozenset(self._deleted)
|
||||
|
||||
@property
|
||||
def superseded_ids(self) -> Mapping[str, tuple[str, ...]]:
|
||||
"""Facts HY superseded in this operation, with the fact that replaced each.
|
||||
|
||||
A successor linked before it existed must have been written here by
|
||||
now; otherwise the old fact points at a row nobody attested.
|
||||
"""
|
||||
self.recheck()
|
||||
if any(new not in self._written for new in self._superseded.values()):
|
||||
self._deny("link_denied")
|
||||
return MappingProxyType({old: (new,) for old, new in self._superseded.items()})
|
||||
|
||||
def recheck(self) -> None:
|
||||
self._assert_open()
|
||||
try:
|
||||
self._lease.recheck()
|
||||
except Exception as error:
|
||||
code = str(error)
|
||||
self._failure = (
|
||||
code
|
||||
if code.startswith("model_content_") and len(code) <= 100
|
||||
else "model_content_hy_coding_store_lease_failed"
|
||||
)
|
||||
raise permission_error(self._failure) from None
|
||||
|
||||
def _assert_open(self) -> None:
|
||||
if self._closed:
|
||||
self._deny("closed")
|
||||
if self._failure:
|
||||
raise permission_error(self._failure)
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._lock:
|
||||
self._closed = True
|
||||
self._permissions.clear()
|
||||
self._written.clear()
|
||||
|
||||
def _deny(self, suffix: str) -> NoReturn:
|
||||
self._failure = self._failure or f"model_content_hy_coding_store_{suffix}"
|
||||
raise permission_error(self._failure)
|
||||
|
||||
def _scope(self, user_id: str | None) -> None:
|
||||
if user_id not in (None, self._user_id):
|
||||
self._deny("scope")
|
||||
|
||||
async def _invoke(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
writing = method in _NATIVE_WRITES
|
||||
self.recheck() if writing else self._assert_open()
|
||||
try:
|
||||
result = await getattr(self._native, method)(*args, **kwargs)
|
||||
except Exception:
|
||||
self._deny("backend_failed")
|
||||
self.recheck() if writing else self._assert_open()
|
||||
return result
|
||||
|
||||
def _visible(self, row: Any, expected: Any) -> bool:
|
||||
if row is None:
|
||||
return False
|
||||
if type(row) is not expected:
|
||||
self._deny("row_shape")
|
||||
key = _row_id(row)
|
||||
proof = self._permissions.get(key)
|
||||
if proof is None or key in self._deleted or row.user_id != self._user_id:
|
||||
return False
|
||||
if hy_coding_fingerprint(row) != proof.fingerprint:
|
||||
self._deny("row_changed")
|
||||
return True
|
||||
|
||||
async def _experience(self, key: str) -> CodingMemory | None:
|
||||
if key not in self._permissions or key in self._deleted:
|
||||
return None
|
||||
row = await self._invoke("get_by_id", key, user_id=self._user_id)
|
||||
return deepcopy(row) if self._visible(row, CodingMemory) else None
|
||||
|
||||
async def _fact(self, key: str) -> WorkingFact | None:
|
||||
if key not in self._permissions or key in self._deleted:
|
||||
return None
|
||||
rows = await self._invoke("get_facts", [key], user_id=self._user_id)
|
||||
row = rows[0] if rows else None
|
||||
return deepcopy(row) if self._visible(row, WorkingFact) else None
|
||||
|
||||
def _new_row_in_scope(self, row: CodingMemory | WorkingFact) -> bool:
|
||||
return bool(
|
||||
row.user_id == self._user_id
|
||||
and row.agent_id == self._agent_id
|
||||
and row.session_id == self._session_id
|
||||
)
|
||||
|
||||
async def _capture(
|
||||
self, key: str, kind: Any, expected: CodingMemory | WorkingFact | None = None
|
||||
) -> None:
|
||||
row: Any
|
||||
if kind is CodingMemory:
|
||||
row = await self._invoke("get_by_id", key, user_id=self._user_id)
|
||||
else:
|
||||
rows = await self._invoke("get_facts", [key], user_id=self._user_id)
|
||||
row = rows[0] if rows else None
|
||||
if type(row) is not kind or row.user_id != self._user_id:
|
||||
self._deny("write_mismatch")
|
||||
if expected is not None:
|
||||
stored, planned = row.to_dict(), expected.to_dict()
|
||||
for field in _TIMESTAMPS:
|
||||
stored.pop(field, None)
|
||||
planned.pop(field, None)
|
||||
if stored != planned:
|
||||
self._deny("write_mismatch")
|
||||
proof = HyNodePermission(hy_coding_fingerprint(row), self._evidence_ids)
|
||||
self._permissions[key] = proof
|
||||
self._written[key] = proof
|
||||
|
||||
async def _plan_new(self, row: CodingMemory | WorkingFact, kind: Any) -> None:
|
||||
if type(row) is not kind or not self._new_row_in_scope(row):
|
||||
self._deny("scope")
|
||||
key = _row_id(row)
|
||||
if not key or key in self._permissions or key in self._deleted:
|
||||
self._deny("scope")
|
||||
if kind is CodingMemory:
|
||||
existing = await self._invoke("get_by_id", key)
|
||||
else:
|
||||
existing = next(iter(await self._invoke("get_facts", [key])), None)
|
||||
if existing is not None:
|
||||
self._deny("scope")
|
||||
|
||||
# Reads
|
||||
|
||||
async def get_by_id(
|
||||
self, experience_id: str, user_id: str | None = None
|
||||
) -> CodingMemory | None:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
return await self._experience(experience_id)
|
||||
|
||||
async def get_many(
|
||||
self, experience_ids: list[str], user_id: str | None = None
|
||||
) -> list[CodingMemory]:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
wanted = [
|
||||
key
|
||||
for key in dict.fromkeys(experience_ids)
|
||||
if key in self._permissions and key not in self._deleted
|
||||
]
|
||||
if not wanted:
|
||||
return []
|
||||
rows = await self._invoke("get_many", wanted, user_id=self._user_id)
|
||||
return [deepcopy(row) for row in rows if self._visible(row, CodingMemory)]
|
||||
|
||||
async def search_by_query_embedding(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
user_id: str,
|
||||
*,
|
||||
workspace_id: str | None = None,
|
||||
branch: str | None = None,
|
||||
top: int = 40,
|
||||
) -> list[dict[str, Any]]:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
hits = await self._invoke(
|
||||
"search_by_query_embedding",
|
||||
deepcopy(query_embedding),
|
||||
self._user_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
top=top * 4,
|
||||
)
|
||||
result = []
|
||||
for hit in hits:
|
||||
key = hit.get("experience_id")
|
||||
if not isinstance(key, str) or await self._experience(key) is None:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"experience_id": key,
|
||||
"score": float(hit.get("score", 0.0)),
|
||||
"matched_key_kind": hit.get("matched_key_kind"),
|
||||
"matched_key_text": hit.get("matched_key_text"),
|
||||
}
|
||||
)
|
||||
return result[:top]
|
||||
|
||||
async def get_fact(self, fact_id: str, user_id: str | None = None) -> WorkingFact | None:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
return await self._fact(fact_id)
|
||||
|
||||
async def get_facts(self, fact_ids: list[str], user_id: str | None = None) -> list[WorkingFact]:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
found = [await self._fact(key) for key in dict.fromkeys(fact_ids)]
|
||||
return [row for row in found if row is not None]
|
||||
|
||||
async def search_facts(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
user_id: str,
|
||||
*,
|
||||
workspace_id: str | None = None,
|
||||
branch: str | None = None,
|
||||
top: int = 5,
|
||||
query_text: str | None = None,
|
||||
min_score: float = 0.0,
|
||||
) -> list[tuple[WorkingFact, float]]:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
hits = await self._invoke(
|
||||
"search_facts",
|
||||
deepcopy(query_embedding),
|
||||
self._user_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
top=top * 4,
|
||||
query_text=query_text,
|
||||
min_score=min_score,
|
||||
)
|
||||
result = []
|
||||
for row, score in hits:
|
||||
if self._visible(row, WorkingFact):
|
||||
result.append((deepcopy(row), float(score)))
|
||||
return result[:top]
|
||||
|
||||
async def get_fact_ancestors(
|
||||
self, fact_id: str, *, user_id: str | None = None, max_depth: int = 8
|
||||
) -> list[WorkingFact]:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
if await self._fact(fact_id) is None:
|
||||
return []
|
||||
rows = await self._invoke(
|
||||
"get_fact_ancestors", fact_id, user_id=self._user_id, max_depth=max_depth
|
||||
)
|
||||
# Oldest first; the chain is only as long as its nearest unapproved link.
|
||||
kept: list[WorkingFact] = []
|
||||
for row in reversed(rows):
|
||||
if not self._visible(row, WorkingFact):
|
||||
break
|
||||
kept.append(deepcopy(row))
|
||||
return list(reversed(kept))
|
||||
|
||||
# Writes
|
||||
|
||||
async def insert(self, memory: CodingMemory) -> None:
|
||||
async with self._lock:
|
||||
self.recheck()
|
||||
await self._plan_new(memory, CodingMemory)
|
||||
await self._invoke("insert", memory)
|
||||
await self._capture(memory.experience_id, CodingMemory, memory)
|
||||
|
||||
async def update(self, memory: CodingMemory) -> None:
|
||||
async with self._lock:
|
||||
self.recheck()
|
||||
if type(memory) is not CodingMemory or not self._new_row_in_scope(memory):
|
||||
self._deny("scope")
|
||||
if await self._experience(memory.experience_id) is None:
|
||||
self._deny("target_denied")
|
||||
await self._invoke("update", memory)
|
||||
await self._capture(memory.experience_id, CodingMemory, memory)
|
||||
|
||||
async def delete(self, experience_id: str, user_id: str) -> bool:
|
||||
async with self._lock:
|
||||
self._scope(user_id)
|
||||
if await self._experience(experience_id) is None:
|
||||
self._deny("target_denied")
|
||||
if await self._invoke("delete", experience_id, user_id=self._user_id) is not True:
|
||||
self._deny("write_failed")
|
||||
if await self._invoke("get_by_id", experience_id) is not None:
|
||||
self._deny("write_mismatch")
|
||||
self._deleted.add(experience_id)
|
||||
self._permissions.pop(experience_id, None)
|
||||
self._written.pop(experience_id, None)
|
||||
return True
|
||||
|
||||
async def insert_fact(self, fact: WorkingFact) -> None:
|
||||
async with self._lock:
|
||||
self.recheck()
|
||||
await self._plan_new(fact, WorkingFact)
|
||||
await self._invoke("insert_fact", fact)
|
||||
await self._capture(fact.fact_id, WorkingFact, fact)
|
||||
|
||||
async def supersede_fact(self, old_fact_id: str, new_fact_id: str) -> bool:
|
||||
async with self._lock:
|
||||
self.recheck()
|
||||
if await self._fact(old_fact_id) is None or old_fact_id == new_fact_id:
|
||||
self._deny("target_denied")
|
||||
# HY links the old fact before it inserts the new one. A successor
|
||||
# that does not exist yet may only become a row this lease writes;
|
||||
# an existing row it cannot attest stays out of reach.
|
||||
if await self._fact(new_fact_id) is None:
|
||||
if not new_fact_id or new_fact_id in self._deleted:
|
||||
self._deny("target_denied")
|
||||
if next(iter(await self._invoke("get_facts", [new_fact_id])), None) is not None:
|
||||
self._deny("target_denied")
|
||||
changed = await self._invoke("supersede_fact", old_fact_id, new_fact_id)
|
||||
await self._capture(old_fact_id, WorkingFact)
|
||||
if changed:
|
||||
self._superseded[old_fact_id] = new_fact_id
|
||||
return bool(changed)
|
||||
|
||||
async def set_fact_derived_experiences(self, fact_id: str, experience_ids: list[str]) -> None:
|
||||
async with self._lock:
|
||||
self.recheck()
|
||||
if await self._fact(fact_id) is None:
|
||||
self._deny("target_denied")
|
||||
for key in experience_ids:
|
||||
if await self._experience(key) is None:
|
||||
self._deny("link_denied")
|
||||
await self._invoke("set_fact_derived_experiences", fact_id, list(experience_ids))
|
||||
await self._capture(fact_id, WorkingFact)
|
||||
|
||||
async def set_experience_source_facts(self, experience_id: str, fact_ids: list[str]) -> None:
|
||||
async with self._lock:
|
||||
self.recheck()
|
||||
if await self._experience(experience_id) is None:
|
||||
self._deny("target_denied")
|
||||
for key in fact_ids:
|
||||
if await self._fact(key) is None:
|
||||
self._deny("link_denied")
|
||||
await self._invoke("set_experience_source_facts", experience_id, list(fact_ids))
|
||||
await self._capture(experience_id, CodingMemory)
|
||||
|
||||
async def get_recent_trace_messages(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
max_user_turns: int = 5,
|
||||
) -> list[Any]:
|
||||
"""Read recent raw trace rows, still bounded by this lease.
|
||||
|
||||
Public SDK 2.0.1 asks for recent history before a coding write. The native call is
|
||||
scoped to the lease's own user and agent, and rows this lease cannot
|
||||
attest are filtered out, so unapproved conversations never reach a
|
||||
coding prompt through this path.
|
||||
"""
|
||||
self._scope(user_id)
|
||||
if agent_id not in (self._agent_id, "*"):
|
||||
self._deny("scope")
|
||||
rows = await self._invoke(
|
||||
"get_recent_trace_messages",
|
||||
user_id=self._user_id,
|
||||
agent_id=self._agent_id,
|
||||
max_user_turns=max_user_turns,
|
||||
)
|
||||
return [row for row in (rows or []) if self._trace_visible(row)]
|
||||
|
||||
async def append_trace(
|
||||
self,
|
||||
messages: Sequence[Any],
|
||||
*,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str | None = None,
|
||||
workspace_id: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> None:
|
||||
"""Append raw trace rows for this lease only.
|
||||
|
||||
Trace rows are raw conversation, so this is a write path: the lease is
|
||||
rechecked around the call and every message must belong to the leased
|
||||
user and session. Nothing outside the lease may be recorded.
|
||||
"""
|
||||
self._scope(user_id)
|
||||
if agent_id not in (self._agent_id, "*"):
|
||||
self._deny("scope")
|
||||
if session_id not in (None, self._session_id):
|
||||
self._deny("scope")
|
||||
for row in messages or []:
|
||||
if not self._trace_visible(row):
|
||||
self._deny("scope")
|
||||
await self._invoke(
|
||||
"append_trace",
|
||||
list(messages or []),
|
||||
request_id=request_id,
|
||||
user_id=self._user_id,
|
||||
agent_id=self._agent_id,
|
||||
session_id=self._session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
)
|
||||
|
||||
def _trace_visible(self, row: Any) -> bool:
|
||||
"""A trace row may be read or written only inside this lease's scope."""
|
||||
if row is None:
|
||||
return False
|
||||
owner = getattr(row, "user_id", None)
|
||||
session = getattr(row, "session_id", None)
|
||||
return not (
|
||||
(owner is not None and owner != self._user_id)
|
||||
or (session is not None and session != self._session_id)
|
||||
)
|
||||
@@ -1,582 +0,0 @@
|
||||
"""Task-exclusive HY wiring; provenance metadata stays beside the existing Core DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager, nullcontext, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from one_memory.database import take_the_write_lock_first
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.hy_memory_bridge import (
|
||||
HyMemoryBridge,
|
||||
HyMemoryBridgeError,
|
||||
HyMemoryIngestRequest,
|
||||
HyMemoryIngestResult,
|
||||
HyMemoryOutput,
|
||||
LocalHyEmbedService,
|
||||
_joined_thread,
|
||||
)
|
||||
from one_memory.hy_permission_coding_store import HyPermissionCodingStore
|
||||
from one_memory.hy_permission_store import (
|
||||
HyNodePermission,
|
||||
HyPermissionGraphStore,
|
||||
HyPermissionVectorStore,
|
||||
)
|
||||
from one_memory.hy_permission_transport import HyPermissionTransport, HyPermissionTransportError
|
||||
from one_memory.hy_response_guard import HyResponseGuard
|
||||
from one_memory.hy_step_timing import HyStepTimer
|
||||
from one_memory.memory_server.migrations import hy_node_permissions_ddl
|
||||
from one_memory.memory_tasks import evidence_ref
|
||||
from one_memory.model_processing_permission_guard import ModelProcessingPermissionLease
|
||||
|
||||
|
||||
class _NativeProofs:
|
||||
def __init__(self, database: Path) -> None:
|
||||
self.database = database
|
||||
self._loaded_states: dict[str, tuple[int, str]] | None = None
|
||||
with main_database(database).write() as db, db:
|
||||
# Schema owned by `memory_server.migrations` (migration 26).
|
||||
db.execute(hy_node_permissions_ddl("sqlite"))
|
||||
|
||||
def load(self, lease: ModelProcessingPermissionLease) -> dict[str, HyNodePermission]:
|
||||
allowed = {record.evidence_id: evidence_ref(record) for record in lease.records}
|
||||
with main_database(self.database).read() as db:
|
||||
rows = db.execute(
|
||||
"SELECT p.node_id,p.fingerprint,p.evidence_json,g.revision,g.status "
|
||||
"FROM hy_node_permissions p LEFT JOIN governed_memories g ON g.memory_id=p.node_id "
|
||||
# Superseded nodes stay attested: every later head links back to them.
|
||||
"WHERE g.status IS NULL OR g.status IN ('confirmed','superseded')"
|
||||
).fetchall()
|
||||
proofs = {}
|
||||
states = {}
|
||||
for identifier, fingerprint, evidence_json, revision, status in rows:
|
||||
refs = json.loads(evidence_json)
|
||||
if (
|
||||
not isinstance(refs, list)
|
||||
or not refs
|
||||
or not all(isinstance(ref, dict) for ref in refs)
|
||||
):
|
||||
continue
|
||||
if all(allowed.get(ref.get("evidence_id")) == ref for ref in refs):
|
||||
proofs[identifier] = HyNodePermission(
|
||||
fingerprint, tuple(ref["evidence_id"] for ref in refs)
|
||||
)
|
||||
if revision is not None:
|
||||
states[identifier] = (revision, status)
|
||||
self._loaded_states = states
|
||||
return proofs
|
||||
|
||||
def guard(self, values: dict[str, HyNodePermission]) -> Callable[[], None]:
|
||||
identifiers = tuple(values)
|
||||
|
||||
def state() -> dict[str, tuple[int, str]]:
|
||||
found: dict[str, tuple[int, str]] = {}
|
||||
with main_database(self.database).read() as db:
|
||||
for start in range(0, len(identifiers), 900):
|
||||
batch = identifiers[start : start + 900]
|
||||
placeholders = ",".join("?" for _ in batch)
|
||||
for identifier, revision, status in db.execute(
|
||||
"SELECT memory_id,revision,status FROM governed_memories "
|
||||
f"WHERE memory_id IN ({placeholders})",
|
||||
batch,
|
||||
):
|
||||
found[identifier] = (revision, status)
|
||||
return found
|
||||
|
||||
if self._loaded_states is None:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
before = dict(self._loaded_states)
|
||||
|
||||
def check() -> None:
|
||||
if state() != before:
|
||||
raise HyMemoryBridgeError("model_content_context_changed")
|
||||
|
||||
check()
|
||||
return check
|
||||
|
||||
def save(
|
||||
self, lease: ModelProcessingPermissionLease, values: dict[str, HyNodePermission]
|
||||
) -> None:
|
||||
refs = {record.evidence_id: evidence_ref(record) for record in lease.records}
|
||||
with main_database(self.database).write(autocommit=True) as db:
|
||||
take_the_write_lock_first(db)
|
||||
with db:
|
||||
lease.recheck()
|
||||
db.executemany(
|
||||
"INSERT INTO hy_node_permissions VALUES (?,?,?) ON CONFLICT(node_id) "
|
||||
"DO UPDATE SET fingerprint=excluded.fingerprint,"
|
||||
"evidence_json=excluded.evidence_json",
|
||||
[
|
||||
(
|
||||
identifier,
|
||||
proof.fingerprint,
|
||||
json.dumps([refs[key] for key in proof.evidence_ids]),
|
||||
)
|
||||
for identifier, proof in values.items()
|
||||
],
|
||||
)
|
||||
|
||||
def refresh(
|
||||
self, lease: ModelProcessingPermissionLease, node_id: str
|
||||
) -> HyNodePermission | None:
|
||||
del lease
|
||||
with main_database(self.database).read() as db:
|
||||
row = db.execute(
|
||||
"SELECT fingerprint,evidence_json FROM hy_node_permissions WHERE node_id=?",
|
||||
(node_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
fingerprint = str(row["fingerprint"])
|
||||
refs = json.loads(row["evidence_json"])
|
||||
if (
|
||||
len(fingerprint) != 64
|
||||
or any(value not in "0123456789abcdef" for value in fingerprint)
|
||||
or not isinstance(refs, list)
|
||||
or not refs
|
||||
or not all(isinstance(ref, dict) for ref in refs)
|
||||
):
|
||||
return None
|
||||
evidence_ids = tuple(ref.get("evidence_id") for ref in refs)
|
||||
if any(not isinstance(value, str) or not value for value in evidence_ids):
|
||||
return None
|
||||
return HyNodePermission(fingerprint, evidence_ids)
|
||||
|
||||
|
||||
class _BoundLease:
|
||||
def __init__(
|
||||
self,
|
||||
lease: ModelProcessingPermissionLease,
|
||||
proof_check: Callable[[], None],
|
||||
step_timer: HyStepTimer | None = None,
|
||||
) -> None:
|
||||
self.lease = lease
|
||||
self.step_timer = step_timer
|
||||
self.user_id = lease.user_id
|
||||
self.profile = lease.profile
|
||||
self.task_id = lease.task_id
|
||||
self.records = lease.records
|
||||
self.source_context = lease.source_context
|
||||
self.proof_check = proof_check
|
||||
self.vector: HyPermissionVectorStore | None = None
|
||||
self.coding: HyPermissionCodingStore | None = None
|
||||
|
||||
def recheck(self) -> None:
|
||||
timer = self.step_timer
|
||||
with timer.step("permission_check") if timer is not None else nullcontext():
|
||||
self.lease.recheck()
|
||||
self.proof_check()
|
||||
if self.vector is not None:
|
||||
self.vector.recheck()
|
||||
if self.coding is not None:
|
||||
self.coding.recheck()
|
||||
|
||||
|
||||
class _Wiring:
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
lease: ModelProcessingPermissionLease,
|
||||
proofs_store: _NativeProofs,
|
||||
proofs: dict[str, HyNodePermission],
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
transport: httpx.AsyncBaseTransport | None,
|
||||
proof_check: Callable[[], None],
|
||||
step_timer: HyStepTimer,
|
||||
) -> None:
|
||||
self.client, self.lease, self.proofs_store, self.proofs = (
|
||||
client,
|
||||
lease,
|
||||
proofs_store,
|
||||
proofs,
|
||||
)
|
||||
self.step_timer = step_timer
|
||||
self.agent_id, self.session_id, self.http_transport = agent_id, session_id, transport
|
||||
self.cross_evidence_scope = False
|
||||
self.proof_check = proof_check
|
||||
self.saved: list[tuple[Any, str, Any]] = []
|
||||
self.transport: HyPermissionTransport
|
||||
self.vector: HyPermissionVectorStore
|
||||
self.graph: HyPermissionGraphStore | None = None
|
||||
self.coding: HyPermissionCodingStore
|
||||
self.active = False
|
||||
|
||||
def replace(self, target: Any, name: str, value: Any) -> None:
|
||||
self.saved.append((target, name, getattr(target, name)))
|
||||
setattr(target, name, value)
|
||||
|
||||
async def enter(self) -> None:
|
||||
client, registry = self.client, self.client._registry
|
||||
config = client._config
|
||||
if config.llm.enable_summary or config.llm.enable_query_rewrite:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
bound = _BoundLease(self.lease, self.proof_check, self.step_timer)
|
||||
self.transport = await HyPermissionTransport(
|
||||
bound, http_transport=self.http_transport, step_timer=self.step_timer
|
||||
).__aenter__()
|
||||
self.active = True
|
||||
try:
|
||||
for guard in getattr(client, "_one_memory_write_guards", ()):
|
||||
if hasattr(type(guard), "step_timer"):
|
||||
self.replace(guard, "step_timer", self.step_timer)
|
||||
system2 = await registry.get_system2_writer()
|
||||
writers = (await registry.get_writer(), system2._writer)
|
||||
native_vector = client._vector_store
|
||||
store_lease = _BoundLease(self.lease, self.proof_check, self.step_timer)
|
||||
self.vector = HyPermissionVectorStore(
|
||||
native_vector,
|
||||
cast(ModelProcessingPermissionLease, store_lease),
|
||||
agent_id=self.agent_id,
|
||||
session_id=self.session_id,
|
||||
cross_evidence_scope=self.cross_evidence_scope,
|
||||
node_permissions=self.proofs,
|
||||
refresh_permission=lambda node_id: asyncio.to_thread(
|
||||
self.proofs_store.refresh, self.lease, node_id
|
||||
),
|
||||
step_timer=self.step_timer,
|
||||
)
|
||||
bound.vector = self.vector
|
||||
embed = client._embed_service
|
||||
if isinstance(embed, LocalHyEmbedService):
|
||||
self.transport.bind_local_embed_service(embed)
|
||||
else:
|
||||
self.transport.bind_embed_service(embed)
|
||||
for writer in writers:
|
||||
provider = writer.mem_agent.llm_provider
|
||||
self.transport.bind_provider(
|
||||
provider.provider if isinstance(provider, HyResponseGuard) else provider
|
||||
)
|
||||
reconciler = writer.reconciler
|
||||
provider = reconciler._get_llm()
|
||||
self.transport.bind_provider(
|
||||
provider.provider if isinstance(provider, HyResponseGuard) else provider
|
||||
)
|
||||
self.replace(writer, "_external_vector_store", self.vector)
|
||||
self.replace(writer, "_vector_store", self.vector)
|
||||
self.replace(writer, "_vector_store_initialized", True)
|
||||
self.replace(writer, "_cache", None)
|
||||
self.replace(client, "_vector_store", self.vector)
|
||||
native_graph = client._graph_store
|
||||
if self.cross_evidence_scope and native_graph is not None:
|
||||
self.graph = HyPermissionGraphStore(native_graph, self.proof_check)
|
||||
self.replace(client, "_graph_store", self.graph)
|
||||
else:
|
||||
self.replace(client, "_graph_store", None)
|
||||
self.replace(registry, "_shared_vector_store", self.vector)
|
||||
self.replace(
|
||||
registry,
|
||||
"_shared_graph_store",
|
||||
self.graph if self.graph is not None else None,
|
||||
)
|
||||
self.replace(registry, "_shared_cache", None)
|
||||
self.replace(system2, "_vector_store", self.vector)
|
||||
self.replace(
|
||||
system2,
|
||||
"_graph_store",
|
||||
self.graph if self.graph is not None else None,
|
||||
)
|
||||
self.replace(system2, "_cache", None)
|
||||
# Result fallback may call search; rebuild only its non-generative reader.
|
||||
self.replace(registry, "_reader", None)
|
||||
self.replace(registry, "_readers", {})
|
||||
await self._enter_coding(bound, embed)
|
||||
self.transport.assert_allowed()
|
||||
except BaseException:
|
||||
await self.exit()
|
||||
raise
|
||||
|
||||
async def _enter_coding(self, bound: _BoundLease, embed: Any) -> None:
|
||||
"""HY judges and writes the coding route itself; every seam it uses is bound here."""
|
||||
client = self.client
|
||||
# Created now, so the judge and every coding stage share one bound provider.
|
||||
writer = await client._get_coding_writer()
|
||||
stages = (writer, writer.extractor, writer.reconciler, writer.fact_reconciler)
|
||||
native_store = client._coding_store
|
||||
if any(
|
||||
stage.store is not native_store
|
||||
for stage in (writer, writer.reconciler, writer.fact_reconciler)
|
||||
) or any(
|
||||
item.embed_service is not embed
|
||||
for item in (native_store, writer, writer.reconciler, writer.fact_reconciler)
|
||||
):
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
providers = {
|
||||
id(p): p for p in (client._coding_llm_provider, *(s.llm_provider for s in stages))
|
||||
}
|
||||
for provider in providers.values():
|
||||
self.transport.bind_provider(provider)
|
||||
self.coding = HyPermissionCodingStore(
|
||||
native_store,
|
||||
cast(
|
||||
ModelProcessingPermissionLease,
|
||||
_BoundLease(self.lease, self.proof_check, self.step_timer),
|
||||
),
|
||||
agent_id=self.agent_id,
|
||||
session_id=self.session_id,
|
||||
node_permissions=self.proofs,
|
||||
)
|
||||
bound.coding = self.coding
|
||||
for stage in (writer, writer.reconciler, writer.fact_reconciler):
|
||||
self.replace(stage, "store", self.coding)
|
||||
self.replace(client, "_coding_store", self.coding)
|
||||
for stage in (writer, writer.extractor, writer.reconciler):
|
||||
self.replace(stage, "cache", None)
|
||||
# The judge logs its prompt through the client's own cache.
|
||||
self.replace(client, "_cache", None)
|
||||
|
||||
async def finish(self) -> dict[str, HyNodePermission]:
|
||||
self.transport.assert_allowed()
|
||||
self.vector.recheck()
|
||||
self.coding.recheck()
|
||||
return {**self.vector.written_nodes, **self.coding.written_nodes}
|
||||
|
||||
async def retired(self) -> tuple[tuple[str, ...], dict[str, tuple[str, ...]]]:
|
||||
self.transport.assert_allowed()
|
||||
return (
|
||||
tuple(sorted(self.vector.retired_node_ids | self.coding.retired_ids)),
|
||||
{**self.vector.superseded_nodes, **self.coding.superseded_ids},
|
||||
)
|
||||
|
||||
async def exit(self) -> None:
|
||||
if not self.active:
|
||||
return
|
||||
self.active = False
|
||||
try:
|
||||
await self.transport.__aexit__(None, None, None)
|
||||
finally:
|
||||
for target, name, value in reversed(self.saved):
|
||||
setattr(target, name, value)
|
||||
self.saved.clear()
|
||||
if hasattr(self, "vector"):
|
||||
await self.vector.close()
|
||||
if self.graph is not None:
|
||||
await self.graph.close()
|
||||
if hasattr(self, "coding"):
|
||||
await self.coding.close()
|
||||
|
||||
|
||||
class _LeasedBridge:
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HyMemoryBridge,
|
||||
lease: ModelProcessingPermissionLease,
|
||||
proofs: _NativeProofs,
|
||||
transport: httpx.AsyncBaseTransport | None,
|
||||
) -> None:
|
||||
self.bridge, self.lease, self.proofs, self.transport = bridge, lease, proofs, transport
|
||||
self.wiring: _Wiring | None = None
|
||||
self.finished = False
|
||||
self.step_timer = HyStepTimer()
|
||||
self.request_id: str | None = None
|
||||
|
||||
def _step(self, name: str) -> Any:
|
||||
return self.step_timer.step(name)
|
||||
|
||||
async def _recheck(self) -> None:
|
||||
def recheck() -> None:
|
||||
with self._step("permission_check"):
|
||||
self.lease.recheck()
|
||||
|
||||
await _joined_thread(recheck)
|
||||
|
||||
async def _start(self, request: HyMemoryIngestRequest) -> None:
|
||||
if self.wiring is not None or self.finished:
|
||||
raise HyMemoryBridgeError("model_content_context_reused")
|
||||
if request.user_id != self.lease.user_id or set(request.evidence_ids) - {
|
||||
item.evidence_id for item in self.lease.records
|
||||
}:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
await self._start_operation(
|
||||
user_id=request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
request_id=request.request_id,
|
||||
session_id=request.session_id,
|
||||
)
|
||||
|
||||
async def _start_operation(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
request_id: str,
|
||||
session_id: str,
|
||||
cross_evidence_scope: bool = False,
|
||||
) -> None:
|
||||
if self.wiring is not None or self.finished:
|
||||
raise HyMemoryBridgeError("model_content_context_reused")
|
||||
if user_id != self.lease.user_id:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
self.request_id = request_id
|
||||
await self._recheck()
|
||||
client = cast(Any, await self.bridge._get_client())
|
||||
with self._step("permission_store_read"):
|
||||
known = await asyncio.to_thread(self.proofs.load, self.lease)
|
||||
proof_check = await asyncio.to_thread(self.proofs.guard, known)
|
||||
self.wiring = _Wiring(
|
||||
client,
|
||||
self.lease,
|
||||
self.proofs,
|
||||
known,
|
||||
agent_id,
|
||||
session_id,
|
||||
self.transport,
|
||||
proof_check,
|
||||
self.step_timer,
|
||||
)
|
||||
self.wiring.cross_evidence_scope = cross_evidence_scope
|
||||
await _joined_thread(client._loop_thread.run, self.wiring.enter())
|
||||
|
||||
async def list_agent_ids(self, *, user_id: str, limit: int = 1_000) -> tuple[str, ...]:
|
||||
if user_id != self.lease.user_id or self.wiring is not None or self.finished:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
await self._recheck()
|
||||
result = await self.bridge.list_agent_ids(user_id=user_id, limit=limit)
|
||||
await self._recheck()
|
||||
return result
|
||||
|
||||
async def digest(self, *, user_id: str, agent_id: str) -> dict[str, object]:
|
||||
await self._start_operation(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
request_id=f"digest:{self.lease.task_id}:{agent_id}",
|
||||
session_id=f"digest:{self.lease.task_id}",
|
||||
cross_evidence_scope=True,
|
||||
)
|
||||
try:
|
||||
result = dict(await self.bridge.digest(user_id=user_id, agent_id=agent_id))
|
||||
except HyMemoryBridgeError:
|
||||
await self._check()
|
||||
raise
|
||||
proofs = await self._check()
|
||||
with self._step("permission_store_write"):
|
||||
await asyncio.to_thread(self.proofs.save, self.lease, proofs)
|
||||
await self.close()
|
||||
await self._recheck()
|
||||
return result
|
||||
|
||||
async def graph_snapshot(self, *, user_id: str, agent_id: str, limit: int = 300) -> Any:
|
||||
await self._start_operation(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
request_id=f"digest-graph:{self.lease.task_id}:{agent_id}",
|
||||
session_id=f"digest:{self.lease.task_id}",
|
||||
cross_evidence_scope=True,
|
||||
)
|
||||
try:
|
||||
result = await self.bridge.graph_snapshot(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
limit=limit,
|
||||
)
|
||||
except HyMemoryBridgeError:
|
||||
await self._check()
|
||||
raise
|
||||
await self._check()
|
||||
await self.close()
|
||||
await self._recheck()
|
||||
return result
|
||||
|
||||
async def ingest(self, request: HyMemoryIngestRequest) -> HyMemoryIngestResult:
|
||||
await self._start(request)
|
||||
try:
|
||||
result = await self.bridge.ingest(request)
|
||||
except HyMemoryBridgeError:
|
||||
await self._check()
|
||||
raise
|
||||
return await self._with_retired(result)
|
||||
|
||||
async def ingest_extracted(
|
||||
self, request: HyMemoryIngestRequest, contents: tuple[str, ...]
|
||||
) -> HyMemoryIngestResult:
|
||||
await self._start(request)
|
||||
try:
|
||||
result = await self.bridge.ingest_extracted(request, contents)
|
||||
except HyMemoryBridgeError:
|
||||
await self._check()
|
||||
raise
|
||||
return await self._with_retired(result)
|
||||
|
||||
async def _with_retired(self, result: HyMemoryIngestResult) -> HyMemoryIngestResult:
|
||||
await self._check()
|
||||
if self.wiring is None:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
try:
|
||||
retired, superseded = await _joined_thread(
|
||||
self.wiring.client._loop_thread.run, self.wiring.retired()
|
||||
)
|
||||
except HyPermissionTransportError as error:
|
||||
await self._recheck()
|
||||
raise HyMemoryBridgeError(error.code) from None
|
||||
return result.model_copy(
|
||||
update={"retired_memory_ids": retired, "superseded_memory_ids": superseded}
|
||||
)
|
||||
|
||||
async def _check(self) -> dict[str, HyNodePermission]:
|
||||
if self.wiring is None or self.finished:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
await self._recheck()
|
||||
try:
|
||||
return await _joined_thread(self.wiring.client._loop_thread.run, self.wiring.finish())
|
||||
except HyPermissionTransportError as error:
|
||||
await self._recheck()
|
||||
raise HyMemoryBridgeError(error.code) from None
|
||||
|
||||
async def profile_memories(self, *, user_id: str, agent_id: str) -> tuple[HyMemoryOutput, ...]:
|
||||
if self.wiring is None or user_id != self.lease.user_id or agent_id != self.wiring.agent_id:
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
result = await self.bridge.profile_memories(user_id=user_id, agent_id=agent_id)
|
||||
proofs = await self._check()
|
||||
with self._step("permission_store_write"):
|
||||
await asyncio.to_thread(self.proofs.save, self.lease, proofs)
|
||||
# SDK context closes before governance can update a previously known memory.
|
||||
await self.close()
|
||||
return result
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.wiring is not None and not self.finished:
|
||||
self.finished = True
|
||||
try:
|
||||
await _joined_thread(self.wiring.client._loop_thread.run, self.wiring.exit())
|
||||
finally:
|
||||
self.step_timer.emit(request_id=self.request_id, task_id=self.lease.task_id)
|
||||
|
||||
|
||||
def permission_bridge_factory(
|
||||
resolve_bridge: Callable[..., Any],
|
||||
*,
|
||||
http_transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> Callable[..., Any]:
|
||||
@asynccontextmanager
|
||||
async def factory(
|
||||
lease: ModelProcessingPermissionLease, secret: str | None, data_dir: Path
|
||||
) -> AsyncIterator[Any]:
|
||||
bridge = resolve_bridge(lease.profile, secret, data_dir)
|
||||
if not isinstance(bridge, HyMemoryBridge):
|
||||
raise HyMemoryBridgeError("model_content_context_unverified")
|
||||
proofs = _NativeProofs(data_dir / "one-memory.sqlite3")
|
||||
async with bridge.exclusive_access():
|
||||
await bridge.prepare()
|
||||
bound = _LeasedBridge(bridge, lease, proofs, http_transport)
|
||||
try:
|
||||
yield bound
|
||||
except BaseException as error:
|
||||
with suppress(Exception):
|
||||
await bound.close()
|
||||
if isinstance(error, HyPermissionTransportError):
|
||||
await _joined_thread(lease.recheck)
|
||||
raise HyMemoryBridgeError(error.code) from None
|
||||
raise
|
||||
else:
|
||||
try:
|
||||
await bound.close()
|
||||
except HyPermissionTransportError as error:
|
||||
raise HyMemoryBridgeError(error.code) from None
|
||||
|
||||
return factory
|
||||
@@ -1,880 +0,0 @@
|
||||
"""Operation-bound read/write capability for the pinned native HY chat vector store.
|
||||
|
||||
No semantic storage or extraction lives here. Pre-existing nodes require a full
|
||||
native-node attestation with complete approved Evidence provenance; known content
|
||||
alone cannot authorize tags, custom fields, vectors or links. Unknown nodes stay
|
||||
in the same HY backend but are not exposed to this operation.
|
||||
|
||||
The caller must exclusively own the native backend's writes during execution:
|
||||
its API has no compare-and-swap, and a swallowed get_by_id error can look missing.
|
||||
This component cannot make that check/upsert interval atomic. Factory integration
|
||||
must replace client/registry/both writers/cached-reader references, bind physical
|
||||
LLM/embedding sends to the lease AND this denial latch, and guard publication.
|
||||
Coding rows go through hy_permission_coding_store instead; graph, entities,
|
||||
digest, sweeper and summary remain unsupported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from types import MappingProxyType
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from hy_memory.models.memory import ( # type: ignore[import-untyped]
|
||||
MemoryLayer,
|
||||
MemoryNode,
|
||||
MemoryStatus,
|
||||
)
|
||||
|
||||
from one_memory.hy_step_timing import HyStepTimer
|
||||
from one_memory.model_processing_permission_guard import (
|
||||
ModelProcessingPermissionLease,
|
||||
permission_error,
|
||||
)
|
||||
|
||||
_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def _native_write_lock() -> asyncio.Lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = _WRITE_LOCKS.get(loop)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_WRITE_LOCKS[loop] = lock
|
||||
return lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def native_store_read_slot() -> AsyncIterator[bool]:
|
||||
"""Admit one native HY read only while no write is active.
|
||||
|
||||
Chroma's native reader may retain the GIL after an asyncio timeout. Running
|
||||
it concurrently with HY's multi-step store writes has deadlocked the Core
|
||||
under sustained imports. Reads therefore share the physical store lock,
|
||||
but never wait behind a writer: host recall can immediately use the local
|
||||
governance ledger instead.
|
||||
"""
|
||||
lock = _native_write_lock()
|
||||
if lock.locked():
|
||||
yield False
|
||||
return
|
||||
await lock.acquire()
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HyNodePermission:
|
||||
"""Trusted caller's full-node attestation, not permission inferred from an ID."""
|
||||
|
||||
fingerprint: str
|
||||
evidence_ids: tuple[str, ...]
|
||||
|
||||
|
||||
def hy_node_fingerprint(node: MemoryNode) -> str:
|
||||
"""Hash the complete native serialized node, including vectors and metadata.
|
||||
|
||||
Timestamp precision follows HY's to_dict contract (integer Unix seconds).
|
||||
Attest the node read from the backend, not an unpersisted input object.
|
||||
"""
|
||||
if type(node) is not MemoryNode:
|
||||
raise permission_error("hy_store_node_shape")
|
||||
return sha256(
|
||||
json.dumps(node.to_dict(), sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
_CHAT_LAYERS = frozenset(
|
||||
{
|
||||
MemoryLayer.L0_BASIC_INFO,
|
||||
MemoryLayer.L1_RAW,
|
||||
MemoryLayer.L2_FACT,
|
||||
MemoryLayer.L4_IDENTITY,
|
||||
MemoryLayer.L7_INTENTION,
|
||||
}
|
||||
)
|
||||
_UPDATE_FIELDS = frozenset(
|
||||
{
|
||||
"content",
|
||||
"embedding",
|
||||
"tags",
|
||||
"tag_ners",
|
||||
"layer",
|
||||
"memory_at",
|
||||
"gmt_modified",
|
||||
"is_latest",
|
||||
"status",
|
||||
"supersedes",
|
||||
"superseded_by",
|
||||
"source_raw_memory_id",
|
||||
# SDK 2.0.1 sends both of these on an in-place reconcile UPDATE.
|
||||
# They are metadata only and must never carry identity, permission or
|
||||
# tenant state: `owner` is a provenance label whose only values are
|
||||
# "user" / "agent" (who said this line), and `custom` is the SDK's own
|
||||
# free-form notes field. Neither may hold a user_id, scope, grant or
|
||||
# tenancy marker, so allowing the field names does not widen the
|
||||
# cross-user boundary that the store still enforces per node.
|
||||
"owner",
|
||||
"custom",
|
||||
}
|
||||
)
|
||||
_DEDUP_AUDIT_FIELDS = frozenset({"shadow_reason", "dedup_kept_id"})
|
||||
_DIGEST_COUNTER_FIELD = "s2_evidence_count"
|
||||
|
||||
|
||||
def _write_view(node: MemoryNode) -> dict[str, Any]:
|
||||
"""Only native Chroma's empty-string and float32 storage normalization."""
|
||||
payload: dict[str, Any] = node.to_dict()
|
||||
payload["source_session_id"] = payload["source_session_id"] or ""
|
||||
# The pinned public SDK serializes tag_ners as JSON, but its Chroma read path omits this
|
||||
# field from the JSON decode list. Decode only the exact list-of-strings
|
||||
# shape that the write path produced; malformed values still fail the
|
||||
# complete-node comparison below.
|
||||
tag_ners = payload.get("tag_ners")
|
||||
if (
|
||||
isinstance(tag_ners, list)
|
||||
and tag_ners
|
||||
and all(isinstance(value, str) and len(value) == 1 for value in tag_ners)
|
||||
and tag_ners[0] == "["
|
||||
and tag_ners[-1] == "]"
|
||||
):
|
||||
tag_ners = "".join(tag_ners)
|
||||
if isinstance(tag_ners, str):
|
||||
try:
|
||||
decoded = json.loads(tag_ners)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
decoded = None
|
||||
if isinstance(decoded, list) and all(isinstance(value, str) for value in decoded):
|
||||
payload["tag_ners"] = decoded
|
||||
if payload.get("tag_ners") in ([], ["[", "]"]):
|
||||
payload["tag_ners"] = []
|
||||
# HY Chroma encodes None as ""; these JSON fields keep "" when json.loads
|
||||
# fails (vector_store_chroma.py:741-788). Only an absent link is equivalent.
|
||||
for field in ("supersedes", "superseded_by"):
|
||||
if payload[field] == "":
|
||||
payload[field] = None
|
||||
if payload["embedding"] is not None:
|
||||
payload["embedding"] = [
|
||||
struct.unpack("f", struct.pack("f", n))[0] for n in payload["embedding"]
|
||||
]
|
||||
if not all(math.isfinite(n) for n in payload["embedding"]):
|
||||
raise ValueError("nonfinite_embedding")
|
||||
return payload
|
||||
|
||||
|
||||
def _same_written_vector(actual: list[float] | None, expected: list[float] | None) -> bool:
|
||||
if actual == expected:
|
||||
return True
|
||||
if actual is None or expected is None or len(actual) != len(expected):
|
||||
return False
|
||||
# Chroma's cosine index can renormalize float32 vectors on readback. Bound
|
||||
# only this write roundtrip; the saved full-node proof uses exact readback.
|
||||
return all(
|
||||
math.isclose(left, right, rel_tol=4 * 2**-23, abs_tol=0.0)
|
||||
for left, right in zip(actual, expected, strict=True)
|
||||
)
|
||||
|
||||
|
||||
_NATIVE_WRITES = frozenset(
|
||||
{"upsert", "upsert_batch", "update_payload", "update_embedding", "delete"}
|
||||
)
|
||||
|
||||
|
||||
class HyPermissionGraphStore:
|
||||
"""Lease-gated graph store for HY System 2's digest transaction.
|
||||
|
||||
The native graph is deliberately absent from ordinary ingest leases. Digest
|
||||
needs it to write L6 schemas, but every operation must remain inside the
|
||||
same frozen permission lease rather than exposing the raw store.
|
||||
"""
|
||||
|
||||
def __init__(self, native: Any, recheck: Any) -> None:
|
||||
self._native = native
|
||||
self._recheck = recheck
|
||||
self._closed = False
|
||||
|
||||
def recheck(self) -> None:
|
||||
if self._closed:
|
||||
raise permission_error("hy_graph_store_closed")
|
||||
self._recheck()
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name.startswith("_") and name != "_run":
|
||||
raise AttributeError(name)
|
||||
attribute = getattr(self._native, name)
|
||||
if not callable(attribute):
|
||||
return attribute
|
||||
|
||||
async def guarded(*args: Any, **kwargs: Any) -> Any:
|
||||
self.recheck()
|
||||
result = await attribute(*args, **kwargs)
|
||||
self.recheck()
|
||||
return result
|
||||
|
||||
return guarded
|
||||
|
||||
|
||||
class HyPermissionVectorStore:
|
||||
"""Closed, task-exclusive adapter; initialize/close never own the native store.
|
||||
|
||||
node_permissions may attest additional native raw/profile nodes not represented
|
||||
in known_memories. Every attestation must cover all its contributors, and all
|
||||
Evidence IDs must already be in this lease. Known entries also constrain exact
|
||||
content/owner. Without a full attestation even a known entry is denied.
|
||||
|
||||
written_nodes records successful writes with the entire lease footprint, a
|
||||
conservative provenance bound, not a claim of semantic citation accuracy.
|
||||
These transient fingerprints must be persisted by the integrating factory if
|
||||
future operations are to reuse raw/profile nodes. No content is stored here.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"__native",
|
||||
"__lease",
|
||||
"__agent_id",
|
||||
"__session_id",
|
||||
"__cross_evidence_scope",
|
||||
"__user_id",
|
||||
"__known",
|
||||
"__permissions",
|
||||
"__written",
|
||||
"__evidence_ids",
|
||||
"__deleted",
|
||||
"__shadowed",
|
||||
"__superseded",
|
||||
"__heads",
|
||||
"__lock",
|
||||
"__closed",
|
||||
"__failure",
|
||||
"__step_timer",
|
||||
"__refresh_permission",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
native: Any,
|
||||
lease: ModelProcessingPermissionLease,
|
||||
*,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
cross_evidence_scope: bool = False,
|
||||
node_permissions: Mapping[str, HyNodePermission] | None = None,
|
||||
refresh_permission: Callable[[str], Awaitable[HyNodePermission | None]] | None = None,
|
||||
step_timer: HyStepTimer | None = None,
|
||||
) -> None:
|
||||
self.__step_timer = step_timer
|
||||
self.__native = native
|
||||
self.__lease = lease
|
||||
self.__agent_id = agent_id
|
||||
self.__session_id = session_id
|
||||
self.__cross_evidence_scope = cross_evidence_scope
|
||||
self.__user_id = lease.user_id
|
||||
self.__failure: str | None = None
|
||||
self.__closed = False
|
||||
self.__lock = asyncio.Lock()
|
||||
self.__deleted: set[str] = set()
|
||||
self.__written: dict[str, HyNodePermission] = {}
|
||||
self.__shadowed: set[str] = set()
|
||||
self.__superseded: dict[str, tuple[str, ...]] = {}
|
||||
self.__heads: dict[str, tuple[str, ...]] = {}
|
||||
self.__refresh_permission = refresh_permission
|
||||
self.recheck()
|
||||
self.__evidence_ids = tuple(record.evidence_id for record in lease.records)
|
||||
current = lease.source_context.current if lease.source_context else lease.records
|
||||
conversation_mismatch = any(r.conversation_id != session_id for r in current)
|
||||
if (
|
||||
not lease.task_id
|
||||
or not agent_id
|
||||
or agent_id == "*"
|
||||
or not session_id
|
||||
or not current
|
||||
or (conversation_mismatch and not cross_evidence_scope)
|
||||
or not self.__evidence_ids
|
||||
or len(set(self.__evidence_ids)) != len(lease.records)
|
||||
or any(r.user_id != self.__user_id for r in lease.records)
|
||||
):
|
||||
self.__deny("scope")
|
||||
known = lease.source_context.known_memories if lease.source_context else ()
|
||||
self.__known = {item.hy_memory_id: item for item in known}
|
||||
if len(self.__known) != len(known):
|
||||
self.__deny("provenance")
|
||||
self.__permissions = dict(node_permissions or {})
|
||||
for key, proof in self.__permissions.items():
|
||||
if (
|
||||
not key
|
||||
or not isinstance(proof, HyNodePermission)
|
||||
or len(proof.fingerprint) != 64
|
||||
or any(c not in "0123456789abcdef" for c in proof.fingerprint)
|
||||
or not isinstance(proof.evidence_ids, tuple)
|
||||
or not proof.evidence_ids
|
||||
or len(set(proof.evidence_ids)) != len(proof.evidence_ids)
|
||||
or not set(proof.evidence_ids) <= set(self.__evidence_ids)
|
||||
):
|
||||
self.__deny("provenance")
|
||||
for key, item in self.__known.items():
|
||||
if not item.evidence_ids or not set(item.evidence_ids) <= set(self.__evidence_ids):
|
||||
self.__deny("provenance")
|
||||
known_proof = self.__permissions.get(key)
|
||||
if known_proof and not set(item.evidence_ids) <= set(known_proof.evidence_ids):
|
||||
self.__deny("provenance")
|
||||
|
||||
@property
|
||||
def written_nodes(self) -> Mapping[str, HyNodePermission]:
|
||||
"""Latest verified write proofs; each access returns a new immutable snapshot.
|
||||
|
||||
Export after SDK writes have drained and before close/governance projection.
|
||||
Merge these rows into the lineage ledger, then remove deleted_node_ids;
|
||||
this write delta does not include untouched pre-existing permissions.
|
||||
"""
|
||||
self.recheck()
|
||||
return MappingProxyType(dict(self.__written))
|
||||
|
||||
@property
|
||||
def deleted_node_ids(self) -> frozenset[str]:
|
||||
"""Verified native deletions in this operation, for lineage-ledger cleanup."""
|
||||
self.recheck()
|
||||
return frozenset(self.__deleted)
|
||||
|
||||
@property
|
||||
def retired_node_ids(self) -> frozenset[str]:
|
||||
"""Nodes HY deleted or shadowed (its logical delete) in this operation."""
|
||||
self.recheck()
|
||||
return frozenset(self.__deleted | self.__shadowed)
|
||||
|
||||
@property
|
||||
def superseded_nodes(self) -> Mapping[str, tuple[str, ...]]:
|
||||
"""Nodes HY marked superseded in this operation, with what replaced them.
|
||||
|
||||
HY writes the two directions separately and may fail either one, so a
|
||||
replacement counts whether it was recorded on the old node or only on
|
||||
the new head.
|
||||
"""
|
||||
self.recheck()
|
||||
result = {}
|
||||
for key, by in self.__superseded.items():
|
||||
heads = set(by) | {head for head, olds in self.__heads.items() if key in olds}
|
||||
heads -= self.__deleted | {key}
|
||||
if key not in self.__deleted and heads:
|
||||
result[key] = tuple(sorted(heads))
|
||||
return MappingProxyType(result)
|
||||
|
||||
@property
|
||||
def supports_fulltext(self) -> bool:
|
||||
return False
|
||||
|
||||
def recheck(self) -> None:
|
||||
"""Must also gate transport/publication after HY swallows a store failure."""
|
||||
self.__assert_open()
|
||||
try:
|
||||
self.__lease.recheck()
|
||||
except Exception as error:
|
||||
code = str(error)
|
||||
self.__failure = (
|
||||
code
|
||||
if code.startswith("model_content_") and len(code) <= 100
|
||||
else "model_content_hy_store_lease_failed"
|
||||
)
|
||||
raise permission_error(self.__failure) from None
|
||||
|
||||
def __assert_open(self) -> None:
|
||||
if self.__closed:
|
||||
self.__deny("closed")
|
||||
if self.__failure:
|
||||
raise permission_error(self.__failure)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self.recheck()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Revoke this wrapper locally without rechecking context or closing HY.
|
||||
|
||||
Cleanup must remain possible after governance projection changes the
|
||||
lease's known-memory revisions, or after permission has been revoked.
|
||||
"""
|
||||
async with self.__lock:
|
||||
self.__closed = True
|
||||
self.__permissions.clear()
|
||||
self.__written.clear()
|
||||
|
||||
async def has_tag_embedding(self, user_id: str, tag: str) -> bool:
|
||||
self.recheck()
|
||||
# Native chat calls this best-effort helper. NotImplemented prevents it
|
||||
# from embedding tags or falling through to an unguarded entity store.
|
||||
raise NotImplementedError("hy_permission_store_tag_index_unsupported")
|
||||
|
||||
def __deny(self, suffix: str) -> NoReturn:
|
||||
self.__failure = self.__failure or f"model_content_hy_store_{suffix}"
|
||||
raise permission_error(self.__failure)
|
||||
|
||||
async def __invoke(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
writing = method in _NATIVE_WRITES
|
||||
self.recheck() if writing else self.__assert_open()
|
||||
step = "vector_store_write" if writing else "vector_store_read"
|
||||
first = args[0] if args else None
|
||||
try:
|
||||
with (
|
||||
self.__step_timer.step(step, items=len(first) if isinstance(first, list) else 1)
|
||||
if self.__step_timer is not None
|
||||
else nullcontext()
|
||||
):
|
||||
result = await getattr(self.__native, method)(*args, **kwargs)
|
||||
except Exception:
|
||||
self.__deny("backend_failed")
|
||||
self.recheck() if writing else self.__assert_open()
|
||||
return result
|
||||
|
||||
async def __read_native(self, key: str) -> MemoryNode | None:
|
||||
value = await self.__invoke("get_by_id", key)
|
||||
if value is not None and (type(value) is not MemoryNode or value.node_id != key):
|
||||
self.__deny("node_shape")
|
||||
return deepcopy(value)
|
||||
|
||||
def __fingerprint(self, node: MemoryNode) -> str:
|
||||
try:
|
||||
return hy_node_fingerprint(node)
|
||||
except (TypeError, ValueError, AttributeError, OverflowError):
|
||||
self.__deny("node_shape")
|
||||
|
||||
def __write_view(self, node: MemoryNode) -> dict[str, Any]:
|
||||
self.__fingerprint(node)
|
||||
try:
|
||||
return _write_view(node)
|
||||
except (TypeError, ValueError, OverflowError, struct.error):
|
||||
self.__deny("node_shape")
|
||||
|
||||
def __in_scope(self, node: MemoryNode) -> bool:
|
||||
if node.user_id != self.__user_id or node.layer not in _CHAT_LAYERS:
|
||||
return False
|
||||
if node.layer is MemoryLayer.L0_BASIC_INFO:
|
||||
# Public SDK 2.0.1 migrates verified legacy agent-level profiles into one
|
||||
# user-level chain. Existing nodes still require a full proof in
|
||||
# __load; accepting their historical agent id does not authorize
|
||||
# unproven cross-agent memories.
|
||||
return True
|
||||
if self.__cross_evidence_scope:
|
||||
return bool(node.agent_id == self.__agent_id)
|
||||
return bool(node.agent_id == self.__agent_id and node.session_id == self.__session_id)
|
||||
|
||||
def __new_node_in_scope(self, node: MemoryNode) -> bool:
|
||||
if not self.__in_scope(node):
|
||||
return False
|
||||
if node.layer is MemoryLayer.L0_BASIC_INFO:
|
||||
return node.agent_id in ("", self.__agent_id) and node.session_id in (
|
||||
"",
|
||||
self.__session_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def __references(self, node: MemoryNode) -> list[str]:
|
||||
refs = []
|
||||
for field in ("supersedes", "superseded_by", "evidence_chain"):
|
||||
values = getattr(node, field)
|
||||
if field != "evidence_chain" and values == "":
|
||||
continue
|
||||
if values is not None:
|
||||
if not isinstance(values, list) or any(
|
||||
not isinstance(v, str) or not v for v in values
|
||||
):
|
||||
self.__deny("node_shape")
|
||||
refs.extend(values)
|
||||
if node.source_raw_memory_id is not None:
|
||||
if not isinstance(node.source_raw_memory_id, str) or not node.source_raw_memory_id:
|
||||
self.__deny("node_shape")
|
||||
refs.append(node.source_raw_memory_id)
|
||||
return refs
|
||||
|
||||
async def __load(
|
||||
self,
|
||||
key: str,
|
||||
first: MemoryNode | None = None,
|
||||
*,
|
||||
allow_deleted_links: bool = False,
|
||||
) -> MemoryNode | None:
|
||||
pending = [key]
|
||||
checked: dict[str, MemoryNode] = {}
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current in checked:
|
||||
continue
|
||||
# HY dedup deletes head then ancestors. Only this operation's
|
||||
# verified tombstones may terminate a closure during deletion.
|
||||
if allow_deleted_links and current != key and current in self.__deleted:
|
||||
continue
|
||||
proof = self.__permissions.get(current)
|
||||
if proof is None or current in self.__deleted:
|
||||
return None
|
||||
value = (
|
||||
first if current == key and first is not None else await self.__read_native(current)
|
||||
)
|
||||
if value is None or not self.__in_scope(value):
|
||||
return None
|
||||
if self.__fingerprint(value) != proof.fingerprint:
|
||||
refreshed = (
|
||||
await self.__refresh_permission(current)
|
||||
if self.__refresh_permission is not None
|
||||
else None
|
||||
)
|
||||
if refreshed is None or self.__fingerprint(value) != refreshed.fingerprint:
|
||||
self.__deny("node_changed")
|
||||
self.__permissions[current] = refreshed
|
||||
proof = refreshed
|
||||
known = self.__known.get(current) if current not in self.__written else None
|
||||
if known and (value.content != known.content or value.owner != known.owner):
|
||||
self.__deny("node_changed")
|
||||
checked[current] = value
|
||||
pending.extend(self.__references(value))
|
||||
return deepcopy(checked[key])
|
||||
|
||||
async def get_by_id(self, node_id: str) -> MemoryNode | None:
|
||||
async with self.__lock:
|
||||
self.recheck()
|
||||
result = await self.__load(node_id)
|
||||
self.recheck()
|
||||
return result
|
||||
|
||||
async def get_by_ids(self, node_ids: list[str]) -> list[MemoryNode]:
|
||||
async with self.__lock:
|
||||
self.recheck()
|
||||
result = [
|
||||
n for key in dict.fromkeys(node_ids) if (n := await self.__load(key)) is not None
|
||||
]
|
||||
self.recheck()
|
||||
return result
|
||||
|
||||
async def get_embeddings(self, node_ids: list[str]) -> dict[str, list[float]]:
|
||||
return {
|
||||
n.node_id: list(n.embedding)
|
||||
for n in await self.get_by_ids(node_ids)
|
||||
if n.embedding is not None
|
||||
}
|
||||
|
||||
def __scope(self, user_id: str | None, agent_id: str | None) -> None:
|
||||
if user_id not in (None, self.__user_id) or agent_id not in (None, self.__agent_id):
|
||||
self.__deny("scope")
|
||||
|
||||
async def list_by_user(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
limit: int = 10000,
|
||||
status_filter: list[Any] | None = None,
|
||||
layers: list[Any] | None = None,
|
||||
user_ids: list[str] | None = None,
|
||||
agent_ids: list[str] | None = None,
|
||||
only_latest: bool = False,
|
||||
with_vectors: bool = True,
|
||||
allow_raw_layer: bool = False,
|
||||
) -> list[MemoryNode]:
|
||||
async with self.__lock:
|
||||
self.recheck()
|
||||
effective_users = user_ids or ([user_id] if user_id else [])
|
||||
effective_agents = agent_ids or ([agent_id] if agent_id else [])
|
||||
if effective_users != [self.__user_id] or any(
|
||||
value != self.__agent_id for value in effective_agents
|
||||
):
|
||||
self.__deny("scope")
|
||||
rows = await self.__invoke(
|
||||
"list_by_user",
|
||||
user_id=self.__user_id,
|
||||
agent_id=agent_id,
|
||||
limit=limit,
|
||||
status_filter=status_filter,
|
||||
layers=layers,
|
||||
user_ids=[self.__user_id],
|
||||
agent_ids=effective_agents or None,
|
||||
only_latest=only_latest,
|
||||
with_vectors=with_vectors,
|
||||
allow_raw_layer=allow_raw_layer,
|
||||
)
|
||||
result = []
|
||||
for row in rows:
|
||||
if type(row) is not MemoryNode:
|
||||
self.__deny("node_shape")
|
||||
value = await self.__load(row.node_id)
|
||||
if (
|
||||
value is not None
|
||||
and (not layers or value.layer in layers or value.layer.value in layers)
|
||||
and (
|
||||
not status_filter
|
||||
or value.status in status_filter
|
||||
or value.status.value in status_filter
|
||||
)
|
||||
):
|
||||
result.append(value)
|
||||
self.recheck()
|
||||
return result[:limit]
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
isolation_key: str = "",
|
||||
isolation_keys: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
user_ids: list[str] | None = None,
|
||||
agent_ids: list[str] | None = None,
|
||||
layers: list[Any] | None = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.0,
|
||||
status_filter: list[Any] | None = None,
|
||||
only_latest: bool = True,
|
||||
tags_match_any: list[str] | None = None,
|
||||
created_after: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
async with self.__lock:
|
||||
self.recheck()
|
||||
self.__scope(user_id, None)
|
||||
key = MemoryNode.build_isolation_key(self.__user_id, self.__agent_id, self.__session_id)
|
||||
if (
|
||||
any(uid != self.__user_id for uid in user_ids or [])
|
||||
or any(aid != self.__agent_id for aid in agent_ids or [])
|
||||
or isolation_key not in ("", key)
|
||||
or any(ik != key for ik in isolation_keys or [])
|
||||
):
|
||||
self.__deny("scope")
|
||||
hits = await self.__invoke(
|
||||
"search",
|
||||
query_embedding=deepcopy(query_embedding),
|
||||
isolation_key=isolation_key,
|
||||
isolation_keys=isolation_keys,
|
||||
user_id=self.__user_id,
|
||||
user_ids=[self.__user_id],
|
||||
agent_ids=[self.__agent_id],
|
||||
layers=layers,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
status_filter=status_filter,
|
||||
only_latest=only_latest,
|
||||
tags_match_any=tags_match_any,
|
||||
created_after=created_after,
|
||||
)
|
||||
result = []
|
||||
for hit in hits:
|
||||
value = await self.__load(hit["node_id"])
|
||||
if value is None:
|
||||
continue
|
||||
score = float(hit["score"])
|
||||
if not math.isfinite(score):
|
||||
self.__deny("node_shape")
|
||||
# Do not forward arbitrary backend hit metadata. Rehydrate the
|
||||
# full fingerprinted node; native search often omits embedding.
|
||||
result.append({"node_id": value.node_id, "node": value, "score": score})
|
||||
self.recheck()
|
||||
return result[:limit]
|
||||
|
||||
async def __target(
|
||||
self,
|
||||
key: str,
|
||||
first: MemoryNode | None = None,
|
||||
*,
|
||||
allow_deleted_links: bool = False,
|
||||
) -> MemoryNode:
|
||||
value = await self.__load(key, first, allow_deleted_links=allow_deleted_links)
|
||||
if value is None:
|
||||
self.__deny("target_denied")
|
||||
return value
|
||||
|
||||
async def __check_links(self, value: MemoryNode) -> None:
|
||||
for ref in self.__references(value):
|
||||
if await self.__load(ref) is None:
|
||||
self.__deny("link_denied")
|
||||
|
||||
async def __plan_upsert(self, value: MemoryNode) -> MemoryNode:
|
||||
value = deepcopy(value)
|
||||
if type(value) is not MemoryNode or not value.node_id or not self.__in_scope(value):
|
||||
self.__deny("scope")
|
||||
existing = await self.__read_native(value.node_id)
|
||||
if existing is not None:
|
||||
await self.__target(value.node_id, existing)
|
||||
elif (
|
||||
not self.__new_node_in_scope(value)
|
||||
or value.node_id in self.__permissions
|
||||
or value.node_id in self.__known
|
||||
or value.node_id in self.__deleted
|
||||
):
|
||||
self.__deny("scope")
|
||||
await self.__check_links(value)
|
||||
self.__write_view(value)
|
||||
return value
|
||||
|
||||
async def __capture(
|
||||
self,
|
||||
expected: MemoryNode,
|
||||
*,
|
||||
allow_native_modified_time: bool = False,
|
||||
) -> None:
|
||||
actual = await self.__read_native(expected.node_id)
|
||||
if actual is None:
|
||||
self.__deny("write_mismatch")
|
||||
actual_view, expected_view = self.__write_view(actual), self.__write_view(expected)
|
||||
if allow_native_modified_time:
|
||||
if actual.gmt_modified < expected.gmt_modified:
|
||||
self.__deny("write_mismatch")
|
||||
actual_view["gmt_modified"] = expected_view["gmt_modified"]
|
||||
actual_vector = actual_view.pop("embedding")
|
||||
expected_vector = expected_view.pop("embedding")
|
||||
if actual_view != expected_view or not _same_written_vector(actual_vector, expected_vector):
|
||||
self.__deny("write_mismatch")
|
||||
proof = HyNodePermission(self.__fingerprint(actual), self.__evidence_ids)
|
||||
self.__permissions[actual.node_id] = proof
|
||||
self.__written[actual.node_id] = proof
|
||||
status = getattr(actual.status, "value", actual.status)
|
||||
if status == MemoryStatus.SHADOW.value:
|
||||
self.__shadowed.add(actual.node_id)
|
||||
else:
|
||||
self.__shadowed.discard(actual.node_id)
|
||||
if status == MemoryStatus.SUPERSEDED.value:
|
||||
by = actual.superseded_by
|
||||
self.__superseded[actual.node_id] = tuple(by) if isinstance(by, list) else ()
|
||||
else:
|
||||
self.__superseded.pop(actual.node_id, None)
|
||||
olds = actual.supersedes
|
||||
if isinstance(olds, list) and olds:
|
||||
self.__heads[actual.node_id] = tuple(olds)
|
||||
else:
|
||||
self.__heads.pop(actual.node_id, None)
|
||||
|
||||
async def upsert(self, node: MemoryNode) -> str:
|
||||
async with _native_write_lock(), self.__lock:
|
||||
self.recheck()
|
||||
expected = await self.__plan_upsert(node)
|
||||
result = await self.__invoke("upsert", deepcopy(expected))
|
||||
if result != expected.node_id:
|
||||
self.__deny("write_mismatch")
|
||||
await self.__capture(expected)
|
||||
return str(result)
|
||||
|
||||
async def upsert_batch(self, nodes: list[MemoryNode]) -> list[str]:
|
||||
async with _native_write_lock(), self.__lock:
|
||||
self.recheck()
|
||||
if len({n.node_id for n in nodes}) != len(nodes):
|
||||
self.__deny("node_shape")
|
||||
# No forward links to uncommitted batch members. Preflight every
|
||||
# existing target before the backend can make a partial write.
|
||||
planned = [await self.__plan_upsert(n) for n in nodes]
|
||||
ids = [n.node_id for n in planned]
|
||||
result = await self.__invoke("upsert_batch", deepcopy(planned))
|
||||
if result != ids:
|
||||
self.__deny("write_mismatch")
|
||||
for expected in planned:
|
||||
await self.__capture(expected)
|
||||
return ids
|
||||
|
||||
async def update_payload(self, node_id: str, updates: dict[str, Any]) -> bool:
|
||||
async with _native_write_lock(), self.__lock:
|
||||
self.recheck()
|
||||
current = await self.__target(node_id)
|
||||
updates = deepcopy(updates)
|
||||
dedup_audit = False
|
||||
if set(updates) & _DEDUP_AUDIT_FIELDS:
|
||||
if (
|
||||
not set(updates) <= (_UPDATE_FIELDS | _DEDUP_AUDIT_FIELDS)
|
||||
or updates.get("status") != MemoryStatus.SHADOW.value
|
||||
or updates.get("is_latest") is not False
|
||||
or not isinstance(updates.get("shadow_reason"), str)
|
||||
or not updates["shadow_reason"].startswith("dedup:")
|
||||
or not isinstance(updates.get("dedup_kept_id"), str)
|
||||
or not updates["dedup_kept_id"]
|
||||
):
|
||||
self.__deny("update_fields")
|
||||
await self.__target(updates["dedup_kept_id"])
|
||||
custom = deepcopy(current.custom)
|
||||
custom["hy_dedup"] = {
|
||||
"reason": updates.pop("shadow_reason"),
|
||||
"kept_id": updates.pop("dedup_kept_id"),
|
||||
}
|
||||
updates["custom"] = custom
|
||||
dedup_audit = True
|
||||
allowed = _UPDATE_FIELDS | ({"custom"} if dedup_audit else set())
|
||||
if self.__cross_evidence_scope and set(updates) == {"custom"}:
|
||||
custom = updates.get("custom")
|
||||
if not isinstance(custom, dict):
|
||||
self.__deny("update_fields")
|
||||
current_custom = current.custom if isinstance(current.custom, dict) else {}
|
||||
previous = current_custom.get(_DIGEST_COUNTER_FIELD, 0)
|
||||
proposed = custom.get(_DIGEST_COUNTER_FIELD)
|
||||
if (
|
||||
not isinstance(previous, int)
|
||||
or isinstance(previous, bool)
|
||||
or not isinstance(proposed, int)
|
||||
or isinstance(proposed, bool)
|
||||
or proposed != previous + 1
|
||||
or set(custom) != set(current_custom) | {_DIGEST_COUNTER_FIELD}
|
||||
or any(custom[key] != value for key, value in current_custom.items())
|
||||
):
|
||||
self.__deny("update_fields")
|
||||
allowed |= {"custom"}
|
||||
# `custom` is an allowed field name (SDK 2.0.1 sends it on a
|
||||
# reconcile UPDATE), but the System 2 evidence counter it can hold
|
||||
# stays digest-only: only the digest scope may advance it, and only
|
||||
# by one per write. Letting the name through must not let an
|
||||
# ordinary write move that counter.
|
||||
if not self.__cross_evidence_scope and _DIGEST_COUNTER_FIELD in (
|
||||
updates.get("custom") or {}
|
||||
):
|
||||
self.__deny("update_fields")
|
||||
if not set(updates) <= allowed:
|
||||
self.__deny("update_fields")
|
||||
try:
|
||||
if "layer" in updates:
|
||||
MemoryLayer(updates["layer"])
|
||||
if "status" in updates:
|
||||
MemoryStatus(updates["status"])
|
||||
if "tag_ners" in updates and (
|
||||
not isinstance(updates["tag_ners"], list)
|
||||
or any(not isinstance(value, str) for value in updates["tag_ners"])
|
||||
):
|
||||
self.__deny("node_shape")
|
||||
expected = MemoryNode.from_dict({**current.to_dict(), **updates})
|
||||
self.__fingerprint(expected)
|
||||
except (TypeError, ValueError):
|
||||
self.__deny("node_shape")
|
||||
if not self.__in_scope(expected):
|
||||
self.__deny("scope")
|
||||
await self.__check_links(expected)
|
||||
self.__write_view(expected)
|
||||
if await self.__invoke("update_payload", node_id, updates) is not True:
|
||||
self.__deny("write_failed")
|
||||
await self.__capture(expected, allow_native_modified_time=True)
|
||||
return True
|
||||
|
||||
async def update_embedding(self, node_id: str, embedding: list[float]) -> bool:
|
||||
async with _native_write_lock(), self.__lock:
|
||||
self.recheck()
|
||||
expected = await self.__target(node_id)
|
||||
expected.embedding = deepcopy(embedding)
|
||||
self.__write_view(expected)
|
||||
if await self.__invoke("update_embedding", node_id, deepcopy(embedding)) is not True:
|
||||
self.__deny("write_failed")
|
||||
await self.__capture(expected, allow_native_modified_time=True)
|
||||
return True
|
||||
|
||||
async def delete(self, node_id: str) -> bool:
|
||||
async with _native_write_lock(), self.__lock:
|
||||
self.recheck()
|
||||
await self.__target(node_id, allow_deleted_links=True)
|
||||
if await self.__invoke("delete", node_id) is not True:
|
||||
self.__deny("write_failed")
|
||||
if await self.__read_native(node_id) is not None:
|
||||
self.__deny("write_mismatch")
|
||||
self.__deleted.add(node_id)
|
||||
self.__permissions.pop(node_id, None)
|
||||
self.__written.pop(node_id, None)
|
||||
self.__superseded.pop(node_id, None)
|
||||
self.__heads.pop(node_id, None)
|
||||
return True
|
||||
File diff suppressed because it is too large.
Load diff
@@ -144,6 +144,10 @@ class HyReconcileGuard:
|
||||
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)
|
||||
@@ -210,6 +214,15 @@ class HyReconcileGuard:
|
||||
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"}:
|
||||
@@ -232,20 +245,6 @@ class HyReconcileGuard:
|
||||
"result_count": len(result.ops),
|
||||
},
|
||||
)
|
||||
timer = self.step_timer
|
||||
try:
|
||||
with (
|
||||
timer.step("write_verification", items=len(result.ops))
|
||||
if timer is not None
|
||||
else nullcontext()
|
||||
):
|
||||
await self._check_targets(result, kwargs)
|
||||
except Exception as error:
|
||||
code = str(error)
|
||||
if not code.startswith("hy_memory_write_verification_") or len(code) > 100:
|
||||
code = "hy_memory_write_verification_failed"
|
||||
self.failures.append(code)
|
||||
raise ValueError(code) from error
|
||||
return result
|
||||
|
||||
async def _check_targets(self, result: Any, kwargs: dict[str, Any]) -> None:
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
HY_MEMORY_DISTRIBUTION = "hy-memory"
|
||||
HY_MEMORY_VERSION = "2.0.1"
|
||||
HY_MEMORY_VERSION = "2.0.3"
|
||||
HY_MEMORY_NAMESPACE = "governed-v2"
|
||||
DiagnosticLogLevel = Literal["error", "warning", "info", "debug"]
|
||||
|
||||
@@ -35,7 +35,7 @@ _COLLECTION_LOGGERS = (
|
||||
# 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.1.
|
||||
# compatibility with public SDK 2.0.3.
|
||||
HY_MEMORY_MODE = "ultra"
|
||||
# The SDK only recognises `legacy` and `hybrid` (see
|
||||
# hy_memory/pipelines/_retrieval/config.py: `ALL_READERS = (READER_LEGACY,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Process-local coordination for HY's native stores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import weakref
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
_WRITE_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def native_store_write_lock() -> asyncio.Lock:
|
||||
"""Return the write lock for the current event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = _WRITE_LOCKS.get(loop)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_WRITE_LOCKS[loop] = lock
|
||||
return lock
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def native_store_read_slot() -> AsyncIterator[bool]:
|
||||
"""Admit a native HY read only while no write is active.
|
||||
|
||||
Chroma's native reader may retain the GIL after an asyncio timeout. Reads
|
||||
therefore share the physical-store lock, but never wait behind a writer:
|
||||
host recall can immediately fall back to the governance ledger.
|
||||
"""
|
||||
lock = native_store_write_lock()
|
||||
if lock.locked():
|
||||
yield False
|
||||
return
|
||||
await lock.acquire()
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
lock.release()
|
||||
@@ -316,6 +316,10 @@ class ImportSupervisor:
|
||||
"source_id": current.source_id,
|
||||
"mode": current.operation,
|
||||
"count": current.stored,
|
||||
"stored_count": current.stored,
|
||||
"revised_count": current.revised,
|
||||
"skipped_count": current.skipped,
|
||||
"error_count": current.errors,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from fastapi.params import Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from one_memory.database_access import main_database
|
||||
from one_memory.hy_memory_bridge import HyMemoryBridgeError
|
||||
from one_memory.imports.models import (
|
||||
EvidenceInput,
|
||||
EvidenceOrigin,
|
||||
@@ -186,6 +185,10 @@ class MemoryNoteStore:
|
||||
]
|
||||
if task is None or task.status == MemoryTaskStatus.WAITING_FOR_MODEL:
|
||||
state = "waiting_for_model"
|
||||
elif task.status == MemoryTaskStatus.WAITING_FOR_PERMISSION:
|
||||
# Upgrade compatibility: old rows are claimable and will leave
|
||||
# this state as soon as the direct SDK runner claims them.
|
||||
state = "waiting_for_permission"
|
||||
elif task.status == MemoryTaskStatus.COMPLETED:
|
||||
state = "ready" if memory_ids else "no_memories"
|
||||
elif task.status == MemoryTaskStatus.RUNNING:
|
||||
@@ -195,22 +198,6 @@ class MemoryNoteStore:
|
||||
else:
|
||||
state = "failed"
|
||||
error = task.last_error_code if task else None
|
||||
if self.processor.model_content_config is not None and state in {
|
||||
"waiting_for_model",
|
||||
"failed",
|
||||
}:
|
||||
try:
|
||||
active = self.processor.profiles.active(self.processor.secrets)
|
||||
if active is not None:
|
||||
self.processor._guard_records((record,), active)
|
||||
except HyMemoryBridgeError as failure:
|
||||
if str(failure).startswith("model_content_"):
|
||||
state = "waiting_for_permission"
|
||||
error = str(failure)
|
||||
else:
|
||||
raise
|
||||
except SecretStoreUnavailableError:
|
||||
error = "secure_storage_unavailable"
|
||||
return MemoryNoteReceipt(
|
||||
evidence_id=record.evidence_id,
|
||||
text=sanitize_model_input(record.verbatim),
|
||||
@@ -274,11 +261,8 @@ def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
# unchanged; replaying the same request returns the original
|
||||
# `entry:<evidence_id>` job instead of a second HY call.
|
||||
if readmit:
|
||||
# `/{evidence_id}/process` is the explicit "the condition
|
||||
# that parked it may have changed" signal: the person just
|
||||
# granted consent or fixed the model. `submit_evidence`
|
||||
# alone would return an existing parked row unchanged, so
|
||||
# without this readmit the note stays parked forever.
|
||||
# `/{evidence_id}/process` is the explicit retry signal.
|
||||
# It also revives pre-upgrade permission waiters.
|
||||
await asyncio.to_thread(notes.intake.queue.readmit_parked)
|
||||
submitted = await asyncio.to_thread(
|
||||
notes.intake.submit_evidence,
|
||||
@@ -296,10 +280,6 @@ def memory_note_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
last_round=1,
|
||||
priority="live",
|
||||
)
|
||||
except HyMemoryBridgeError as error:
|
||||
if not str(error).startswith("model_content_"):
|
||||
raise
|
||||
return await asyncio.to_thread(notes.receipt, record.evidence_id)
|
||||
except SecretStoreUnavailableError:
|
||||
saved = await asyncio.to_thread(notes.receipt, record.evidence_id)
|
||||
return saved.model_copy(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,7 +29,6 @@ class ConfigDocument(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
|
||||
|
||||
schema_version: int = Field(default=1, ge=1, le=1)
|
||||
model_content_allowed: StrictBool = False
|
||||
sources: dict[str, SourceConfig] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("sources")
|
||||
@@ -64,10 +63,6 @@ class ConfigStore:
|
||||
source = self._document.sources.get(source_id)
|
||||
return bool(source and source.sync_enabled)
|
||||
|
||||
def model_content_allowed(self) -> bool:
|
||||
with self._lock:
|
||||
return self._document.model_content_allowed
|
||||
|
||||
def set_sync(self, source_id: str, enabled: bool) -> ConfigDocument:
|
||||
return self.set_sync_many({source_id: enabled})
|
||||
|
||||
@@ -86,20 +81,17 @@ class ConfigStore:
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def set_model_content_allowed(self, allowed: bool) -> ConfigDocument:
|
||||
if type(allowed) is not bool:
|
||||
raise ValueError("invalid_model_content_config")
|
||||
with self._lock:
|
||||
updated = self._document.model_copy(update={"model_content_allowed": allowed})
|
||||
self._write(updated)
|
||||
self._document = updated
|
||||
return updated
|
||||
|
||||
def _read(self) -> ConfigDocument:
|
||||
if not self.path.exists():
|
||||
return ConfigDocument()
|
||||
try:
|
||||
return ConfigDocument.model_validate_json(self.path.read_text(encoding="utf-8"))
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(value, dict):
|
||||
# Builds through 1.0.2 persisted this global consent bit.
|
||||
# The embedded SDK is now trusted to use all local memory, so
|
||||
# accept existing files while discarding the retired setting.
|
||||
value.pop("model_content_allowed", None)
|
||||
return ConfigDocument.model_validate(value)
|
||||
except (OSError, ValueError) as error:
|
||||
raise ConfigFileError("config_invalid") from error
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ LEGACY_MEMORY_WRITE_JOB_COLUMNS = (
|
||||
("last_error", "TEXT"),
|
||||
)
|
||||
|
||||
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL, UNKNOWN)
|
||||
_FIFO_BLOCKING_STATES = (PENDING, RUNNING, WAITING_FOR_PERMISSION, WAITING_FOR_MODEL)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -1492,6 +1492,59 @@ class DurableWriteQueue:
|
||||
)
|
||||
return len(job_ids)
|
||||
|
||||
def retry_first_unknown_after_reconcile(
|
||||
self,
|
||||
*,
|
||||
older_than: datetime,
|
||||
limit: int = 32,
|
||||
) -> int:
|
||||
"""Retry one unresolved unknown outcome once, after reconciliation.
|
||||
|
||||
The caller first asks HY and checks linked governed output. Rows still
|
||||
unknown may have lost the response after the SDK call, so one bounded
|
||||
retry is useful. `attempts=1` is the guard: the retry claim increments
|
||||
it to two, and a second unknown is left for explicit user recovery.
|
||||
"""
|
||||
if type(limit) is not int or limit < 1:
|
||||
raise ValueError("limit must be positive")
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with (
|
||||
main_database(self.database_path).write(row_factory=sqlite3.Row) as connection,
|
||||
connection,
|
||||
):
|
||||
rows = connection.execute(
|
||||
"SELECT job_id FROM memory_write_jobs "
|
||||
"WHERE state=? AND attempts=1 AND attempts < max_attempts "
|
||||
"AND updated_at <= ? ORDER BY updated_at, job_id LIMIT ?",
|
||||
(UNKNOWN, older_than.isoformat(), limit),
|
||||
).fetchall()
|
||||
job_ids = tuple(str(row["job_id"]) for row in rows)
|
||||
if not job_ids:
|
||||
return 0
|
||||
placeholders = ",".join("?" for _ in job_ids)
|
||||
connection.execute(
|
||||
f"UPDATE memory_write_jobs SET state=?, lease_owner=NULL, "
|
||||
f"lease_expires_at=NULL, hy_invoked=0, "
|
||||
f"last_error='model_content_unknown_auto_retry', updated_at=? "
|
||||
f"WHERE state=? AND attempts=1 AND job_id IN ({placeholders})",
|
||||
(PENDING, now, UNKNOWN, *job_ids),
|
||||
)
|
||||
if connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='memory_tasks'"
|
||||
).fetchone():
|
||||
connection.execute(
|
||||
f"UPDATE memory_tasks SET status=?, runner_token=NULL, "
|
||||
f"last_error_code='model_content_unknown_auto_retry', updated_at=? "
|
||||
f"WHERE status=? AND task_id IN ("
|
||||
f"SELECT inputs.task_id FROM memory_task_inputs AS inputs "
|
||||
f"JOIN memory_write_job_evidence AS linked ON EXISTS ("
|
||||
f"SELECT 1 FROM json_each(inputs.refs_json) AS ref "
|
||||
f"WHERE json_extract(ref.value, '$.evidence_id')=linked.evidence_id) "
|
||||
f"WHERE linked.job_id IN ({placeholders}))",
|
||||
(MemoryTaskStatus.PENDING, now, MemoryTaskStatus.UNKNOWN, *job_ids),
|
||||
)
|
||||
return len(job_ids)
|
||||
|
||||
def resolve_unknown_from_linked_output(self, job_id: str) -> bool:
|
||||
"""Close an unknown durable row when local governance output proves success."""
|
||||
with main_database(self.database_path).read(row_factory=sqlite3.Row) as connection:
|
||||
@@ -1896,6 +1949,13 @@ class DurableWriteDrain:
|
||||
#: Reaping is a write; it runs at most once per this interval on the
|
||||
#: idle path so an empty queue does not issue one write per poll.
|
||||
self._reap_interval = reap_interval
|
||||
# Unknown recovery is user-visible and lightweight enough to check
|
||||
# sooner than general database housekeeping. Tests that set the common
|
||||
# interval to zero keep immediate maintenance.
|
||||
self._unknown_reconcile_interval = min(
|
||||
reap_interval,
|
||||
timedelta(seconds=10),
|
||||
)
|
||||
#: The maintenance writes below are recoverability/retention work, not
|
||||
#: idle housekeeping, so each has its own interval and runs at the start
|
||||
#: of every drain tick. Sharing one timestamp would let a busy tick
|
||||
@@ -2308,7 +2368,8 @@ class DurableWriteDrain:
|
||||
)
|
||||
if self.reconcile_unknown is not None and (
|
||||
self._last_unknown_reconcile is None
|
||||
or tick_started - self._last_unknown_reconcile >= self._reap_interval
|
||||
or tick_started - self._last_unknown_reconcile
|
||||
>= self._unknown_reconcile_interval
|
||||
):
|
||||
self._last_unknown_reconcile = tick_started
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The result decides whether the `unknown` job state can ever be retired, so it
|
||||
is probed against the installed SDK rather than written down from memory.
|
||||
|
||||
What was verified against `hy_memory-internal==2.0.1`:
|
||||
What was verified against `hy-memory==2.0.3`:
|
||||
|
||||
- `HyMemoryClient.add(..., request_id=...)` accepts a caller-supplied id and
|
||||
echoes it back. It is described as a request/receipt handle, and the SDK
|
||||
|
||||
@@ -1404,7 +1404,14 @@ async def _024_matter_model_cache(cursor: Any) -> None:
|
||||
|
||||
|
||||
async def _026_hy_node_permissions(cursor: Any) -> None:
|
||||
await cursor.execute(hy_node_permissions_ddl(_dialect(cursor)))
|
||||
# Historical migration retained as a ledger slot. Fresh databases no
|
||||
# longer create this retired proof table; migration 51 removes it from
|
||||
# databases that ran the original v26 body.
|
||||
return None
|
||||
|
||||
|
||||
async def _051_drop_hy_node_permissions(cursor: Any) -> None:
|
||||
await cursor.execute("DROP TABLE IF EXISTS hy_node_permissions")
|
||||
|
||||
|
||||
def model_profiles_ddl(dialect: str = "mysql") -> str:
|
||||
@@ -2756,7 +2763,7 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
),
|
||||
Migration(23, "source_candidates", _023_source_candidates, ("source_candidates",)),
|
||||
Migration(24, "matter_model_cache", _024_matter_model_cache, ("matter_model_cache",)),
|
||||
Migration(26, "hy_node_permissions", _026_hy_node_permissions, ("hy_node_permissions",)),
|
||||
Migration(26, "hy_node_permissions", _026_hy_node_permissions),
|
||||
Migration(
|
||||
27,
|
||||
"model_profiles",
|
||||
@@ -2841,4 +2848,5 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
"model_probe_operations_reserved_at",
|
||||
_050_model_probe_operations_reserved_at,
|
||||
),
|
||||
Migration(51, "drop_hy_node_permissions", _051_drop_hy_node_permissions),
|
||||
)
|
||||
@@ -267,15 +267,13 @@ class MemoryTaskOutput(BaseModel):
|
||||
MEMORY_TASK_SCHEMA = memory_tasks_ddl("sqlite")
|
||||
|
||||
|
||||
#: Transport refusals raised before the physical send, so provably nothing
|
||||
#: reached HY: nothing can have been written and retrying cannot duplicate.
|
||||
#: Legacy transport refusals raised before the physical send, so provably
|
||||
#: nothing reached HY: nothing can have been written and retrying cannot
|
||||
#: duplicate. Kept to recover rows written by builds through 1.0.2.
|
||||
#:
|
||||
#: Every name here is denied by `HyPermissionTransport._check_request` (the
|
||||
#: httpx request hook) or by `_PhysicalTransport.handle_async_request` before
|
||||
#: it counts the request — both run inside httpx, before the socket. Explicit
|
||||
#: on purpose (D88): the transport also denies `budget_exceeded`, `redirect`,
|
||||
#: `lease_*` and `request_failed`, all of which can arrive after HY persisted
|
||||
#: a node, so a prefix rule would requeue work that may already exist.
|
||||
#: The retired request hook denied every name below before opening a socket.
|
||||
#: This stays explicit: other old transport codes may have happened after HY
|
||||
#: persisted a node and must not be blindly requeued.
|
||||
_PRE_SEND_REFUSALS = (
|
||||
"model_content_transport_headers_mismatch",
|
||||
"model_content_transport_body_mismatch",
|
||||
@@ -2163,6 +2161,43 @@ class MemoryTaskStore:
|
||||
)
|
||||
return changed.rowcount
|
||||
|
||||
def retry_first_unknown_after_reconcile(
|
||||
self,
|
||||
*,
|
||||
older_than: datetime,
|
||||
limit: int = 128,
|
||||
) -> int:
|
||||
"""Automatically retry a task-only unknown exactly once.
|
||||
|
||||
Reconciliation runs before this transition. Attempts are preserved, so
|
||||
the retry claim moves `attempts=1` to two and another unknown is left
|
||||
for explicit user recovery.
|
||||
"""
|
||||
if type(limit) is not int or limit < 1:
|
||||
raise ValueError("limit must be positive")
|
||||
now = datetime.now(UTC).isoformat()
|
||||
with self._connect(writing=True) as connection:
|
||||
changed = connection.execute(
|
||||
"""
|
||||
UPDATE memory_tasks
|
||||
SET status=?, runner_token=NULL,
|
||||
last_error_code='model_content_unknown_auto_retry', updated_at=?
|
||||
WHERE task_id IN (
|
||||
SELECT task_id FROM memory_tasks
|
||||
WHERE status=? AND attempts=1 AND updated_at <= ?
|
||||
ORDER BY updated_at, task_id LIMIT ?
|
||||
)
|
||||
""",
|
||||
(
|
||||
MemoryTaskStatus.PENDING,
|
||||
now,
|
||||
MemoryTaskStatus.UNKNOWN,
|
||||
older_than.isoformat(),
|
||||
limit,
|
||||
),
|
||||
)
|
||||
return changed.rowcount
|
||||
|
||||
def recover_running(self) -> int:
|
||||
"""Return tasks orphaned by a previous process to the pending queue."""
|
||||
return self.recover_orphan_running()
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""One global final-state permission for remote model content egress."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, cast
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.params import Depends
|
||||
from pydantic import BaseModel, ConfigDict, StrictBool
|
||||
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
|
||||
|
||||
class ModelContentPermissionState(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
allowed: bool
|
||||
|
||||
|
||||
class SetModelContentPermission(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
allowed: StrictBool
|
||||
|
||||
|
||||
def model_content_router(dependencies: Sequence[Depends]) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/model-content-permission", dependencies=dependencies)
|
||||
|
||||
def config(request: Request) -> ConfigStore:
|
||||
return cast(ConfigStore, request.app.state.config_store)
|
||||
|
||||
Config = Annotated[ConfigStore, Depends(config)]
|
||||
|
||||
@router.get("", response_model=ModelContentPermissionState)
|
||||
async def current(store: Config) -> ModelContentPermissionState:
|
||||
return ModelContentPermissionState(allowed=store.model_content_allowed())
|
||||
|
||||
@router.post("", response_model=ModelContentPermissionState)
|
||||
async def set_permission(
|
||||
request: Request,
|
||||
value: SetModelContentPermission,
|
||||
store: Config,
|
||||
) -> ModelContentPermissionState:
|
||||
await asyncio.to_thread(store.set_model_content_allowed, value.allowed)
|
||||
if value.allowed:
|
||||
request.app.state.start_memory_backfill()
|
||||
return ModelContentPermissionState(allowed=value.allowed)
|
||||
|
||||
return router
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Live global permission checks for remote model-content egress.
|
||||
|
||||
``config.json`` is the only durable permission state. Tasks do not carry a
|
||||
permission receipt, revision, source scope, or model pin. Every remote send
|
||||
and every publication boundary comes back through the lease and reads the
|
||||
current final-state boolean.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from one_memory.hy_source_context import HySourceContext
|
||||
from one_memory.imports.models import EvidenceRecord
|
||||
from one_memory.memory_governance import MemoryGovernanceStore, MemoryStatus
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
from one_memory.memory_tasks import MemoryTask, MemoryTaskStatus
|
||||
from one_memory.model_profiles import ModelProfileSnapshot, ModelProfileStore
|
||||
|
||||
|
||||
def permission_error(code: str) -> RuntimeError:
|
||||
from one_memory.hy_memory_bridge import HyMemoryBridgeError
|
||||
|
||||
return HyMemoryBridgeError(
|
||||
code if code.startswith("model_content_") else f"model_content_{code}"
|
||||
)
|
||||
|
||||
|
||||
def error_is_policy_stop(code: str) -> bool:
|
||||
"""A disabled final-state permission is an intentional stop."""
|
||||
|
||||
return code in {
|
||||
"model_content_source_disabled",
|
||||
"hy_memory_evidence_changed",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelProcessingPermissionLease:
|
||||
task_id: str
|
||||
user_id: str
|
||||
profile: ModelProfileSnapshot
|
||||
records: tuple[EvidenceRecord, ...]
|
||||
source_context: HySourceContext | None
|
||||
_guard: ModelProcessingPermissionGuard = field(repr=False, compare=False)
|
||||
|
||||
def recheck(self) -> None:
|
||||
"""Read current config/profile immediately before an egress boundary."""
|
||||
|
||||
self.recheck_permission()
|
||||
self._guard.check_context(self.records, self.source_context)
|
||||
|
||||
def recheck_permission(self) -> None:
|
||||
self._guard.check(self.profile, self.records)
|
||||
|
||||
|
||||
class ModelProcessingPermissionGuard:
|
||||
def __init__(
|
||||
self,
|
||||
config: ConfigStore,
|
||||
governance: MemoryGovernanceStore,
|
||||
profiles: ModelProfileStore,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.governance = governance
|
||||
self.profiles = profiles
|
||||
|
||||
def require_allowed(self) -> None:
|
||||
if not self.config.model_content_allowed():
|
||||
raise permission_error("permission_required")
|
||||
|
||||
def check(
|
||||
self,
|
||||
profile: ModelProfileSnapshot,
|
||||
records: tuple[EvidenceRecord, ...] = (),
|
||||
) -> None:
|
||||
"""Validate only live global consent and the currently active model."""
|
||||
|
||||
self.require_allowed()
|
||||
if self.profiles.active_identity() != (profile.profile_id, profile.revision):
|
||||
raise permission_error("profile_changed")
|
||||
if records and any(record.user_id != records[0].user_id for record in records):
|
||||
raise permission_error("context_scope_invalid")
|
||||
|
||||
def lease(
|
||||
self,
|
||||
task: MemoryTask,
|
||||
records: tuple[EvidenceRecord, ...],
|
||||
context: HySourceContext | None,
|
||||
) -> ModelProcessingPermissionLease:
|
||||
combined = self.context_records(records, context)
|
||||
lease = ModelProcessingPermissionLease(
|
||||
task_id=task.task_id,
|
||||
user_id=task.user_id,
|
||||
profile=task.profile_snapshot,
|
||||
records=combined,
|
||||
source_context=context,
|
||||
_guard=self,
|
||||
)
|
||||
lease.recheck()
|
||||
return lease
|
||||
|
||||
@staticmethod
|
||||
def context_records(
|
||||
records: tuple[EvidenceRecord, ...],
|
||||
context: HySourceContext | None,
|
||||
) -> tuple[EvidenceRecord, ...]:
|
||||
by_id = {record.evidence_id: record for record in records}
|
||||
if len(by_id) != len(records):
|
||||
raise permission_error("context_scope_invalid")
|
||||
if context is not None:
|
||||
for record in (*context.current, *context.history, *context.supporting):
|
||||
previous = by_id.get(record.evidence_id)
|
||||
if previous is not None and previous != record:
|
||||
raise permission_error("evidence_changed")
|
||||
by_id[record.evidence_id] = record
|
||||
return tuple(by_id.values())
|
||||
|
||||
def check_context(
|
||||
self,
|
||||
records: tuple[EvidenceRecord, ...],
|
||||
context: HySourceContext | None,
|
||||
) -> None:
|
||||
if context is None:
|
||||
return
|
||||
ids = {record.evidence_id for record in records}
|
||||
if not context.current:
|
||||
raise permission_error("context_scope_invalid")
|
||||
context_records = (*context.current, *context.history, *context.supporting)
|
||||
if len({record.evidence_id for record in context_records}) != len(context_records):
|
||||
raise permission_error("context_scope_invalid")
|
||||
for known in context.known_memories:
|
||||
if not known.evidence_ids or not set(known.evidence_ids) <= ids:
|
||||
raise permission_error("context_support_missing")
|
||||
memory = self.governance.get(
|
||||
known.governed_memory_id,
|
||||
user_id=context.current[0].user_id,
|
||||
)
|
||||
if (
|
||||
memory is None
|
||||
or memory.revision != known.revision
|
||||
or memory.content != known.content
|
||||
or set(memory.evidence_ids) != set(known.evidence_ids)
|
||||
or memory.status is not MemoryStatus.CONFIRMED
|
||||
):
|
||||
raise permission_error("context_changed")
|
||||
|
||||
def reject_unstarted(self, task_id: str, code: str) -> None:
|
||||
stopped = error_is_policy_stop(code)
|
||||
with self._task_write() as tasks:
|
||||
tasks.execute(
|
||||
"UPDATE memory_tasks SET status=?, last_error_code=?, updated_at=? "
|
||||
"WHERE task_id=? AND status IN (?,?,?,?)",
|
||||
(
|
||||
MemoryTaskStatus.CANCELLED if stopped else MemoryTaskStatus.FAILED,
|
||||
str(permission_error(code)),
|
||||
datetime.now(UTC).isoformat(),
|
||||
task_id,
|
||||
MemoryTaskStatus.PENDING,
|
||||
MemoryTaskStatus.WAITING_FOR_PERMISSION,
|
||||
MemoryTaskStatus.WAITING_FOR_MODEL,
|
||||
MemoryTaskStatus.DEFERRED_OUTSIDE_WINDOW,
|
||||
),
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _task_write(self) -> Iterator[object]:
|
||||
from one_memory.database import take_the_write_lock_first
|
||||
from one_memory.database_access import main_database
|
||||
|
||||
with main_database(self.governance.database_path).write(
|
||||
autocommit=True,
|
||||
foreign_keys=True,
|
||||
) as connection:
|
||||
take_the_write_lock_first(connection)
|
||||
with connection:
|
||||
yield connection
|
||||
@@ -68,6 +68,16 @@ COMPLETE_INSERT_MYSQL = (
|
||||
"singleton, status, selected_source_ids_json, started_at, completed_at, updated_at"
|
||||
") VALUES (1, 'completed', '[]', NULL, %s, %s)"
|
||||
)
|
||||
RESET_UPDATE_SQLITE = (
|
||||
"UPDATE onboarding_state SET status = 'not_started', "
|
||||
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
||||
"updated_at = ? WHERE singleton = 1"
|
||||
)
|
||||
RESET_UPDATE_MYSQL = (
|
||||
"UPDATE onboarding_state SET status = 'not_started', "
|
||||
"selected_source_ids_json = '[]', started_at = NULL, completed_at = NULL, "
|
||||
"updated_at = %s WHERE singleton = 1"
|
||||
)
|
||||
|
||||
|
||||
class OnboardingState(BaseModel):
|
||||
@@ -140,6 +150,30 @@ class OnboardingStore:
|
||||
)
|
||||
return self.get()
|
||||
|
||||
def reset(self) -> OnboardingState:
|
||||
"""Return onboarding to its first-run state, touching nothing else.
|
||||
|
||||
The completion flag is stored beside the memories on purpose: a fresh
|
||||
install of the app must not greet someone who already has memories as a
|
||||
first-time user. That makes the way back to the cold-start screens an
|
||||
explicit action rather than a side effect of reinstalling, and it is
|
||||
the whole reason this exists: QA and long-time users need to see those
|
||||
screens again without giving up a single memory.
|
||||
|
||||
Sources, memories and settings are separate tables and are not read or
|
||||
written here.
|
||||
"""
|
||||
now = datetime.now(UTC).isoformat()
|
||||
mysql = is_mysql(self._store)
|
||||
with self._connect(writing=True) as connection:
|
||||
execute(
|
||||
connection,
|
||||
mysql,
|
||||
RESET_UPDATE_MYSQL if mysql else RESET_UPDATE_SQLITE,
|
||||
(now,),
|
||||
)
|
||||
return self.get()
|
||||
|
||||
@contextmanager
|
||||
def _connect(
|
||||
self,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Continuous recovery monitor for model processing.
|
||||
|
||||
The monitor is intentionally retained. It observes the global config final
|
||||
state plus ``memory_tasks`` and asks the supervisor to recover work; it owns no
|
||||
second request/receipt state machine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Hashable
|
||||
|
||||
from one_memory.memory_processing import MemoryProcessingSupervisor
|
||||
from one_memory.memory_server.config_state import ConfigStore
|
||||
|
||||
|
||||
class ProcessingCoordinator:
|
||||
RESCAN_SECONDS = 60.0
|
||||
POLL_SECONDS = 2.0
|
||||
|
||||
def __init__(self, config: ConfigStore, supervisor: MemoryProcessingSupervisor):
|
||||
self.config = config
|
||||
self.supervisor = supervisor
|
||||
self._stopping = False
|
||||
self._monitor: asyncio.Task[None] | None = None
|
||||
self._error: str | None = None
|
||||
self._observed: tuple[Hashable, ...] | None = None
|
||||
self._last_scan = 0.0
|
||||
self._clock = time.monotonic
|
||||
|
||||
async def recover(self) -> None:
|
||||
"""Start the one process-owned monitor, whether permission is on or off."""
|
||||
|
||||
if self._stopping:
|
||||
return
|
||||
if self._monitor is None or self._monitor.done():
|
||||
self._monitor = asyncio.create_task(
|
||||
self._monitor_loop(),
|
||||
name="one-memory-processing-monitor",
|
||||
)
|
||||
|
||||
def _identity(self) -> tuple[Hashable, ...]:
|
||||
counts = self.supervisor.tasks.counts()
|
||||
return (
|
||||
self.config.model_content_allowed(),
|
||||
tuple(sorted((str(status), count) for status, count in counts.items())),
|
||||
)
|
||||
|
||||
async def _tick(self) -> None:
|
||||
identity = await asyncio.to_thread(self._identity)
|
||||
now = self._clock()
|
||||
if identity[0] and (
|
||||
identity != self._observed or now - self._last_scan >= self.RESCAN_SECONDS
|
||||
):
|
||||
await self.supervisor.resume_allowed()
|
||||
self._last_scan = now
|
||||
identity = await asyncio.to_thread(self._identity)
|
||||
self._observed = identity
|
||||
self._error = None
|
||||
|
||||
async def _monitor_loop(self) -> None:
|
||||
while not self._stopping:
|
||||
try:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
self._error = "model_content_recovery_blocked"
|
||||
await asyncio.sleep(self.POLL_SECONDS)
|
||||
|
||||
def status(self) -> dict[str, object]:
|
||||
return {
|
||||
"monitoring": self._monitor is not None and not self._monitor.done(),
|
||||
"allowed": self._observed[0] if self._observed else None,
|
||||
"error": self._error,
|
||||
}
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._stopping = True
|
||||
if self._monitor is not None:
|
||||
self._monitor.cancel()
|
||||
await asyncio.gather(self._monitor, return_exceptions=True)
|
||||
@@ -13,10 +13,7 @@
|
||||
| `test_archive_extract.py` | `contract` | Unpacking an archive a stranger sent, onto this user's disk. |
|
||||
| `test_ask_workspace_path.py` | `contract` | Ask must honour the same workspace_path expansion recall uses. |
|
||||
| `test_async_bridge.py` | `contract` | 覆盖 reader rejects parallel workers that could close during next、cancelling a read stops the worker before the batch finishes、starting a read does not stop the app answering 等 10 个场景。 |
|
||||
| `test_async_dispatch_checks.py` | `contract` | 覆盖 quiet arrival guard is off loop and 5000 events coalesce、live preparation cancel joins thread without late dispatch、quiet preparation rechecks permission before credentials or durable work 等 6 个场景。 |
|
||||
| `test_auth.py` | `integration` | 覆盖 local api requires bearer token、browser origin is checked in addition to token 场景。 |
|
||||
| `test_backfill_acceptance_lifecycle.py` | `integration` | A processing acknowledgement belongs to one consent revision and live worker. |
|
||||
| `test_backfill_conversation_isolation.py` | `contract` | One stale conversation cannot invalidate an entire backfill scan. |
|
||||
| `test_background_work.py` | `contract` | 覆盖 a request does not wait behind a catch up、bulk work is kept to a few threads at a time、the pool outlives one hand off 等 4 个场景。 |
|
||||
| `test_backup_destroy_restore_reconciliation.py` | `contract` | Backup -> destroy -> restore, reconciled row for row. |
|
||||
| `test_backup_endpoint.py` | `integration` | The 'POST /api/v1/backup' endpoint the CLI's 'backup create' calls. |
|
||||
@@ -25,7 +22,6 @@
|
||||
| `test_bug01_workbuddy_root.py` | `contract` | 3925a kernel item 2 (BUG-01, backend half): WorkBuddy import root. |
|
||||
| `test_bug03_identity_key.py` | `contract` | BUG-03: the hook lane and the file scan must share one identity for one message. |
|
||||
| `test_bug04_thinking_strip.py` | `contract` | BUG-04: Cursor thinking text must not be stored as the assistant's answer. |
|
||||
| `test_bulk_import_experience.py` | `contract` | A-6:大数据量导入的自动检查。 |
|
||||
| `test_c1_append_per_source.py` | `contract` | Append, per source, including the SQLite-backed ones. |
|
||||
| `test_c1_attribution_per_source.py` | `contract` | Attribution per source, asserted on stored Evidence, not just on parsed events. |
|
||||
| `test_c1_conversation_identity_scope.py` | `contract` | Same-named source sessions must stay separate through the read path. |
|
||||
@@ -65,34 +61,17 @@
|
||||
| `test_cli_lifecycle.py` | `integration` | 覆盖 cli starts and stops without residual runtime 场景。 |
|
||||
| `test_cli_restore.py` | `contract` | 覆盖 restore requires a source、restore refuses while the runtime is running、restore previews without confirmation 等 4 个场景。 |
|
||||
| `test_codex_header_provenance.py` | `contract` | 覆盖 header workspace versions and native ids reach file origin、new adapter rereads header after a real checkpoint、machine and unknown session sources do not establish human or agent sender 等 14 个场景。 |
|
||||
| `test_compact_model_content.py` | `contract` | Server-held previews preserve exact grants without round-tripping all refs. |
|
||||
| `test_concurrent_import_processing.py` | `integration` | Four real source formats/Core scheduling, with a blocked failing model boundary. |
|
||||
| `test_connection_lifetime.py` | `integration` | Every SQLite connection a request opens must be closed when the request ends. |
|
||||
| `test_context_access.py` | `contract` | 覆盖 memory only never reads raw authority、raw permission does not grant memory and removes revoked sources、raw requires all apps and source 等 13 个场景。 |
|
||||
| `test_context_api.py` | `integration` | 覆盖 no model local query returns real raw and governed items、existing single lane authorization never runs denied reader、authentication and origin remain required 等 14 个场景。 |
|
||||
| `test_context_auto_recall.py` | `contract` | 覆盖 needed history queries trigger without excluding recent turns、ordinary messages do not trigger、auto recall respects disable permission and satisfied state 等 7 个场景。 |
|
||||
| `test_context_endpoint_guard.py` | `contract` | 覆盖 composite route accepts existing read capabilities for later body validation、other capabilities do not enable composite read、composite read does not open other routes 等 5 个场景。 |
|
||||
| `test_context_expand.py` | `contract` | 覆盖 signed expansion returns only bounded same conversation、expansion uses source order not database insertion order、reference is bound to runtime host scope and revision 等 11 个场景。 |
|
||||
| `test_context_feedback.py` | `contract` | 覆盖 explicit feedback contains only offline label and versioned refs、click copy injection and assistant claims are not feedback、feedback cannot be unversioned duplicated or retained forever by omission 等 6 个场景。 |
|
||||
| `test_context_host_artifact.py` | `unit` | 覆盖 each priority host gets an independent artifact、host deadline drops content but preserves source coverage receipt、host character budget omits whole group and never truncates a citation 等 12 个场景。 |
|
||||
| `test_context_hy_primary.py` | `contract` | MemHub_context memory lane: HY decides the order, keywords only when HY does not answer. |
|
||||
| `test_context_memory.py` | `contract` | 覆盖 permissions precede candidate limit and do not leak counts、scope user status and active source isolation、denied scope does not open database 等 60 个场景。 |
|
||||
| `test_context_memory_scale_benchmark.py` | `contract` | 覆盖 memory scale probe covers the required layers and target、memory scale rows preserve governed memory schema shape、memory scale builds the candidate index before measuring 等 9 个场景。 |
|
||||
| `test_context_mutations.py` | `contract` | 覆盖 raw candidate never survives update delete or revoke、memory candidate never survives update delete revoke or lifecycle、denied memory never consumes candidate limit before return 等 5 个场景。 |
|
||||
| `test_context_query_planner.py` | `contract` | 覆盖 local mode performs zero authorization and network calls、authorization precedes exactly one audited transport call、denied or expired budget never calls transport 等 6 个场景。 |
|
||||
| `test_context_raw.py` | `contract` | 覆盖 empty authority and unrequested path do not open database、permissions precede candidate limit and no model needed、explicit source filter never broadens authority 等 37 个场景。 |
|
||||
| `test_context_raw_scale_benchmark.py` | `performance` | 覆盖 raw scale probe uses fts and has a nonzero positive control、object scale probe has exact positive control and plan 场景。 |
|
||||
| `test_context_resume.py` | `contract` | 覆盖 resume packet keeps literal sources and missing fields、agent report and tool output are not system acceptance、only trusted receipt identity marks system acceptance 等 9 个场景。 |
|
||||
| `test_context_raw_scale_benchmark.py` | `contract` | 覆盖 object scale probe has exact positive control and plan、raw scale probe uses fts and has a nonzero positive control 场景。 |
|
||||
| `test_context_search.py` | `contract` | 覆盖 authorization and requested paths never invoke other lane、empty partial zero unavailable and safe failures、parallel common deadline and finalizers wait for both reads 等 24 个场景。 |
|
||||
| `test_context_search_both_paths.py` | `integration` | The objective names 'recall' and requires BOTH paths: raw and memory. |
|
||||
| `test_context_v2_contract_parity.py` | `unit` | Mechanical parity checks for the shared v2 coverage receipt contract. |
|
||||
| `test_context_v2_coordinator.py` | `contract` | 覆盖 no model path returns raw and memory with exact evidence、semantic failure is not recast as empty、empty lexical with incomplete semantic index is not complete no hit 等 52 个场景。 |
|
||||
| `test_continuous_completed_input_review.py` | `contract` | Independent review of completed-input reuse; real stores and controlled HY only. |
|
||||
| `test_continuous_coordinator.py` | `contract` | 覆盖 restart replays lost callback into same durable task queue、new journal event and source regrant wake replay without legacy request、activation hook and startup recovery start single monitor then stop 等 6 个场景。 |
|
||||
| `test_continuous_outcome_unknown.py` | `contract` | 覆盖 crashed continuous send is not replayed but new event progresses、legacy running recovery remains pending and no continuous table、a timed out non continuous task also becomes unknown 等 8 个场景。 |
|
||||
| `test_continuous_policy.py` | `contract` | 覆盖 explicit policy never upgrades legacy or sends or creates tasks、backlog snapshot over one hundred thousand is compact、legacy backlog receipt remains readable 等 19 个场景。 |
|
||||
| `test_continuous_processing_bridge.py` | `contract` | 覆盖 continuous prepare lease ingest has no legacy receipt or old context、continuous inactive never falls back to legacy、revocation rechecked at runner and publication boundaries 等 17 个场景。 |
|
||||
| `test_continuous_processing_integration.py` | `integration` | Real temporary Core/stores with controlled probe and HY boundaries, never real data. |
|
||||
| `test_contract_parity.py` | `contract` | Python 的枚举和 TypeScript 里那份手写的联合类型必须一致。 |
|
||||
| `test_conversation_search.py` | `integration` | Searching imported conversations before any generation model is configured. |
|
||||
| `test_conversation_seed_ids.py` | `contract` | 覆盖 seed ids read metadata only without hydrating、seed eligibility keeps person and memory content only、latest activity includes nonseed turns and import time fallback 等 13 个场景。 |
|
||||
@@ -105,10 +84,9 @@
|
||||
| `test_daemon_contract.py` | `unit` | A5-01/A5-03: process ownership, and the three grants kept apart. |
|
||||
| `test_daemon_scheduler.py` | `contract` | A5-02/A5-04: the daemon's scan scheduler and composed health. |
|
||||
| `test_data_safety.py` | `contract` | A5-05: backup usability/rotation, crash recovery, log retention. |
|
||||
| `test_database_contention.py` | `integration` | Writers meet in this one file, and the loser used to lose data. |
|
||||
| `test_database_contention.py` | `contract` | Writers meet in this one file, and the loser used to lose data. |
|
||||
| `test_database_file_permissions.py` | `contract` | 覆盖 a new main database is readable by its owner alone、the wal sidecars are not left world readable、a database from an older build is tightened on open 场景。 |
|
||||
| `test_db_session_utc.py` | `live` | The MySQL session must be pinned to UTC. |
|
||||
| `test_delete_no_conversation_freeze.py` | `contract` | 删掉一条记忆,不得让同一对话之后的新 turn 停止沉淀(RC10)。 |
|
||||
| `test_db_session_utc.py` | `contract` | The MySQL session must be pinned to UTC. |
|
||||
| `test_delivery_board.py` | `contract` | 覆盖 parses only the marked structured record、verified requires evidence with a positive control、rejects unknown dependency 等 5 个场景。 |
|
||||
| `test_derived_echo.py` | `contract` | A host that saves our injection back must not mint a new user decision. |
|
||||
| `test_detailed_logging.py` | `contract` | 覆盖 detailed pipeline logging defaults off and survives restart、live log level applies to full hy and provider call chain、info collects hy info without enabling debug pipeline content 等 7 个场景。 |
|
||||
@@ -122,7 +100,6 @@
|
||||
| `test_dual_engine_helper.py` | `contract` | Tests for the dual-engine harness itself. |
|
||||
| `test_event_loop_blocking.py` | `contract` | Proof, as a test, that blocking work on the loop freezes it — and the fix works. |
|
||||
| `test_evidence_reconcile_primitive.py` | `integration` | A bounded, read-only discovery primitive for the H23 Evidence reconcile. |
|
||||
| `test_evidence_revision_read_review.py` | `contract` | Read invalidation review: real guard/provenance, controlled HY, no model/network. |
|
||||
| `test_evidence_store.py` | `contract` | 覆盖 same source message merges origins without duplication、identical text from different sources is not deduplicated、rescanning an unchanged file does not write the message again 等 26 个场景。 |
|
||||
| `test_export_probe.py` | `contract` | What a probe may say about a stranger's chat history, and what it may not. |
|
||||
| `test_external_endpoint_scopes.py` | `integration` | Core boundary tests using the broker's envelope, never a host's claimed identity. |
|
||||
@@ -136,10 +113,9 @@
|
||||
| `test_file_import_operations.py` | `integration` | Bounded synthetic uploads; operation identity is not a content dedup key. |
|
||||
| `test_file_import_operations_review.py` | `contract` | Upload refusal/legacy compatibility review with bounded synthetic files only. |
|
||||
| `test_file_publish_job_boundary.py` | `contract` | Staged uploads cannot widen an earlier directory import's uncaptured input. |
|
||||
| `test_four_app_live_ingest.py` | `integration` | Actual source readers + OS watch + Core routes; no model or native host calls. |
|
||||
| `test_four_app_live_ingest.py` | `contract` | Actual source readers + OS watch + Core routes; no model or native host calls. |
|
||||
| `test_frozen_core_layout.py` | `contract` | 覆盖 single file layout remains available without private preset、folder layout keeps same core and all native dependencies、unknown layout does not silently build another package 等 5 个场景。 |
|
||||
| `test_golden_corpus.py` | `contract` | A7-01: the golden corpus comparison. |
|
||||
| `test_governance_authority_review.py` | `contract` | D04 authority and workspace scope through the real continuous permission guard. |
|
||||
| `test_governance_permission_commit.py` | `contract` | 覆盖 permission guard is rechecked inside write transaction and rolls back、guard context does not leak into subsequent manual actions、guard check and commit hold database write authority 场景。 |
|
||||
| `test_governance_read_path_does_not_take_the_writer.py` | `contract` | The recall read path must not acquire the single global writer. |
|
||||
| `test_governance_store_root.py` | `contract` | 生产代码里治理 store 只在 Core 启动的组合根建一次。 |
|
||||
@@ -147,7 +123,7 @@
|
||||
| `test_graph_width_export.py` | `unit` | EXPORT_FIRST has to actually export. |
|
||||
| `test_graph_width_guard.py` | `unit` | Graph width mismatch must ask, never silently archive. |
|
||||
| `test_harness_contract_suite.py` | `contract` | A4-09: one contract, ten harnesses. |
|
||||
| `test_health.py` | `integration` | 覆盖 health reports the single core、core refuses to start a database written by a newer build、core stays ready when legacy policy upgrade cannot read keychain 等 15 个场景。 |
|
||||
| `test_health.py` | `contract` | 覆盖 a backfill that fails says so rather than disappearing、a failed catch up leaves a traceback behind、configured model without content consent does not start backfill 等 15 个场景。 |
|
||||
| `test_history_listing_rotation.py` | `contract` | 覆盖 history listing alternates started and untouched files、history listing drains whichever cohort is longer 场景。 |
|
||||
| `test_home_summary.py` | `integration` | 覆盖 summary counts beyond page limit and excludes ineligible rows、summary revocation excludes whole mixed memory not only citation、latest is bounded and other users revocations do not hide local data 等 6 个场景。 |
|
||||
| `test_home_summary_permissions.py` | `contract` | 覆盖 bad active link excludes whole latest memory、bad inactive link does not hide memory or enter source set、other user memory and grant are isolated 等 10 个场景。 |
|
||||
@@ -155,32 +131,19 @@
|
||||
| `test_hourly_c1_peak_replay_contract.py` | `contract` | Guard the package peak-replay probe's error accounting. |
|
||||
| `test_hourly_c1_recall_stage_probe_contract.py` | `contract` | Guard the packaged recall stage probe's command and error semantics. |
|
||||
| `test_hy_baseline_source.py` | `contract` | 覆盖 first import replay copies raw prefix without selecting message ids、frozen transcript keeps middle user message and excludes later records 场景。 |
|
||||
| `test_hy_coding_lease.py` | `contract` | HY's own coding/working route under the permission lease, on the pinned SDK. |
|
||||
| `test_hy_coding_reply.py` | `contract` | R-07: HY's coding-shaped replies read as "no memories at all". |
|
||||
| `test_hy_lease_responsiveness.py` | `contract` | Blocked lease checks, local failing doubles only; no network or credentials. |
|
||||
| `test_hy_lifecycle_sync.py` | `contract` | HY decides deletions; the ledger mirrors them. |
|
||||
| `test_hy_log_privacy.py` | `contract` | HY SDK records are not rewritten by One Memory's logging setup. |
|
||||
| `test_hy_memory_bridge.py` | `contract` | 覆盖 hy client initialization is serial across bridges、native hy clients share one runtime per data directory、ollama profile ignores ambient remote storage and models 等 41 个场景。 |
|
||||
| `test_hy_metrics_lifecycle.py` | `contract` | What the pinned public SDK's MetricsCollector does, and what that costs us. |
|
||||
| `test_hy_native_write_chain.py` | `contract` | HY writes with its own prompts and judgment; the product adds no audit of its own. |
|
||||
| `test_hy_network_egress.py` | `contract` | Where a real HY client is allowed to send bytes, and where it is not. |
|
||||
| `test_hy_permission_context.py` | `contract` | Whole native HY client with synthetic content and HTTP MockTransport, never cloud. |
|
||||
| `test_hy_permission_context_races.py` | `contract` | Bounded factory races with a temporary native HY client and no model calls. |
|
||||
| `test_hy_permission_raw_context.py` | `contract` | Real pinned HY SDK/Chroma and guards; scripted HTTP is not model acceptance. |
|
||||
| `test_hy_permission_store.py` | `contract` | Native HY node/algorithm tests with synthetic data and an in-memory backend only. |
|
||||
| `test_hy_permission_transport.py` | `contract` | Native HY entry points, synthetic inputs and MockTransport only. |
|
||||
| `test_hy_response_guard.py` | `contract` | 覆盖 invalid response retries same hy provider without changing prompt、repeated empty response fails instead of becoming no op、failure receipt survives a later successful model call 等 23 个场景。 |
|
||||
| `test_hy_response_shapes.py` | `contract` | HY response shape selection only; no provider, write routing or model calls. |
|
||||
| `test_hy_runtime_config.py` | `contract` | MEMORY.md is the audited record of what we send HY; keep it answerable. |
|
||||
| `test_hy_sdk_idempotency_probe.py` | `contract` | A0-03: probe whether the HY SDK can make a write idempotent. |
|
||||
| `test_hy_source_context.py` | `contract` | 覆盖 bound context rejects a same named conversation in another workspace、bound context rejects a duplicated evidence id、bound context rejects a same conversation from another source 等 9 个场景。 |
|
||||
| `test_hy_stage_trace.py` | `contract` | 覆盖 observer returns native results without editing or requesting models、observer redacts content without recording prompts or exception details 场景。 |
|
||||
| `test_hy_state_source.py` | `contract` | RC4:记忆生命周期(有效 / 被取代 / 去重隐藏 / 过期)直接读 HY,产品账本不再逐类镜像。 |
|
||||
| `test_hy_step_timing.py` | `contract` | Where an HY write spends the time that is not a model call. |
|
||||
| `test_hy_step_trace_switch.py` | `contract` | Rule 15: the per-step HY trace is off unless the debug tier asks for it. |
|
||||
| `test_hy_supersede_sync.py` | `contract` | HY decides supersessions; the ledger mirrors them. |
|
||||
| `test_hy_trial_budget.py` | `contract` | Offline proofs for the real-model acceptance budget (no network, no Keychain). |
|
||||
| `test_hy_turn_input.py` | `contract` | HY gets one turn at a time, exactly as it happened, and picks its own route. |
|
||||
| `test_hy_writeback.py` | `integration` | HY 的结果只有一扇门进治理表,而且每次进门都带着调用开始时的证据快照。 |
|
||||
| `test_import_api.py` | `integration` | 覆盖 resume during cleanup returns conflict without internal error、authorize detect import and revoke api flow、protected api rejects missing token and foreign origin 等 5 个场景。 |
|
||||
| `test_import_control_races_review.py` | `contract` | Read-only review repro: real runner/store, synthetic delayed adapter cleanup. |
|
||||
@@ -190,9 +153,8 @@
|
||||
| `test_import_snapshots.py` | `contract` | Atomic snapshot publication plus frozen manifest, path, mode, and per-file checksum guards. |
|
||||
| `test_import_supervisor.py` | `contract` | 覆盖 supervisor starts all granted sources together、supervisor runs enqueued job in background、harness freeze keeps pending job and thaw runs the same row 等 11 个场景。 |
|
||||
| `test_import_three_scenarios.py` | `contract` | The three import scenarios a user actually meets. |
|
||||
| `test_import_three_scenarios_real.py` | `performance` | The three import scenarios, on a real Core at a real volume. |
|
||||
| `test_import_three_scenarios_real.py` | `contract` | The three import scenarios, on a real Core at a real volume. |
|
||||
| `test_imported_index_background.py` | `contract` | The ordering index is built off the startup path. |
|
||||
| `test_incremental_memory_backfill.py` | `contract` | 覆盖 accept backfill returns after first batch while later history is still read、first task is dispatched before later conversation is prepared、cancellation joins the preparation thread before return 等 18 个场景。 |
|
||||
| `test_ingest_journal.py` | `contract` | 覆盖 baseline and empty initialize are explicit transactional and idempotent、old date new import and revise noop and rollbacks、import metadata changes invalidate without authorizing content 等 12 个场景。 |
|
||||
| `test_input_sanitization.py` | `unit` | 覆盖 redacts cli and assignment secrets without removing context、redacts bearer known tokens urls and private keys、redacts quoted json assignments and basic authorization 等 8 个场景。 |
|
||||
| `test_installer.py` | `contract` | 覆盖 installer create verify and rollback is transactional、installer updates owned file and restores backup、installer refuses unowned or changed target 场景。 |
|
||||
@@ -220,7 +182,6 @@
|
||||
| `test_main_database_fairness.py` | `contract` | Waiting for the one database has to be a queue, not a scramble. |
|
||||
| `test_main_database_isolation.py` | `contract` | What one checkout of the main database can do, and what it leaves for the next. |
|
||||
| `test_managed_restore.py` | `contract` | 覆盖 restore rejects a source outside the managed backup directory、restore rejects a symlinked source、restore rejects a non one memory database 等 9 个场景。 |
|
||||
| `test_manual_memory_via_hy.py` | `integration` | T04: what the person explicitly asks to remember is written through HY's import path. |
|
||||
| `test_manual_transcript_import.py` | `integration` | 覆盖 manual json keeps authors unknown time and exact text、marked text keeps code fences and never invents timestamps、ambiguous partial or invalid manual data never leaves partial import 等 4 个场景。 |
|
||||
| `test_matter_collection.py` | `integration` | 覆盖 hy windows cover every turn including leading and boundary assistants、oversize turn is split without silently dropping content、partial timestamps preserve source sequence 等 8 个场景。 |
|
||||
| `test_matter_discovery.py` | `contract` | 覆盖 synthesis materializes only known verbatim citations、duplicate claims keep all governed refs and distinct states、synthesis cannot echo the original sixty claim list 等 27 个场景。 |
|
||||
@@ -247,7 +208,7 @@
|
||||
| `test_memory_governance_api.py` | `integration` | 覆盖 memory governance api preserves evidence and reports cleanup、memory validation does not echo content 场景。 |
|
||||
| `test_memory_graph_api.py` | `integration` | 覆盖 graph labels where every edge came from、a created memory is on the map with its source links、drawing the map does not stop the app answering 等 12 个场景。 |
|
||||
| `test_memory_language.py` | `contract` | R-03: memory language follows the message, so Kristin's gets written in English. |
|
||||
| `test_memory_library.py` | `performance` | 覆盖 full library is reachable in stable pages、permission filter precedes count search source and page、query filters before expensive freshness 等 26 个场景。 |
|
||||
| `test_memory_library.py` | `contract` | 覆盖 full library is reachable in stable pages、permission filter precedes count search source and page、query filters before expensive freshness 等 26 个场景。 |
|
||||
| `test_memory_library_api.py` | `integration` | 覆盖 auth and external policy reject before reading、invalid parameters are 422 before projection、empty unknown source and query boundary are success not failure 等 12 个场景。 |
|
||||
| `test_memory_mutation_receipts.py` | `integration` | Read-only mutation receipts from real governance history, without model calls. |
|
||||
| `test_memory_no_candidate.py` | `integration` | 新记忆一经创建即有效;「待确认」「已拒绝」只作为旧库迁移的输入存在。 |
|
||||
@@ -278,30 +239,18 @@
|
||||
| `test_memory_write_registry.py` | `contract` | A3-01: acceptance and outcome recording for the unified write path. |
|
||||
| `test_memory_write_routes.py` | `integration` | A3-01: the HTTP write entry, tested against a real FastAPI app. |
|
||||
| `test_migrate_off_sqlite.py` | `contract` | A1-06: the migration is only done when reconciliation agrees. |
|
||||
| `test_model_content_bulk.py` | `contract` | Bulk consent reads preserve exact identities and SQLite snapshot boundaries. |
|
||||
| `test_model_content_parse_cost.py` | `contract` | Memoize parsing only; every permission decision still reads current SQLite state. |
|
||||
| `test_model_content_permissions.py` | `contract` | Content permission tests use only a temporary database and synthetic sources. |
|
||||
| `test_model_content_preview_races.py` | `contract` | Confirmation must tolerate append-only import without broadening consent. |
|
||||
| `test_model_content_routes.py` | `integration` | 覆盖 permission route reads and writes only config final state、core composition starts with config permission and durable drain 场景。 |
|
||||
| `test_model_content_scope_seals.py` | `contract` | Indexed scope invalidation retains complete-snapshot semantics without rereading it. |
|
||||
| `test_model_content_snapshot_cost.py` | `contract` | Reuse derived fingerprints only after reading every raw row in this transaction. |
|
||||
| `test_model_creation_api.py` | `integration` | Normal protected model creation routes with temporary data and in-memory secrets. |
|
||||
| `test_model_creation_operations.py` | `contract` | 覆盖 lookup without table is read only and legacy create does not initialize、operation commits with profile and version without autoactivation、any replay conflicts before secret put 等 21 个场景。 |
|
||||
| `test_model_creation_validation_boundary.py` | `integration` | Real ASGI validation must precede creation reservation and secret writes. |
|
||||
| `test_model_policy_activation.py` | `contract` | 覆盖 current is read only without policy tables、switch and policy commit together without old activate、policy append failure rolls back active and initialization 等 21 个场景。 |
|
||||
| `test_model_policy_routes.py` | `contract` | 覆盖 get does not initialize and capability defaults false、all routes require auth、factory refuses no auth dependency 等 10 个场景。 |
|
||||
| `test_model_probe.py` | `contract` | 覆盖 probe still measures embedding when structured output fails、probe records the real embedding width on the profile、openai compatible probe checks model and structured output 等 23 个场景。 |
|
||||
| `test_model_probe_input_revision.py` | `contract` | 覆盖 input revision precedes measured width update、concurrent update still rejects width or capability cas、early returns record the actual starting revision 等 6 个场景。 |
|
||||
| `test_model_probe_operations.py` | `integration` | Persisted probe identity, real SQLite/API and synthetic transport only. |
|
||||
| `test_model_processing_permissions.py` | `contract` | 覆盖 lease reads current allowed before each boundary、lease refuses a task profile that is no longer active 场景。 |
|
||||
| `test_model_profile_api.py` | `integration` | 覆盖 model profile api never returns key and enforces revision、model profile probe route returns only machine results、model profile validation never echoes api key 等 4 个场景。 |
|
||||
| `test_model_profile_context_reset.py` | `integration` | 覆盖 create context default and inclusive bounds、explicit null clears override and preserves history and secret、omitted or same value is noop with unchanged revision and metadata 等 11 个场景。 |
|
||||
| `test_model_profile_identity.py` | `contract` | Stable model editing and configured-vs-available credentials; no Keychain/network. |
|
||||
| `test_model_profile_queue_handover.py` | `contract` | Queued work when the model changes or its configuration is deleted (D75). |
|
||||
| `test_model_profiles.py` | `contract` | 覆盖 profile uses one model and keeps key out of database、api key updates reuse one stable secret reference、startup cleanup removes only secrets not referenced by current profiles 等 15 个场景。 |
|
||||
| `test_model_request_budget.py` | `contract` | 覆盖 persists failed request reservations without content、parallel leases cannot exceed request limit、unknown or excessive output and body are denied 等 6 个场景。 |
|
||||
| `test_model_request_budget_integration.py` | `integration` | Real HY leaf retries plus durable budget accounting; no network or provider. |
|
||||
| `test_model_scope_lifecycle.py` | `contract` | Bound derived/temporary data without changing consent or original history. |
|
||||
| `test_mysql_lifecycle.py` | `contract` | A1-07: the decisions Runtime makes around the bundled MySQL. |
|
||||
| `test_mysql_platform.py` | `contract` | A1-07: the machine-facing half of managing the bundled MySQL. |
|
||||
| `test_obs03_health_probe.py` | `contract` | OBS-03: '/health' answered with the web app instead of a health payload. |
|
||||
@@ -310,28 +259,23 @@
|
||||
| `test_oversized_file_guard.py` | `contract` | One pass over a source file is bounded by a byte budget, not skipped. |
|
||||
| `test_parent_watchdog.py` | `integration` | A Core that outlives its launcher makes the next launch impossible. |
|
||||
| `test_policy_api_wiring.py` | `integration` | 覆盖 global model permission off does not backfill、backfill puts history on write jobs without scheduling memory tasks、source pause skips history until the source is reenabled 场景。 |
|
||||
| `test_policy_operation_lookup.py` | `contract` | 覆盖 missing table returns not found without initializing or reading secrets、current operation uses safe receipt without callback or writes、superseded operation preserves historical receipt not current activation 等 12 个场景。 |
|
||||
| `test_policy_production_entry.py` | `contract` | 覆盖 production serve passes only supported app options、maintenance serve does not own the runtime 场景。 |
|
||||
| `test_precipitation_concurrency_setting.py` | `contract` | Rule 2/9: precipitation concurrency is configurable, not hardcoded to 1. |
|
||||
| `test_primary_source_adapters.py` | `contract` | 覆盖 pipeline json is host generated even when truncated、workbuddy record parsing never blocks the event loop、cursor store user info envelope is not attributed to the person 等 34 个场景。 |
|
||||
| `test_probe_code_contract_parity.py` | `contract` | 探测码在三处必须同时存在:python 枚举、TS 契约、界面文案表。 |
|
||||
| `test_probe_payment_required.py` | `contract` | 覆盖 http 402 without a useful body is still understood as unpaid、a wrong address is still a missing model not an unpaid one 场景。 |
|
||||
| `test_processing_coordinator.py` | `contract` | 覆盖 monitor is retained and observes config plus tasks 场景。 |
|
||||
| `test_processing_merged_context.py` | `contract` | 覆盖 new chunk omits known memory with changed support but keeps original history 场景。 |
|
||||
| `test_processing_requests.py` | `contract` | 覆盖 receipt is small frozen and persistent、grant deduplication never retriggers or changes profile、concurrent create same grant has one id and profile 等 14 个场景。 |
|
||||
| `test_product_contract_coverage.py` | `contract` | 覆盖 full app contract is part of the existing acceptance manifest、core visual phase has its own contract without erasing full product scope 场景。 |
|
||||
| `test_processing_requests.py` | `contract` | 覆盖 concurrent claim only one snapshot wins、concurrent create same grant has one id and profile、concurrent different profile never reuses another scope 等 14 个场景。 |
|
||||
| `test_quality_acceptance.py` | `contract` | 覆盖 acceptance pack is deterministic and scores personal boundary、acceptance score fails missing personal memory 场景。 |
|
||||
| `test_raw_search_without_model.py` | `integration` | Search imported conversations with no generative model configured at all. |
|
||||
| `test_raw_vector_scale_benchmark.py` | `performance` | 覆盖 vector scale probe uses real 512 dimension filtered search 场景。 |
|
||||
| `test_raw_vector_scale_benchmark.py` | `contract` | 覆盖 vector scale probe uses real 512 dimension filtered search 场景。 |
|
||||
| `test_read_path_freshness_batch.py` | `contract` | The row-by-row read check must not scale its query count with the page. |
|
||||
| `test_reader_app.py` | `integration` | The Reader process stays read-only and never blocks a read on its side effects. |
|
||||
| `test_reader_conversation_hit.py` | `contract` | A search hit carries who wrote the row it matched. |
|
||||
| `test_real_load_metrics.py` | `performance` | Run the real access layer under a production-like combined load. |
|
||||
| `test_real_model_prereq_guards.py` | `contract` | Real-model first-run preconditions must fail closed before any egress. |
|
||||
| `test_real_load_metrics.py` | `contract` | Run the real access layer under a production-like combined load. |
|
||||
| `test_recall.py` | `contract` | 覆盖 recall is confirmed only grounded and scope bound、wildcard scope means every scope just like it does in qa、recall returns empty instead of unrelated fallback 等 10 个场景。 |
|
||||
| `test_recall_during_import_probe_contract.py` | `contract` | Guard the source-level recall-during-import probe's sampling semantics. |
|
||||
| `test_recall_excluded_sources.py` | `contract` | D94: an empty result names the sources this host may not read. |
|
||||
| `test_recall_fusion_live.py` | `live` | RCL-04 against a real HY instance instead of a stand-in. |
|
||||
| `test_recall_fusion_live.py` | `integration` | RCL-04 against a real HY instance instead of a stand-in. |
|
||||
| `test_recall_hy_primary.py` | `integration` | Host recall: HY search decides the order, the ledger only filters. |
|
||||
| `test_recall_library_scale.py` | `contract` | 覆盖 recall reaches a memory that is not among the newest 500、semantic recall reaches a memory outside the newest 500、recall still works just below the boundary 等 5 个场景。 |
|
||||
| `test_recall_log_probe_exclusion.py` | `contract` | A-21:R-10 一旦引入召回日志,「探针不污染」必须自动变红而不是静默失效。 |
|
||||
@@ -343,14 +287,13 @@
|
||||
| `test_recall_scope_off_loop.py` | `contract` | The workspace lookup behind an external recall must not run on the loop. |
|
||||
| `test_recent_first_import.py` | `contract` | Recent-file priority survives restart through a selection-bound frozen copy. |
|
||||
| `test_retrieval_chunking.py` | `contract` | 覆盖 default 384 body 64 overlap and exact source slices、all prefix and special tokens count toward strict 512 budget、real tokenizer covers long chinese middle and tail object locations 等 20 个场景。 |
|
||||
| `test_retrieval_eligibility_benchmark.py` | `performance` | 覆盖 scale probe proves composite authority index changes the plan 场景。 |
|
||||
| `test_retrieval_eligibility_benchmark.py` | `contract` | 覆盖 scale probe proves composite authority index changes the plan 场景。 |
|
||||
| `test_retrieval_index_store.py` | `contract` | 覆盖 constructor and reads do not create database or schema、explicit initialization rolls back and never grants model policy、consumer and jobs commit together 等 19 个场景。 |
|
||||
| `test_retrieval_index_supervisor.py` | `contract` | 覆盖 stop waits for native exit and does not close resources、async cancellation repeatedly waits for native thread、idle no trigger no spin and duplicate start rejected 等 14 个场景。 |
|
||||
| `test_retrieval_index_worker.py` | `contract` | 覆盖 tick indexes complete original and reopen can search、change during native encoding never publishes、cancel after encode does not publish and is reclaimable 等 10 个场景。 |
|
||||
| `test_retrieval_lifespan.py` | `integration` | 覆盖 retrieval defaults off and does not create index、enabling retrieval requires runtime ownership、index enabled serve imports without building an unmanaged app 等 6 个场景。 |
|
||||
| `test_retrieval_mapped_content.py` | `contract` | 覆盖 raw authors keep exact body and do not mutate evidence、raw and author qualification rejects without tokenizing、ambiguous generated and body hygiene are not admitted 等 22 个场景。 |
|
||||
| `test_retrieval_mapped_integration.py` | `contract` | 覆盖 empty materialization rechecks excluded map、completed excluded missing active binding is not empty success、old empty completion is invalidated when a new indexed record arrives 等 24 个场景。 |
|
||||
| `test_retrieval_models.py` | `unit` | RCL v2 wire contracts only; no service, model or database is initialized. |
|
||||
| `test_retrieval_provisioning.py` | `contract` | The index has to come up through the product, not through a test helper. |
|
||||
| `test_retrieval_query_plan.py` | `contract` | Local QueryPlan: extract URL/path objects. |
|
||||
| `test_retrieval_raw_candidates.py` | `contract` | 覆盖 positive only current authorized published chunks are searched、empty authority never reads database or vector、unapproved source never enters vector query 等 15 个场景。 |
|
||||
@@ -384,16 +327,14 @@
|
||||
| `test_secret_store_error_codes.py` | `integration` | 3925a kernel item 3: keychain failure classification and backoff that heals. |
|
||||
| `test_secure_storage.py` | `integration` | 覆盖 isolated keychain service namespace、custom keychain service is refused outside isolated tests、packaged keychain budget covers loaded process startup 等 20 个场景。 |
|
||||
| `test_sedimentation_concurrency.py` | `contract` | Sedimentation runs several harnesses at once, but one harness at a time. |
|
||||
| `test_service_level_pressure_acceptance.py` | `performance` | 覆盖 workbuddy recall remains available during 100k import 场景。 |
|
||||
| `test_service_level_pressure_acceptance.py` | `contract` | 覆盖 workbuddy recall remains available during 100k import 场景。 |
|
||||
| `test_service_levels.py` | `contract` | 覆盖 background never consumes the realtime reservation、realtime is next for the same source after background yields、import history gets one slice after nine realtime admissions 等 8 个场景。 |
|
||||
| `test_session_inbox.py` | `contract` | A session inbox is not a shared mailbox and a pull is not adoption. |
|
||||
| `test_session_lease.py` | `contract` | A session file on disk is not proof that the session is online. |
|
||||
| `test_session_registry.py` | `contract` | Trusted session identity is not a workspace path. |
|
||||
| `test_settings_policy_lifecycle.py` | `contract` | Settings data-plane lifecycle: real temporary Core, fake probe/HY, no UI or package. |
|
||||
| `test_simplified_model_permission_migrations.py` | `contract` | 覆盖 fresh schema omits removed permission state machines 场景。 |
|
||||
| `test_snapshot_adapter.py` | `contract` | Selection-only binding reopens checksum-verified frozen copies. |
|
||||
| `test_snapshot_pool.py` | `contract` | A whole-directory snapshot must not hold a thread everything else is waiting for. |
|
||||
| `test_soak_probe_effectiveness.py` | `contract` | The soak probe must observe the load it reports on. |
|
||||
| `test_soak_probe_effectiveness.py` | `integration` | The soak probe must observe the load it reports on. |
|
||||
| `test_source_access.py` | `contract` | 覆盖 source root requires explicit user grant、sensitive home directory is rejected without requesting os access、authorized file cannot escape root through a symlink 等 24 个场景。 |
|
||||
| `test_source_candidate_api.py` | `integration` | Candidate location changes must not implicitly grant or import anything. |
|
||||
| `test_source_candidate_connections.py` | `contract` | 覆盖 empty candidate revisions preserve default fingerprint、persisted candidate requires exact revision、connection resolves candidate in transaction and prior replay never regrants 等 7 个场景。 |
|
||||
@@ -406,7 +347,7 @@
|
||||
| `test_source_parsing.py` | `contract` | 覆盖 reminders are removed only at message edges、original supported outer context and query envelopes、attributed reminder is opaque before finding the outer query 等 11 个场景。 |
|
||||
| `test_source_permission_races.py` | `integration` | Permission execution regressions using temporary stores and Core ASGI instances. |
|
||||
| `test_source_query_preview.py` | `contract` | 覆盖 preserves exact user text and span、supported outer wrapper is stripped without paraphrasing、cursor host prefixes are not shown as the user query 等 8 个场景。 |
|
||||
| `test_source_read_revocation.py` | `integration` | 覆盖 source revocation stops every outward read but keeps other sources、revocation during model wait prevents late answer 场景。 |
|
||||
| `test_source_read_revocation.py` | `contract` | 覆盖 revocation during model wait prevents late answer、source revocation stops every outward read but keeps other sources 场景。 |
|
||||
| `test_source_readers.py` | `contract` | 覆盖 jsonl is resumable and does not advance over an incomplete tail、sqlite reader uses monotonic row checkpoint、zstd jsonl reader resumes by completed record 等 7 个场景。 |
|
||||
| `test_source_registry_and_grants.py` | `contract` | 覆盖 catalog resolves defaults and marks environment overrides、recall hosts that can append join the live sync poller、declarations with no reader are named rather than mistaken for behaviour 等 15 个场景。 |
|
||||
| `test_source_revocation_parking.py` | `integration` | Revoking a source parks its unfinished extraction instead of failing it (D73). |
|
||||
@@ -416,23 +357,19 @@
|
||||
| `test_source_sync_idempotent.py` | `integration` | Final-state Harness sync is idempotent and resumes missed source work. |
|
||||
| `test_source_sync_permissions.py` | `integration` | S1.3 source permission tests use temporary files only, never real host history. |
|
||||
| `test_source_totals.py` | `contract` | 覆盖 the first ask is answered from the rows、same named sessions in two workspaces count as two conversations、insert during count is not marked as already counted 等 7 个场景。 |
|
||||
| `test_source_upload_intents.py` | `integration` | Upload identity binds retries without replacing file receipts or directory consent. |
|
||||
| `test_source_upload_intents.py` | `contract` | Upload identity binds retries without replacing file receipts or directory consent. |
|
||||
| `test_source_write_switch.py` | `contract` | A frozen Harness produces no extraction tasks. |
|
||||
| `test_sql_compat.py` | `contract` | The shared compatibility layer, especially 'rowcount'. |
|
||||
| `test_sqlite_semantics_gate.py` | `contract` | D56: prevent SQLite implementation details spreading outside the data layer. |
|
||||
| `test_sqlite_write_contention.py` | `performance` | Measure whether SQLite's single writer actually blocks us. |
|
||||
| `test_sqlite_write_contention.py` | `contract` | Measure whether SQLite's single writer actually blocks us. |
|
||||
| `test_storage_ingest_journal.py` | `contract` | 覆盖 legacy put and reopen never initialize journal、initialized put records final origins create revision and noop、attribution propagation journals old rows as maintenance only 等 11 个场景。 |
|
||||
| `test_store_binding.py` | `contract` | A2: which store is in force, and honest reporting of it. |
|
||||
| `test_store_binding_ratchet.py` | `contract` | D56: ratchet the retired dual-engine 'store_for' seam out of the Core. |
|
||||
| `test_store_port.py` | `contract` | A1-04/A2: the store port, and that real implementations satisfy it. |
|
||||
| `test_subagent_sessions.py` | `contract` | D98: a sub agent's transcript is its own conversation, not part of the main one. |
|
||||
| `test_subagent_turns.py` | `contract` | D98: a sub agent's turn is ordinary user-assistant data. |
|
||||
| `test_subscription_visibility.py` | `unit` | A shared directory is not a read grant. |
|
||||
| `test_sync_facade.py` | `contract` | A2: the sync facade must keep the sync shape call sites already use. |
|
||||
| `test_tail_metadata_lane.py` | `contract` | Post-response metadata retries must be bounded and never block the route. |
|
||||
| `test_terminal_payload_reaping.py` | `contract` | Rule 14: dead-end payloads are actually reaped, not just reapable. |
|
||||
| `test_tool_evidence_hy_turns.py` | `contract` | Every adapter's tool records reach HY inside the turn they happened in. |
|
||||
| `test_training_extraction_and_baseline.py` | `contract` | R-17:抽取结果落盘成训练数据,评测基线冻结成可复跑产物。 |
|
||||
| `test_two_level_discovery.py` | `contract` | 3925a kernel item 1: two-level discovery. |
|
||||
| `test_ui_session.py` | `contract` | 覆盖 exchange is single use and creates a session、expired exchange cannot create a session、exchange is bound to the core instance 等 5 个场景。 |
|
||||
| `test_ui_session_api.py` | `integration` | 覆盖 ui session exchange sets httponly strict cookie、ui session requires bearer for exchange、ui session reports missing web assets 场景。 |
|
||||
@@ -444,8 +381,8 @@
|
||||
| `test_windows_process_control.py` | `contract` | R-29:Windows 上 'os.kill' 不是发信号,是无条件杀进程。 |
|
||||
| `test_workbuddy_boundary.py` | `contract` | WorkBuddy only injects on SessionStart and UserPromptSubmit. |
|
||||
| `test_workbuddy_parsing.py` | `contract` | 覆盖 automation preserves query without claiming fresh human authorship、automation context attributes do not depend on order or quote style、quoted full automation envelope is not scheduled provenance 等 17 个场景。 |
|
||||
| `test_workbuddy_prompt_backfill.py` | `integration` | When the prompt hook's capture fails, the transcript file is what brings the words in. |
|
||||
| `test_workbuddy_prompt_backfill.py` | `contract` | When the prompt hook's capture fails, the transcript file is what brings the words in. |
|
||||
| `test_workspace_recall.py` | `contract` | Workspace memories were unreachable from every host; these pin the way in. |
|
||||
| `test_write_queue_lease_renewal.py` | `contract` | Rule 8: a lease must outlive the work it covers, and be renewed while it runs. |
|
||||
|
||||
共 443 个测试文件。
|
||||
共 380 个测试文件。
|
||||
@@ -14,14 +14,13 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
PRIMARY_MARKERS = ("unit", "contract", "integration", "performance", "live")
|
||||
EXPECTED_TEST_FILE_COUNT = 443
|
||||
EXPECTED_TEST_FILES_SHA256 = "6d4815ac6a76cfa3f6208672052b785c7562083cf9d122cc9ddc796b8e624d43"
|
||||
EXPECTED_TEST_FILE_COUNT = 380
|
||||
EXPECTED_TEST_FILES_SHA256 = "9f075d18403328882c25325d29ebe77d0e3365d675ba19d56134a6a0fb9b64b4"
|
||||
|
||||
# Pure transformations, validation models, parity checks and state machines.
|
||||
# This list is intentionally small: uncertain files default to contract.
|
||||
UNIT_FILES = frozenset(
|
||||
{
|
||||
"test_context_host_artifact.py",
|
||||
"test_context_v2_contract_parity.py",
|
||||
"test_daemon_contract.py",
|
||||
"test_diagnostic_payload_keys.py",
|
||||
@@ -34,7 +33,6 @@ UNIT_FILES = frozenset(
|
||||
"test_memory_server_config_state.py",
|
||||
"test_memory_server_contracts.py",
|
||||
"test_memory_server_job_state.py",
|
||||
"test_retrieval_models.py",
|
||||
"test_scan_roots.py",
|
||||
"test_scanner_classifier.py",
|
||||
"test_scanner_sessionizer.py",
|
||||
@@ -50,7 +48,6 @@ UNIT_FILES = frozenset(
|
||||
INTEGRATION_FILES = frozenset(
|
||||
{
|
||||
"test_auth.py",
|
||||
"test_backfill_acceptance_lifecycle.py",
|
||||
"test_backup_endpoint.py",
|
||||
"test_backup_restore_api.py",
|
||||
"test_c1_import_via_api_path.py",
|
||||
@@ -58,11 +55,9 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_capture_deadline.py",
|
||||
"test_chat_exports.py",
|
||||
"test_cli_lifecycle.py",
|
||||
"test_concurrent_import_processing.py",
|
||||
"test_connection_lifetime.py",
|
||||
"test_context_api.py",
|
||||
"test_context_search_both_paths.py",
|
||||
"test_continuous_processing_integration.py",
|
||||
"test_conversation_search.py",
|
||||
"test_credential_responsiveness.py",
|
||||
"test_d93_same_process_load.py",
|
||||
@@ -73,9 +68,7 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_external_installation_permissions.py",
|
||||
"test_fault_matrix.py",
|
||||
"test_file_import_operations.py",
|
||||
"test_four_app_live_ingest.py",
|
||||
"test_graph_width_endpoint.py",
|
||||
"test_health.py",
|
||||
"test_home_summary.py",
|
||||
"test_hy_writeback.py",
|
||||
"test_import_api.py",
|
||||
@@ -85,7 +78,6 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_log_privacy.py",
|
||||
"test_loopback_origin_write.py",
|
||||
"test_main_database_entry.py",
|
||||
"test_manual_memory_via_hy.py",
|
||||
"test_manual_transcript_import.py",
|
||||
"test_matter_collection.py",
|
||||
"test_matter_hy_integration.py",
|
||||
@@ -107,13 +99,11 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_memory_supersede.py",
|
||||
"test_memory_visibility.py",
|
||||
"test_memory_write_routes.py",
|
||||
"test_model_content_routes.py",
|
||||
"test_model_creation_api.py",
|
||||
"test_model_creation_validation_boundary.py",
|
||||
"test_model_probe_operations.py",
|
||||
"test_model_profile_api.py",
|
||||
"test_model_profile_context_reset.py",
|
||||
"test_model_request_budget_integration.py",
|
||||
"test_onboarding_profile.py",
|
||||
"test_parent_watchdog.py",
|
||||
"test_policy_api_wiring.py",
|
||||
@@ -132,17 +122,14 @@ INTEGRATION_FILES = frozenset(
|
||||
"test_source_connection_intents.py",
|
||||
"test_source_import_receipts.py",
|
||||
"test_source_permission_races.py",
|
||||
"test_source_read_revocation.py",
|
||||
"test_source_revocation_parking.py",
|
||||
"test_source_session_count_api.py",
|
||||
"test_source_status_latency.py",
|
||||
"test_source_sync_idempotent.py",
|
||||
"test_source_sync_permissions.py",
|
||||
"test_source_upload_intents.py",
|
||||
"test_ui_session_api.py",
|
||||
"test_unified_write_entry_wiring.py",
|
||||
"test_visibility_background.py",
|
||||
"test_workbuddy_prompt_backfill.py",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user