Compare commits
383
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ea7bb041 | ||
|
|
865c66de52 | ||
|
|
b7731ac834 | ||
|
|
9560327ee2 | ||
|
|
f445ad69c4 | ||
|
|
45dbf46545 | ||
|
|
fac7a43ffb | ||
|
|
6e4b22a363 | ||
|
|
c186e5b2fb | ||
|
|
ad84e5decd | ||
|
|
0c43973fc2 | ||
|
|
ebf16b9399 | ||
|
|
87d70b0eb9 | ||
|
|
603e0843c5 | ||
|
|
80f4231e46 | ||
|
|
2f9e028c50 | ||
|
|
4cbf4f1501 | ||
|
|
e5959578c8 | ||
|
|
df5a65797c | ||
|
|
2ea48e68a6 | ||
|
|
b8fdde7d0b | ||
|
|
5134524743 | ||
|
|
e34101cb0b | ||
|
|
995c7e6c32 | ||
|
|
f1afdb03cd | ||
|
|
4df4ce72bd | ||
|
|
a6d9894975 | ||
|
|
218bcf779b | ||
|
|
7e4ee0d8ea | ||
|
|
bce58e638d | ||
|
|
b0e429b9ae | ||
|
|
01faf28942 | ||
|
|
fb1b1373ab | ||
|
|
2823bd743d | ||
|
|
837c084be2 | ||
|
|
bf52724461 | ||
|
|
964d8610f3 | ||
|
|
e1933d3307 | ||
|
|
5fdaf6c280 | ||
|
|
58f2fa884a | ||
|
|
c0910621c1 | ||
|
|
dd13c551c4 | ||
|
|
e7d82ee5e8 | ||
|
|
63e009bf6c | ||
|
|
d4cfd1bb74 | ||
|
|
2822cbf368 | ||
|
|
8d23347e1a | ||
|
|
8ae0d1889e | ||
|
|
2479de22eb | ||
|
|
265a22d612 | ||
|
|
93d0c64490 | ||
|
|
1589ae31bc | ||
|
|
38ad2292d1 | ||
|
|
413b9033f9 | ||
|
|
0953c5af35 | ||
|
|
470ea4459a | ||
|
|
b58e95fdae | ||
|
|
42b577f6f8 | ||
|
|
2554fb3b96 | ||
|
|
e32dc987c2 | ||
|
|
598e83525e | ||
|
|
01ace26d08 | ||
|
|
ba2f647a30 | ||
|
|
82d9514d62 | ||
|
|
369351719d | ||
|
|
1429ba2d19 | ||
|
|
4e7d752980 | ||
|
|
fb13a6fda2 | ||
|
|
6ff8b3a3b4 | ||
|
|
17441d368c | ||
|
|
89d29c9c41 | ||
|
|
5271bddce3 | ||
|
|
eac795b2d2 | ||
|
|
d938f1d5b2 | ||
|
|
8cee64c813 | ||
|
|
1ffcc9eb57 | ||
|
|
23b74f91bc | ||
|
|
b36452d110 | ||
|
|
a8b92d21e1 | ||
|
|
e58a7c0d14 | ||
|
|
1663c9424e | ||
|
|
fba9d8cf26 | ||
|
|
f754f8bc0f | ||
|
|
2155c4c773 | ||
|
|
18e2fca34d | ||
|
|
88feb0adca | ||
|
|
d2e9cdaf06 | ||
|
|
8277aaee51 | ||
|
|
65550621e0 | ||
|
|
1a67427bf3 | ||
|
|
58f1477680 | ||
|
|
76c459e39a | ||
|
|
b864e0bda6 | ||
|
|
6c235ef093 | ||
|
|
2483412475 | ||
|
|
f9ee983790 | ||
|
|
4746681efe | ||
|
|
a218a92d4e | ||
|
|
6845adf083 | ||
|
|
76098cf060 | ||
|
|
850c728c9d | ||
|
|
ce9801b449 | ||
|
|
576d9bf408 | ||
|
|
34f406e96d | ||
|
|
a653bd4065 | ||
|
|
07b426ba7f | ||
|
|
4b83b79b2b | ||
|
|
675cc30e5a | ||
|
|
3511153f9f | ||
|
|
23facf75d5 | ||
|
|
384c7476ad | ||
|
|
71b8f9be24 | ||
|
|
d257188d09 | ||
|
|
bde0220373 | ||
|
|
a88d227cc0 | ||
|
|
059125a09f | ||
|
|
0d0cefb25e | ||
|
|
be8cf028ff | ||
|
|
d0a4fd60f2 | ||
|
|
aae877b880 | ||
|
|
ef5ff06590 | ||
|
|
7eb38aa6ad | ||
|
|
2624228f6f | ||
|
|
670c7c6cb1 | ||
|
|
aa633623da | ||
|
|
37d8662b93 | ||
|
|
9459a1107b | ||
|
|
2c6f269170 | ||
|
|
7a2ff2f4c4 | ||
|
|
35128f2c84 | ||
|
|
f12a95ce7a | ||
|
|
2b75adc718 | ||
|
|
75ac1b01f2 | ||
|
|
e786657a0b | ||
|
|
b2c91d9751 | ||
|
|
16da11f788 | ||
|
|
7df371bb10 | ||
|
|
f38c370fbd | ||
|
|
e87d6305be | ||
|
|
7a78fca807 | ||
|
|
1520b6de1f | ||
|
|
3742b3e453 | ||
|
|
98bc52b9f4 | ||
|
|
30fea9394f | ||
|
|
e966e913b6 | ||
|
|
ba6248b90b | ||
|
|
6cad02bc82 | ||
|
|
fdc57822b5 | ||
|
|
4e1748f65a | ||
|
|
b20331aa39 | ||
|
|
104fbcce09 | ||
|
|
302fd30af1 | ||
|
|
8d0a069950 | ||
|
|
cdb44658cf | ||
|
|
2ab04291a9 | ||
|
|
3772a69c6b | ||
|
|
80d3b55723 | ||
|
|
b5ec0861f0 | ||
|
|
47e938da1b | ||
|
|
f5ea0af75d | ||
|
|
1c2f48579f | ||
|
|
d29615abeb | ||
|
|
0965519e16 | ||
|
|
7e9f0d98d8 | ||
|
|
0ff4538f36 | ||
|
|
a9709788d8 | ||
|
|
c1e5916b51 | ||
|
|
97de5480bd | ||
|
|
97d9309ca8 | ||
|
|
b1b3e0a291 | ||
|
|
53507c7abe | ||
|
|
5a1d252787 | ||
|
|
8895fea775 | ||
|
|
78882f3300 | ||
|
|
988667cd87 | ||
|
|
72968f831f | ||
|
|
af25f51f9e | ||
|
|
b3f13e130f | ||
|
|
a621262fac | ||
|
|
e027635d3f | ||
|
|
4cb33b7108 | ||
|
|
b50c9f33eb | ||
|
|
545b7d38bf | ||
|
|
5f78d67363 | ||
|
|
33d7713d4e | ||
|
|
68a4b546f9 | ||
|
|
cbcc50f35e | ||
|
|
528d2c9fd7 | ||
|
|
0eb9a2844e | ||
|
|
8e56bf7a37 | ||
|
|
352fffb3da | ||
|
|
5103492762 | ||
|
|
082aeee1f2 | ||
|
|
201901d35e | ||
|
|
42c0ca1d14 | ||
|
|
0ca2e56ccc | ||
|
|
f328cdbf8e | ||
|
|
3f57a3c978 | ||
|
|
19a7af0e71 | ||
|
|
5791c0d1e7 | ||
|
|
bf92105210 | ||
|
|
3387247842 | ||
|
|
791285f4c9 | ||
|
|
dc762df78b | ||
|
|
8277a72aea | ||
|
|
5eed0f81fb | ||
|
|
59ca5afca6 | ||
|
|
2648012ad5 | ||
|
|
63a80092c2 | ||
|
|
6ba49bf112 | ||
|
|
cf410aa9cf | ||
|
|
81ed1d816f | ||
|
|
30c0b1d5ff | ||
|
|
1086f4d582 | ||
|
|
32539e7a82 | ||
|
|
cfae020a31 | ||
|
|
5d51513ec8 | ||
|
|
9db5b49ccb | ||
|
|
cb7df2a022 | ||
|
|
812d1c3846 | ||
|
|
c8b660651e | ||
|
|
bdd2d9cf84 | ||
|
|
fc2e51a8cc | ||
|
|
d4fca0639e | ||
|
|
a17230dadb | ||
|
|
f7961f340a | ||
|
|
cfebae118d | ||
|
|
d76bfa6474 | ||
|
|
b169906fcb | ||
|
|
f3b417ced4 | ||
|
|
e9698a293c | ||
|
|
aaebe64244 | ||
|
|
f3d13c4351 | ||
|
|
3e8a006f65 | ||
|
|
a70830970d | ||
|
|
556d95df84 | ||
|
|
ad05303d23 | ||
|
|
423cb00dbf | ||
|
|
e09fbd16f9 | ||
|
|
dd3809a34b | ||
|
|
efe7df4048 | ||
|
|
072e0e5e90 | ||
|
|
a0221bc434 | ||
|
|
754555e3c3 | ||
|
|
b43c967baa | ||
|
|
dac2c15b3f | ||
|
|
0b775d7d61 | ||
|
|
4e217f845e | ||
|
|
2102a298d4 | ||
|
|
e540386db1 | ||
|
|
1374ac26b7 | ||
|
|
47dafbb102 | ||
|
|
49ad1b3086 | ||
|
|
3064515b43 | ||
|
|
e5c8cc3819 | ||
|
|
27f64f1b7a | ||
|
|
3c5451db24 | ||
|
|
a08f69a747 | ||
|
|
099f5ae7c0 | ||
|
|
e7625032d8 | ||
|
|
3aa3b5957d | ||
|
|
409ab9a9b4 | ||
|
|
c2f50dce06 | ||
|
|
2735d8693a | ||
|
|
47bbfb4b27 | ||
|
|
3f169c9951 | ||
|
|
24f699a726 | ||
|
|
cb97aa5822 | ||
|
|
38a4da6849 | ||
|
|
48a5d70026 | ||
|
|
b21c929728 | ||
|
|
b9b3c587ff | ||
|
|
6718ba8bdf | ||
|
|
5a5650fc94 | ||
|
|
0e149b6c9c | ||
|
|
0dd2bcd6b1 | ||
|
|
567a8b349d | ||
|
|
d89afb3887 | ||
|
|
0abf87d4b6 | ||
|
|
4e5a44b8dc | ||
|
|
bf36ba765b | ||
|
|
e9cce69650 | ||
|
|
2fed7adf6b | ||
|
|
7308ed9df2 | ||
|
|
1c0cbf6d05 | ||
|
|
71854a3510 | ||
|
|
fcb1bcd866 | ||
|
|
6305e4cea8 | ||
|
|
a4b0969248 | ||
|
|
e0920311a1 | ||
|
|
e6426f6fd0 | ||
|
|
99e3c8d9d7 | ||
|
|
896a9c6c83 | ||
|
|
193fabeeb4 | ||
|
|
0aeb3e8760 | ||
|
|
70aaa7be85 | ||
|
|
18046152ab | ||
|
|
7b946a28ef | ||
|
|
e5ce0cd964 | ||
|
|
d8837b4fb2 | ||
|
|
0c36a6c960 | ||
|
|
9bdce08847 | ||
|
|
7cbc2fd632 | ||
|
|
6b0bd86679 | ||
|
|
91250d7c87 | ||
|
|
3c89a535fb | ||
|
|
1151878265 | ||
|
|
e82c4c939e | ||
|
|
ca4a66349b | ||
|
|
82044d6172 | ||
|
|
7f23b74c36 | ||
|
|
617e2d0ba6 | ||
|
|
083b837f32 | ||
|
|
1dc7e957c0 | ||
|
|
3b44d8381c | ||
|
|
790d6d3d5e | ||
|
|
4c311b2e93 | ||
|
|
801a2af36b | ||
|
|
0a8e43e570 | ||
|
|
f5a38c38b3 | ||
|
|
d08e070816 | ||
|
|
29bd9146a4 | ||
|
|
26c33ee40f | ||
|
|
d926844173 | ||
|
|
de4292ed4e | ||
|
|
366befc2aa | ||
|
|
a4c8c7e5fb | ||
|
|
b7d9f0e0f4 | ||
|
|
6d1d29e16d | ||
|
|
c0737acb44 | ||
|
|
0244ebe38c | ||
|
|
e3b5a1bc9c | ||
|
|
8aeb042bb7 | ||
|
|
635d2572c9 | ||
|
|
6113e2351e | ||
|
|
b5a8a78c69 | ||
|
|
fd12c8f1c0 | ||
|
|
e089d4b800 | ||
|
|
b370663870 | ||
|
|
8ea6996859 | ||
|
|
dc740604d2 | ||
|
|
55bf946bcc | ||
|
|
14fcc6fe38 | ||
|
|
1ef0766891 | ||
|
|
3370a715dd | ||
|
|
638d1b7bde | ||
|
|
d6890793ec | ||
|
|
32bf05f821 | ||
|
|
b6e6a3fb43 | ||
|
|
4e44f59026 | ||
|
|
35bd56ddd0 | ||
|
|
2cea39da4f | ||
|
|
059e3a4903 | ||
|
|
0a9b10dbfc | ||
|
|
3a74a33456 | ||
|
|
514bf5c47b | ||
|
|
4bc918af7a | ||
|
|
75163a0d1e | ||
|
|
09e4bc8a30 | ||
|
|
4dbf7b6ed2 | ||
|
|
8f1c4045e9 | ||
|
|
8918098fa8 | ||
|
|
0afe787c95 | ||
|
|
67ae43f920 | ||
|
|
3ce89ea560 | ||
|
|
7b7fd217a8 | ||
|
|
7efe054e83 | ||
|
|
fdbcd9934d | ||
|
|
3171007b3c | ||
|
|
a04fcb50d5 | ||
|
|
a28ade2faf | ||
|
|
1c424928a2 | ||
|
|
688878171e | ||
|
|
a3f09f4cac | ||
|
|
0c59413ad8 | ||
|
|
625f877b43 | ||
|
|
1608a4cfc2 | ||
|
|
20b92d8a89 | ||
|
|
2a4c5e38e7 | ||
|
|
c891a1fd1d | ||
|
|
7741e17f09 | ||
|
|
2a4dc2ffa2 | ||
|
|
61b5ef30a9 |
No files matched your search
@@ -40,6 +40,7 @@ MEMORY_EMBEDDING_DIMS=1024
|
||||
# MEMORY_EMBEDDER_TIMEOUT=15
|
||||
# MEMORY_EMBEDDER_MAX_RETRIES=2
|
||||
# MEMORY_EMBEDDER_RETRY_DELAY=0.3
|
||||
# MEMORY_EMBEDDER_BATCH_SIZE=64
|
||||
# MEMORY_EMBEDDER_EXTRA_HEADERS={}
|
||||
# MEMORY_EMBEDDER_EXTRA_BODY={}
|
||||
|
||||
@@ -77,6 +78,7 @@ MEMORY_VECTOR_STORE=chroma
|
||||
|
||||
# ── 处理模式 ──
|
||||
# MEMORY_MODE=pro # lite | pro | ultra
|
||||
# MEMORY_LANGUAGE= # 提示词/记忆语言:en | zh-Hans | zh-Hant | ja | es | de | pt | fr | ko | ru | id;空=zh/en 自动检测
|
||||
|
||||
# ── Coding Memory(生产力 / 编码场景独立链路,默认开启)──
|
||||
# 独立于 chat 记忆的一套链路:写入端对 messages 判类,coding 场景抽「任务→方案」存 sqlite;
|
||||
@@ -86,6 +88,7 @@ MEMORY_VECTOR_STORE=chroma
|
||||
# MEMORY_CODING_DB_PATH= # coding_memory.db 路径(默认 {DATA_DIR}/data/coding_memory.db)
|
||||
# MEMORY_CODING_TOOL_RESULT_MAX_BYTES=2048 # 单条 tool_result 截断字节上限
|
||||
# MEMORY_CODING_VERBATIM_FALLBACK_ENABLED=false # 模型无结果时是否保存原文 fact;默认关,避免 tool trace 外泄
|
||||
# MEMORY_CODING_CHAIN_BLOCK_ENABLED=false # search 是否注入版本演进链块(diff);默认关,省 token
|
||||
# MEMORY_EXTRACT_HISTORY_TURNS=5 # 除本次 write 外,额外注入 extractor 的历史 user-turn 数
|
||||
|
||||
# ── 写入流程开关 ──
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Hermes provider
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "plugins/hermes/**"
|
||||
- ".github/workflows/hermes-provider.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "plugins/hermes/**"
|
||||
- ".github/workflows/hermes-provider.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
provider:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: false
|
||||
- name: Download and verify native Hermes contract
|
||||
run: |
|
||||
uv run --no-project --python 3.12 python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
|
||||
url = "https://raw.githubusercontent.com/NousResearch/hermes-agent/818c13be1dc4fd28987e1e881a9408224afd4535/agent/memory_provider.py"
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
data = response.read()
|
||||
expected = "a4e44a293013fff831ac136ab77c556541cd18817f53bd662e97090cd80bd5a6"
|
||||
if hashlib.sha256(data).hexdigest() != expected:
|
||||
raise SystemExit("Hermes contract SHA-256 mismatch")
|
||||
snapshot = Path(os.environ["RUNNER_TEMP"]) / "hermes-memory-provider.py"
|
||||
snapshot.write_bytes(data)
|
||||
with open(os.environ["GITHUB_ENV"], "a") as env:
|
||||
env.write(f"HERMES_MEMORY_PROVIDER_SNAPSHOT={snapshot}\n")
|
||||
PY
|
||||
- name: Test provider with native contract
|
||||
run: uv run --no-project --python 3.12 --with pytest python -m pytest plugins/hermes/tests -o addopts= -o log_cli=false -q
|
||||
@@ -122,19 +122,25 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
version=$(node -p 'require("./package.json").version')
|
||||
node scripts/verify-release-version.mjs \
|
||||
--channel stable \
|
||||
--version "$version" \
|
||||
--build-number "$(node -p 'require("./package.json").release.build_number')" \
|
||||
--tag "$GITHUB_REF_NAME"
|
||||
mkdir -p publish-assets
|
||||
for arch in x64 arm64; do
|
||||
archive="release-assets/memhub-runtime-v${version}-linux-${arch}.tar.gz"
|
||||
test -f "$archive"
|
||||
test -f "$archive.sha256"
|
||||
name="memhub-runtime-v${version}-linux-${arch}.tar.gz"
|
||||
archives=(release-assets/**/"$name")
|
||||
checksums=(release-assets/**/"$name.sha256")
|
||||
test "${#archives[@]}" -eq 1
|
||||
test "${#checksums[@]}" -eq 1
|
||||
cp -- "${archives[0]}" "publish-assets/$name"
|
||||
cp -- "${checksums[0]}" "publish-assets/$name.sha256"
|
||||
(
|
||||
cd release-assets
|
||||
sha256sum -c "$(basename "$archive.sha256")"
|
||||
cd publish-assets
|
||||
sha256sum -c "$name.sha256"
|
||||
)
|
||||
done
|
||||
- name: Upload Linux assets to a draft GitHub Release
|
||||
@@ -160,7 +166,7 @@ jobs:
|
||||
--title "$GITHUB_REF_NAME"
|
||||
fi
|
||||
gh release upload "$GITHUB_REF_NAME" \
|
||||
release-assets/memhub-runtime-v*-linux-*.tar.gz \
|
||||
release-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
|
||||
publish-assets/memhub-runtime-v*-linux-*.tar.gz \
|
||||
publish-assets/memhub-runtime-v*-linux-*.tar.gz.sha256 \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--clobber
|
||||
@@ -26,6 +26,9 @@ package-lock.json
|
||||
ui_walkthrough_*/
|
||||
|
||||
# Internal release/open-source coordination must never enter the public tree.
|
||||
HANDSOFF.md
|
||||
新鲜度_todo.md
|
||||
memhub/docs/LOCAL_PRODUCT_APP_MIGRATION_TEST.md
|
||||
OPEN_SOURCE_TODO.md
|
||||
release_todo.md
|
||||
|
||||
|
||||
+628
-1
@@ -4,6 +4,422 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- MemHub host capture hooks no longer write the database. `one-memory
|
||||
capture <host>` used to open the main SQLite file from its own short-lived
|
||||
process on every hook, re-create the retired `evidence` / FTS tables on an
|
||||
SDK-authority home and write the write queue outside Core's single writer,
|
||||
contending for the file lock with Core and with other hosts' hooks. It now
|
||||
reads only the runtime receipt and token and posts the hook's JSON to the
|
||||
running Core (`POST /api/v1/live-capture/{source}`, 1.5 s budget). Core
|
||||
applies the same gates (grant, sync switch, rejection counting, automated
|
||||
sessions, the Cursor/Claude Code rule; the migration fence stays on the hook
|
||||
side) and stages the turn in-process through the live file-sync path. When
|
||||
Core is not running the turn is dropped silently; nothing falls back to a
|
||||
local write.
|
||||
- MemHub no longer sends one turn to the SDK twice when a host's hook and live
|
||||
file sync both see it. The two lanes gave the same message different
|
||||
identities (the hook carries no workspace and often no message id) and
|
||||
separate assembly rows, so both were written as raws. Hook input for a
|
||||
source that live sync reads from its own files (every hooked host except
|
||||
Qwen Work, while live sync is on) is now left to the file path; only a
|
||||
source without file polling is staged from its hook. Tables an older hook
|
||||
re-created on an SDK home are dropped by the existing startup cleanup.
|
||||
|
||||
## Hy Memory 2.0.14 — 2026-11-04
|
||||
|
||||
- `search()` / `async_search()` take an optional `statuses` for explicit
|
||||
L1_RAW search (`layers=["l1_raw"]`, or any search in lite mode), e.g.
|
||||
`statuses=["active", "shadow"]`, so raw turns a pro extraction already
|
||||
shadowed can still be searched. Default stays active-only. Raw hits now
|
||||
report their real `status`. Passing `statuses` to a non-raw search or to
|
||||
`scene="all"` raises `ValueError` instead of being ignored.
|
||||
|
||||
## Hy Memory 2.0.13 — 2026-11-02
|
||||
|
||||
- A model failure during a write now reports failure and stores
|
||||
nothing. **Behaviour change for direct SDK users:** writes that used to
|
||||
return `success: true` in a degraded state now return `success: false`;
|
||||
retry them with a new `request_id` once the model is back.
|
||||
- Reconcile (pro / ultra `add` and `add_extracted`): when the reconcile model
|
||||
call fails, returns HTTP 200 with empty content, or returns a reply that
|
||||
cannot be parsed, or reconcile raises before anything is stored, the write
|
||||
used to fall back to adding every extracted memory as-is (no dedup or
|
||||
evolution) and report success. That fallback is removed, with its
|
||||
`degraded` / `degraded_reason` result fields. The write now returns
|
||||
`success: false`, `error_code: 502`,
|
||||
`agent_error_code: "RECONCILE_FAILED"`, and the cause in the new
|
||||
`reconcile_error_code` (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`,
|
||||
`JSON_PARSE_FAILED`, `RECONCILE_EXCEPTION`). None of the new memories are
|
||||
stored, the episode counter does not advance, and `add` rolls back its
|
||||
raw turn the same way as an extraction failure
|
||||
(`l1_rollback.retry_safe: true`). An empty 200 reply was previously
|
||||
counted as "no changes" and reported success with 0 memories; only a
|
||||
literal `[]` reply still means that. `RECONCILE_ENABLED=false` is a
|
||||
configuration choice and still writes directly.
|
||||
- Working / coding path: an extraction model failure used to return
|
||||
success with 0 operations. It now returns `success: false` with
|
||||
`agent_error_code` `LLM_ERROR`, `EMPTY_RESPONSE` or `JSON_PARSE_FAILED`.
|
||||
A model that answers with nothing worth remembering is still a success.
|
||||
An embedding failure used to store the experience or fact without a
|
||||
vector, where search never finds it, and report success; it now returns
|
||||
`EMBEDDING_FAILED` and the node is not stored. A fact embedding is checked
|
||||
before any older fact is superseded. A failed write no longer appends its
|
||||
turns to the recent trace and records no `chain_rounds`.
|
||||
With `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` turned on, extraction
|
||||
failures keep the verbatim fallback.
|
||||
- Working / coding reconcile: when the experience reconcile or the fact
|
||||
reconcile model call fails, returns empty content, or returns a reply that
|
||||
is not a JSON object with an `ops` list, the write returns
|
||||
`success: false`, `agent_error_code: "RECONCILE_FAILED"` and the cause in
|
||||
`reconcile_error_code` (`LLM_UNAVAILABLE`, `EMPTY_RESPONSE`,
|
||||
`JSON_PARSE_FAILED`), the same as chat. Before, experience reconcile fell
|
||||
back to adding every draft, and fact reconcile inserted the new fact
|
||||
without superseding older ones. Both model decisions now run before
|
||||
anything is stored, so a failure in either leaves no experience or fact
|
||||
from the write and supersedes no older fact. An experience reply of
|
||||
`{"ops": []}` is a valid "no changes" decision: the write succeeds and no
|
||||
experience is stored. A non-empty `ops` list with no recognizable op is a
|
||||
parse failure. A fact reply of `{"ops": []}` inserts the new fact without
|
||||
superseding anything. Fact reconcile still makes no model call when there
|
||||
are no candidates.
|
||||
|
||||
## Hy Memory 2.0.12 — 2026-11-02
|
||||
|
||||
- Intention dedup is now configurable per client
|
||||
(`MemoryConfig.recall.intention_dedup_enabled`, default `true`;
|
||||
`recall.intention_dedup_threshold`, default `MEMORY_DEDUP_THRESHOLD` or
|
||||
`0.95`). Env defaults: `MEMORY_INTENTION_DEDUP_ENABLED`,
|
||||
`MEMORY_INTENTION_DEDUP_THRESHOLD`. When on, behaviour is unchanged
|
||||
(duplicates found at read time are still deleted); when off, no intention
|
||||
dedup runs, at read time or within a write batch, and nothing is hidden,
|
||||
dropped or deleted. The threshold applies to read-time dedup only. Refs #241.
|
||||
|
||||
- MemHub pins `hy-memory==2.0.12` and exposes that intention dedup switch in
|
||||
its memory-features setting (`GET`/`PUT /api/v1/settings/memory-features`,
|
||||
`memhub settings features`): the feature `intention_dedup` (default on) and
|
||||
the numeric `intention_dedup_threshold` (default 0.95, accepted 0.8–1.0,
|
||||
anything else is 422 or a CLI usage error). Unset, both keep the SDK
|
||||
default, so nothing changes for the App; a change rebuilds the SDK clients
|
||||
like the other feature switches. The API answer gains `parameters`, and
|
||||
`pipeline.features` can now carry the threshold as a number. Refs #241.
|
||||
|
||||
## MemHub 1.0.6 — 2026-11-04
|
||||
|
||||
Build 43, on `hy-memory==2.0.14`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime.
|
||||
|
||||
- MemHub App now exposes the complete memory-mode lifecycle. New installs
|
||||
explicitly choose lite or pro during onboarding; pro completes only after a
|
||||
generation model is active, while ultra remains an advanced Settings choice.
|
||||
Settings shows mode effects, graph backfill, the default 03:00 evolve
|
||||
schedule and manual evolve. The memory page shows original conversations in
|
||||
lite and advanced memories in pro/ultra. Existing installs remain pro unless
|
||||
the user changes them, and embedding maintenance blocks mode/evolve changes.
|
||||
- MemHub pins `hy-memory==2.0.14`. Lite search is plain RAG over raw: the
|
||||
App's lite search, CLI context search, `/api/v1/recall` and the Host Bridge
|
||||
(`MemHub_recall`, context search) return every vector hit above the floor,
|
||||
ranked by score, over active and shadow raws, so history pro already
|
||||
processed is still found after switching to lite. The turn view no longer
|
||||
drops a hit whose turns do not literally contain the query; it shows the
|
||||
hit's closest turn instead. Pro and ultra raw search stays active-only.
|
||||
Fixes every raw search failing in packaged builds since #316, which passed
|
||||
`statuses` to an SDK (2.0.13) that did not accept it. The lite raw-turn
|
||||
page also stops re-listing the whole raw layer on each poll when a live
|
||||
session only adds new raws.
|
||||
- MemHub Core no longer stalls writes or freezes the App for tens of seconds
|
||||
on a machine with a long live-sync history. Finished live-sync import jobs
|
||||
are pruned to the newest 20 per source (at startup, in bounded batches, and
|
||||
every 10 minutes); history imports and every unfinished job are kept. Job
|
||||
lookups are filtered in SQL with new indexes (schema migration 63) instead
|
||||
of reading the whole table. The durable write queue runs its claims,
|
||||
settles and lease renewals on its own threads, so import work can no longer
|
||||
starve a lease. Import abandon, resume and enqueue, and the file watcher's
|
||||
path attribution, no longer touch the database or the disk on the event
|
||||
loop, and the WAL cadence checkpoint runs on its own thread instead of on
|
||||
the writer's. The write drain now restarts itself after an unexpected exit,
|
||||
reports the failure with its stack, and shows `write_drain` /
|
||||
`write_drain_error` in memory-processing and `memhub status`.
|
||||
- Model Center (#293, #295): Settings splits into 界面语言 / 记忆模式 /
|
||||
模型配置 pages, with separate 记忆整理 (generation) and 记忆搜索
|
||||
(embedding) lanes. Changing one lane sends only that lane's patch. An
|
||||
embedding change shows the switch plan, confirmation, progress, cancel and
|
||||
rollback, and the search model only changes after the rebuilt index is
|
||||
verified. Model panels open inside the lane that launched them and close
|
||||
only after Core confirms the profile is active (#310).
|
||||
- Lite keeps raw conversations (#297): lite raw writes are no longer stopped
|
||||
by the "pause model sedimentation" switch, the memory page lists every
|
||||
imported raw conversation by role (by turn since #316), and lite adds a
|
||||
manual raw input (source `one-memory-manual`) that never calls a
|
||||
generation model.
|
||||
- The home page follows the memory mode (#298): lite counts raw
|
||||
conversations and previews the latest one; pro and ultra read memories.
|
||||
Pro and ultra now require an active generation model, in both the mode
|
||||
preview and the switch.
|
||||
- Onboarding (#304) asks for lite or pro and resumes after a restart (schema
|
||||
migration 62); pro finishes only once a generation model is active.
|
||||
- Smaller App fixes: compact home status copy (#307), clearer mode-switch
|
||||
effects (#312), Ultra topic nodes are hover-only (#315), and settings
|
||||
controls use the content width (#317).
|
||||
- Interfaces in 11 languages: English, Simplified and Traditional Chinese,
|
||||
Japanese, Spanish, German, Portuguese, French, Korean, Russian and
|
||||
Indonesian. Every interface language, Simplified Chinese included, sets the
|
||||
SDK prompt language (#270). **User-visible:** zh-Hans users now get Chinese
|
||||
prompts and Chinese memory text for every conversation, English ones
|
||||
included; the App sends the language it displays, also when it follows the
|
||||
system. Only an unset language (CLI/API installs, `memhub settings language
|
||||
system`) keeps the SDK's zh/en detection.
|
||||
- Memory modes: lite (no LLM, keeps raw conversations), pro (LLM extraction)
|
||||
and ultra (pro plus the knowledge graph and a nightly evolve run). Set with
|
||||
`GET`/`PUT /api/v1/settings/memory-mode` or `memhub settings mode`. Raw
|
||||
conversations are now kept permanently. `POST /api/v2/evolve` and
|
||||
`memhub evolve` run System 2 on demand.
|
||||
- Safe embedding-model switching: an embedding-space registry, a switch that
|
||||
pauses writes, re-embeds every user's vectors and the graph, verifies and
|
||||
commits atomically, and resumes after a crash or an App update. Every start
|
||||
scans the vector store and merges data left in other spaces. When the
|
||||
embedding service is unavailable, search returns `embedding_unavailable`.
|
||||
- Multi-user Runtime: memory APIs and the CLI take `user_id` (default
|
||||
`local`); records are isolated per user; Hermes profiles can be connected as
|
||||
separate users. The App keeps using `local`. Hardening (#280): read
|
||||
responses echo `user_id`; deleting another user's memory, or one that does
|
||||
not exist, is 404 `memory_not_found` (the App and `memhub memories delete`
|
||||
treat it as already gone); Hermes never reads a `profiles/` directory, and
|
||||
a root that is the `profiles` container or overlaps another user's grant is
|
||||
refused (`hermes_root_is_profiles_container`,
|
||||
`hermes_root_overlaps_other_user`).
|
||||
- Configurable SDK features through the CLI and API (`memhub settings
|
||||
features`): graph rerank, episode recall, session episodes, episode
|
||||
summaries, intention and few-shot, and the intention dedup switch with its
|
||||
threshold (`intention_dedup`, `intention_dedup_threshold`, #277). Defaults
|
||||
are unchanged.
|
||||
- Memory writes retry themselves (#278), each time under a new SDK request
|
||||
id after checking whether the previous one landed; interrupted writes
|
||||
re-run automatically. A model provider failure (401/403, 404, 408, 429,
|
||||
5xx, timeout, unreachable) keeps the job pending in place without spending
|
||||
a retry, and a per-profile circuit breaker paces the queue (5 s doubling to
|
||||
180 s, one probe job at a time). Any other failure is retried after 30, 60
|
||||
and 120 s, then `failed`. With `hy-memory==2.0.13` a model failure is no
|
||||
longer a silent success, and a failed job's `error_code` names the SDK's
|
||||
cause (`hy_sdk_reconcile_empty_response`, `hy_sdk_llm_error`, ...) rather
|
||||
than `hy_sdk_write_failed`. **User-visible:** the manual 重新整理 button,
|
||||
the "整理结果不确定" display and
|
||||
`POST /api/v1/memory-processing/retry-unknown` are gone; a banner
|
||||
("模型配置有问题……记忆暂停处理,已有 N 条在排队") with a button to the model
|
||||
settings shows while a model problem pauses processing, and clears on
|
||||
recovery.
|
||||
- Queue, WAL and recall (#278): the WAL stays bounded (64 MiB journal limit,
|
||||
idle `TRUNCATE` checkpoints); a disabled or revoked source's jobs are
|
||||
`suspended` and resume when it is turned back on; SDK writes and recall
|
||||
reads use separate thread pools, and recall has a total budget (504
|
||||
`recall_timeout`, `broker_timeout` in clients) instead of starving behind
|
||||
stuck writes.
|
||||
- One Runtime per data dir (#273). App quit asks the service manager at quit
|
||||
time: an enabled background service is never stopped, a disabled one has
|
||||
every Runtime of the data dir stopped (only after an authenticated identity
|
||||
check), and an unknown state stops nothing. At start the App adopts a
|
||||
Runtime of the same build, replaces an older one, and silently adopts a
|
||||
newer one. The tray quit label follows the current state.
|
||||
- DeepSeek Harness: the app page's one switch installs and grants recall for
|
||||
every detected profile, and keeps new profiles granted without a restart
|
||||
(#230); versioned session files (`session.v3.jsonl.zstd`,
|
||||
`session.v4.jsonl.zstd`) are imported, and v4 tool output goes through the
|
||||
same clipping and scrubbing (#175).
|
||||
- The packaged Runtime accepts only a full 40-character commit, lowercased; a
|
||||
malformed `ONE_MEMORY_COMMIT` fails the build (#235, #236).
|
||||
- Search and recall pass through the SDK's search parameters with server-side
|
||||
score floors; the memory library filters by layer; `/api/v2/raw` returns
|
||||
data again; v2 source and import routes; profile version history.
|
||||
- Upgrade fixes: migration no longer stops without a model profile (#234) or
|
||||
on a pre-existing readback mismatch (#229, #240); source grants work after
|
||||
the cutover (#233); reads use the active embedding (#242); the embedding
|
||||
endpoint no longer follows the generation `base_url`.
|
||||
- Hermes import drops gateway notices and probes and unwraps relayed
|
||||
messages; `memhub logs` no longer reports valid older records as damaged.
|
||||
|
||||
## Hy Memory 2.0.11 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.11`](https://pypi.org/project/hy-memory/2.0.11/).
|
||||
- LLM prompts now ship in 11 languages: `en`, `zh-Hans`,
|
||||
`zh-Hant`, `ja`, `es`, `de`, `pt`, `fr`, `ko`, `ru` and `id`. They live in
|
||||
one registry, `hy_memory/prompts/`, with one module per language. Call
|
||||
sites use `get_prompt(name, language)`, so a new language only needs a new
|
||||
module. The registry covers chat extraction (system prompt, basic_info,
|
||||
intentions, few-shot including Example 11), the no-intention variant,
|
||||
reconcile and its search-query prompt, the working/coding extractor, both
|
||||
coding reconcilers and the chain summary, the episode and topic summaries,
|
||||
System 2 evolve, and the user name line. The English and Simplified Chinese
|
||||
strings are unchanged byte for byte, and a test pins them. The old names
|
||||
(`prompts_zh.*_ZH`, `extractor.EXTRACT_SYSTEM_PROMPT`, ...) still import.
|
||||
The nine new languages are machine translations, and native-speaker review
|
||||
is recommended.
|
||||
- New optional `language` to choose the prompt language
|
||||
explicitly. Set it on `MemoryConfig.language` (env `MEMORY_LANGUAGE`) for
|
||||
a client default, or per call on `add` / `async_add`, `add_extracted`,
|
||||
`finalize_session`, `evolve` and `sweep`. A per-call value overrides the
|
||||
config. The value goes through every LLM call of the request: extract and
|
||||
basic profile, reconcile, the coding path, the summaries and System 2.
|
||||
Aliases: `zh`, `zh-CN` → `zh-Hans`; `zh-TW`, `zh-HK` → `zh-Hant`; `pt-BR` →
|
||||
`pt`. An unknown code raises `ValueError` listing the supported codes, and
|
||||
the HTTP server returns 400. With an explicit language, memories are
|
||||
written in that language even when the conversation uses another one.
|
||||
Without a language, the existing zh/en auto-detection runs unchanged and
|
||||
memories keep the source language. `search` has no `language`: its judge
|
||||
and rewrite prompts are English-only and keep the query's language.
|
||||
- The HTTP server accepts `language` on `/api/v1/add` and
|
||||
`/api/v1/add_extracted`.
|
||||
|
||||
## Hy Memory 2.0.10 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.10`](https://pypi.org/project/hy-memory/2.0.10/).
|
||||
- New `hy_memory.reembed` module for switching the embedding
|
||||
model. Writers must be paused for the whole run. Nothing is derived from
|
||||
the provider or endpoint: source and target are named explicitly, and the
|
||||
identity of a space is the model name plus the width (`space_identity`).
|
||||
Only Chroma and Kuzu are supported.
|
||||
- `vector_space_names(config, base_name=None)` returns every physical
|
||||
collection a client opens for the six vector roles: memory, entities,
|
||||
coding keys and working facts, each in the working and coding variants.
|
||||
- `reembed_vector_space(source, target, embedding=...)` pages every raw
|
||||
source collection, covering all users and all layers including L1_RAW. It
|
||||
embeds the text the write path embedded (`reembed_text`: the canonical
|
||||
state's `state_retrieval_text`, a schema's `retrieval_text`, otherwise the
|
||||
document). It then writes each record into the target with the same id,
|
||||
document and metadata. Each batch's widths are checked before anything is
|
||||
written. A cancel stops at a batch boundary (`ReembedCancelled`). A rerun
|
||||
skips the ids the target already holds, so it resumes, and a merge never
|
||||
overwrites the target. The source is never written.
|
||||
- `plan_reembed`, `verify_reembed` and `drop_vector_space` complete the
|
||||
vector side. `verify_reembed` checks ids, metadata, per-user counts and
|
||||
the width, and runs a self-recall sample. `drop_vector_space` refuses
|
||||
collections that a live client in this process holds open.
|
||||
- `scan_vector_spaces(persist_directory)` is read-only. It lists every
|
||||
space on disk with its roles, counts, stored width, per-user counts and
|
||||
the models recorded in metadata. `diff_spaces(source, target)` returns the
|
||||
ids that the source holds and the target lacks, whatever their status.
|
||||
- `reembed_graph(source_path, target_path, embedding=..., target_dims=...)`
|
||||
builds a new Kuzu graph at the new width. It copies every node and edge
|
||||
and recomputes `Memory.embedding`, `Memory.beh_embedding`, and the Topic
|
||||
and HierarchyTopic embeddings, then rebuilds the HNSW index. The source
|
||||
graph is opened read-only and kept for rollback. A run that fails or is
|
||||
cancelled leaves no target behind, and a rerun starts over.
|
||||
`verify_graph_reembed` and `drop_graph` complete the graph side.
|
||||
- `EmbedService.embed_batch` now splits large batches into
|
||||
requests of `embedder.batch_size` texts each (`MEMORY_EMBEDDER_BATCH_SIZE`,
|
||||
default 64); before, it sent them all in one request. On a 429 it waits for
|
||||
the `Retry-After` time plus jitter, capped at 60 s. It takes a per-call
|
||||
`max_retries`. A returned vector whose width differs from the configured
|
||||
`embedding_dims` now raises `EmbeddingDimensionMismatchError` and is not
|
||||
retried.
|
||||
- Raw conversations are stored already trimmed. MemHub now keeps every raw
|
||||
forever and lite mode stores nothing else, so tool output and long pasted
|
||||
logs no longer stay in full. One rule (`hy_memory/utils/raw_trim.py`) is
|
||||
applied to each message when the raw is written:
|
||||
- `user` and `system` turns are kept in full;
|
||||
- `assistant` turns and other speakers (`[John]:`) longer than 500
|
||||
characters, after stripping, are cut to the first 500 plus `...`. This is
|
||||
the limit history injection used before;
|
||||
- `tool` turns keep their head and tail, and the middle becomes
|
||||
`[...N bytes omitted by truncate...]`, with the coding limit
|
||||
`coding.tool_result_max_bytes` (`MEMORY_CODING_TOOL_RESULT_MAX_BYTES`,
|
||||
default 2048).
|
||||
|
||||
The rule is idempotent. It covers chat `L1_RAW` in lite, pro and ultra (lite
|
||||
keeps tool turns, because it has no coding route) and the `memory_history`
|
||||
ADD record. The coding/working `coding_trace` was already trimmed when it
|
||||
was written. Its tool and assistant truncations are now idempotent, so
|
||||
reading the history back no longer rewrites the omitted-count markers. The
|
||||
text embedded for `L1_RAW` is the trimmed text. New raws carry
|
||||
`custom.raw_trimmed=true`.
|
||||
- Extractor history ("last k messages") now joins the stored raws directly
|
||||
and does not trim them again. Raws written before this change have no
|
||||
`raw_trimmed` flag, so they are still trimmed with the same rule when they
|
||||
are read. The extractor still receives the current batch in full (tool
|
||||
turns are still removed on the chat route).
|
||||
|
||||
## MemHub 1.0.5 — 2026-10-28
|
||||
|
||||
Build 36, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime. Upgrading from 1.0.4 migrates the existing library to SDK
|
||||
authority on first launch.
|
||||
|
||||
- MemHub turns off the HY pipeline log and trace at every diagnostic level
|
||||
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
|
||||
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
|
||||
`hy-memory/governed-v2/logs/` and the `pipeline_logs` rows stop growing.
|
||||
The Debug panel no longer shows the HY pipeline section. Existing files are
|
||||
left in place.
|
||||
- HY SDK text logs are written once, to `hy_memory.log`. They are no longer
|
||||
copied to `service.log` and `service-error.log` through the SDK's
|
||||
stdout/stderr streams. SDK warnings and errors also go to `core.log`, so the
|
||||
Debug panel still shows them. A live switch of the diagnostic level now also
|
||||
changes the level of `hy_memory.log`.
|
||||
|
||||
- MemHub shows when working and coding experiences were talked about. After
|
||||
the SDK upgrade every one of them read "时间未知" (time unknown) because the
|
||||
SDK never stored their conversation time; hy-memory 2.0.7 stores it and
|
||||
Core now passes it through the memory list, detail, search and home
|
||||
summary. An experience stored before 2.0.7 shows its write time, labelled
|
||||
"写入于" / "写入时间", instead of "时间未知". Metadata repair also writes
|
||||
the conversation time it recovers from the migration bundle into the SDK's
|
||||
coding/working stores when they already have the column.
|
||||
- The SDK migration no longer fails its first run on a home with legacy
|
||||
vectors: reattaching them left a Chroma instance open with its own settings,
|
||||
and the readback that followed could not open the same store
|
||||
("An instance of Chroma already exists ... with different settings").
|
||||
- MemHub "添加记忆" (add a memory) no longer waits behind unprocessed history.
|
||||
Notes are claimed ahead of the event-time window that keeps history strictly
|
||||
old to new; they still run one at a time and in order within their session.
|
||||
- The SDK memory page now reads its list, count, source and time labels and
|
||||
graph from one page response for one source filter, so rows, totals and the
|
||||
map no longer disagree. Memories with no recorded source are shown and
|
||||
filterable as "unidentified source"; library search keeps only memories
|
||||
that contain the words within the SDK's relevance window.
|
||||
- Metadata repair v2 follows `governed_memory_evidence` across every
|
||||
pre-cutover bundle, which restores the source of memories the cutover
|
||||
backfilled without one, and moves impossible future conversation times back
|
||||
to the write time.
|
||||
- Reused the existing three-column memory map for SDK-authority list/detail
|
||||
views and removed the retired "original evidence" surface. SDK-native status,
|
||||
source, conversation time and creation time now render without fabricated
|
||||
revisions; source and text filters are applied by the paged Core endpoint.
|
||||
- SDK memory search now calls the SDK search contract instead of listing the
|
||||
entire library in Core. Corrupt empty records are skipped and diagnosed
|
||||
without mutating data or failing a page, and post-cutover metadata repair
|
||||
restores source and original times from the verified migration bundle.
|
||||
- Kept Cursor's WAL change token separate from the file mtime so a hash can no
|
||||
longer appear as a conversation date centuries in the future.
|
||||
- A trusted packaged Runtime now repairs a dirty same-build installation by
|
||||
staging a clean replacement, and `runtime_service_not_ready` is rendered as
|
||||
user-facing startup guidance instead of an internal code.
|
||||
- Fixed legacy-to-SDK startup migration to adopt native Coding Experience and
|
||||
Working Fact IDs instead of importing them as Chat layers. Raw migration now
|
||||
rebuilds bounded user/assistant turns from the latest three days, excludes
|
||||
tools, and enforces the same rolling retention after cutover.
|
||||
- Paused host capture writes with a live Desktop migration fence; Cursor
|
||||
History catch-up resumes from its existing checkpoint after cutover.
|
||||
- Removed MemHub's private `hy_memory_bridge` implementation. MemHub now calls
|
||||
public `HyMemoryClient` methods at runtime; prompt/reconcile/response
|
||||
monkey-patches and private VDB/Coding runtime access were deleted. Kuzu Graph
|
||||
projection/admin and stopped-writer App migration are the documented product
|
||||
exceptions.
|
||||
- Added a MemHub-managed native Hermes MemoryProvider with automatic prefetch,
|
||||
persistent per-session memory-ID deduplication, authorized Broker reads, and
|
||||
recall-context stripping to prevent memory recursion.
|
||||
- Added an extensible MemHub interface-localization layer with Simplified
|
||||
Chinese, English, and Spanish catalogs shared by Web and Electron-native
|
||||
surfaces. Interface language remains independent from memory content and
|
||||
model processing language.
|
||||
- Fixed governed-memory revisions and repeated evidence links failing when the
|
||||
visibility dirty-marker trigger encounters the same memory twice. Startup
|
||||
now replaces the affected legacy trigger without changing schema versions
|
||||
or governed and visibility data.
|
||||
- Kept post-write authority verification failures available for read-only
|
||||
reconciliation while preventing automatic or generic confirmed retries from
|
||||
invoking the model again. Automatic reconciliation skips those sticky rows
|
||||
before applying its batch limit, and linked durable jobs exclusively own
|
||||
retry scheduling for their memory tasks.
|
||||
- Removed MemHub's task-scoped HyPermission store wrappers and global
|
||||
model-content consent switch. The trusted embedded Hy Memory SDK now uses
|
||||
its native local stores directly.
|
||||
@@ -12,6 +428,193 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||
profiles, SDK stores, and source/host grants are preserved.
|
||||
|
||||
## Hy Memory 2.0.9 — 2026-11-01
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.9`](https://pypi.org/project/hy-memory/2.0.9/).
|
||||
- Placeholder values no longer overwrite the user's basic profile (L0). A
|
||||
small extraction model filled `basic_info` with `{"name": "用户"}`,
|
||||
`The user` or `N/A` when the user had said nothing about themselves, and
|
||||
each placeholder superseded the real value, so the L0 chain read
|
||||
费天翔 → Fei → 用户 → The user. Two defences:
|
||||
- `upsert_basic_profile` drops placeholder values: empty, `用户`,
|
||||
`the user`, `user`, `n/a`, `na`, `none`, `null`, `unknown`, `未提供`,
|
||||
`未知`, `不详`, `-`, `?` and the field's own name, compared
|
||||
case-insensitively with whitespace and punctuation stripped. When nothing
|
||||
is left it writes no new version and no new L0 head. Placeholders already
|
||||
stored in the chain no longer override earlier real values when the
|
||||
profile is assembled, so `get_user_profile` of an affected user shows the
|
||||
last real value again without any migration. This also stops an
|
||||
assistant quoting an old memory ("The user's name is 用户.") from being
|
||||
written back.
|
||||
- The extraction prompts (zh and en) state the rule: only what the user
|
||||
said in a `user` turn, never the field name or a placeholder, `{}`
|
||||
otherwise, with a small-talk counter-example.
|
||||
|
||||
## Hy Memory 2.0.8 — 2026-10-28
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.8`](https://pypi.org/project/hy-memory/2.0.8/).
|
||||
- New `MEMORY_PIPELINE_LOG_ENABLED` (default `true`) turns the pipeline JSONL
|
||||
log (`{MEMORY_LOG_DIR}/<subdir>/<date>.log`) off. With `false` no JSONL
|
||||
file or directory is written. When `MEMORY_PIPELINE_TRACE_ENABLED` is also
|
||||
`false`, `store_pipeline_log` becomes a no-op that returns `True` at once:
|
||||
no timing, redaction, background task or database write. The init log line
|
||||
now states whether the JSONL log is on or off.
|
||||
- Conversation and memory text no longer appears in INFO or WARNING logs. The
|
||||
`[add]` input, search queries and rewrites, coding experience tasks, fact
|
||||
statements and subjects, reconcile op details, extracted info, embedding
|
||||
inputs and unparsable model output moved to DEBUG. INFO lines keep ids,
|
||||
counts and character lengths; parse-failure warnings remain but report only
|
||||
the length of the output, e.g. `failed to parse output (812 chars)`. The
|
||||
`[embed]` INFO line now carries `request.input_chars` instead of
|
||||
`request.input`. The `JSON_PARSE_FAILED` error message from extraction no
|
||||
longer embeds the model output either.
|
||||
|
||||
## Hy Memory 2.0.7 — 2026-10-28
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.7`](https://pypi.org/project/hy-memory/2.0.7/).
|
||||
- Coding and working experiences and facts now keep their conversation time.
|
||||
`add(..., memory_at=...)` reached only the chat path before; the
|
||||
productivity path dropped it, so every experience had only its write time
|
||||
and MemHub showed them as "时间未知" (time unknown). `coding_memory_meta`
|
||||
and `working_fact` gain a nullable `memory_at` column (epoch seconds) in
|
||||
both the working and coding stores; existing databases get it on open, and
|
||||
their old rows stay `None`. Without `memory_at` a write uses the time the
|
||||
request arrived, as chat does. When an experience is updated by a later
|
||||
conversation it keeps the later of the two times.
|
||||
- `list_memories` (`scene="coding"`, `"working"` and `"all"`), `get` and
|
||||
`search` return `memory_at` for experiences and facts in the same field and
|
||||
format as chat (epoch seconds, `None` when unknown). Experience search
|
||||
results now also carry `created_at`.
|
||||
|
||||
## Hy Memory 2.0.6 — 2026-10-27
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.6`](https://pypi.org/project/hy-memory/2.0.6/).
|
||||
- `search(..., rerank=False)` skips every reranking stage for that one
|
||||
call (the local cross-encoder, the memory-graph rerank, and the BM25 rerank of
|
||||
working/coding facts), so results come back in retrieval-score order. The
|
||||
flag travels on the read request, so concurrent searches do not affect each
|
||||
other. The default (`True`) keeps current behavior; `/api/v1/search` accepts
|
||||
it too.
|
||||
- MMR diversity selection now runs only when the requested `limit` is
|
||||
below 20 (`MMR_MAX_TOP_K`) and is skipped for larger limits, where its
|
||||
quadratic token-overlap pass is slow and gains little. Previously it ran for
|
||||
every limit. `MEMORY_HYBRID_V2_MMR_LAMBDA` still sets the weight.
|
||||
Near-duplicate demotion follows the same gate: it compares each candidate
|
||||
with every earlier non-duplicate, which is quadratic in a pool that grows
|
||||
with `limit`, and large result pages should show similar items anyway.
|
||||
- `search(scene="all")` searches chat, working and coding at the same time
|
||||
and merges them with Reciprocal Rank Fusion (k=60) over each scene's own
|
||||
ranking, so no scene is crowded out by the other scenes' score scales. Each
|
||||
result says which scene and kind it came from. A scene that fails or is turned
|
||||
off is reported in `errors` and does not fail the search. Source filters apply
|
||||
to all three.
|
||||
- `list_memories(scene="all")` lists chat, working and coding memories
|
||||
together, newest update first, with `limit`/`offset` paging over the merged
|
||||
order and `total` summing all three. `scene="coding"` and `"working"` now
|
||||
honor `include_sources` / `exclude_sources`, and `total` counts only the
|
||||
filtered memories.
|
||||
- `update()` and `delete()` now work on working/coding experiences and
|
||||
facts, not just chat memories. They take the same optional `kind` / `variant`
|
||||
as `get()`, and an ID found in more than one place raises
|
||||
`AmbiguousMemoryIdError` instead of guessing. Updating an experience replaces
|
||||
its solution and keeps its task, unless a new `task=` is also passed; updating
|
||||
a fact replaces its statement. Deleting removes the stored row and its vectors.
|
||||
- `list_memories(scene="chat" | "coding" | "working")` lists one kind of
|
||||
memory. `coding` and `working` list that scene's whole store, newest update
|
||||
first (`workspace_id` still narrows it), and `total` is now the real count
|
||||
rather than the page size. The new `memory_stats()` returns the count and the
|
||||
latest created/updated time for each scene and their total, without reading
|
||||
any memory text. Previously coding and working memories could not be listed
|
||||
or counted without a workspace, so a library of 45 memories listed 22.
|
||||
- Reconcile keeps time order. Both reconcile prompts now state that only
|
||||
`memory_at` decides which memory is newer, that an older memory must never
|
||||
supersede or overwrite a newer one, and that an unknown time is not newer;
|
||||
the Chinese prompt now lists `memory_at` among the new-memory fields. The
|
||||
reconciler also enforces this in code: a SUPERSEDE or UPDATE aimed at a memory
|
||||
with a later `memory_at` becomes an ADD, so the newer memory stays current.
|
||||
- The HTTP server returns 409 `ambiguous_memory_id` and 400 for a bad
|
||||
`kind`/`variant` instead of 500, passes `kind`/`variant` (and `task` on PUT)
|
||||
to get/update/delete, and no longer reads a PUT/DELETE query string as part
|
||||
of the memory id.
|
||||
- `memory_stats()` reads working/coding times, which are stored as naive UTC,
|
||||
as UTC rather than local time.
|
||||
|
||||
## Hy Memory 2.0.5 — 2026-10-22
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.5`](https://pypi.org/project/hy-memory/2.0.5/).
|
||||
- Extended `get()` across Chat, Coding Experience, Working Fact, and raw IDs;
|
||||
the returned `layer`, `kind`, and `variant` identify the actual record.
|
||||
- Added explicit raw retrieval through the existing `search()` and
|
||||
`list_memories()` contracts, including user, agent, session, and source
|
||||
filters.
|
||||
- Added public embedding-service injection and corrected extraction language
|
||||
detection for structured OpenAI-style messages.
|
||||
- Added lifecycle status filters, durable idempotent write outcomes, and
|
||||
quiesced local data-plane snapshot/restore.
|
||||
- Kept legacy migration mechanics out of the public Client API; separate
|
||||
migration-only raw/import methods from development previews were removed.
|
||||
- Physical delete now reports partial VDB/Graph cleanup as failure.
|
||||
- Coding search no longer injects the version-evolution diff block by default;
|
||||
opt in with `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`.
|
||||
- Serialized SDK SQLite cache/history connection lifecycle and tightened
|
||||
Coding reconciliation to its agent/session/workspace authority.
|
||||
- Clarified persistent caller context through `meta`, while `metadata` remains
|
||||
legacy processing configuration.
|
||||
|
||||
## Hy Memory 2.0.5.dev4 — 2026-10-21
|
||||
|
||||
- Development preview used for SDK-authority migration validation; not the
|
||||
final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev4`](https://pypi.org/project/hy-memory/2.0.5.dev4/).
|
||||
|
||||
## Hy Memory 2.0.5.dev3 — 2026-10-21
|
||||
|
||||
- Development preview; not the final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev3`](https://pypi.org/project/hy-memory/2.0.5.dev3/).
|
||||
- Lets the existing `search` API explicitly query SDK raw with
|
||||
`layers=["l1_raw"]`, including existing source filters.
|
||||
|
||||
## Hy Memory 2.0.5.dev2 — 2026-10-21
|
||||
|
||||
- Development preview; not the final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev2`](https://pypi.org/project/hy-memory/2.0.5.dev2/).
|
||||
- Adds exact, idempotent semantic-memory import for legacy MemHub cutover
|
||||
without extraction, reconciliation or identity changes.
|
||||
|
||||
## Hy Memory 2.0.5.dev1 — 2026-10-21
|
||||
|
||||
- Development preview for the MemHub SDK-authority migration; this is not the
|
||||
final `2.0.5` release.
|
||||
- Published as
|
||||
[`hy-memory==2.0.5.dev1`](https://pypi.org/project/hy-memory/2.0.5.dev1/).
|
||||
- Adds lifecycle status filtering, durable idempotent write outcomes, exact raw
|
||||
APIs, offline local data-plane snapshot/restore, and fail-closed physical
|
||||
delete receipts.
|
||||
|
||||
## Hy Memory 2.0.4 — 2026-10-17
|
||||
|
||||
- Published
|
||||
[`hy-memory==2.0.4`](https://pypi.org/project/hy-memory/2.0.4/) as a
|
||||
maintenance release.
|
||||
|
||||
## Hy Memory 2.0.3 — 2026-10-17
|
||||
|
||||
- Added an optional runtime HTTP transport factory so embedded products can
|
||||
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.
|
||||
|
||||
## Hy Memory 2.0.2 — 2026-10-17
|
||||
|
||||
- Published
|
||||
@@ -26,7 +629,31 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
Because PyPI release metadata is immutable, the next SDK patch release must
|
||||
carry the updated license metadata; `2.0.1` retains its published metadata.
|
||||
|
||||
## MemHub 1.0.2 — Unreleased
|
||||
## MemHub 1.0.5 — Unreleased
|
||||
|
||||
- Updated the source version to `1.0.5` (build 14).
|
||||
- Pinned the public SDK to the stable `hy-memory==2.0.5` release.
|
||||
|
||||
## MemHub 1.0.4 — 2026-10-18
|
||||
|
||||
- Updated the source version to `1.0.4` (build 13).
|
||||
- Published the `v1.0.4` source and release metadata.
|
||||
|
||||
## MemHub 1.0.3 — 2026-10-17
|
||||
|
||||
- Updated the source version to `1.0.3` (build 7).
|
||||
- Pinned the public SDK to `hy-memory==2.0.3`.
|
||||
- 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.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -32,7 +32,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.2`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.2/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **Released [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)** with interfaces in 11 languages, memory modes (lite / pro / ultra with nightly evolve), safe embedding-model switching, multi-user support for the CLI and API, memory writes that retry themselves and pause while the model provider is down, and more reliable upgrades, on **`hy-memory==2.0.14`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
|
||||
* *<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 +67,13 @@ Welcome to the **Hy Memory Research Family**, covering cognitive chat memory, lo
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.2 # core (includes ChromaDB and Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.2" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.2" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.2" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.2" # + MySQL cache
|
||||
pip install "hy-memory[rerank]==2.0.2" # + local multilingual cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.2" # everything
|
||||
pip install hy-memory==2.0.4 # core (includes ChromaDB and Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.4" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.4" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.4" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.4" # + MySQL cache
|
||||
pip install "hy-memory[rerank]==2.0.4" # + local multilingual cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.4" # everything
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
@@ -95,6 +99,12 @@ export OPENAI_API_KEY="sk-your-key"
|
||||
|
||||
See the [Environment Variable Reference](docs/env_reference.md) for the full list.
|
||||
|
||||
Prompts ship in 11 languages (`en`, `zh-Hans`, `zh-Hant`, `ja`, `es`, `de`, `pt`,
|
||||
`fr`, `ko`, `ru`, `id`). By default the SDK picks Chinese or English from the
|
||||
input. Set `MEMORY_LANGUAGE` (or `language=` on `add()`) to choose one
|
||||
explicitly; memories are then written in that language. See
|
||||
[Prompt language](docs/usage.md#提示词语言language).
|
||||
|
||||
### 3. Chat Memory
|
||||
|
||||
```python
|
||||
@@ -151,7 +161,7 @@ result = client.search(
|
||||
|
||||
result["facts"] # fact layer
|
||||
result["experiences"] # experience layer
|
||||
result["chain_block"] # coding only: version-evolution context
|
||||
result["chain_block"] # coding only: version-evolution context (empty unless MEMORY_CODING_CHAIN_BLOCK_ENABLED)
|
||||
```
|
||||
|
||||
### 5. Routing, Source Isolation & Advanced APIs
|
||||
@@ -251,6 +261,7 @@ That version is usually not in the working tree or at HEAD either, so it cannot
|
||||
- [Graph memory design](docs/pro_graph_design.md) — graph projection, rerank, relation paths, and deployment limits
|
||||
- [Environment template](.env.example) — a copy-ready configuration template
|
||||
- [MemHub README](memhub/README.md) — local-first desktop & CLI product overview, runtime commands, and privacy boundaries
|
||||
- [Hermes native memory provider](plugins/hermes/README.md) — explicit owner/agent scope; separate HTTP service; automatic capture and recall opt-in.
|
||||
- [OpenClaw plugin](plugins/openclaw/README.md) — agent integrations live under `plugins/` (overview: [plugins/README.md](plugins/README.md))
|
||||
|
||||
### License
|
||||
|
||||
+19
-9
@@ -32,7 +32,11 @@
|
||||
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.2`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.2/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-11]**: 📦 **正式发布 [MemHub v1.0.6](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.6)**:界面支持 11 种语言,新增记忆模式(lite / pro / ultra,ultra 每晚自动整理),可安全切换嵌入模型,CLI 和 API 支持多用户,记忆写入自动重试、模型服务异常时暂停排队,升级更可靠;底层使用 **`hy-memory==2.0.14`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
|
||||
* *<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 +67,13 @@
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.2 # 核心(含 ChromaDB 与 Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.2" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.2" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.2" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.2" # + MySQL 缓存
|
||||
pip install "hy-memory[rerank]==2.0.2" # + 本地多语言 cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.2" # 全部
|
||||
pip install hy-memory==2.0.4 # 核心(含 ChromaDB 与 Kuzu)
|
||||
pip install "hy-memory[qdrant]==2.0.4" # + Qdrant
|
||||
pip install "hy-memory[faiss]==2.0.4" # + FAISS
|
||||
pip install "hy-memory[graph]==2.0.4" # + Neo4j
|
||||
pip install "hy-memory[mysql]==2.0.4" # + MySQL 缓存
|
||||
pip install "hy-memory[rerank]==2.0.4" # + 本地多语言 cross-encoder rerank
|
||||
pip install "hy-memory[all]==2.0.4" # 全部
|
||||
```
|
||||
|
||||
### 2. 配置
|
||||
@@ -95,6 +99,11 @@ export OPENAI_API_KEY="sk-your-key"
|
||||
|
||||
完整配置见 [环境变量参考](docs/env_reference.md)。
|
||||
|
||||
提示词提供 11 种语言(`en`、`zh-Hans`、`zh-Hant`、`ja`、`es`、`de`、`pt`、`fr`、
|
||||
`ko`、`ru`、`id`)。默认按输入自动在中文与英文之间选择;设置 `MEMORY_LANGUAGE`(或在
|
||||
`add()` 上传 `language=`)可显式指定,记忆随之用该语言书写。详见
|
||||
[提示词语言](docs/usage.md#提示词语言language)。
|
||||
|
||||
### 3. Chat 记忆
|
||||
|
||||
```python
|
||||
@@ -151,7 +160,7 @@ result = client.search(
|
||||
|
||||
result["facts"] # 事实层
|
||||
result["experiences"] # 经验层
|
||||
result["chain_block"] # 仅 coding:版本演进上下文
|
||||
result["chain_block"] # 仅 coding:版本演进上下文(默认空;需 MEMORY_CODING_CHAIN_BLOCK_ENABLED)
|
||||
```
|
||||
|
||||
### 5. 自动路由、来源过滤与进阶 API
|
||||
@@ -251,6 +260,7 @@ Coding 与 Working **复用同一套双层 pipeline 和数据 schema**,区别
|
||||
- [Graph 记忆设计](docs/pro_graph_design.md) — 图投影、重排、关系路径与部署限制
|
||||
- [环境变量模板](.env.example) — 可直接复制的配置模板
|
||||
- [MemHub 说明文档](memhub/README.md) — 本地优先桌面端与 CLI 产品总览、Runtime 命令与隐私边界
|
||||
- [Hermes 原生记忆 Provider](plugins/hermes/README.md) — 显式用户与 Agent 隔离,独立 HTTP 服务,自动记录与召回默认关闭。
|
||||
- [OpenClaw 插件](plugins/openclaw/README.md) — Agent 集成见 `plugins/`(总览:[plugins/README.md](plugins/README.md))
|
||||
|
||||
### License
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|---|---|---|
|
||||
| `MEMORY_MODE` | `pro` | `lite` / `pro` / `ultra` |
|
||||
| `MEMORY_DATA_DIR` | `~/.hy_memory` | 本地数据、缓存和日志根目录 |
|
||||
| `MEMORY_LANGUAGE` | — | LLM 提示词与记忆正文语言:`en` / `zh-Hans` / `zh-Hant` / `ja` / `es` / `de` / `pt` / `fr` / `ko` / `ru` / `id`(别名 `zh`、`zh-CN` → `zh-Hans`,`zh-TW`、`zh-HK` → `zh-Hant`,`pt-BR` → `pt`)。未设置时按输入做 zh/en 自动检测;per-call `language=` 优先 |
|
||||
|
||||
- `lite`:纯 Embedding 写入,Graph 强制关闭。
|
||||
- `pro`:LLM 提取与 reconcile;Graph 由 `MEMORY_ENABLE_GRAPH` 控制。
|
||||
@@ -48,6 +49,7 @@
|
||||
| `MEMORY_EMBEDDER_TIMEOUT` | `15` | 单次超时,秒 |
|
||||
| `MEMORY_EMBEDDER_MAX_RETRIES` | `2` | 最大重试次数 |
|
||||
| `MEMORY_EMBEDDER_RETRY_DELAY` | `0.3` | 重试间隔,秒 |
|
||||
| `MEMORY_EMBEDDER_BATCH_SIZE` | `64` | `embed_batch` 单次请求最多发送的文本条数,超出按此切块 |
|
||||
| `MEMORY_EMBEDDER_EXTRA_HEADERS` | — | JSON object,附加 HTTP headers |
|
||||
| `MEMORY_EMBEDDER_EXTRA_BODY` | — | JSON object,附加请求 body |
|
||||
|
||||
@@ -180,9 +182,29 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
||||
| `MEMORY_RECALL_STRENGTH_TAU` | `180` | 衰减时间常数,天 |
|
||||
| `MEMORY_RECALL_ACCESS_TRACKING` | `true` | Strength 开启时是否回写访问信息 |
|
||||
| `MEMORY_ENTITY_STORE_ENABLED` | `false` | 是否维护独立 entity collection;当前 Reader 不直接读取 |
|
||||
| `MEMORY_INTENTION_DEDUP_ENABLED` | `true` | 是否对 L7 Intention 判重(读侧和写入侧批内);见下文 |
|
||||
| `MEMORY_INTENTION_DEDUP_THRESHOLD` | `MEMORY_DEDUP_THRESHOLD`,再缺省为 `0.95` | Intention 读侧判重的 cosine 阈值 |
|
||||
|
||||
旧数据可调用当前公开方法 `client.build_entity_store(user_id)` 补建 Entity Store。
|
||||
|
||||
Intention 判重:开启时(默认,与以往行为一致),Search 召回的 L7 Intention 中,
|
||||
同 `user_id` + 同 `agent_id` + `memory_at` 同一天 + `valid_until` 相同、且 cosine ≥ 阈值的
|
||||
条目视为复本,只返回最早写入的一条,其余在后台从向量库**删除**。判重不区分内容中的项目、
|
||||
对象等语义差异,内容高度相似但对象不同的意图可能被误删;写入时同一批抽取结果也会做一次
|
||||
批内判重。关闭后这两处都不判重,不隐藏、不丢弃、也不删除意图。两项对应
|
||||
`MemoryConfig.recall.intention_dedup_enabled` /
|
||||
`recall.intention_dedup_threshold`,按 client 生效,环境变量只提供默认值:
|
||||
|
||||
```python
|
||||
client = HyMemoryClient.from_config({
|
||||
"recall": {"intention_dedup_enabled": False},
|
||||
})
|
||||
```
|
||||
|
||||
`MEMORY_DEDUP_THRESHOLD` 同时被写入侧批内意图判重和 dedup 内核使用;
|
||||
`MEMORY_INTENTION_DEDUP_THRESHOLD` 只影响读侧 Intention 判重,写入侧批内判重的阈值
|
||||
仍取 `MEMORY_DEDUP_THRESHOLD`。
|
||||
|
||||
## 日志、Trace 与历史
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
@@ -191,12 +213,14 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
|
||||
| `MEMORY_LOG_FORMAT` | `text` | `text` / `json` |
|
||||
| `MEMORY_LOG_DIR` | — | 统一日志根目录 |
|
||||
| `MEMORY_TRACE_LOG_DIR` | — | JSONL 日志目录的兼容旧名;优先使用 `MEMORY_LOG_DIR` |
|
||||
| `MEMORY_PIPELINE_LOG_ENABLED` | `true` | 是否写 pipeline JSONL 日志(`{MEMORY_LOG_DIR}/<subdir>/<date>.log`);`false` 完全不写 |
|
||||
| `MEMORY_PIPELINE_TRACE_ENABLED` | `true` | 是否写入 cache 的 pipeline trace 表 |
|
||||
| `MEMORY_MEMORY_OPERATIONS_ENABLED` | `false` | 是否写入 memory operation 审计记录 |
|
||||
| `MEMORY_HISTORY_ENABLE` | `true` | 是否启用历史记录 |
|
||||
| `MEMORY_HISTORY_DB_PATH` | `{DATA_DIR}/data/history.db` | History SQLite 路径 |
|
||||
| `MEMORY_HISTORY_RECORD_SEARCHES` | `false` | 是否记录 Search 操作 |
|
||||
| `MEMORY_CODING_CHAIN_REPO_ROOT` | — | coding 版本链读取本地 Git diff 的仓库根目录 |
|
||||
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search(scene=coding) 是否注入版本演进链块(diff / `chain_block`);默认关 |
|
||||
|
||||
## HTTP Server
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ default 多-query 超过 `MEMORY_QUERY_REWRITE_MAX_CHARS` 时不改写:Coding
|
||||
Coding 关闭则直接使用最后一条 query。
|
||||
|
||||
路由到 `working` / `coding` 后,Search 读取独立生产力记忆库,返回 `facts` /
|
||||
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`。
|
||||
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`
|
||||
(默认空;需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 才注入 diff 块)。
|
||||
|
||||
## Chat 成功返回结构
|
||||
|
||||
@@ -152,7 +153,7 @@ client = HyMemoryClient.from_config({
|
||||
安装可选依赖:
|
||||
|
||||
```bash
|
||||
pip install 'hy-memory[rerank]==2.0.1'
|
||||
pip install 'hy-memory[rerank]==2.0.4'
|
||||
```
|
||||
|
||||
启用后,`HyMemoryClient(...)` 构造阶段会完成模型下载/读取、device 放置和一次 dummy
|
||||
|
||||
+2
-3
@@ -37,8 +37,7 @@ do not belong in the public source tree.
|
||||
- Root GitHub workflows run SDK and MemHub checks from their respective working
|
||||
directories. Native lifecycle workflows produce reviewable Actions artifacts;
|
||||
tag releases remain fail-closed on public identity, signing and notarization.
|
||||
- Hy Memory is published as `hy-memory==2.0.2`. MemHub pins that public
|
||||
distribution; its selected npm package is `memhub-cli`, which is not yet
|
||||
published.
|
||||
- Hy Memory is published as `hy-memory==2.0.4`. MemHub pins that public
|
||||
distribution; its selected npm package is `memhub-cli` 1.0.5.
|
||||
- Hy-Memory source uses Apache-2.0 at the repository root. Third-party
|
||||
components retain their own licenses and attribution notices.
|
||||
@@ -107,5 +107,5 @@ L1_RAW 保存的内容使用上述渲染格式。历史回读时:
|
||||
```bash
|
||||
python -m pytest -q \
|
||||
tests/test_multi_speaker_format.py \
|
||||
tests/test_reconcile_fallback.py
|
||||
tests/test_reconcile_failure.py
|
||||
```
|
||||
@@ -150,7 +150,23 @@ working/coding 写入返回双层结果:
|
||||
}
|
||||
```
|
||||
|
||||
`scene="coding"` 还会返回 `chain_rounds`。出于历史兼容,该字段当前统计本次识别并处理
|
||||
抽取 LLM 调用失败、返回空 content 或回复解析不出 JSON,以及经验 / 事实向量化失败时,
|
||||
写入返回 `success=False`、`error_code=502`,`agent_error_code` 依次为 `LLM_ERROR`、
|
||||
`EMPTY_RESPONSE`、`JSON_PARSE_FAILED`、`EMBEDDING_FAILED`,`error_message` 以
|
||||
`[CODE]` 开头;没有向量的经验 / 事实不会落库,失败的写入也不追加对话 trace,换一个
|
||||
`request_id` 重跑即可。模型正常作答但没有值得记的内容仍是 `success=True`、0 条。
|
||||
显式开启 `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` 时抽取失败沿用原文兜底。
|
||||
|
||||
经验层 / 事实层 reconcile 的模型决策同样不再兜底:LLM 调用失败、返回空 content 或回复
|
||||
不是带 `ops` 列表的 JSON 对象时,写入返回 `success=False`、
|
||||
`agent_error_code="RECONCILE_FAILED"`,原因在 `reconcile_error_code`(`LLM_UNAVAILABLE` /
|
||||
`EMPTY_RESPONSE` / `JSON_PARSE_FAILED`,与 chat 链路一致)。两层的模型决策都在落库之前
|
||||
完成,任何一层失败时本次一条经验 / 事实都不存,旧事实也不会被覆盖。经验层回复
|
||||
`{"ops": []}` 表示「无需任何操作」,写入成功、经验层不存;`ops` 非空却没有一条可识别的
|
||||
op 视为解析失败。事实层回复 `{"ops": []}` 表示都不覆盖、照常插入新事实;没有候选时不
|
||||
调用 LLM。
|
||||
|
||||
`scene="coding"` 还会返回 `chain_rounds`(写入失败时不记录演进链,也不返回该字段)。出于历史兼容,该字段当前统计本次识别并处理
|
||||
的 diff hunk 数,而不保证等于最终合并后的唯一演进 round 数。`0` 表示没有识别到可记录
|
||||
的代码修改,不代表双层 memory 写入失败。
|
||||
|
||||
@@ -168,9 +184,11 @@ working/coding 搜索返回:
|
||||
|
||||
`limit` 分别限制 facts 与 experiences,因此两层最多各返回 `limit` 条。
|
||||
|
||||
`scene="coding"` 额外返回 `chain_block` 和 `chain_used`:
|
||||
`scene="coding"` 额外返回 `chain_block` 和 `chain_used` 字段(键始终存在;默认内容为空):
|
||||
|
||||
- `chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
|
||||
- 注入 diff / 版本演进块需显式开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`
|
||||
(或 `CodingConfig.chain_block_enabled=True`)。默认关闭,以节约 prompt token。
|
||||
- 开启后,`chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
|
||||
- `chain_used` 是已经进入 chain block 的 `facts` 下标。
|
||||
- SDK 不会从 `facts` 删除这些条目。调用方若要拼成“chain block + 剩余 facts”,需自行按 `chain_used` 过滤。
|
||||
|
||||
@@ -222,10 +240,12 @@ working/coding 搜索返回:
|
||||
| `MEMORY_CODING_DB_PATH` | `{DATA_DIR}/data/coding_memory.db` | working 基础 SQLite 路径;coding/trace 路径由此派生 |
|
||||
| `MEMORY_CODING_TOOL_RESULT_MAX_BYTES` | `2048` | 单条 tool result 的字节上限 |
|
||||
| `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` | `false` | 模型无结果时是否保存原文 fact;默认关闭,避免 assistant/tool trace 直接展示 |
|
||||
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search 是否注入版本演进链块(`chain_block` / diff);默认关闭(opt-in) |
|
||||
| `MEMORY_EXTRACT_HISTORY_TURNS` | `5` | 回读的历史 user-turn 数;当前 prompt 最终最多使用 5 |
|
||||
| `MEMORY_CODING_CHAIN_REPO_ROOT` | 空 | coding 版本链读取本地 Git diff 的仓库路径 |
|
||||
|
||||
`HCM_*` 变量属于版本链的高级调优项,不是稳定的公共配置契约;普通接入不应依赖其默认值。
|
||||
其中 `HCM_BLOCK_OFF=1` 可在已开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 时做消融(完全不出块)。
|
||||
|
||||
## 8. 定向验证
|
||||
|
||||
|
||||
+82
-8
@@ -5,14 +5,14 @@
|
||||
## 安装与快速开始
|
||||
|
||||
```bash
|
||||
pip install hy-memory==2.0.2
|
||||
pip install hy-memory==2.0.4
|
||||
|
||||
# 可选后端
|
||||
pip install 'hy-memory[qdrant]==2.0.1'
|
||||
pip install 'hy-memory[faiss]==2.0.1'
|
||||
pip install 'hy-memory[graph]==2.0.1' # Neo4j;Kuzu 已在核心依赖中
|
||||
pip install 'hy-memory[mysql]==2.0.1'
|
||||
pip install 'hy-memory[rerank]==2.0.1' # 本地多语言 cross-encoder rerank
|
||||
pip install 'hy-memory[qdrant]==2.0.4'
|
||||
pip install 'hy-memory[faiss]==2.0.4'
|
||||
pip install 'hy-memory[graph]==2.0.4' # Neo4j;Kuzu 已在核心依赖中
|
||||
pip install 'hy-memory[mysql]==2.0.4'
|
||||
pip install 'hy-memory[rerank]==2.0.4' # 本地多语言 cross-encoder rerank
|
||||
```
|
||||
|
||||
默认 LLM 和 Embedding 均使用 OpenAI-compatible 接口。使用 OpenAI 时可只设置:
|
||||
@@ -103,9 +103,19 @@ result = client.add(
|
||||
metadata=None,
|
||||
meta=None,
|
||||
embed_override=None,
|
||||
language=None,
|
||||
)
|
||||
```
|
||||
|
||||
`metadata` 与 `meta` 不是同一字段:
|
||||
|
||||
- `metadata` 是兼容旧调用方保留的 SDK 处理选项,会进入内部
|
||||
`WriteRequest.extra`,不保证持久化或随查询结果返回。
|
||||
- `meta` 是调用方的业务上下文,会持久化并在 `search()`、`get()` 等结果中返回。
|
||||
同一记忆被多次合并时,各次 `meta` 会按写入顺序保存在列表中。
|
||||
|
||||
需要以后读回的数据必须放在 `meta`,不要放在 `metadata`。
|
||||
|
||||
`scene` 的公开取值:
|
||||
|
||||
- `None`:普通文本直接走 chat;messages 含 tool 时执行快速场景判断。
|
||||
@@ -118,6 +128,14 @@ result = client.add(
|
||||
双层 reconcile 操作、`experience_ids`、`fact_ids` 等字段,完整结构见
|
||||
[scene_coding_usage.md](scene_coding_usage.md#4-返回结构)。
|
||||
|
||||
写入失败时 `success=False`,`error_code` 为 HTTP 风格的整数,`agent_error_code` 给出稳定
|
||||
失败码。pro / ultra 的 reconcile(新事实与已有记忆判重、合并)模型调用失败、返回空
|
||||
content 或回复解析不出时,`add` 与 `add_extracted` 都报 `agent_error_code="RECONCILE_FAILED"`,
|
||||
具体原因在 `reconcile_error_code`(`LLM_UNAVAILABLE` / `EMPTY_RESPONSE` /
|
||||
`JSON_PARSE_FAILED` / `RECONCILE_EXCEPTION`)。此时本次一条新记忆都不落库,`add` 已写入
|
||||
的原始对话按抽取失败同样回滚(`l1_rollback.retry_safe=True`);模型恢复后换一个
|
||||
`request_id` 重跑即可。`RECONCILE_ENABLED=false` 时不调用 reconcile,直接写入。
|
||||
|
||||
`mode="lite"` 不执行 LLM 抽取,写入内容直接以 `L1_RAW` 形式保存。lite 的 Search 是
|
||||
严格的 raw-only 向量检索:无论调用方传入什么 `layers`、`scene` 或 `search_mode`,都只
|
||||
召回 `L1_RAW`,不会进入 working/coding、Schema 或 Intention 链路,也不会调用场景判断
|
||||
@@ -127,6 +145,54 @@ LLM。pro / ultra 仍遵循各自的层级与场景参数,默认不公开原
|
||||
正交:前者说明“从哪个产品/入口写入”,后者仍是记忆隔离中的 agent 身份。source 会
|
||||
执行 `strip().lower()` 后存储,并随 chat、working、coding 记忆一起返回。
|
||||
|
||||
### 提示词语言(`language`)
|
||||
|
||||
SDK 的 LLM 提示词提供 11 种语言,按语言码注册在 `hy_memory/prompts/`
|
||||
(每种语言一个模块,`get_prompt(name, language)` 统一取用):
|
||||
|
||||
| 语言码 | 语言 | 别名 |
|
||||
|---|---|---|
|
||||
| `en` | English | `en-US`、`en-GB` |
|
||||
| `zh-Hans` | 简体中文 | `zh`、`zh-CN`、`zh-SG` |
|
||||
| `zh-Hant` | 繁體中文 | `zh-TW`、`zh-HK`、`zh-MO` |
|
||||
| `ja` | 日本語 | |
|
||||
| `es` | Español | |
|
||||
| `de` | Deutsch | |
|
||||
| `pt` | Português | `pt-BR`、`pt-PT` |
|
||||
| `fr` | Français | |
|
||||
| `ko` | 한국어 | |
|
||||
| `ru` | Русский | |
|
||||
| `id` | Bahasa Indonesia | |
|
||||
|
||||
语言码大小写不敏感,`_` 与 `-` 等价;未知语言码抛 `ValueError` 并列出支持的语言码。
|
||||
|
||||
```python
|
||||
# client 默认值(也可用环境变量 MEMORY_LANGUAGE=ja)
|
||||
client = HyMemoryClient.from_config({"mode": "pro", "language": "ja"})
|
||||
|
||||
# per-call 覆盖 client 默认值
|
||||
client.add(messages, user_id="u1", language="de")
|
||||
client.add_extracted(["Der Nutzer trinkt keinen Kaffee."], user_id="u1", language="de")
|
||||
client.finalize_session(user_id="u1", session_id="s1", language="de")
|
||||
client.evolve(user_id="u1", language="de") # ultra
|
||||
client.sweep(user_ids=["u1"], language="de") # ultra
|
||||
```
|
||||
|
||||
优先级:per-call `language` > `MemoryConfig.language`(`MEMORY_LANGUAGE`)> 自动检测。
|
||||
|
||||
- **未指定语言(默认)**:行为与以前完全一致。SDK 按输入做 zh/en 自动检测,中文输入用
|
||||
简体中文提示词,其余用英文提示词;自动检测不会选到其他语言。提示词里的「输出语言与
|
||||
输入语言一致」规则仍然生效,所以记忆正文保持对话的原始语言。
|
||||
- **显式指定语言**:本次请求的全部 LLM 调用(抽取、基础画像、reconcile、working /
|
||||
coding 链路的抽取与两个 reconciler 及演变总结、episode / topic 摘要、System 2)都使用
|
||||
该语言的提示词,并在提示词末尾追加该语言的「输出语言」规则:记忆正文、摘要等自然语言
|
||||
字段一律用该语言书写,即使对话是另一种语言。JSON 键、字段名、枚举值、代码、路径、
|
||||
报错信息和专有名词保持原样;tags 仍是英文小写关键词。
|
||||
- 只有英文版的提示词(session / dynamic episode、cross-domain sweeper)在显式指定语言时
|
||||
同样追加该输出规则;搜索侧的场景判断 / query rewrite 只有英文版且保持 query 原语言,
|
||||
因此 `search()` 不接受 `language`。
|
||||
- 自定义 `extract_system_prompt_zh` / `extract_system_prompt_en` 只覆盖 `zh-Hans` / `en`。
|
||||
|
||||
### `search()` / `async_search()`
|
||||
|
||||
普通 chat 检索:
|
||||
@@ -201,7 +267,8 @@ result = client.search(
|
||||
```
|
||||
|
||||
`working` / `coding` 路径返回 `scene="productivity"`、`facts`、`experiences`,不使用
|
||||
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used`。
|
||||
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used` 字段(默认空;
|
||||
需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` 才注入 diff 块)。
|
||||
|
||||
#### `search_mode`
|
||||
|
||||
@@ -263,6 +330,8 @@ catalog 也可以在没有基础 L0 时形成只读的合成画像。
|
||||
|
||||
```python
|
||||
memory = client.get("memory_id")
|
||||
# raw ID 返回 L1_RAW,fact ID 返回对应事实层。
|
||||
raw_memory = client.get("raw_id")
|
||||
result = client.update("memory_id", "更新后的内容")
|
||||
result = client.delete("memory_id")
|
||||
result = client.delete_all(user_id="user_001")
|
||||
@@ -272,7 +341,7 @@ page = client.list_memories(
|
||||
limit=100,
|
||||
offset=0,
|
||||
order="desc",
|
||||
layers=None,
|
||||
layers=["l1_raw"], # 显式列出 raw;不传仍沿用默认语义层列表
|
||||
with_evolution=False,
|
||||
)
|
||||
|
||||
@@ -282,6 +351,11 @@ result = client.clone_user("user_001", "user_002", agent_id=None)
|
||||
对应异步方法为 `async_get()`、`async_update()`、`async_delete()`、
|
||||
`async_delete_all()`、`async_list_memories()`、`async_clone_user()`。
|
||||
|
||||
`get()` / `async_get()` 是统一的 ID 精确读取:返回结果的 `layer`、`kind` 和
|
||||
`variant` 表明实际类型。授权由调用 SDK 前的产品边界完成,不由 SDK 根据 layer
|
||||
猜测。`list_memories()` 默认仍不包含 `L1_RAW`;显式传
|
||||
`layers=["l1_raw"]` 时按同一列表合同返回 raw。
|
||||
|
||||
普通 `list_memories()` 返回 `vdb` 桶,并在 Graph 有结果时附带 `graph`;传入
|
||||
`workspace_id` 时返回生产力记忆的 `scene="productivity"` 和 `experiences`。
|
||||
|
||||
|
||||
@@ -55,9 +55,49 @@ except Exception:
|
||||
from .client import HyMemoryClient
|
||||
from .inspector import MemoryInspector
|
||||
from .runtime import SharedRuntime
|
||||
from .reembed import (
|
||||
EmbeddingSpaceIdentity,
|
||||
GraphReembedPlan,
|
||||
GraphReembedProgress,
|
||||
GraphReembedReport,
|
||||
GraphVerification,
|
||||
ReembedCancelled,
|
||||
ReembedPlan,
|
||||
ReembedProgress,
|
||||
ReembedReport,
|
||||
ReembedVerification,
|
||||
ScannedSpace,
|
||||
SpaceDiff,
|
||||
VectorSpace,
|
||||
diff_spaces,
|
||||
drop_graph,
|
||||
drop_vector_space,
|
||||
plan_reembed,
|
||||
reembed_graph,
|
||||
reembed_text,
|
||||
reembed_vector_space,
|
||||
scan_vector_spaces,
|
||||
space_identity,
|
||||
vector_space_names,
|
||||
verify_graph_reembed,
|
||||
verify_reembed,
|
||||
)
|
||||
from .snapshot import (
|
||||
DataPlaneRestore,
|
||||
DataPlaneSnapshot,
|
||||
DataPlaneSnapshotRejected,
|
||||
restore_local_data_plane,
|
||||
snapshot_local_data_plane,
|
||||
)
|
||||
|
||||
# ====== 配置 ======
|
||||
from .config import MemoryConfig
|
||||
from .embedding import (
|
||||
EmbeddingDimensionMismatchError,
|
||||
EmbeddingService,
|
||||
EmbeddingServiceProtocol,
|
||||
)
|
||||
from .retrieval import AmbiguousMemoryIdError, MemoryKind, MemoryVariant
|
||||
|
||||
# ====== Embedding 端点覆盖(per-call,不改 model/dims,只换接入端点)======
|
||||
from .core.embed_service import embed_endpoint_override
|
||||
@@ -111,8 +151,46 @@ __all__ = [
|
||||
"HyMemoryClient",
|
||||
"MemoryInspector",
|
||||
"SharedRuntime",
|
||||
"DataPlaneRestore",
|
||||
"DataPlaneSnapshot",
|
||||
"DataPlaneSnapshotRejected",
|
||||
"restore_local_data_plane",
|
||||
"snapshot_local_data_plane",
|
||||
# 换 embedding 模型:把向量库和图重新 embed 进新的 embedding 空间
|
||||
"EmbeddingSpaceIdentity",
|
||||
"GraphReembedPlan",
|
||||
"GraphReembedProgress",
|
||||
"GraphReembedReport",
|
||||
"GraphVerification",
|
||||
"ReembedCancelled",
|
||||
"ReembedPlan",
|
||||
"ReembedProgress",
|
||||
"ReembedReport",
|
||||
"ReembedVerification",
|
||||
"ScannedSpace",
|
||||
"SpaceDiff",
|
||||
"VectorSpace",
|
||||
"diff_spaces",
|
||||
"drop_graph",
|
||||
"drop_vector_space",
|
||||
"plan_reembed",
|
||||
"reembed_graph",
|
||||
"reembed_text",
|
||||
"reembed_vector_space",
|
||||
"scan_vector_spaces",
|
||||
"space_identity",
|
||||
"vector_space_names",
|
||||
"verify_graph_reembed",
|
||||
"verify_reembed",
|
||||
# 配置
|
||||
"MemoryConfig",
|
||||
# 公共扩展协议 / 精确读取类型
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceProtocol",
|
||||
"EmbeddingDimensionMismatchError",
|
||||
"MemoryKind",
|
||||
"MemoryVariant",
|
||||
"AmbiguousMemoryIdError",
|
||||
# Embedding 端点覆盖
|
||||
"embed_endpoint_override",
|
||||
# 高级 API
|
||||
|
||||
@@ -22,7 +22,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ================================================================
|
||||
# 数据结构
|
||||
# ================================================================@dataclass
|
||||
# ================================================================
|
||||
@dataclass
|
||||
class SessionSummaryResult:
|
||||
"""Session 摘要结果 (V2)"""
|
||||
success: bool
|
||||
|
||||
@@ -39,9 +39,16 @@ import logging
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ..utils.source_filter import merge_sources
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,56 +121,9 @@ Messages:
|
||||
"""
|
||||
|
||||
|
||||
EPISODE_SUMMARY_PROMPT = """You are summarizing ONE episode of conversation with the user.
|
||||
|
||||
An episode is a single stretch of discussion — one thing that was talked about,
|
||||
possibly across several sessions of writing.
|
||||
|
||||
## Memories captured during this episode (oldest → newest)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
Write a single dense summary of what happened / what was discussed in this episode.
|
||||
|
||||
Rules:
|
||||
|
||||
* 2–4 sentences, third person, starting with "The user ..." (or the named person
|
||||
if the memories are about someone else).
|
||||
* Tie the memories together into one coherent account — an episode usually has a
|
||||
thread running through it (a plan, a problem, an outing, a decision).
|
||||
* Preserve concrete details: names, places, numbers, dates.
|
||||
* Do NOT invent anything that is not in the memories.
|
||||
* Do NOT enumerate the memories one by one; synthesize them.
|
||||
* Output language must match the language of the memories.
|
||||
|
||||
Return ONLY the summary text, no preamble, no JSON, no quotes."""
|
||||
|
||||
|
||||
EPISODE_SUMMARY_PROMPT_ZH = """你在为**一段讨论**(一个 episode)做总结。
|
||||
|
||||
一个 episode 是一段连续的讨论 —— 围绕一件事,可能跨越好几次写入。
|
||||
|
||||
## 这段讨论中抽取到的记忆(时间正序)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
写一段关于这次讨论内容的高密度摘要。
|
||||
|
||||
要求:
|
||||
|
||||
* 2–4 句,第三人称,以"用户…"开头(若记忆讲的是别人,则以那个人的名字开头)。
|
||||
* 把这些记忆串成一个连贯的叙述 —— 一段讨论通常有一根主线(一个计划、一个问题、
|
||||
一次出行、一个决定)。
|
||||
* 保留具体细节:人名、地点、数字、日期。
|
||||
* 绝不编造记忆里没有的内容。
|
||||
* 不要逐条罗列记忆,要综合成一段。
|
||||
* 输出语言与记忆的语言一致。
|
||||
|
||||
只返回摘要正文,不要任何前言、不要 JSON、不要引号。"""
|
||||
# 多语言版本见 hy_memory/prompts/<language>.py;旧名字保留为别名。
|
||||
from ..prompts.en import EPISODE_SUMMARY_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.zh_hans import EPISODE_SUMMARY_PROMPT as EPISODE_SUMMARY_PROMPT_ZH # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
SESSION_EPISODE_PROMPT = """Convert this complete conversation session into one retrieval-friendly episode narrative.
|
||||
@@ -252,6 +212,17 @@ def _series_episode_id(
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, "hy-memory:series-episode:" + raw))
|
||||
|
||||
|
||||
def _series_memory_at(times: List[int]) -> Optional[datetime]:
|
||||
"""Earliest member timestamp as the same local wall clock it came from.
|
||||
|
||||
``_node_timestamp`` produces ``datetime.timestamp()`` values. Reading them
|
||||
back with ``utcfromtimestamp`` turns them into a naive *UTC* datetime, which
|
||||
the vector payload later re-interprets as local time and shifts by the
|
||||
machine's UTC offset.
|
||||
"""
|
||||
return datetime.fromtimestamp(min(times)) if times else None
|
||||
|
||||
|
||||
def _node_timestamp(node: Any) -> Optional[int]:
|
||||
value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None)
|
||||
if value is None:
|
||||
@@ -584,9 +555,13 @@ def _assign_facts_to_segments(facts: List[Any], segments: List[dict]) -> Dict[in
|
||||
async def finalize_dynamic_episode_capsules(
|
||||
*, vector_store, embed_service, llm_provider, user_id: str,
|
||||
agent_id: Optional[str], session_id: str, summary_concurrency: int = 3,
|
||||
language: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Split a completed session, create compact L3 capsules, and link its facts.
|
||||
|
||||
The capsule prompt is English-only; an explicit ``language`` appends that
|
||||
language's output rule so the capsule text is written in it.
|
||||
|
||||
Boundary and summary generation are separate calls. Boundary failure degrades
|
||||
to one whole-session capsule. A segmented-summary failure also retries the
|
||||
session as one capsule so the write never loses the complete event context.
|
||||
@@ -790,6 +765,7 @@ async def finalize_dynamic_episode_capsules(
|
||||
start = end + 1
|
||||
|
||||
semaphore = asyncio.Semaphore(max(1, min(16, int(summary_concurrency or 1))))
|
||||
explicit_language = normalize_language(language)
|
||||
|
||||
async def summarize(spec):
|
||||
segment_index, segment_start, segment_end, segment = spec
|
||||
@@ -797,9 +773,13 @@ async def finalize_dynamic_episode_capsules(
|
||||
try:
|
||||
async with semaphore:
|
||||
response = await llm_provider.complete(
|
||||
prompt=DYNAMIC_CAPSULE_PROMPT.format(
|
||||
timestamp=timestamp_text,
|
||||
messages=_render_messages(segment),
|
||||
prompt=with_output_language(
|
||||
DYNAMIC_CAPSULE_PROMPT.format(
|
||||
timestamp=timestamp_text,
|
||||
messages=_render_messages(segment),
|
||||
),
|
||||
explicit_language,
|
||||
explicit=explicit_language is not None,
|
||||
),
|
||||
max_tokens=4096,
|
||||
)
|
||||
@@ -974,8 +954,6 @@ async def finalize_series_episode_containers(
|
||||
Stored text is only a title plus a structural intro. Exact L2 bodies remain in
|
||||
their canonical nodes and are referenced by ID from dated timeline segments.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType
|
||||
from ..pipelines._retrieval.session_episode import (
|
||||
add_parent_episode_membership,
|
||||
@@ -1061,7 +1039,7 @@ async def finalize_series_episode_containers(
|
||||
segment["memory_at"] for segment in plan["timeline"]
|
||||
if segment.get("memory_at") is not None
|
||||
]
|
||||
memory_at = datetime.utcfromtimestamp(min(times)) if times else None
|
||||
memory_at = _series_memory_at(times)
|
||||
content = f"{plan['title']}. {plan['intro']}".strip()
|
||||
series = MemoryNode(
|
||||
node_id=series_id, user_id=user_id,
|
||||
@@ -1118,9 +1096,13 @@ def _cosine(a: List[float], b: List[float]) -> float:
|
||||
async def finalize_session_episode(
|
||||
*, vector_store, embed_service, llm_provider, user_id: str,
|
||||
agent_id: Optional[str], session_id: str,
|
||||
language: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create one first-class narrative from all raw chunks in a caller session.
|
||||
|
||||
The prompt is English-only; an explicit ``language`` appends that
|
||||
language's output rule so the narrative is written in it.
|
||||
|
||||
This is deliberately explicit: the SDK cannot infer that the final write of a
|
||||
session has arrived. Repeated calls upsert the same deterministic node id.
|
||||
"""
|
||||
@@ -1203,6 +1185,10 @@ async def finalize_session_episode(
|
||||
timestamp=timestamp_text,
|
||||
conversation=conversation,
|
||||
)
|
||||
explicit_language = normalize_language(language)
|
||||
prompt = with_output_language(
|
||||
prompt, explicit_language, explicit=explicit_language is not None,
|
||||
)
|
||||
response = await llm_provider.complete(prompt=prompt, max_tokens=3000)
|
||||
narrative = (getattr(response, "content", "") or "").strip()
|
||||
if not narrative:
|
||||
@@ -1264,9 +1250,12 @@ async def summarize_closed_episode(
|
||||
max_memories: int = 60,
|
||||
temperature: Optional[float] = None,
|
||||
trace=None,
|
||||
language: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""总结一个**刚刚关闭**的 episode,返回摘要节点 id(跳过则 None)。
|
||||
|
||||
language 为显式 prompt 语言码;None 时按记忆内容做 zh/en 自动检测。
|
||||
|
||||
由 writer 在检测到 episode 滚动时 fire-and-forget 调用:此时上一个 episode
|
||||
不会再有新内容进来,所以这一次总结就是最终产物,不需要后续 merge / 重写。
|
||||
"""
|
||||
@@ -1307,6 +1296,7 @@ async def summarize_closed_episode(
|
||||
_t0 = _dt.now()
|
||||
summary, prompt, tokens = await _generate(
|
||||
llm_provider=llm_provider, memories=contents, temperature=temperature,
|
||||
language=language,
|
||||
)
|
||||
_elapsed = (_dt.now() - _t0).total_seconds() * 1000
|
||||
if trace is not None:
|
||||
@@ -1341,15 +1331,16 @@ async def summarize_closed_episode(
|
||||
|
||||
async def _generate(
|
||||
*, llm_provider, memories: List[str], temperature: Optional[float] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""返回 (摘要文本, 实际 prompt, token 用量) —— 后两个供埋点,线上要能复盘。"""
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
body = "\n".join(f"- {c}" for c in memories if c)
|
||||
if not body:
|
||||
return "", "", {}
|
||||
tpl = EPISODE_SUMMARY_PROMPT_ZH if is_chinese(body) else EPISODE_SUMMARY_PROMPT
|
||||
prompt = tpl.format(memories=body)
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, body)
|
||||
prompt = get_prompt("EPISODE_SUMMARY_PROMPT", code).format(memories=body)
|
||||
prompt = with_output_language(prompt, code, explicit=explicit is not None)
|
||||
resp = await llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=400,
|
||||
|
||||
+75
-816
@@ -20,6 +20,12 @@ import re
|
||||
|
||||
from .llm_provider import LLMProvider, _content_fingerprint, _log_llm_content_enabled
|
||||
from ..config import LLMConfig as GlobalLLMConfig
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,671 +170,13 @@ class V2ExtractResult:
|
||||
# - 不再输出 time_ref 字段。时间相关信息如果是时间敏感的(schedule / past event),
|
||||
# 让 LLM 把已解析的时间自然嵌入 content 即可;稳定偏好类 content 保持不含时间。
|
||||
|
||||
# System prompt(固定,不参与 format)
|
||||
EXTRACT_SYSTEM_PROMPT = """# ROLE
|
||||
|
||||
You are a Memory Extractor.
|
||||
|
||||
Extract durable, high-value memories from conversations and convert them into structured, self-contained statements.
|
||||
|
||||
Only extract information that is likely to matter in future conversations.
|
||||
|
||||
---
|
||||
|
||||
# INPUTS
|
||||
|
||||
You will receive:
|
||||
|
||||
* Last k Messages
|
||||
* New Messages
|
||||
* Memory Date
|
||||
* Current Date
|
||||
|
||||
Use:
|
||||
|
||||
* Last k Messages only for coreference resolution
|
||||
* Memory Date as the ONLY temporal anchor
|
||||
* Current Date only as metadata, never for resolving relative time
|
||||
|
||||
---
|
||||
|
||||
# WHAT TO EXTRACT
|
||||
|
||||
Extract ONLY genuinely new information introduced in the NEW user messages.
|
||||
|
||||
Good memories include:
|
||||
|
||||
* preferences
|
||||
* long-term projects
|
||||
* habits
|
||||
* goals
|
||||
* recurring activities
|
||||
* opinions
|
||||
* relationships
|
||||
* important experiences
|
||||
* stable traits
|
||||
* future plans
|
||||
* decisions
|
||||
* constraints
|
||||
* accepted recommendations
|
||||
|
||||
Do NOT extract:
|
||||
|
||||
* small talk
|
||||
* acknowledgements
|
||||
* generic emotions
|
||||
* temporary conversational context
|
||||
* assistant reminders of old memories
|
||||
* information already implied by prior context
|
||||
* vague personality judgments
|
||||
|
||||
---
|
||||
|
||||
# SUBJECT
|
||||
|
||||
Every memory must be self-contained, written in third person, with an explicit
|
||||
subject. Which subject to use is decided by **who spoke**:
|
||||
|
||||
| Speaker | Subject to write | Example |
|
||||
| --- | --- | --- |
|
||||
| `role: "user"` | the user | "The user ..." / "用户..." |
|
||||
| any speaker with a `name` other than the user (e.g. `name: "John"`) | that person, by name | "John ..." |
|
||||
| `role: "assistant"` with NO `name` | the AI assistant | "The assistant ..." |
|
||||
|
||||
The two input sections express the speaker differently:
|
||||
|
||||
* **New Messages** is a JSON array — read `role` and the optional `name` field.
|
||||
* **Last k Messages** is rendered text — the speaker is the bracket label,
|
||||
`[user]:` / `[John]:` / `[assistant]:`, optionally preceded by one line
|
||||
"The user's name is Tim" / "用户名叫Tim".
|
||||
|
||||
Rules:
|
||||
|
||||
* The user is the person these memories belong to. Even when the user's own name
|
||||
is known (from `name` on their message, or from the leading name line), still
|
||||
write "The user ...", NEVER "Tim ...".
|
||||
* **`role: "assistant"` WITH a `name` is another real person, not the AI.**
|
||||
Write their memories with their name as the subject: "John ...".
|
||||
* Only a nameless `role: "assistant"` is the AI assistant.
|
||||
* Never invent a name for the AI assistant, it has none.
|
||||
|
||||
---
|
||||
|
||||
# LANGUAGE RULES
|
||||
|
||||
* Output language must match input language.
|
||||
* Preserve original scripts and technical terms exactly.
|
||||
* Never transliterate names, brands, or equations.
|
||||
|
||||
---
|
||||
|
||||
# TWO KINDS OF MEMORY
|
||||
|
||||
You extract two kinds of memory: **memory** and **intentions**.
|
||||
|
||||
## memory
|
||||
|
||||
Everything durable and worth remembering about the user — and about any other
|
||||
named person in the conversation — goes here:
|
||||
|
||||
* preferences, opinions, values, attitudes
|
||||
* events, actions, experiences
|
||||
* habits, behavioral patterns
|
||||
* relationships, traits
|
||||
* objective facts, decisions, constraints
|
||||
* ongoing activities
|
||||
|
||||
Implicit preferences MUST be inferred when strongly suggested
|
||||
(e.g. resuming an activity → positive interest; avoiding something →
|
||||
negative preference; repeated voluntary effort → sustained interest).
|
||||
|
||||
Do NOT separate "what kind of person the user is" from "what happened".
|
||||
Both go into `memory`.
|
||||
|
||||
## intentions
|
||||
|
||||
A forward-looking thing the user **plans or intends to do in the future**,
|
||||
which can be acted on and which becomes irrelevant once done or past.
|
||||
|
||||
Examples:
|
||||
|
||||
* "The user has a job interview next Tuesday."
|
||||
* "The user plans to move apartments at the end of the month."
|
||||
* "The user intends to finish the report before Friday."
|
||||
|
||||
NOT intentions (these belong in `memory`):
|
||||
|
||||
* stable preferences / traits ("The user likes basketball")
|
||||
* completed past events ("The user moved to Beijing last year")
|
||||
* general goals with no actionable future step
|
||||
|
||||
### valid_until
|
||||
|
||||
For each intention, provide `valid_until`: the date after which the
|
||||
intention no longer matters (the deadline / target date), resolved
|
||||
against **Memory Date** (e.g. "next Tuesday" → that absolute date).
|
||||
If no deadline can be determined, set `valid_until` to `null`.
|
||||
|
||||
---
|
||||
|
||||
# OWNER
|
||||
|
||||
Every memory and intention MUST carry an `owner` saying which memory store it
|
||||
belongs to. There are exactly two values:
|
||||
|
||||
* `"user"` — anything belonging to the user's own memory store. This includes
|
||||
facts/preferences/traits about the user themselves, AND facts about other
|
||||
named real people in the conversation, because those are still things the
|
||||
user's memory should hold.
|
||||
* `"agent"` — information the AI assistant (a nameless `role: "assistant"`)
|
||||
provides (recommendations, confirmations, things it researched), or a future
|
||||
action the user wants the **assistant** to perform ("remind me in 8 hours").
|
||||
|
||||
A named person is NEVER `owner: "agent"` — they are a real person, not the AI,
|
||||
even though their messages carry `role: "assistant"`. Their memories are
|
||||
`owner: "user"` with their name as the content subject.
|
||||
|
||||
Decide `owner` per item. When unsure, default to `"user"`.
|
||||
|
||||
---
|
||||
|
||||
# TEMPORAL RULES
|
||||
|
||||
Resolve relative time using Memory Date.
|
||||
|
||||
Examples:
|
||||
|
||||
* "next week"
|
||||
* "last month"
|
||||
* "tomorrow"
|
||||
|
||||
If time cannot be resolved safely:
|
||||
|
||||
* remove the relative wording
|
||||
* keep the memory atemporal
|
||||
|
||||
Do not invent chronology.
|
||||
|
||||
**Just-happened events are events — keep them.** When the user reports something as
|
||||
just completed ("just got back", "just finished", "this morning", "yesterday"), the
|
||||
completion date is the Memory Date. Emit the **event itself** with that date; do not
|
||||
fold it into a background statement.
|
||||
|
||||
* "I just got back from a trip with my family"
|
||||
→ "The user returned from a family trip on <Memory Date>."
|
||||
NOT → "The user takes family trips." (the return date is the answer to "when did
|
||||
they get back", and dropping it makes the question unanswerable)
|
||||
|
||||
Where an event has both a start and an end that the conversation gives separately,
|
||||
record both rather than collapsing them into one span.
|
||||
|
||||
---
|
||||
|
||||
# QUALITY RULES
|
||||
|
||||
Each memory must be:
|
||||
|
||||
* self-contained
|
||||
* contextually complete
|
||||
* retrieval-friendly
|
||||
* information-dense
|
||||
|
||||
Retain important details:
|
||||
|
||||
* names
|
||||
* products
|
||||
* locations
|
||||
* numbers
|
||||
* equations
|
||||
* constraints
|
||||
|
||||
Never generalise a specific into a category. Write what was actually said.
|
||||
|
||||
* "Banff" → keep "Banff", NOT "the Rocky Mountains"
|
||||
* "a 3-year-old border collie named Luna" → keep the breed, age and name
|
||||
|
||||
**Specifics supplied by the other speaker still count.** A fact is often built across
|
||||
turns: the other speaker names the thing, the user confirms it. The subject rules
|
||||
decide *whose* memory it is — they do not license dropping detail.
|
||||
|
||||
* [Sam]: "Did you take advantage of the skiing opportunities in Banff?"
|
||||
[user]: "Yeah, it was great for skiing!"
|
||||
→ "The user went skiing in Banff and found the snow amazing."
|
||||
NOT → "The user enjoys winter sports." (both the place and the activity were
|
||||
established, one by each speaker)
|
||||
|
||||
Avoid fragmentary memories lacking context.
|
||||
|
||||
Choose the smallest number of memories that still preserves distinct future
|
||||
retrieval needs.
|
||||
|
||||
* Compression means combining, not omitting. Every explicit durable proposition
|
||||
in the new messages must appear exactly once in the output.
|
||||
* Combine details when they share the same subject and the same real-world event,
|
||||
state, preference, plan, or decision. A coherent event may use 1–3 sentences.
|
||||
* Do not emit both a broad statement and narrower restatements of the same evidence.
|
||||
* Split only when the subject differs, the events happened independently, or the
|
||||
memories describe different states.
|
||||
* Keep a cause, recommendation, or consequence with the fact it explains when
|
||||
separating them would lose the relationship.
|
||||
* Never merge separate dated occurrences merely because they share a topic.
|
||||
* Preserve the source's time precision. Do not turn vague phrases such as "last
|
||||
weekend" or "last summer" into exact calendar dates unless the date is explicit
|
||||
or uniquely determined; "the weekend before <Memory Date>" is acceptable.
|
||||
|
||||
---
|
||||
|
||||
# TAGS
|
||||
|
||||
Each memory must include 1–3 concise tags.
|
||||
- Topical keywords stay lowercase (travel, health, work).
|
||||
- Proper nouns keep capitalization (Jasper, Canada, Banff, Prius).
|
||||
|
||||
Examples:
|
||||
|
||||
* work
|
||||
* travel
|
||||
* Jasper
|
||||
* Canada
|
||||
* health
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
STRUCTURE_METADATA_PROMPT_EN = """
|
||||
|
||||
---
|
||||
|
||||
# STRUCTURAL ORGANIZATION
|
||||
|
||||
For every `memory`, also output:
|
||||
|
||||
* `topic_paths`: a backward-compatible list of zero to three broad-to-specific
|
||||
semantic paths.
|
||||
* `event_facet`: an object with only `subject`, `participants`, `action`,
|
||||
`objects`, `locations`, and `state`; state is `planned`, `ongoing`,
|
||||
`completed`, or `unknown`.
|
||||
* `topic_assignments`: zero to six subject-specific memberships. Each item is
|
||||
`{subject, role, path}`, where `path` is a list of `{name, type}` segments and
|
||||
`type` is one of `topic`, `facet`, `series`, or `episode`. Use `topic` for a
|
||||
broad domain, `facet` for a concrete activity/semantic aspect, `series` only
|
||||
for repeated occurrences of the same concrete activity/state line, and
|
||||
`episode` only for one bounded real-world occurrence. A shared event may have
|
||||
one assignment per explicit participant while keeping one canonical memory.
|
||||
Types follow `topic -> facet -> series -> episode`; levels may be skipped but
|
||||
types may not repeat or move backward.
|
||||
|
||||
This extractor sees only the current write and therefore must not emit a
|
||||
`series` segment. Emit Topic/Facet/Episode here. Reconcile may later establish a
|
||||
Series after comparing independent stored occurrences.
|
||||
|
||||
This is write-time organization. Do not infer Series/Episode from path depth,
|
||||
manufacture hierarchy from a proper noun or broad tag, or assign a person only
|
||||
because they co-occur in unrelated context. Stop at the most specific supported
|
||||
level and use empty values when uncertain. Never encode query forms such as
|
||||
"when", "how many", "who", plurality, or ordinal wording.
|
||||
"""
|
||||
|
||||
# Few-shot examples (appended to the system prompt only when few_shot_enabled).
|
||||
# Each example mirrors the real input shape (Last k Messages / New Messages /
|
||||
# Memory Date) and our output schema (memory / intentions / basic_info with
|
||||
# owner & tags), and ends with a one-line takeaway.
|
||||
EXTRACT_FEW_SHOT_EN = """
|
||||
---
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
## Example 1 — Boundary: old context is for resolution only, never re-extracted
|
||||
|
||||
Last k Messages:
|
||||
[{"role": "user", "content": "I've been looking at rescue dogs at the shelter downtown."},
|
||||
{"role": "assistant", "content": "That's lovely — found one you like?"}]
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Yeah, picked her up yesterday — a 3-year-old border collie named Luna."}]
|
||||
Memory Date: 2025-04-10
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user adopted a 3-year-old border collie named Luna from a downtown shelter on 2025-04-09.", "tags": ["pets", "family"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The old messages only resolve "her" and supply context ("downtown shelter"). Do NOT emit a stale memory like "the user is considering adopting a dog" — only the genuinely new fact (the completed adoption) is extracted, with "yesterday" resolved against Memory Date.
|
||||
|
||||
## Example 2 — Condense a long assistant reply
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Write me a short essay on why remote work boosts productivity."},
|
||||
{"role": "assistant", "content": "Sure — opening: 'Remote work has reshaped how teams operate...' [~2000 words of body omitted] ...closing: 'In short, autonomy and fewer interruptions drive higher output.'"}]
|
||||
Memory Date: 2025-03-22
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The assistant drafted a short essay for the user arguing that remote work boosts productivity because autonomy and fewer interruptions drive higher output.", "tags": ["writing", "remote-work"], "owner": "agent"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The assistant's reply is long. Never store the verbatim essay — distill it to topic + thesis. owner is "agent" because the assistant produced it. Bloated verbatim content makes memory unusable.
|
||||
|
||||
## Example 3 — Composite extraction: basic_info + intention + condensed agent plan
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Hi, I'm Kenji. I'm planning a trip to Japan next month — could you put together a 7-day itinerary?"},
|
||||
{"role": "assistant", "content": "# 7-Day Classic Japan Golden Route (Tokyo → Kyoto → Nara → Osaka)\\nFly into Tokyo, Shinkansen south to Kyoto, day-trip Nara, depart from Osaka (Kansai).\\n\\n## Day 1: Arrival in Tokyo\\n- Land at Haneda/Narita, drop bags at hotel near Tokyo Station.\\n- Late afternoon: Sensoji Temple (Asakusa), wander Nakamise Street, try taiyaki.\\n- Evening: Shibuya Crossing + Shibuya Sky rooftop for sunset; izakaya dinner (yakitori, sake).\\n\\n## Day 2: Tokyo markets & landmarks — [omitted]\\n## Day 3: Shinkansen to Kyoto, Fushimi Inari, Gion — [omitted]\\n## Day 4: Kyoto temples (Kiyomizu-dera, Kinkaku-ji, Ginkaku-ji) — [omitted]\\n## Day 5: Nara day trip (Nara Park deer, Todai-ji) — [omitted]\\n## Day 6: Osaka (Osaka Castle, Dotonbori street food) — [omitted]\\n## Day 7: Osaka sightseeing + depart from Kansai Airport — [omitted]\\n\\nTip: buy a 7-day JR Pass before arriving. Want me to adjust anything?"}]
|
||||
Memory Date: 2025-09-15
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The assistant created a 7-day Japan itinerary for the user along the classic golden route: Tokyo (Asakusa, Shibuya), Shinkansen to Kyoto (Fushimi Inari, Kiyomizu-dera, Kinkaku-ji, Gion), a Nara day trip (deer park, Todai-ji), then Osaka (Osaka Castle, Dotonbori), departing from Kansai Airport.", "tags": ["travel", "japan", "itinerary"], "owner": "agent"}
|
||||
],
|
||||
"intentions": [
|
||||
{"content": "The user plans to take a 7-day trip to Japan in October 2025.", "tags": ["travel", "japan"], "valid_until": "2025-10-31", "owner": "user"}
|
||||
],
|
||||
"basic_info": {"name": "Kenji"}
|
||||
}
|
||||
```
|
||||
One turn produces three outputs in different buckets: the name goes to basic_info, the trip is a forward-looking intention with a deadline, and the assistant's long multi-section itinerary is condensed into ONE dense agent memory — the route and key stops per city, NOT every day's bullets, tips, or food lists. Storing the whole reply verbatim would bloat memory; storing the skeleton keeps it queryable.
|
||||
|
||||
## Example 4 — A later message confirming/adopting an earlier recommendation is itself a fact
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "My 6-year-old won't eat any vegetables. Any ideas?"},
|
||||
{"role": "assistant", "content": "Try blending spinach or carrots into pasta sauce — the color and flavor mostly disappear, and many kids accept it without noticing."},
|
||||
{"role": "user", "content": "Oh nice, we'll try that tonight. By the way, what's a good bedtime for that age?"},
|
||||
{"role": "assistant", "content": "For a 6-year-old, aim for 7–9 PM with about 10–11 hours of sleep."}]
|
||||
Memory Date: 2025-06-20
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user has a 6-year-old child who refuses to eat vegetables, and accepted the assistant's suggestion to blend spinach or carrots into pasta sauce to hide them.", "tags": ["parenting", "food"], "owner": "user"},
|
||||
{"content": "The assistant advised the user that a 6-year-old should aim for a 7–9 PM bedtime with about 10–11 hours of sleep.", "tags": ["parenting", "sleep"], "owner": "agent"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
In the third message the user said "we'll try that tonight" — explicitly adopting the earlier suggestion. That acceptance is valuable and must be recorded ("accepted the assistant's suggestion to ..."), not discarded as small talk. The bedtime advice was just given and not yet adopted, so it is recorded as advice (owner "agent").
|
||||
|
||||
## Example 5 — RAG/injected context: never store the injected text
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "[Retrieved context: The Eiffel Tower is 330m tall and was completed in 1889.] Given the above, I'm going to propose to my girlfriend at the top of the Eiffel Tower this summer."}]
|
||||
Memory Date: 2025-05-02
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [
|
||||
{"content": "The user plans to propose to his girlfriend at the top of the Eiffel Tower in summer 2025.", "tags": ["relationship", "travel"], "valid_until": "2025-08-31", "owner": "user"}
|
||||
],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The bracketed retrieved context (the tower's height and year) is RAG-injected reference material, NOT something the user told us about themselves — do NOT store it. Only the user's own forward-looking plan is extracted. System/RAG-injected text is context for the model, never a memory.
|
||||
|
||||
## Example 6 — Shared reference material: extract the content, not the sharing action
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Remember my grandma's dumpling recipe so I don't lose it. Filling: 500g ground pork, 300g napa cabbage, 2 tbsp soy sauce, 1 tbsp sesame oil, a thumb of ginger minced. Cabbage salted 15 min then squeezed dry. Boil dumplings, add cold water twice, ready when they float."},
|
||||
{"role": "assistant", "content": "Got it, saved!"}]
|
||||
Memory Date: 2025-07-01
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user's grandmother's dumpling recipe: filling of 500g ground pork, 300g napa cabbage (salted 15 minutes then squeezed dry), 2 tbsp soy sauce, 1 tbsp sesame oil, and a thumb of minced ginger; boil the dumplings, add cold water twice, and they are done when they float.", "tags": ["food", "recipe", "family"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
Extract the actual factual content (ingredients, quantities, the salt-and-squeeze step, the boiling method) — NOT "the user shared a recipe" or "the user asked to remember the recipe". The sharing action is worthless; the content is what's queryable.
|
||||
|
||||
## Example 7 — Structured numbers and attributes preserved exactly
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Logging my home-gym PRs this cycle: Squat 140kg x 3, Bench 100kg x 5, Deadlift 180kg x 2, Overhead Press 60kg x 6. Bodyweight 78kg, 3 sets per lift, 3 min rest."},
|
||||
{"role": "assistant", "content": "Logged — strong numbers!"}]
|
||||
Memory Date: 2026-01-20
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user's current lifting PRs are Squat 140kg x 3, Bench 100kg x 5, Deadlift 180kg x 2, and Overhead Press 60kg x 6, at a bodyweight of 78kg.", "tags": ["fitness", "strength"], "owner": "user"},
|
||||
{"content": "The user trains with 3 sets per lift and 3 minutes of rest between sets.", "tags": ["fitness", "training"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
Every number and unit (kg, reps, sets, rest time) is preserved exactly — dropping them would destroy the most queryable information. Split into two clean topics (PRs vs. training protocol), but never round or omit the specifics.
|
||||
|
||||
## Example 8 — Long multi-topic conversation: do not stop after the first topic
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Big week — I finally defended my PhD thesis on Tuesday!"},
|
||||
{"role": "assistant", "content": "Huge congrats! How do you feel?"},
|
||||
{"role": "user", "content": "Relieved! I also signed a lease on an apartment in Berlin and move next month. And I've started learning German on Duolingo — 30-day streak. My partner Sofia is staying in Madrid for now, which is hard."}]
|
||||
Memory Date: 2025-11-08
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user defended their PhD thesis on 2025-11-04.", "tags": ["education", "milestone"], "owner": "user"},
|
||||
{"content": "The user signed a lease on an apartment in Berlin.", "tags": ["housing"], "owner": "user"},
|
||||
{"content": "The user has been learning German on Duolingo and has a 30-day streak.", "tags": ["language", "learning"], "owner": "user"},
|
||||
{"content": "The user's partner Sofia is staying in Madrid for now, which the user finds hard.", "tags": ["relationship"], "owner": "user"}
|
||||
],
|
||||
"intentions": [
|
||||
{"content": "The user plans to move to Berlin next month (around December 2025).", "tags": ["housing", "relocation"], "valid_until": "2025-12-31", "owner": "user"}
|
||||
],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
Four+ topics — thesis defense, the Berlin apartment, learning German, and the partner in Madrid. Do not stop after the first topic. Note one turn split into a completed fact (lease signed) AND an intention (the upcoming move), and the emotional-but-substantive detail about Sofia is kept because it carries lasting relationship context.
|
||||
|
||||
## Example 9 — Two real people: `name` on an assistant message means a real person
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "name": "Tim", "content": "Just got back from the climbing gym, third time this week. Finally sent that overhang route."},
|
||||
{"role": "assistant", "name": "John", "content": "Nice! I've been swimming instead — my shoulder still hurts from last month."}]
|
||||
Memory Date: 2025-08-14
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "The user has been climbing three times a week and recently completed a difficult overhang route.", "tags": ["fitness", "climbing"], "owner": "user"},
|
||||
{"content": "John has been swimming instead of other exercise because his shoulder still hurt after an injury around July 2025.", "tags": ["fitness", "health"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {"name": "Tim"}
|
||||
}
|
||||
```
|
||||
John carries `role: "assistant"` but he has a `name`, so he is another real person, NOT the AI: his memory uses "John ..." as the subject and is still `owner: "user"` (it belongs to the user's memory store). The user's own memory stays "The user ..." even though we know the name is Tim — never "Tim has been climbing". `basic_info.name` comes from `name: "Tim"` on the user message, which is authoritative even though Tim never said his own name out loud.
|
||||
|
||||
## Example 10 — Counter-example: another person's attributes never go into basic_info
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "name": "Tim", "content": "你最近在忙什么?"},
|
||||
{"role": "assistant", "name": "John", "content": "我在柏林一家医院当护士,上的是夜班,挺累的。"}]
|
||||
Memory Date: 2025-09-02
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [
|
||||
{"content": "John是柏林一家医院的护士,上夜班,觉得很累。", "tags": ["work", "health"], "owner": "user"}
|
||||
],
|
||||
"intentions": [],
|
||||
"basic_info": {"name": "Tim"}
|
||||
}
|
||||
```
|
||||
John's occupation and location are HIS attributes, so they go into `memory` with "John" as the subject. They must NEVER land in `basic_info.occupation` / `basic_info.location` — `basic_info` is exclusively the user's own profile. Only `name` (Tim, from the user message's `name` field) belongs there.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
EXTRACT_PROMPT = """## Last k Messages
|
||||
|
||||
{last_messages}
|
||||
|
||||
---
|
||||
|
||||
## New Messages
|
||||
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
## Memory Date
|
||||
|
||||
{memory_at}
|
||||
|
||||
---
|
||||
|
||||
## Current Date
|
||||
|
||||
{current_date}
|
||||
|
||||
---
|
||||
|
||||
# Output Format
|
||||
|
||||
Return ONLY a JSON object inside a ```json fenced block.
|
||||
|
||||
```json
|
||||
{{
|
||||
"memory": [
|
||||
{{
|
||||
"content": "...",
|
||||
"tags": ["..."],
|
||||
"owner": "user"
|
||||
}}
|
||||
],
|
||||
"intentions": [
|
||||
{{
|
||||
"content": "...",
|
||||
"tags": ["..."],
|
||||
"valid_until": "YYYY-MM-DD",
|
||||
"owner": "user"
|
||||
}}
|
||||
],
|
||||
"basic_info": {{}}
|
||||
}}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* `memory` = durable info worth remembering: preferences, opinions, attitudes,
|
||||
traits, events, plans, experiences, habits, decisions — about the user or
|
||||
about any other named real person, with the subject written per "# SUBJECT"
|
||||
* `intentions` = forward-looking things the user (or another named person) plans /
|
||||
intends to do in the future, with `valid_until` = the deadline/target date
|
||||
(resolved against Memory Date) or `null` if none
|
||||
* `owner` (required on every memory & intention) = `"user"` or `"agent"`
|
||||
(see "# OWNER" above; default `"user"` when unsure)
|
||||
* `basic_info` = the **user's own profile only**. Include stable profile
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
* NEVER put another real person's attributes here. If John (an assistant-role
|
||||
message carrying `name`) says "I'm a nurse in Berlin", that is a `memory`
|
||||
("John is a nurse in Berlin."), NOT `basic_info.occupation` / `.location`.
|
||||
* No duplicate information across sections
|
||||
* Tags must contain 1–3 concise lowercase keywords
|
||||
* Use empty arrays `[]` or `{{}}` when nothing is extracted
|
||||
* Return valid JSON only
|
||||
|
||||
Output the JSON now."""
|
||||
|
||||
|
||||
# User prompt when MEMORY_ENABLE_INTENTION=false(无 intentions 桶)
|
||||
EXTRACT_PROMPT_NO_INTENTION = """## Last k Messages
|
||||
|
||||
{last_messages}
|
||||
|
||||
---
|
||||
|
||||
## New Messages
|
||||
|
||||
{content}
|
||||
|
||||
---
|
||||
|
||||
## Memory Date
|
||||
|
||||
{memory_at}
|
||||
|
||||
---
|
||||
|
||||
## Current Date
|
||||
|
||||
{current_date}
|
||||
|
||||
---
|
||||
|
||||
# Output Format
|
||||
|
||||
Return ONLY a JSON object inside a ```json fenced block.
|
||||
|
||||
```json
|
||||
{{
|
||||
"memory": [
|
||||
{{
|
||||
"content": "...",
|
||||
"tags": ["..."],
|
||||
"owner": "user"
|
||||
}}
|
||||
],
|
||||
"basic_info": {{}}
|
||||
}}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* `memory` = durable info worth remembering: preferences, opinions, attitudes,
|
||||
traits, events, experiences, habits, decisions, **and future plans / upcoming
|
||||
actions** — about the user or about any other named real person, with the
|
||||
subject written per "# SUBJECT". Do **not** invent a separate intentions bucket.
|
||||
* `owner` (required on every memory) = `"user"` or `"agent"`
|
||||
(see "# OWNER" above; default `"user"` when unsure)
|
||||
* `basic_info` = the **user's own profile only**. Include stable profile
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
* NEVER put another real person's attributes here. If John (an assistant-role
|
||||
message carrying `name`) says "I'm a nurse in Berlin", that is a `memory`
|
||||
("John is a nurse in Berlin."), NOT `basic_info.occupation` / `.location`.
|
||||
* No duplicate information across sections
|
||||
* Tags: 1–3 concise keywords; topical tags lowercase; proper nouns keep capitalization
|
||||
* Use empty arrays `[]` or `{{}}` when nothing is extracted
|
||||
* Return valid JSON only — **never** include an `intentions` key
|
||||
|
||||
Output the JSON now."""
|
||||
# Prompt 文本统一放在 hy_memory/prompts/<language>.py,按语言码经 get_prompt()
|
||||
# 取用。以下旧名字保留为英文版的别名(向后兼容)。
|
||||
from ..prompts.en import EXTRACT_SYSTEM_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import STRUCTURE_METADATA_PROMPT as STRUCTURE_METADATA_PROMPT_EN # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import EXTRACT_FEW_SHOT as EXTRACT_FEW_SHOT_EN # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import EXTRACT_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import EXTRACT_PROMPT_NO_INTENTION # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -837,101 +185,26 @@ Output the JSON now."""
|
||||
|
||||
|
||||
def _system_prompt_without_intention(system_prompt: str, *, lang: str) -> str:
|
||||
"""从 system prompt 去掉 intentions 分桶,改为单一 memory(含未来计划)。"""
|
||||
"""从 system prompt 去掉 intentions 分桶,改为单一 memory(含未来计划)。
|
||||
|
||||
``lang`` 是语言码(``en`` / ``zh-Hans`` / ``ja`` ...;旧值 ``zh`` 视作 zh-Hans)。
|
||||
每种语言的替换片段在 ``hy_memory/prompts/<lang>.py`` 中定义。
|
||||
"""
|
||||
import re
|
||||
|
||||
if lang == "zh":
|
||||
repl = (
|
||||
"# 记忆\n"
|
||||
"\n"
|
||||
"你只提取一类记忆:**memory(记忆)**。\n"
|
||||
"\n"
|
||||
"## memory(记忆)\n"
|
||||
"\n"
|
||||
"一切值得长期记住的信息都放这里——既包括用户本人的,也包括对话中其他具名真人的:\n"
|
||||
"\n"
|
||||
"* 偏好、观点、态度、价值观\n"
|
||||
"* 事件、行动、经历\n"
|
||||
"* 习惯、行为模式\n"
|
||||
"* 关系、稳定特质\n"
|
||||
"* 客观事实、决策、约束条件\n"
|
||||
"* 正在进行的活动\n"
|
||||
"* **未来计划 / 即将发生的安排**(若值得记住,直接写进 `memory`;"
|
||||
"不要单独开 intentions 桶)\n"
|
||||
"\n"
|
||||
"如果用户隐式表达偏好,也应提取。\n"
|
||||
"\n"
|
||||
"不要把\"用户是怎样的人\"和\"发生了什么\"分开。两者都放进 `memory`。\n"
|
||||
"\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
)
|
||||
system_prompt = re.sub(
|
||||
r"# 两类记忆.*?(?=\n# OWNER)",
|
||||
repl,
|
||||
system_prompt,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
system_prompt = system_prompt.replace(
|
||||
"每条 memory 和 intention 都必须带 `owner`",
|
||||
"每条 memory 都必须带 `owner`",
|
||||
)
|
||||
system_prompt = system_prompt.replace(
|
||||
"每条 memory 和 intention 都必须带",
|
||||
"每条 memory 都必须带",
|
||||
)
|
||||
# few-shot 里若仍出现 intentions,用硬约束盖住
|
||||
system_prompt += (
|
||||
"\n\n---\n\n# INTENTION 关闭\n"
|
||||
"当前配置关闭了 intention 抽取。即使示例里出现 `intentions`,"
|
||||
"也**不要**输出该字段;未来计划写入 `memory`。\n"
|
||||
)
|
||||
else:
|
||||
repl = (
|
||||
"# MEMORY\n"
|
||||
"\n"
|
||||
"You extract one kind of memory: **memory**.\n"
|
||||
"\n"
|
||||
"## memory\n"
|
||||
"\n"
|
||||
"Everything durable and worth remembering about the user — and about any other\n"
|
||||
"named person in the conversation — goes here:\n"
|
||||
"\n"
|
||||
"* preferences, opinions, values, attitudes\n"
|
||||
"* events, actions, experiences\n"
|
||||
"* habits, behavioral patterns\n"
|
||||
"* relationships, traits\n"
|
||||
"* objective facts, decisions, constraints\n"
|
||||
"* ongoing activities\n"
|
||||
"* **future plans / upcoming actions** that are worth remembering\n"
|
||||
" (put them in `memory`; do NOT invent a separate intentions bucket)\n"
|
||||
"\n"
|
||||
"Implicit preferences MUST be inferred when strongly suggested.\n"
|
||||
"\n"
|
||||
"Do NOT separate \"what kind of person the user is\" from \"what happened\".\n"
|
||||
"Both go into `memory`.\n"
|
||||
"\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
)
|
||||
system_prompt = re.sub(
|
||||
r"# TWO KINDS OF MEMORY.*?(?=\n# OWNER\n)",
|
||||
repl,
|
||||
system_prompt,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
system_prompt = system_prompt.replace(
|
||||
"Every memory and intention MUST carry an `owner`",
|
||||
"Every memory MUST carry an `owner`",
|
||||
)
|
||||
system_prompt += (
|
||||
"\n\n---\n\n# INTENTION DISABLED\n"
|
||||
"Intention extraction is disabled. Even if few-shot examples show "
|
||||
"`intentions`, do **not** emit that key; put future plans in `memory`.\n"
|
||||
)
|
||||
return system_prompt
|
||||
language = "zh-Hans" if lang == "zh" else lang
|
||||
repl = get_prompt("NO_INTENTION_MEMORY_SECTION", language)
|
||||
system_prompt = re.sub(
|
||||
get_prompt("NO_INTENTION_SECTION_PATTERN", language),
|
||||
lambda _m: repl,
|
||||
system_prompt,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
for old, new in get_prompt("NO_INTENTION_REPLACEMENTS", language):
|
||||
system_prompt = system_prompt.replace(old, new)
|
||||
# few-shot 里若仍出现 intentions,用硬约束盖住
|
||||
return system_prompt + get_prompt("INTENTION_DISABLED_NOTE", language)
|
||||
|
||||
|
||||
class Extractor:
|
||||
@@ -968,6 +241,7 @@ class Extractor:
|
||||
tool_context: Optional[Dict[str, Any]] = None,
|
||||
history_context: str = "",
|
||||
basic_profile_fields: Optional[Dict[str, str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> ExtractResult:
|
||||
"""
|
||||
统一提取:identity / facts / basic_info
|
||||
@@ -989,6 +263,9 @@ class Extractor:
|
||||
**DEPRECATED**: 曾用于渲染 EXTRACT prompt 的基础画像 schema 段落,
|
||||
现已移除该段落(basic_info 由内置 few-shot / 指令引导)。保留参数
|
||||
仅为向后兼容,传入不再生效。
|
||||
language: 显式 prompt 语言码(见 hy_memory.prompts.SUPPORTED_LANGUAGES,
|
||||
接受别名)。None 时按 content 做 zh/en 自动检测。显式指定时
|
||||
追加 OUTPUT_LANGUAGE_DIRECTIVE,记忆正文用该语言书写。
|
||||
"""
|
||||
MAX_TOOL_ROUNDS = 5
|
||||
|
||||
@@ -1015,73 +292,52 @@ class Extractor:
|
||||
else:
|
||||
_memory_at = _current_date
|
||||
|
||||
# Select prompt based on input language
|
||||
from ..utils.lang_detect import is_chinese
|
||||
# 选 prompt 语言:显式 language(per-call / MemoryConfig.language)优先;
|
||||
# 未指定时沿用 zh/en 自动检测(行为与以前完全一致)。
|
||||
_explicit_language = normalize_language(language)
|
||||
_language = resolve_language(_explicit_language, content)
|
||||
|
||||
_enable_intention = bool(
|
||||
getattr(self._llm_config, "enable_intention", True)
|
||||
)
|
||||
if is_chinese(content):
|
||||
from .prompts_zh import (
|
||||
EXTRACT_PROMPT_ZH,
|
||||
EXTRACT_PROMPT_ZH_NO_INTENTION,
|
||||
EXTRACT_SYSTEM_PROMPT_ZH,
|
||||
EXTRACT_FEW_SHOT_ZH,
|
||||
)
|
||||
|
||||
# client 自定义 system prompt 仅覆盖对应语言(zh → zh-Hans, en → en);
|
||||
# 直接用,不追加内置 few-shot
|
||||
_custom_sys = None
|
||||
if _language == "zh-Hans":
|
||||
_custom_sys = getattr(
|
||||
self._llm_config, "extract_system_prompt_zh", None
|
||||
)
|
||||
if _custom_sys:
|
||||
# client 自定义 system prompt:直接用,不追加内置 few-shot
|
||||
system_prompt = _custom_sys
|
||||
else:
|
||||
system_prompt = EXTRACT_SYSTEM_PROMPT_ZH
|
||||
if getattr(self._llm_config, "few_shot_enabled", False):
|
||||
system_prompt = system_prompt + EXTRACT_FEW_SHOT_ZH
|
||||
if not _enable_intention and not _custom_sys:
|
||||
system_prompt = _system_prompt_without_intention(
|
||||
system_prompt, lang="zh"
|
||||
)
|
||||
if getattr(self._llm_config, "structure_metadata_enabled", False):
|
||||
from .prompts_zh import STRUCTURE_METADATA_PROMPT_ZH
|
||||
system_prompt += STRUCTURE_METADATA_PROMPT_ZH
|
||||
_user_tmpl = (
|
||||
EXTRACT_PROMPT_ZH
|
||||
if _enable_intention
|
||||
else EXTRACT_PROMPT_ZH_NO_INTENTION
|
||||
)
|
||||
prompt = _user_tmpl.format(
|
||||
content=content,
|
||||
current_date=_current_date,
|
||||
memory_at=_memory_at,
|
||||
last_messages=_last_messages,
|
||||
)
|
||||
else:
|
||||
elif _language == "en":
|
||||
_custom_sys = getattr(
|
||||
self._llm_config, "extract_system_prompt_en", None
|
||||
)
|
||||
if _custom_sys:
|
||||
system_prompt = _custom_sys
|
||||
else:
|
||||
system_prompt = EXTRACT_SYSTEM_PROMPT
|
||||
if getattr(self._llm_config, "few_shot_enabled", False):
|
||||
system_prompt = system_prompt + EXTRACT_FEW_SHOT_EN
|
||||
if not _enable_intention and not _custom_sys:
|
||||
system_prompt = _system_prompt_without_intention(
|
||||
system_prompt, lang="en"
|
||||
if _custom_sys:
|
||||
system_prompt = _custom_sys
|
||||
else:
|
||||
system_prompt = get_prompt("EXTRACT_SYSTEM_PROMPT", _language)
|
||||
if getattr(self._llm_config, "few_shot_enabled", False):
|
||||
system_prompt = system_prompt + get_prompt(
|
||||
"EXTRACT_FEW_SHOT", _language
|
||||
)
|
||||
if getattr(self._llm_config, "structure_metadata_enabled", False):
|
||||
system_prompt += STRUCTURE_METADATA_PROMPT_EN
|
||||
_user_tmpl = (
|
||||
EXTRACT_PROMPT if _enable_intention else EXTRACT_PROMPT_NO_INTENTION
|
||||
)
|
||||
prompt = _user_tmpl.format(
|
||||
content=content,
|
||||
current_date=_current_date,
|
||||
memory_at=_memory_at,
|
||||
last_messages=_last_messages,
|
||||
if not _enable_intention and not _custom_sys:
|
||||
system_prompt = _system_prompt_without_intention(
|
||||
system_prompt, lang=_language
|
||||
)
|
||||
if getattr(self._llm_config, "structure_metadata_enabled", False):
|
||||
system_prompt += get_prompt("STRUCTURE_METADATA_PROMPT", _language)
|
||||
system_prompt = with_output_language(
|
||||
system_prompt, _language, explicit=_explicit_language is not None,
|
||||
)
|
||||
_user_tmpl = get_prompt(
|
||||
"EXTRACT_PROMPT" if _enable_intention else "EXTRACT_PROMPT_NO_INTENTION",
|
||||
_language,
|
||||
)
|
||||
prompt = _user_tmpl.format(
|
||||
content=content,
|
||||
current_date=_current_date,
|
||||
memory_at=_memory_at,
|
||||
last_messages=_last_messages,
|
||||
)
|
||||
|
||||
# 累计 token 统计
|
||||
if _log_llm_content_enabled():
|
||||
@@ -1123,6 +379,7 @@ class Extractor:
|
||||
max_tokens=self._llm_config.agent_max_tokens,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
audit_stage="extract",
|
||||
)
|
||||
self._call_count += 1
|
||||
total_tokens += response.tokens_used
|
||||
@@ -1176,7 +433,7 @@ class Extractor:
|
||||
if not tc_id:
|
||||
logger.error(
|
||||
f"[extractor] tool_call_id is empty! "
|
||||
f"raw tc: {json.dumps(tc_raw, ensure_ascii=False, default=str)[:500]}"
|
||||
f"raw_keys={list(tc_raw.keys())}"
|
||||
)
|
||||
try:
|
||||
t_res = await tool_registry.dispatch(
|
||||
@@ -1233,6 +490,7 @@ class Extractor:
|
||||
max_tokens=self._llm_config.agent_max_tokens,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
audit_stage="extract",
|
||||
)
|
||||
self._call_count += 1
|
||||
total_tokens += response.tokens_used
|
||||
@@ -1309,11 +567,12 @@ class Extractor:
|
||||
if parsed is None:
|
||||
if not all_tool_calls:
|
||||
logger.warning(
|
||||
f"[extractor] JSON parse failed: {raw_text[:200]}"
|
||||
f"[extractor] JSON parse failed ({len(raw_text)} chars)"
|
||||
)
|
||||
logger.debug(f"[extractor] unparsable output: {raw_text[:200]!r}")
|
||||
return ExtractResult(
|
||||
success=False,
|
||||
error=f"JSON parse failed: {raw_text[:200]}",
|
||||
error=f"JSON parse failed ({len(raw_text)} chars)",
|
||||
error_code="JSON_PARSE_FAILED",
|
||||
raw_response=raw_text,
|
||||
tokens_used=total_tokens,
|
||||
|
||||
@@ -34,6 +34,7 @@ import os
|
||||
import weakref
|
||||
|
||||
from ..config import MemoryConfig
|
||||
from ..utils.model_call_context import model_call_stage
|
||||
from ..core.workload import llm_slot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -128,8 +129,7 @@ def _parse_tool_calls_list(raw_calls: list, _json) -> List[Dict[str, Any]]:
|
||||
tc_id = tc.get("id") or ""
|
||||
if not tc_id:
|
||||
logger.error(
|
||||
f"[llm] tool_call missing 'id'! raw: "
|
||||
f"{_json.dumps(tc, ensure_ascii=False, default=str)[:500]}"
|
||||
f"[llm] tool_call missing 'id'! keys={list(tc.keys())}"
|
||||
)
|
||||
|
||||
# 保留原始 tc 完整结构,只规范化 arguments
|
||||
@@ -348,6 +348,7 @@ class LLMProvider:
|
||||
stop: List[str] = None,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[Any] = None,
|
||||
audit_stage: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
@@ -386,15 +387,16 @@ class LLMProvider:
|
||||
len(prompt), _content_fingerprint(prompt),
|
||||
)
|
||||
|
||||
response = await self._call_llm(
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
stop=stop,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
with model_call_stage(audit_stage):
|
||||
response = await self._call_llm(
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
stop=stop,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
self._total_calls += 1
|
||||
self._total_tokens += response.tokens_used
|
||||
@@ -595,6 +597,7 @@ class LLMProvider:
|
||||
temperature: Optional[float] = None,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[Any] = None,
|
||||
audit_stage: Optional[str] = None,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
多轮 messages 调用(支持 tool results 回传)。
|
||||
@@ -622,13 +625,14 @@ class LLMProvider:
|
||||
f"msgs={len(messages)} tools={len(tools) if tools else 0}"
|
||||
)
|
||||
|
||||
response = await self._chat_completions(
|
||||
messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
with model_call_stage(audit_stage):
|
||||
response = await self._chat_completions(
|
||||
messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
|
||||
self._total_calls += 1
|
||||
self._total_tokens += response.tokens_used
|
||||
|
||||
@@ -144,6 +144,7 @@ class MemAgent:
|
||||
tool_context: Optional[Dict[str, Any]] = None,
|
||||
history_context: str = "",
|
||||
basic_profile_fields: Optional[Dict[str, str]] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> AgentResult:
|
||||
"""
|
||||
处理添加记忆请求
|
||||
@@ -161,6 +162,7 @@ class MemAgent:
|
||||
basic_profile_fields:
|
||||
{field_name: description} 字段表,由调用方从 MemoryConfig.basic_profile.fields
|
||||
透传过来,extractor 会渲染到 prompt 中要求 LLM 在 JSON basic_info 字段返回。
|
||||
language: 显式 prompt 语言码,透传给 extractor;None 时 zh/en 自动检测。
|
||||
|
||||
Returns:
|
||||
处理结果
|
||||
@@ -187,6 +189,7 @@ class MemAgent:
|
||||
tool_context=tool_context,
|
||||
history_context=history_context,
|
||||
basic_profile_fields=basic_profile_fields,
|
||||
language=language,
|
||||
)
|
||||
|
||||
_t_parallel = datetime.now()
|
||||
@@ -234,7 +237,7 @@ class MemAgent:
|
||||
f"identity={len(identity)} facts={len(facts)} "
|
||||
f"basic_info={basic_field_count}fields"
|
||||
)
|
||||
logger.info(f"MemAgent: extracted_info={extract_result.info}")
|
||||
logger.debug(f"MemAgent: extracted_info={extract_result.info}")
|
||||
|
||||
# 3. 冲突检测(如果有已有记忆)
|
||||
if mode == ProcessMode.FULL and existing_memories:
|
||||
|
||||
+15
-1191
File diff suppressed because it is too large.
Load diff
+167
-516
@@ -50,7 +50,7 @@ def is_canonical_state_node(node: Any) -> bool:
|
||||
|
||||
|
||||
def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
||||
"""以单条 INFO(JSON list)输出 reconcile 的逐条结果,便于线上 debug。"""
|
||||
"""以单条 DEBUG(JSON list)输出 reconcile 的逐条结果;含记忆正文,不进 INFO。"""
|
||||
if not ops:
|
||||
return
|
||||
view = [
|
||||
@@ -68,7 +68,7 @@ def _log_ops_detail(ops: List["ReconcileOp"]) -> None:
|
||||
}
|
||||
for o in ops
|
||||
]
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f"[reconciler] ops detail: {json.dumps(view, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
@@ -136,10 +136,10 @@ class ReconcileResult:
|
||||
ops: List[ReconcileOp] = field(default_factory=list)
|
||||
success: bool = True
|
||||
error: Optional[str] = None
|
||||
# True = reconcile 失败后走了「全部 ADD」兜底(见 _fallback_add_ops)。
|
||||
# 结果仍然可用(success=True),但没有做过去重/演化判定,可能引入重复。
|
||||
degraded: bool = False
|
||||
degraded_reason: Optional[str] = None
|
||||
# success=False 时的稳定失败码:LLM_UNAVAILABLE / EMPTY_RESPONSE /
|
||||
# JSON_PARSE_FAILED / RECONCILE_EXCEPTION。失败时 ops 恒为空:writer 什么都
|
||||
# 不落库,整次写入报失败,调用方换个 request_id 重跑即可(reconcile 会判重)。
|
||||
error_code: Optional[str] = None
|
||||
# reconcile 链路 LLM token 消耗(search-query + reconcile 两次 LLM 调用之和)
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
@@ -151,7 +151,7 @@ class ReconcileResult:
|
||||
# 用 content 作 key 而不是 node_id:新记忆此刻还没入库、没有 id。
|
||||
candidate_scores: Dict[str, List[Tuple[str, float]]] = field(default_factory=dict)
|
||||
# reconcile 候选检索已经为每条新 memory 算过 embedding。把原始文本对应向量交回
|
||||
# writer,ADD-all / degraded ADD 可直接落库,避免同一事实再次请求 embedding。
|
||||
# writer,原文不变的 ADD 可直接落库,避免同一事实再次请求 embedding。
|
||||
# 仅按完全相同 content 复用;LLM 改写后的 UPDATE/SUPERSEDE 会重新计算,防止错配。
|
||||
new_memory_embeddings: Dict[str, List[float]] = field(default_factory=dict)
|
||||
|
||||
@@ -160,273 +160,18 @@ class ReconcileResult:
|
||||
# Prompts
|
||||
# ================================================================
|
||||
|
||||
SEARCH_QUERY_PROMPT = """You are a search query generator. Given a list of newly extracted memories, generate a set of short search queries that can be used to find related existing memories in a vector database.
|
||||
|
||||
The goal is to maximize recall — find existing memories that are semantically related to the new memories, even if the wording is very different.
|
||||
|
||||
## New memories:
|
||||
{new_memories}
|
||||
|
||||
## Instructions
|
||||
|
||||
Generate search queries that cover:
|
||||
- Key topics, entities, and themes mentioned in the memories
|
||||
- Rephrased or abstracted versions of the core concepts
|
||||
- Related concepts that might exist in the user's memory store
|
||||
|
||||
Output a JSON array of query strings (5-15 queries, short and focused):
|
||||
|
||||
["query1", "query2", "query3", ...]
|
||||
|
||||
Output JSON array only, no other text."""
|
||||
|
||||
RECONCILE_PROMPT = """You are a memory management system.
|
||||
|
||||
Your task is to merge new memories into an existing memory base while keeping it:
|
||||
|
||||
* lossless
|
||||
* non-contradictory
|
||||
* compact
|
||||
* highly retrievable
|
||||
|
||||
---
|
||||
|
||||
# Operations
|
||||
|
||||
## ADD
|
||||
|
||||
Use when the information is entirely new.
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "ADD",
|
||||
"content": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UPDATE
|
||||
|
||||
Use when:
|
||||
|
||||
* the new memory is compatible with an existing one
|
||||
* they describe the same topic
|
||||
* merging improves completeness or retrievability
|
||||
|
||||
Rules:
|
||||
|
||||
* merge losslessly
|
||||
* preserve all useful information
|
||||
* do NOT create duplicates
|
||||
|
||||
### Same occurrence vs. another occurrence
|
||||
|
||||
Use **UPDATE** only when the new memory adds detail to the same real-world
|
||||
occurrence or durable fact already recorded.
|
||||
|
||||
If the same person does the same kind of activity/event again at another time
|
||||
(another trip, game, injury, purchase, health scare, etc.), keep the occurrence
|
||||
as a separate **ADD**. Preserve its own date and details. When an existing memory
|
||||
is clearly another occurrence in that same concrete series, link it with
|
||||
`related: [{{"memory_id":"...","relation":"same_series"}}]`.
|
||||
|
||||
Do not merge separate dated occurrences into one long count/timeline memory.
|
||||
The retrieval layer builds timelines from the linked L2 memories.
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "UPDATE",
|
||||
"memory_id": "...",
|
||||
"content": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SUPERSEDE
|
||||
|
||||
Use ONLY when:
|
||||
|
||||
* the new memory contradicts an existing memory
|
||||
* both cannot be true at the same time
|
||||
* the old memory would now give the wrong current-state answer
|
||||
|
||||
Examples:
|
||||
|
||||
* city changed
|
||||
* employer changed
|
||||
* relationship status changed
|
||||
|
||||
Do NOT use for:
|
||||
|
||||
* added detail
|
||||
* compatible preferences
|
||||
* wording differences
|
||||
* semantic equivalence
|
||||
|
||||
Rules:
|
||||
|
||||
* content must contain ONLY the new state
|
||||
* do NOT copy unchanged information
|
||||
* the system keeps the old node and links it onto the evolution chain automatically; you don't need to handle history
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "SUPERSEDE",
|
||||
"memory_id": "...",
|
||||
"content": "...",
|
||||
"supersede_reason": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
### Folding several existing memories into one chain
|
||||
|
||||
If TWO OR MORE existing memories track the SAME dimension and the new memory is
|
||||
the latest change on that dimension, you may fold them into a single evolution
|
||||
chain. Use `memory_ids` (an ordered list, OLDEST → NEWEST) instead of a single
|
||||
`memory_id`:
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "SUPERSEDE",
|
||||
"memory_ids": ["<oldest_id>", "<newer_id>"],
|
||||
"content": "<only the newest state>",
|
||||
"supersede_reason": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."]
|
||||
}}
|
||||
```
|
||||
|
||||
The listed memories are chained oldest → newest and the new memory becomes the
|
||||
chain head. `content` still describes ONLY the newest state — never copy the
|
||||
older memories' text into it; they remain queryable in the chain history.
|
||||
|
||||
---
|
||||
|
||||
# Rules
|
||||
|
||||
* Preserve all meaningful information.
|
||||
* Skip exact duplicates.
|
||||
* Prefer merging related fragments.
|
||||
* Separate dated occurrences stay separate ADDs; use `same_series` only for the
|
||||
same subject and same concrete activity/event type.
|
||||
* Keep each memory focused on one coherent topic.
|
||||
* Do not invent chronology.
|
||||
* `memory_at: null` means unknown time.
|
||||
* **NEVER merge or supersede across different subjects.** The subject is whoever
|
||||
the content is about: "The user ..." vs "John ..." vs "Mary ...". Memories about
|
||||
different people are independent facts even when the sentence is otherwise
|
||||
identical — "The user likes coffee" and "John likes coffee" are two separate
|
||||
memories, never an UPDATE or a SUPERSEDE of each other.
|
||||
* `owner` (`user`/`agent`): copy it from the memory being written. NEVER merge or supersede across different owners — a user's fact and the assistant's fact are distinct even on the same topic.
|
||||
* A SUPERSEDE either targets ONE memory (`memory_id`) or folds several memories on the same dimension into one chain (`memory_ids`, oldest→newest). An UPDATE targets exactly ONE memory.
|
||||
* Never touch the same existing memory from more than one op.
|
||||
* Output language matches input memory language.
|
||||
|
||||
---
|
||||
|
||||
# Optional: `related`
|
||||
|
||||
On any op you MAY add a `related` array recording how the memory being written
|
||||
relates to an EXISTING memory. This does not change what gets stored — it only
|
||||
records a link.
|
||||
|
||||
```json
|
||||
{{
|
||||
"op": "ADD",
|
||||
"content": "...",
|
||||
"owner": "user",
|
||||
"tags": ["..."],
|
||||
"related": [{{"memory_id": "<existing_id>", "relation": "causes"}}]
|
||||
}}
|
||||
```
|
||||
|
||||
Only these four relations are allowed:
|
||||
|
||||
* `causes` — the two memories are in a cause/effect relationship
|
||||
(e.g. "started running daily" → "knee started hurting")
|
||||
* `elaborates` — adds detail to, or is a later development of, the existing memory
|
||||
* `contrasts` — they are in tension but BOTH still true (if one makes the other
|
||||
false, that is a SUPERSEDE, not a `contrasts` link)
|
||||
* `same_series` — separate dated occurrences of the same concrete activity or
|
||||
event type for the same subject (for example, two different skiing trips)
|
||||
|
||||
Rules:
|
||||
|
||||
* Only link to memories that appear in "Existing memories" — never invent an id.
|
||||
* Only emit a link when the relationship is **explicit and specific**. Two memories
|
||||
merely being about the same broad topic is NOT a relation — leave `related` out.
|
||||
* `same_series` requires the same subject and a concrete repeated action/event;
|
||||
shared broad tags such as health, travel, sport, food, or work are insufficient.
|
||||
* Omit the field entirely when there is nothing to link. Zero links is the normal case.
|
||||
* Never use `related` as a substitute for UPDATE / SUPERSEDE.
|
||||
|
||||
---
|
||||
{few_shot_section}
|
||||
###################
|
||||
Input Data
|
||||
###################
|
||||
|
||||
Current date: {current_date}
|
||||
|
||||
## Existing memories
|
||||
|
||||
{existing_memories}
|
||||
|
||||
## New memories
|
||||
|
||||
{new_memories}
|
||||
|
||||
---
|
||||
|
||||
# Output
|
||||
|
||||
Return ONLY a JSON array inside a fenced ```json code block.
|
||||
|
||||
If no changes are needed:
|
||||
|
||||
```json
|
||||
[]
|
||||
```
|
||||
|
||||
Now produce the JSON array."""
|
||||
|
||||
RECONCILE_STRUCTURE_METADATA_PROMPT_EN = """
|
||||
|
||||
Additional structural output contract:
|
||||
|
||||
For every ADD, UPDATE, or SUPERSEDE result, preserve or refine `topic_paths`,
|
||||
`event_facet`, and `topic_assignments`. `event_facet` contains only `subject`,
|
||||
`participants`, `action`, `objects`, `locations`, and `state`.
|
||||
`topic_assignments` contains subject-specific `{subject, role, path}` entries;
|
||||
each path segment is `{name, type}` and type is `topic|facet|series|episode`.
|
||||
Types follow `topic -> facet -> series -> episode`; levels may be skipped but
|
||||
types may not repeat or move backward.
|
||||
Use Series only for repeated occurrences of one concrete activity/state line and
|
||||
Episode only for one bounded real-world occurrence. Never infer these types from
|
||||
depth, a broad shared tag, or unrelated co-occurrence. When an Existing memory
|
||||
already has a semantically equivalent subject-specific path, reuse its exact node
|
||||
names and types instead of creating a synonym fork. To request exact reuse, emit
|
||||
`structure_references: [{"memory_id":"<existing_id>", "subject":"...",
|
||||
"through":"topic|facet|series|episode"}]`. The referenced ID must appear in
|
||||
Existing memories. `through` means copy that existing path prefix through the
|
||||
named structural type; an explicitly authored deeper suffix remains new. Reuse
|
||||
is forbidden when the subject, concrete activity/state line, or Episode boundary
|
||||
differs. These fields describe the memory, never a future query form such as
|
||||
"when", "how many", or "who".
|
||||
|
||||
Series is a reconcile-time relation: establish it only when the new memory and
|
||||
at least one independent Existing memory are distinct occurrences of the same
|
||||
concrete activity/state line. Multiple propositions from the current write do
|
||||
not prove repetition.
|
||||
"""
|
||||
# Prompt 文本统一放在 hy_memory/prompts/<language>.py(get_prompt 按语言码取);
|
||||
# 以下旧名字保留为英文版别名(向后兼容)。
|
||||
from ..prompts import ( # noqa: E402
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ..prompts.en import SEARCH_QUERY_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import RECONCILE_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import RECONCILE_STRUCTURE_METADATA_PROMPT as RECONCILE_STRUCTURE_METADATA_PROMPT_EN # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import RECONCILE_FEW_SHOT as RECONCILE_FEW_SHOT_EN # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
def append_reconcile_structure_prompt(
|
||||
@@ -435,10 +180,83 @@ def append_reconcile_structure_prompt(
|
||||
"""Append the structure contract only for an explicit experiment config."""
|
||||
if not enabled:
|
||||
return prompt
|
||||
if language == "zh":
|
||||
from .prompts_zh import RECONCILE_STRUCTURE_METADATA_PROMPT_ZH
|
||||
return prompt + RECONCILE_STRUCTURE_METADATA_PROMPT_ZH
|
||||
return prompt + RECONCILE_STRUCTURE_METADATA_PROMPT_EN
|
||||
code = "zh-Hans" if language == "zh" else language
|
||||
return prompt + get_prompt("RECONCILE_STRUCTURE_METADATA_PROMPT", code)
|
||||
|
||||
|
||||
def _as_aware(value: Any) -> Optional[datetime]:
|
||||
"""memory_at as a comparable aware datetime; None when unknown or unreadable."""
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
value = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(value, datetime):
|
||||
return None
|
||||
# Stored nodes rebuild memory_at from epoch seconds as naive local time
|
||||
# (MemoryNode.from_dict), so a naive value is local, not UTC.
|
||||
return value if value.tzinfo is not None else value.astimezone()
|
||||
|
||||
|
||||
def enforce_time_order(
|
||||
ops: List["ReconcileOp"],
|
||||
new_memory_at: Any,
|
||||
existing: Dict[str, Any],
|
||||
) -> List["ReconcileOp"]:
|
||||
"""An older memory must never replace a newer one, whatever the model says.
|
||||
|
||||
Memories are not always written in the order they happened: history is
|
||||
imported late, and a person can add a memory while older history is still
|
||||
being processed. The prompt tells the model to compare `memory_at`; this
|
||||
makes it hold. A SUPERSEDE or UPDATE aimed at an existing memory whose
|
||||
`memory_at` is later than the memory being written is turned into an ADD,
|
||||
so the newer memory stays the current state. Unknown times are not
|
||||
compared. A content-less UPDATE (the legacy DELETE mapping) is dropped.
|
||||
"""
|
||||
written_at = _as_aware(new_memory_at)
|
||||
if written_at is None:
|
||||
return ops
|
||||
kept: List[ReconcileOp] = []
|
||||
for op in ops:
|
||||
if op.op not in ("SUPERSEDE", "UPDATE"):
|
||||
kept.append(op)
|
||||
continue
|
||||
targets = list(op.memory_ids) if op.memory_ids else [op.memory_id]
|
||||
newer = [
|
||||
target for target in targets
|
||||
if target and (node := existing.get(target)) is not None
|
||||
and (target_at := _as_aware(getattr(node, "memory_at", None))) is not None
|
||||
and target_at > written_at
|
||||
]
|
||||
if not newer:
|
||||
kept.append(op)
|
||||
continue
|
||||
logger.warning(
|
||||
"[reconciler] %s blocked: memory_at %s is older than existing %s; "
|
||||
"kept the newer memory",
|
||||
op.op, written_at.isoformat(), ",".join(newer),
|
||||
)
|
||||
if not op.content:
|
||||
continue
|
||||
kept.append(ReconcileOp(
|
||||
op="ADD",
|
||||
content=op.content,
|
||||
layer=op.layer,
|
||||
owner=op.owner,
|
||||
tags=list(op.tags),
|
||||
tag_ners=list(op.tag_ners),
|
||||
topic_paths=list(op.topic_paths),
|
||||
event_facet=dict(op.event_facet),
|
||||
topic_assignments=list(op.topic_assignments),
|
||||
structure_references=list(op.structure_references),
|
||||
topic_paths_provided=op.topic_paths_provided,
|
||||
event_facet_provided=op.event_facet_provided,
|
||||
topic_assignments_provided=op.topic_assignments_provided,
|
||||
speculate=op.speculate,
|
||||
related=list(op.related),
|
||||
reason=f"time_order: older than {','.join(newer)}; {op.reason}"[:500],
|
||||
))
|
||||
return kept
|
||||
|
||||
|
||||
def build_reconcile_prompt(
|
||||
@@ -449,147 +267,42 @@ def build_reconcile_prompt(
|
||||
current_date: str,
|
||||
few_shot_enabled: bool = False,
|
||||
structure_metadata_enabled: bool = False,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build the language-matched reconcile prompt used by the write path."""
|
||||
from ..utils.lang_detect import is_chinese
|
||||
"""Build the reconcile prompt used by the write path.
|
||||
|
||||
if is_chinese("\n".join(new_memories)):
|
||||
from .prompts_zh import RECONCILE_FEW_SHOT_ZH, RECONCILE_PROMPT_ZH
|
||||
|
||||
few_shot = ("\n" + RECONCILE_FEW_SHOT_ZH + "\n") if few_shot_enabled else ""
|
||||
prompt = RECONCILE_PROMPT_ZH.format(
|
||||
current_date=current_date,
|
||||
new_memories=new_memories_block,
|
||||
existing_memories=existing_memories_block,
|
||||
few_shot_section=few_shot,
|
||||
)
|
||||
return append_reconcile_structure_prompt(
|
||||
prompt,
|
||||
enabled=structure_metadata_enabled,
|
||||
language="zh",
|
||||
)
|
||||
|
||||
few_shot = ("\n" + RECONCILE_FEW_SHOT_EN + "\n") if few_shot_enabled else ""
|
||||
prompt = RECONCILE_PROMPT.format(
|
||||
``language`` set → that language's prompt plus the output-language rule;
|
||||
None → zh/en auto-detection over the new memories (unchanged behaviour).
|
||||
"""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, "\n".join(new_memories))
|
||||
few_shot = (
|
||||
("\n" + get_prompt("RECONCILE_FEW_SHOT", code) + "\n")
|
||||
if few_shot_enabled else ""
|
||||
)
|
||||
prompt = get_prompt("RECONCILE_PROMPT", code).format(
|
||||
current_date=current_date,
|
||||
new_memories=new_memories_block,
|
||||
existing_memories=existing_memories_block,
|
||||
few_shot_section=few_shot,
|
||||
)
|
||||
return append_reconcile_structure_prompt(
|
||||
prompt = append_reconcile_structure_prompt(
|
||||
prompt,
|
||||
enabled=structure_metadata_enabled,
|
||||
language="en",
|
||||
language=code,
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None)
|
||||
|
||||
|
||||
# Reconcile few-shot examples (injected into {few_shot_section} only when
|
||||
# few_shot_enabled). Each shows the existing/new input and the expected ops,
|
||||
# with a one-line takeaway. IDs are shortened for readability.
|
||||
RECONCILE_FEW_SHOT_EN = """
|
||||
###################
|
||||
Examples
|
||||
###################
|
||||
|
||||
## Example 1 — One new memory: part conflicts with an existing chain, part is unrelated
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "m1", "content": "The user works as a product manager at Stripe.", "owner": "user", "history_versions": [{"content": "The user works as a product manager at a fintech startup."}]}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "The user has left Stripe and is now a product lead at Notion, and has recently taken up pottery on weekends.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "SUPERSEDE", "memory_id": "m1", "content": "The user works as a product lead at Notion.", "supersede_reason": "employer changed from Stripe to Notion", "owner": "user", "tags": ["work"]},
|
||||
{"op": "ADD", "content": "The user has recently taken up pottery on weekends.", "owner": "user", "tags": ["hobby"]}
|
||||
]
|
||||
```
|
||||
A SINGLE new memory carries two things: the job change (same dimension as the existing employment chain m1, which already has history → SUPERSEDE, becomes the new chain head) and an unrelated detail (pottery → ADD). Split the one memory into the right op per part — the new head describes ONLY the employment state, never the pottery.
|
||||
|
||||
## Example 2 — Fold two unlinked existing memories + the new change into one 3-node chain
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "a1", "content": "The user is learning to play the guitar.", "owner": "user", "memory_at": "2024-02-10 09:00"},
|
||||
{"memory_id": "a2", "content": "The user can now play a few full songs on the guitar.", "owner": "user", "memory_at": "2024-09-05 09:00"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "The user now performs guitar at local open-mic nights.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "SUPERSEDE", "memory_ids": ["a1", "a2"], "content": "The user performs guitar at local open-mic nights.", "supersede_reason": "guitar skill progressed: learning → playing songs → performing live", "owner": "user", "tags": ["music", "hobby"]}
|
||||
]
|
||||
```
|
||||
a1 and a2 were never linked, but they track the same dimension (guitar progress) and the new memory is the latest step. Fold them with `memory_ids` ordered oldest→newest; they become a 3-node chain (a1 ← a2 ← new head). The head `content` describes ONLY the current state — it does not restate "learning" or "a few songs".
|
||||
|
||||
## Example 3 — New head must NOT copy the old memory's content
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "c1", "content": "The user lives in a rented one-bedroom apartment in Shibuya, Tokyo, pays ¥180,000/month, loves the 5-minute walk to the station, but complains the place is noisy at night and has no parking.", "owner": "user"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "The user just bought and moved into a three-bedroom house in the suburbs of Yokohama with a garden and a two-car garage.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "SUPERSEDE", "memory_id": "c1", "content": "The user bought and moved into a three-bedroom house in suburban Yokohama, with a garden and a two-car garage.", "supersede_reason": "residence changed from a rented Shibuya apartment to an owned Yokohama house", "owner": "user", "tags": ["housing"]}
|
||||
]
|
||||
```
|
||||
The new memory SUPERSEDEs c1 and becomes the chain head. The head `content` describes ONLY the new Yokohama house. Do NOT drag the old apartment's distinctive details (Shibuya, ¥180,000 rent, the station walk, the noise/parking complaints) into the new node — those clearly belong to the old residence, not the new one. They stay in chain history and remain retrievable; copying them would make the head self-contradictory (renting in Shibuya AND owning in Yokohama at once).
|
||||
|
||||
## Example 4 — Different subjects are never merged, however similar the wording
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "d1", "content": "The user drinks black coffee every morning.", "owner": "user"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "John drinks black coffee every morning.", "owner": "user"},
|
||||
{"content": "The user now drinks black coffee twice a day, morning and after lunch.", "owner": "user"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "ADD", "content": "John drinks black coffee every morning.", "owner": "user", "tags": ["food"]},
|
||||
{"op": "UPDATE", "memory_id": "d1", "content": "The user drinks black coffee twice a day, in the morning and after lunch.", "owner": "user", "tags": ["food"]}
|
||||
]
|
||||
```
|
||||
The two new memories are near-identical in wording but have DIFFERENT subjects. "John ..." is about another person, so it is a plain ADD — never an UPDATE or SUPERSEDE of d1, even though d1 says almost the same thing about the user. Only the memory whose subject is also "The user" may merge with d1.
|
||||
|
||||
## Example 5 — Recurring event: bump count and keep a timeline (UPDATE, not ADD)
|
||||
|
||||
Existing memories:
|
||||
[
|
||||
{"memory_id": "i1", "content": "John injured his ankle last season and needed time off plus physical therapy.", "owner": "user", "memory_at": "2023-05-21 19:48"}
|
||||
]
|
||||
New memories:
|
||||
[
|
||||
{"content": "John twisted his ankle again during a recent game.", "owner": "user", "memory_at": "2023-10-13 13:50"}
|
||||
]
|
||||
|
||||
Output:
|
||||
```json
|
||||
[
|
||||
{"op": "UPDATE", "memory_id": "i1", "content": "John has injured his ankle two times: first last season (around the 2022–2023 season), needing time off and physical therapy; second during a game around 2023-10-13.", "owner": "user", "tags": ["health", "basketball", "injury"]}
|
||||
]
|
||||
```
|
||||
Same event type happening again → UPDATE the old memory. State the **count** and list occurrences with times when known. Do NOT ADD a second "John injured his ankle" fact, and do NOT SUPERSEDE (the first injury did not stop being true).
|
||||
"""
|
||||
def build_search_query_prompt(
|
||||
new_memories: List[str], *, language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Search-query generation prompt (same language rule as reconcile)."""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, "\n".join(new_memories))
|
||||
mem_lines = "\n".join(f"{i+1}. {m}" for i, m in enumerate(new_memories))
|
||||
prompt = get_prompt("SEARCH_QUERY_PROMPT", code).format(new_memories=mem_lines)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -644,7 +357,9 @@ class MemoryReconciler:
|
||||
self._llm = LLMProvider(self.config)
|
||||
return self._llm
|
||||
|
||||
async def _generate_search_queries(self, new_memories: List[str]) -> tuple:
|
||||
async def _generate_search_queries(
|
||||
self, new_memories: List[str], language: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Pre-process: 从全部新 memory 中提取 search queries,
|
||||
用于补充向量搜索的召回能力。
|
||||
@@ -652,23 +367,14 @@ class MemoryReconciler:
|
||||
Returns:
|
||||
(queries: List[str], llm_response: Optional[LLMResponse])
|
||||
"""
|
||||
mem_lines = "\n".join(f"{i+1}. {m}" for i, m in enumerate(new_memories))
|
||||
|
||||
# Select prompt based on input language
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
if is_chinese("\n".join(new_memories)):
|
||||
from .prompts_zh import SEARCH_QUERY_PROMPT_ZH
|
||||
|
||||
prompt = SEARCH_QUERY_PROMPT_ZH.format(new_memories=mem_lines)
|
||||
else:
|
||||
prompt = SEARCH_QUERY_PROMPT.format(new_memories=mem_lines)
|
||||
prompt = build_search_query_prompt(new_memories, language=language)
|
||||
|
||||
try:
|
||||
llm = self._get_llm()
|
||||
response = await llm.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=500,
|
||||
audit_stage="reconcile",
|
||||
)
|
||||
text = response.content.strip()
|
||||
if "```json" in text:
|
||||
@@ -688,49 +394,6 @@ class MemoryReconciler:
|
||||
|
||||
return [], None
|
||||
|
||||
@staticmethod
|
||||
def _fallback_add_ops(
|
||||
new_memories: List[str],
|
||||
new_memories_with_meta: Optional[List[Dict]] = None,
|
||||
) -> List[ReconcileOp]:
|
||||
"""reconcile 彻底失败时的兜底:把抽取结果**原样全部 ADD**。
|
||||
|
||||
为什么必须有这个兜底:reconcile 只是决定「新事实和旧记忆怎么合」。它挂了(网关 500、
|
||||
JSON 解析不出)不代表抽取结果没价值 —— 此前的行为是把一整批完好的事实**全部丢弃**,
|
||||
线上实测因此丢掉过整块内容(含评测题的正确答案)。宁可留重复也不要丢内容:
|
||||
重复还能被后续 dedup / reconcile 收拾,丢了就永远找不回来。
|
||||
"""
|
||||
metas = new_memories_with_meta or []
|
||||
by_content = {
|
||||
(m.get("content") or ""): m for m in metas if isinstance(m, dict)
|
||||
}
|
||||
ops: List[ReconcileOp] = []
|
||||
for text in new_memories:
|
||||
if not text:
|
||||
continue
|
||||
meta = by_content.get(text) or {}
|
||||
ops.append(ReconcileOp(
|
||||
op="ADD",
|
||||
content=text,
|
||||
layer=meta.get("layer"),
|
||||
tags=meta.get("tags") or [],
|
||||
owner=meta.get("owner"),
|
||||
topic_paths=meta.get("topic_paths") or [],
|
||||
event_facet=meta.get("event_facet") or {},
|
||||
topic_assignments=meta.get("topic_assignments") or [],
|
||||
topic_paths_provided=bool(
|
||||
meta.get("topic_paths_provided", "topic_paths" in meta)
|
||||
),
|
||||
event_facet_provided=bool(
|
||||
meta.get("event_facet_provided", "event_facet" in meta)
|
||||
),
|
||||
topic_assignments_provided=bool(meta.get(
|
||||
"topic_assignments_provided", "topic_assignments" in meta,
|
||||
)),
|
||||
reason="reconcile unavailable → stored as-is (degraded)",
|
||||
))
|
||||
return ops
|
||||
|
||||
async def reconcile(
|
||||
self,
|
||||
new_memories: List[str],
|
||||
@@ -747,6 +410,7 @@ class MemoryReconciler:
|
||||
graph_store=None, # Optional GraphStore:Entity MENTIONS boost
|
||||
new_memory_embeddings: Optional[Dict[str, List[float]]] = None,
|
||||
source: str = "default",
|
||||
language: Optional[str] = None,
|
||||
) -> ReconcileResult:
|
||||
"""
|
||||
对 new_memories 做 reconcile。
|
||||
@@ -754,6 +418,7 @@ class MemoryReconciler:
|
||||
Args:
|
||||
new_memories: 新记忆内容列表(content only,用于 embed 搜索)
|
||||
new_memories_with_meta: 可选,含 tags 的完整 meta
|
||||
language: 显式 prompt 语言码;None 时按新记忆做 zh/en 自动检测
|
||||
"""
|
||||
if not new_memories:
|
||||
return ReconcileResult(ops=[], success=True)
|
||||
@@ -774,7 +439,7 @@ class MemoryReconciler:
|
||||
|
||||
if self._enable_search_query:
|
||||
search_queries, sq_response = await self._generate_search_queries(
|
||||
new_memories
|
||||
new_memories, language=language,
|
||||
)
|
||||
sq_elapsed = (_time.perf_counter() - t_sq) * 1000
|
||||
|
||||
@@ -785,18 +450,10 @@ class MemoryReconciler:
|
||||
|
||||
# 写 SEARCH_QUERY pipeline log
|
||||
if cache and request_id:
|
||||
mem_lines = "\n".join(f"{i+1}. {m}" for i, m in enumerate(new_memories))
|
||||
if self._enable_search_query:
|
||||
from ..utils.lang_detect import is_chinese as _is_zh
|
||||
|
||||
if _is_zh("\n".join(new_memories)):
|
||||
from .prompts_zh import SEARCH_QUERY_PROMPT_ZH
|
||||
|
||||
sq_prompt = SEARCH_QUERY_PROMPT_ZH.format(
|
||||
new_memories=mem_lines
|
||||
)
|
||||
else:
|
||||
sq_prompt = SEARCH_QUERY_PROMPT.format(new_memories=mem_lines)
|
||||
sq_prompt = build_search_query_prompt(
|
||||
new_memories, language=language,
|
||||
)
|
||||
else:
|
||||
sq_prompt = "[search query disabled — hybrid retrieval per new memory]"
|
||||
try:
|
||||
@@ -1345,6 +1002,7 @@ class MemoryReconciler:
|
||||
getattr(self.config.llm, "few_shot_enabled", False)
|
||||
),
|
||||
structure_metadata_enabled=_structure_metadata_on,
|
||||
language=language,
|
||||
)
|
||||
|
||||
# ── Step 5: LLM 调用 + 重试 ──
|
||||
@@ -1383,11 +1041,12 @@ class MemoryReconciler:
|
||||
response = await llm.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=dyn_max_tokens,
|
||||
audit_stage="reconcile",
|
||||
)
|
||||
except Exception as e:
|
||||
# LLM 彻底不可用(provider 已按 max_retries 重试过,网关 500 /
|
||||
# 连接错误等)。**不能让它冒泡** —— 冒泡会让整批抽取结果连带丢弃。
|
||||
# 交给下面的 ADD-all 兜底。
|
||||
# 连接错误等)。不冒泡:下面统一记 RECONCILE 失败日志,再返回
|
||||
# success=False —— writer 不落任何新记忆,整次写入报失败。
|
||||
_llm_error = f"{type(e).__name__}: {e}"
|
||||
logger.warning(f"[reconciler] RECONCILE LLM unavailable: {_llm_error}")
|
||||
break
|
||||
@@ -1395,24 +1054,28 @@ class MemoryReconciler:
|
||||
_tok_pt += response.prompt_tokens
|
||||
_tok_ct += response.completion_tokens
|
||||
_tok_total += response.tokens_used
|
||||
raw_response = response.content
|
||||
raw_response = response.content or ""
|
||||
ops = self._parse_ops(raw_response)
|
||||
ops = enforce_time_order(ops, current_time, candidate_map)
|
||||
ops = self._inherit_exact_input_structure(
|
||||
ops, new_memories_with_meta,
|
||||
)
|
||||
if _structure_metadata_on:
|
||||
ops = self._resolve_structure_references(ops, candidate_map)
|
||||
|
||||
_is_empty_array = self._strip_code_fence(raw_response) in ("[]", "")
|
||||
# 只有字面量 "[]" 才是「模型判定无需任何操作」。HTTP 200 但 content
|
||||
# 为空不是决策,是失败:按 EMPTY_RESPONSE 处理,不能当成 0 条成功。
|
||||
_is_empty_array = self._strip_code_fence(raw_response) == "[]"
|
||||
if ops or _is_empty_array:
|
||||
_parse_ok = True
|
||||
break
|
||||
logger.warning(
|
||||
f"[reconciler] RECONCILE JSON parse failed "
|
||||
f"(attempt {_attempt+1}/{_MAX_PARSE_ATTEMPTS}), "
|
||||
f"retrying: raw="
|
||||
f"{raw_response[:200] if _log_llm_content_enabled() else '<redacted>'}"
|
||||
f"retrying: raw({len(raw_response or '')} chars)"
|
||||
)
|
||||
if _log_llm_content_enabled():
|
||||
logger.debug(f"[reconciler] unparsable raw={raw_response[:200]!r}")
|
||||
|
||||
recon_elapsed = (_time.perf_counter() - t_recon) * 1000
|
||||
|
||||
@@ -1424,15 +1087,15 @@ class MemoryReconciler:
|
||||
error_code = "LLM_UNAVAILABLE"
|
||||
elif raw_response.strip():
|
||||
error_msg = (
|
||||
f"RECONCILE JSON parse failed after {_n_attempts} attempts: "
|
||||
f"{raw_response[:200] if _log_llm_content_enabled() else '<redacted>'}"
|
||||
f"RECONCILE JSON parse failed after {_n_attempts} attempts "
|
||||
f"({len(raw_response)} chars)"
|
||||
)
|
||||
error_code = "JSON_PARSE_FAILED"
|
||||
else:
|
||||
error_msg = (
|
||||
f"RECONCILE LLM returned empty response after {_n_attempts} attempts"
|
||||
)
|
||||
error_code = "EMPTY_RESPONSE"
|
||||
error_code = "EMPTY_RESPONSE"
|
||||
logger.warning(f"[reconciler] {error_msg}")
|
||||
|
||||
if cache and request_id:
|
||||
@@ -1449,14 +1112,13 @@ class MemoryReconciler:
|
||||
{
|
||||
"error": error_code,
|
||||
"message": error_msg,
|
||||
"fallback": "ADD_ALL",
|
||||
"fallback_count": len(new_memories or []),
|
||||
"stored": 0,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
elapsed_ms=recon_elapsed,
|
||||
# LLM 彻底不可用时 response 是 None —— 这行本身会抛
|
||||
# AttributeError,而它正好在兜底路径上,会把兜底也一起废掉
|
||||
# LLM 彻底不可用时 response 是 None —— 直接取属性会抛
|
||||
# AttributeError,把失败日志本身也废掉
|
||||
prompt_tokens=getattr(response, "prompt_tokens", 0) or 0,
|
||||
completion_tokens=getattr(response, "completion_tokens", 0) or 0,
|
||||
total_tokens=getattr(response, "tokens_used", 0) or 0,
|
||||
@@ -1466,22 +1128,16 @@ class MemoryReconciler:
|
||||
f"[reconciler] store failed RECONCILE log failed: {e}"
|
||||
)
|
||||
|
||||
# 兜底:JSON 解析不出来不等于抽取结果没价值 —— 全部 ADD,别丢内容。
|
||||
_fb = self._fallback_add_ops(new_memories, new_memories_with_meta)
|
||||
logger.warning(
|
||||
f"[reconciler] falling back to ADD-all: {len(_fb)} memories "
|
||||
f"({error_code})"
|
||||
)
|
||||
# 不做 ADD-all 兜底:没经过判重 / 演化的整批 ADD 会在重跑时产生重复,
|
||||
# 而且调用方看到 success 就不会重试。这里报失败、不落库,由调用方重跑。
|
||||
return ReconcileResult(
|
||||
ops=_fb,
|
||||
success=bool(_fb),
|
||||
error=None if _fb else error_msg,
|
||||
degraded=bool(_fb),
|
||||
degraded_reason=error_code,
|
||||
ops=[],
|
||||
success=False,
|
||||
error=error_msg,
|
||||
error_code=error_code,
|
||||
prompt_tokens=_tok_pt,
|
||||
completion_tokens=_tok_ct,
|
||||
total_tokens=_tok_total,
|
||||
new_memory_embeddings=new_memory_embeddings,
|
||||
)
|
||||
|
||||
# 写 RECONCILE pipeline log
|
||||
@@ -1546,20 +1202,14 @@ class MemoryReconciler:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[reconciler] failed: {e}", exc_info=True)
|
||||
# 兜底同样适用于「reconcile 链路任意环节炸了」(候选检索、embed、
|
||||
# prompt 组装…)。此前这里返回 ops=[] → writer 直接丢弃整批抽取结果。
|
||||
_fb = self._fallback_add_ops(new_memories, new_memories_with_meta)
|
||||
if _fb:
|
||||
logger.warning(
|
||||
f"[reconciler] falling back to ADD-all after unexpected error: "
|
||||
f"{len(_fb)} memories"
|
||||
)
|
||||
return ReconcileResult(
|
||||
ops=_fb, success=True,
|
||||
degraded=True, degraded_reason=f"EXCEPTION:{type(e).__name__}",
|
||||
new_memory_embeddings=dict(new_memory_embeddings or {}),
|
||||
)
|
||||
return ReconcileResult(ops=[], success=False, error=str(e))
|
||||
# reconcile 链路任意环节炸了(候选检索、embed、prompt 组装…):此时还
|
||||
# 没有任何新记忆落库,报失败让整次写入可以干净地重跑。
|
||||
return ReconcileResult(
|
||||
ops=[],
|
||||
success=False,
|
||||
error=f"{type(e).__name__}: {e}",
|
||||
error_code="RECONCILE_EXCEPTION",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inherit_exact_input_structure(
|
||||
@@ -1702,7 +1352,8 @@ class MemoryReconciler:
|
||||
except Exception as _re:
|
||||
logger.debug(f"[reconciler] json_repair failed: {_re}")
|
||||
if data is None:
|
||||
logger.warning(f"[reconciler] failed to parse ops JSON: {text[:200]}")
|
||||
logger.warning(f"[reconciler] failed to parse ops JSON ({len(text)} chars)")
|
||||
logger.debug(f"[reconciler] unparsable ops output: {text[:200]!r}")
|
||||
return []
|
||||
|
||||
if not isinstance(data, list):
|
||||
@@ -1914,11 +1565,11 @@ class MemoryReconciler:
|
||||
if clash is not None:
|
||||
logger.warning(
|
||||
f"[reconciler] memory_id {clash} already touched by '{touched[clash]}', "
|
||||
f"current op '{op.op} ({op.reason[:60]})' dropped"
|
||||
f"current op '{op.op}' dropped"
|
||||
)
|
||||
continue
|
||||
for t in target_ids:
|
||||
touched[t] = f"{op.op}({op.reason[:40]})"
|
||||
touched[t] = op.op
|
||||
validated.append(op)
|
||||
|
||||
return validated
|
||||
@@ -211,7 +211,9 @@ def parse_tool_calls_from_json(raw: Any) -> List[ToolCall]:
|
||||
try:
|
||||
args = json.loads(args_raw) if args_raw.strip() else {}
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"[tools] failed to parse arguments as JSON: {args_raw[:120]}")
|
||||
logger.warning(
|
||||
f"[tools] failed to parse arguments as JSON ({len(args_raw)} chars)"
|
||||
)
|
||||
args = {}
|
||||
elif isinstance(args_raw, dict):
|
||||
args = args_raw
|
||||
|
||||
@@ -26,6 +26,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
@@ -208,6 +210,86 @@ def _sanitize_kv(
|
||||
return result
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 占位符守卫(P0-1)
|
||||
# ================================================================
|
||||
# 弱模型(如 qwen3.5-2b)在用户什么都没说时,会把 schema 字段说明里的词当值
|
||||
# 填进 basic_info:{"name": "用户"} / "The user" / "N/A"。每个占位符都会
|
||||
# supersede 掉真实值,演化链变成 费天翔 → Fei → 用户 → The user。
|
||||
# 规则:占位符永远不入库;读侧组装 KV 时占位符也不覆盖已有真实值。
|
||||
|
||||
_PLACEHOLDER_VALUES = frozenset({
|
||||
"用户", "该用户", "本用户", "用户本人",
|
||||
"the user", "the user's", "user", "a user", "this user",
|
||||
"n/a", "na", "none", "null", "nil", "unknown", "unspecified",
|
||||
"not provided", "not specified", "not mentioned",
|
||||
"未提供", "未知", "不详", "未说明", "未提及", "无",
|
||||
"-", "?",
|
||||
# 其他提示词语言里的「用户」/「未知」(显式 language 时模型可能照抄)
|
||||
"使用者", "該使用者", "用戶", "該用戶", "不詳", "無",
|
||||
"ユーザー", "ユーザ", "不明", "なし",
|
||||
"사용자", "유저", "알 수 없음", "없음",
|
||||
"el usuario", "usuario", "la usuaria", "desconocido", "ninguno",
|
||||
"der nutzer", "nutzer", "der benutzer", "benutzer", "unbekannt", "keine",
|
||||
"o usuário", "usuário", "o utilizador", "utilizador", "desconhecido", "nenhum",
|
||||
"l'utilisateur", "utilisateur", "inconnu", "aucun",
|
||||
"пользователь", "неизвестно", "нет",
|
||||
"pengguna", "tidak diketahui", "tidak ada",
|
||||
})
|
||||
|
||||
# 内置字段的中文标签:弱模型也会把字段说明的中文词当值。
|
||||
_FIELD_LABELS: Dict[str, tuple] = {
|
||||
"name": ("姓名", "名字", "用户名", "用户姓名", "用户名字"),
|
||||
"age": ("年龄", "用户年龄"),
|
||||
"location": ("所在地", "地点", "城市", "用户所在地"),
|
||||
"occupation": ("职业", "工作", "用户职业"),
|
||||
"employer": ("雇主", "公司", "用户雇主"),
|
||||
}
|
||||
|
||||
# 去掉标点/符号后再比较,让 "The user." / "用户。" / "N/A" 都命中。
|
||||
# "-" / "?" 本身就是纯标点,剥完为空串,同样判为占位符。
|
||||
_PUNCT_RE = re.compile(r"[\s\.,;:!?'\"`~()\[\]{}<>/\\|_\-\u3000-\u303f\uff00-\uff0f\uff1a-\uff20]+")
|
||||
|
||||
|
||||
def _placeholder_key(value: str) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(value or "")).strip().lower()
|
||||
s = s.replace("\u2019", "'")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
_PLACEHOLDER_KEYS = frozenset(_placeholder_key(v) for v in _PLACEHOLDER_VALUES)
|
||||
|
||||
|
||||
def is_placeholder_value(field_name: str, value: Any) -> bool:
|
||||
"""value 是否为占位符(空 / 用户 / The user / N/A / 字段名本身 等)。
|
||||
|
||||
大小写不敏感、首尾空白与标点不敏感。数字(如 age=30)永远不是占位符。
|
||||
"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return False
|
||||
key = _placeholder_key(value)
|
||||
if not key:
|
||||
return True
|
||||
if key in _PLACEHOLDER_KEYS:
|
||||
return True
|
||||
field_key = _placeholder_key(field_name)
|
||||
if field_key and key in (field_key, field_key.replace("_", " ")):
|
||||
return True
|
||||
for label in _FIELD_LABELS.get(field_name, ()):
|
||||
if key == _placeholder_key(label):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _drop_placeholders(kv: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {k: v for k, v in kv.items() if not is_placeholder_value(k, v)}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 公共入口:upsert_basic_profile
|
||||
# ================================================================
|
||||
@@ -287,6 +369,8 @@ async def _upsert_basic_profile_unlocked(
|
||||
# L0 Basic Info 从输入、唯一性到落盘都只属于 user_id。
|
||||
|
||||
new_kv = _sanitize_kv(kv or {}, allowed_fields=allowed_fields)
|
||||
# 单调性守卫:占位符永远不覆盖已有值,过滤后为空就不写新版本、不产生新链头。
|
||||
new_kv = _drop_placeholders(new_kv)
|
||||
if not new_kv:
|
||||
return BasicProfileUpsertResult(
|
||||
success=True, changed=False, reason="no valid fields after sanitize"
|
||||
@@ -513,5 +597,8 @@ def _assemble_full_kv_from_nodes(
|
||||
for k, v in kv.items():
|
||||
if allowed_set is not None and k not in allowed_set:
|
||||
continue
|
||||
# 历史上已落盘的占位符版本(如 name=用户)不覆盖更早的真实值。
|
||||
if is_placeholder_value(k, v):
|
||||
continue
|
||||
full_kv[k] = v
|
||||
return full_kv
|
||||
+1926
-200
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ HY Memory - Coding Memory 模块
|
||||
|
||||
from .types import (
|
||||
CodingMemory,
|
||||
CodingWriteError,
|
||||
CodingMemoryDraft,
|
||||
ReconcileOp,
|
||||
WorkingFact,
|
||||
@@ -26,6 +27,7 @@ from .types import (
|
||||
|
||||
__all__ = [
|
||||
"CodingMemory",
|
||||
"CodingWriteError",
|
||||
"CodingMemoryDraft",
|
||||
"ReconcileOp",
|
||||
"WorkingFact",
|
||||
|
||||
@@ -647,7 +647,9 @@ class HyCodeMem:
|
||||
top-k,但它们的 diff 对当前工作区不可能有用,因此挂链前必须做同仓过滤。
|
||||
"""
|
||||
facts = facts or []
|
||||
# 开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
|
||||
# 高级消融开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
|
||||
# 公共 opt-in 见 CodingConfig.chain_block_enabled /
|
||||
# MEMORY_CODING_CHAIN_BLOCK_ENABLED(默认关;客户端在调用 decorate 前拦截)。
|
||||
if os.environ.get("HCM_BLOCK_OFF") == "1":
|
||||
return "", []
|
||||
picked, used = [], []
|
||||
|
||||
+101
-704
@@ -21,14 +21,19 @@ from typing import List, Optional, Dict, Any, Tuple
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
from ..pipelines.base import ChatMessage
|
||||
from ..utils.lang_detect import is_chinese
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from .preproc import (
|
||||
extract_files,
|
||||
keep_last_k_user_turns,
|
||||
prepare_extractor_messages,
|
||||
)
|
||||
from .judge import _store_coding_log
|
||||
from .types import CodingMemoryDraft, WorkingFactDraft
|
||||
from .types import CodingMemoryDraft, CodingWriteError, WorkingFactDraft
|
||||
|
||||
|
||||
|
||||
@@ -65,332 +70,7 @@ _VERBATIM_STATEMENT_CHARS = 600
|
||||
# Prompt
|
||||
# ================================================================
|
||||
|
||||
EXTRACT_PROMPT = """\
|
||||
You extract durable WORKING memories from a task-execution session segment.
|
||||
A "working" session is any hands-on task where the user operates a computer /
|
||||
system through tools — this covers BOTH software/code work (writing, debugging,
|
||||
refactoring, building, deploying, API/library usage) AND system & operations
|
||||
work (running shell/PowerShell commands, disk/storage analysis or cleanup, file
|
||||
& folder operations, installing/configuring software, environment/device setup,
|
||||
hardware/disk health inspection, backup, scheduling/automation, data processing).
|
||||
|
||||
You produce TWO layers from the SAME segment:
|
||||
|
||||
EXPERIENCES (procedural, "how to do it") — reusable method, extracted only
|
||||
when it clears the VALUE BAR below. A (task, search_keys, solution,
|
||||
boundary_envs) bundle.
|
||||
|
||||
FACT (episodic, "what happened this time") — EXACTLY ONE record of THIS
|
||||
session: what the user wanted, how it was gone about, how it came out. No
|
||||
value bar, but there is never more than one.
|
||||
|
||||
The two layers answer different questions and are stored separately. A method
|
||||
that generalizes goes in EXPERIENCES; the story of this particular session —
|
||||
only true for this system / this project / right now — goes in the FACT.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR (EXPERIENCES ONLY) — DO NOT cross this lightly.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
A memory MUST satisfy ALL of:
|
||||
(a) Solves a problem likely to recur for the same user
|
||||
(b) Solution is non-trivial: combines multiple pieces of info / steps, OR
|
||||
carries a decision rationale, OR captures a hard-won workaround, OR is a
|
||||
reusable procedure/recipe for an operational task (e.g. how to analyze &
|
||||
clean up disk usage, how to batch-organize files, how to inspect hardware
|
||||
health, how to set up a scheduled job)
|
||||
(c) A future agent / user would prefer looking it up over re-discovering it
|
||||
|
||||
IMPORTANT — analysis / diagnostic / investigation tasks COUNT:
|
||||
A task does NOT have to make a change or "fix" something to be worth remembering.
|
||||
Read-only / analytical / troubleshooting work is valuable when its PROCEDURE or
|
||||
CONCLUSION is reusable. Explicitly extract:
|
||||
- Analysis / diagnostic recipes: the reusable procedure + what it revealed
|
||||
(e.g. "how to analyze which folders eat C-drive space" — the scan commands,
|
||||
the gotchas hit, and the finding). "It only produced a report, no change"
|
||||
is NOT a reason to skip — the reusable METHOD is the memory.
|
||||
- Investigation outcomes, INCLUDING negative / not-found results
|
||||
(e.g. "searched folder X across C: and D: with rg / fs_search / SQL index —
|
||||
not found anywhere"). A recorded dead-end saves the next agent from repeating
|
||||
the same fruitless search, so it clears the bar.
|
||||
Do NOT reject a memory merely because "the assistant could regenerate the
|
||||
commands" or "nothing was actually modified". If the procedure or the conclusion
|
||||
would save future effort, keep it.
|
||||
|
||||
DO NOT extract:
|
||||
- Truly trivial single-step ops with no reusable procedure ("rename one file",
|
||||
"git status", "run ls once")
|
||||
- One-off issues already resolved with no transferable lesson
|
||||
- Information that is already in the repo/system (don't duplicate package.json)
|
||||
- Per-PR / per-session throwaway tasks unless they encode a reusable pattern
|
||||
- The act of ASKING THE USER for missing information. Requesting a parameter,
|
||||
listing what is unknown, or eliciting required fields is conversational
|
||||
behaviour, not a method: every agent already knows how to ask a question, so
|
||||
there is nothing to look up later. Never emit an experience whose task or
|
||||
search_keys are shaped like "how to ask for X", "gather the missing Y",
|
||||
"eliciting required fields", "what to clarify before starting". Note this
|
||||
stays out of EXPERIENCES only — that the session ended waiting on the user
|
||||
is real state and belongs in the FACT.
|
||||
|
||||
If the segment made no substantive progress because it stalled waiting on the
|
||||
user — the assistant only asked for parameters, or was blocked before doing any
|
||||
real work — emit "experiences": [] and write the FACT alone. Nothing was
|
||||
learned yet, so there is no method to store. This matters more than it looks:
|
||||
an experience distilled from a stall reads as a legitimate skill on retrieval,
|
||||
and a later segment of the same task will match it and ask the user again
|
||||
instead of proceeding, so the stall reproduces itself for the rest of the
|
||||
session. Judge substance by what the tools actually did, not by whether the
|
||||
turn produced fluent text.
|
||||
|
||||
Heuristic: if the user (or a future agent doing the same kind of task) would
|
||||
want this written down instead of figuring it out again, keep it. A multi-step
|
||||
operational procedure (even a system/ops or read-only analysis/search one, not
|
||||
just code) usually qualifies. Only drop it if it is a single command any user
|
||||
would find via `--help` in 30 seconds AND carries no decision/gotcha worth
|
||||
remembering.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE FIELDS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — Query-shaped name; phrase as how a user would later ASK.
|
||||
"publish a release to the xx platform" not
|
||||
"publish_release_internal"
|
||||
Self-contained (no "this", "the file", "we").
|
||||
|
||||
search_keys — 0 to 5 alternative phrasings capturing important aspects of
|
||||
solution that `task` alone wouldn't match. Phrase as questions
|
||||
or topics future users would ASK ABOUT. Distinct from task.
|
||||
Omit if task already covers everything.
|
||||
|
||||
solution — Complete, self-contained content the user actually needs:
|
||||
commands, credential locations (NOT credential VALUES),
|
||||
file paths, ordered steps, gotchas, decision rationale.
|
||||
Aggregate multi-turn end-to-end work into ONE solution if it
|
||||
forms a coherent task; split into multiple memories only when
|
||||
the user did unrelated tasks.
|
||||
|
||||
boundary_envs — Concrete pinning that solution depends on, multi-line "key: value":
|
||||
runtime / SDK / library + version
|
||||
API endpoint or method signature
|
||||
external service / middleware version
|
||||
env var NAME (not value)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
If solution is environment-agnostic, leave as "" (empty).
|
||||
Purpose: when the underlying SDK/middleware upgrades, future
|
||||
consumers can inspect this to decide if solution still applies.
|
||||
|
||||
confidence — 0.0 to 1.0
|
||||
1.0 user explicitly stated this as a rule / instruction
|
||||
0.7 inferred with good evidence from tool actions
|
||||
0.5 weakly inferred; one observation; uncertain
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT FIELD — EXACTLY ONE per segment, no value bar
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
You write ONE fact for the whole segment — a recoverable state record for this
|
||||
session, not a polished retrospective and never a list of separate findings. A
|
||||
future agent must be able to resume from it without mistaking a suggestion,
|
||||
inference, or partial search for something verified.
|
||||
|
||||
subject — WHO or WHAT this session is about: the single entity the work was
|
||||
performed on or for. One short noun phrase, no sentence, no verb.
|
||||
Pick the most specific entity that the session is *about*, not the
|
||||
tool it used:
|
||||
a person's name when the session serves one named person
|
||||
("Dylan", "Matthew")
|
||||
the file / module / symbol when the work targets code
|
||||
("auth/middleware.ts", "parse_plan_text")
|
||||
the service / host / device / drive for ops work
|
||||
("D:\\WXWork", "redis-cache-prod", "ci.yml")
|
||||
This is a HARD KEY, not prose: two sessions about different people
|
||||
or different files MUST get different subjects, and two sessions
|
||||
about the same one MUST get the same string, spelled identically.
|
||||
Prefer the exact identifier as it appears in the trace over a
|
||||
description of it. If the segment genuinely has no single subject,
|
||||
output "".
|
||||
|
||||
Why it matters: subject is the gate that decides whether a later
|
||||
session is allowed to overwrite this one as "the same task,
|
||||
continued". Get it wrong and one person's state silently replaces
|
||||
another's.
|
||||
|
||||
statement — A compact record of THIS session. Two parts, and the second is the
|
||||
one that gets lost.
|
||||
Preserve task-scoped givens and concrete constraints instead of
|
||||
replacing them with a generic evaluation of the outcome. State
|
||||
each rule once: repeated instruction text competes with the data
|
||||
the memory must retain.
|
||||
|
||||
PART 1 — what happened, about 300 latin characters (~150 CJK).
|
||||
Start with the specific goal and object ("reclaim disk space taken
|
||||
by D:\\WXWork", "locate the new_file_search folder"), not a generic
|
||||
"fix a bug" / "find a file". Then roughly what was inspected, tried,
|
||||
or changed. Then how it ended, with the semantics of exactly one of:
|
||||
completed, partially completed, failed, blocked, or
|
||||
still awaiting verification. Include negative results and dead ends
|
||||
without overstating them.
|
||||
|
||||
PART 2 — the TASK-SCOPED GIVENS: everything the WHOLE task runs on,
|
||||
even when this segment did not establish it and even when it feels
|
||||
too elementary to state. Bindings (what each name/symbol/label
|
||||
stands for), fixed values the task supplies rather than derives,
|
||||
conventions adopted (indexing base, units, timezone, defaults), and
|
||||
this segment's own result WITH its value ("x = 7", not "the value
|
||||
was computed").
|
||||
|
||||
Write PART 2 as data, one item per line, values as given:
|
||||
retry budget = 5; timeout 30s; index starts at 0
|
||||
step 3 input: n=64, tolerance 1e-6
|
||||
slot B: type=private, cost 688.7-721.3 per unit
|
||||
A line should be almost entirely values. Keep the trace's notation —
|
||||
symbols, units, numbers, spellings — and drop its sentences: strip
|
||||
the phrases that announce a given rather than being one ("I would
|
||||
prefer", "it should be somewhere between … and …"). They repeat once
|
||||
per item, so a 15-item list pays for them 15 times.
|
||||
Carry them on EVERY fact; never write "as above" or "same as the
|
||||
previous session" — retrieval returns a handful of facts by
|
||||
similarity and the referent may not be among them.
|
||||
|
||||
Aim under 1200 characters total. If PART 2 does not fit, fix the
|
||||
notation, never the coverage: statement is the retrieval vector AND
|
||||
the only field a later segment can read (details is neither embedded
|
||||
nor injected), so anything cut from it is gone. Compress the wording
|
||||
around a given; never the given.
|
||||
|
||||
The test: if the next segment cannot see the original material, can
|
||||
it continue from this record alone? Only a yes counts. A missing
|
||||
binding does not fail loudly — the name still looks meaningful, so
|
||||
it gets read with its general-usage meaning and the answer that
|
||||
follows is fluent and about a different question.
|
||||
|
||||
LANGUAGE — same language as the session trace: statement is the
|
||||
retrieval vector and queries arrive in the user's working language,
|
||||
so another language pays a cross-lingual penalty on every lookup.
|
||||
|
||||
EVIDENCE FIDELITY — record only actions actually executed and
|
||||
evidence explicitly present in the trace. Preserve material
|
||||
commands, flags, arguments, errors, and numbers verbatim.
|
||||
NEVER complete a command with customary flags, and never turn
|
||||
advice or plans into actions:
|
||||
"ran Get-ChildItem -Force" → "ran Get-ChildItem"
|
||||
(the trace did not contain `-Force`; do not invent it)
|
||||
Scope every negative finding to what was actually searched, and
|
||||
enumerate EVERY explicit coverage limit that could invalidate it,
|
||||
especially permission failures and depth limits: keep the roots,
|
||||
filters, and depth that were in effect ("not found under C:\\ and
|
||||
D:\\ at depth 3; some directories were not covered due to Access
|
||||
denied"), never widen it to "does not exist". Do not omit one limit
|
||||
to make the record prettier.
|
||||
Never promote a partial search to global absence.
|
||||
A blocked attempt keeps its blocker.
|
||||
Preserve the distinction among attempted, observed, verified,
|
||||
inferred, and unresolved — express it naturally ("tried … but was
|
||||
blocked by Access denied", "suspected …, not yet verified"), not as
|
||||
labels. Without tool output or explicit confirmation, NEVER claim a
|
||||
fix succeeded, a cause was established, or a path does not exist.
|
||||
|
||||
Do NOT record greetings or pure chatter — that is the only case
|
||||
getting "fact": null. A segment that merely stated requirements, or
|
||||
stalled before doing work, still established something.
|
||||
|
||||
details — The session's concrete OUTPUT, copied over verbatim. `statement` is
|
||||
a summary and a summary necessarily drops the individual values; a
|
||||
later lookup almost always wants one specific value, not the gist.
|
||||
So whatever addressable result this session produced, reproduce it
|
||||
here in its original form and structure:
|
||||
a plan / schedule / itinerary → keep every line, every day, every
|
||||
slot, and every price exactly as written
|
||||
a config or code block → keep the block
|
||||
a command's material output → keep the lines that carry the
|
||||
findings (sizes, counts, versions, error text)
|
||||
a structured result (table, list of records) → keep the rows
|
||||
Preserve the original layout — line breaks, `Day 1:` / key: value
|
||||
prefixes, units, currency symbols, ids. Do not re-summarize, do not
|
||||
reorder, do not round numbers, do not translate.
|
||||
|
||||
Copy ONLY what is present in the trace. Never fill a gap: if a slot
|
||||
was left unfilled, leave it unfilled or say so — an invented value
|
||||
is worse than a missing one, because it will be retrieved and used.
|
||||
Cap around 4000 characters; if the output is longer, keep the parts
|
||||
carrying concrete values and drop decorative prose, noting what was
|
||||
dropped.
|
||||
|
||||
"" when the session produced no such artifact (pure investigation
|
||||
with no findings yet, or a stall waiting on the user).
|
||||
|
||||
This field is NOT part of the retrieval vector — `statement` and
|
||||
`subject` do the finding, `details` supplies the value once found.
|
||||
So do not write retrieval bait here, and do not duplicate the
|
||||
summary; write the raw thing.
|
||||
|
||||
files — 0..N absolute or repo-relative FILE OR DIRECTORY paths actually
|
||||
operated on or investigated during this session. Keep investigated
|
||||
roots such as `C:\\` and `D:\\`. Only include paths that were
|
||||
actually accessed/searched, not paths merely suggested or planned;
|
||||
[] if none apply.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT (given by SDK)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
When no workspace_id is present, the solution cannot rely on unstated
|
||||
project-local context — spell out any repo / path / service it needs inside
|
||||
`solution` or `boundary_envs` so the memory stays usable on its own.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"subject": "...",
|
||||
"statement": "...",
|
||||
"files": ["..."],
|
||||
"details": "..."
|
||||
}}
|
||||
}}
|
||||
|
||||
DEFAULT TO EXTRACTING. When the segment did real work, you MUST output at least
|
||||
one experience — even if the task looks simple or was a one-shot success, still
|
||||
capture the reusable essence of what was done (the method / command / how-to).
|
||||
When the session is thin, the single most valuable experience is enough.
|
||||
|
||||
The ONE exception is the stall case above: if the segment did no real work
|
||||
because it was blocked asking the user for missing information, output
|
||||
"experiences": [] and write only the fact. Do not manufacture an experience to
|
||||
satisfy this instruction — a fabricated one ("how to ask for the departure
|
||||
city") is worse than none, because it is later retrieved and followed. Empty is
|
||||
allowed ONLY for that reason, never as a shortcut when work was actually done.
|
||||
|
||||
"fact" is a SINGLE object, never a list — one record per session.
|
||||
|
||||
ALWAYS write the fact. The bar for the fact is not "was this valuable" but "did
|
||||
anything at all happen", and a segment reaching you has already cleared that.
|
||||
This includes segments that look like setup rather than work: a single
|
||||
user message stating requirements, parameters, or a starting state, with no
|
||||
tools run, is exactly the material a later session cannot reconstruct on its own
|
||||
— 把需求、参数、起始状态连同情形一起写进 `statement`(紧凑记法,不要照抄对话原句),
|
||||
不要推给 `details`(`details` 不进检索向量,读取侧也不再注入它)。Use
|
||||
"fact": null ONLY for greetings and pure chatter with no content whatsoever.
|
||||
When in doubt, write the fact: an unneeded record costs one row, while a dropped
|
||||
one costs every later session that needed it and has no way to know it existed.
|
||||
"""
|
||||
from ..prompts.en import CODING_EXTRACT_PROMPT as EXTRACT_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -421,350 +101,7 @@ one costs every later session that needed it and has no way to know it existed.
|
||||
#
|
||||
# 修改 prompt 时应使用代表性 coding traces 做回归,确认坐标、决策与验证状态仍保留。
|
||||
|
||||
EXTRACT_PROMPT_CODING = """\
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ HARD CONSTRAINT — read this before anything else, it overrides any ║
|
||||
║ phrasing later in this prompt that sounds like it wants a narrative. ║
|
||||
║ ║
|
||||
║ You are NOT writing a report of what a person did. You are writing what a ║
|
||||
║ FUTURE AGENT needs in order to act. That agent already has its own task; ║
|
||||
║ it does not care what this user wanted. It needs to know: what was decided ║
|
||||
║ here, at which code coordinates, what was tried and rejected, and whether ║
|
||||
║ it was verified. ║
|
||||
║ ║
|
||||
║ Concretely, in every `statement` and every `solution`: ║
|
||||
║ · The grammatical subject is the CODE or THE DECISION — never "the user" ║
|
||||
║ and never "the agent". Do not write "The user attempted to…", ║
|
||||
║ "The user wanted…", "The agent then checked…". ║
|
||||
║ · Never close with a bookkeeping phrase like "the session was completed", ║
|
||||
║ "the error was noted", "no solution was found yet". Close with the ║
|
||||
║ decision and its verification status. If nothing was decided, close ║
|
||||
║ with what was RULED OUT and the next unexplored lead — that is the ║
|
||||
║ reusable part. ║
|
||||
║ · Keep the concrete symptom (error string, failing command, wrong ║
|
||||
║ output) — that is what a future search will match on. ║
|
||||
║ ║
|
||||
║ bad: "The user attempted to write a QTable to RST with header_rows and ║
|
||||
║ hit a TypeError. The session was completed with the error noted." ║
|
||||
║ good: "ascii.rst's RST writer rejects header_rows: `tbl.write(..., \ ║
|
||||
║ format='ascii.rst', header_rows=['name','unit'])` raises TypeError ║
|
||||
║ because RST.__init__ does not forward it, unlike ascii.fixed_width ║
|
||||
║ which accepts it. No fix written — the writer needs the same ║
|
||||
║ header_rows plumbing as fixed_width." ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
You extract durable WORKING memories from a task-execution session segment.
|
||||
A "working" session is any hands-on task where the user operates a computer /
|
||||
system through tools — this covers BOTH software/code work (writing, debugging,
|
||||
refactoring, building, deploying, API/library usage) AND system & operations
|
||||
work (running shell/PowerShell commands, disk/storage analysis or cleanup, file
|
||||
& folder operations, installing/configuring software, environment/device setup,
|
||||
hardware/disk health inspection, backup, scheduling/automation, data processing).
|
||||
|
||||
You produce TWO layers from the SAME segment:
|
||||
|
||||
EXPERIENCES (procedural, "how to do it") — reusable method, extracted only
|
||||
when it clears the VALUE BAR below. A (task, search_keys, solution,
|
||||
boundary_envs) bundle.
|
||||
|
||||
FACT (episodic, "what was established this time") — EXACTLY ONE record of THIS
|
||||
session. It is NOT a narrative of what the user did. It records, in this order:
|
||||
the task and its concrete symptom (this is what makes it findable later), the
|
||||
code coordinates, WHAT WAS SETTLED ON and what was rejected, and how far it was
|
||||
verified. No value bar, but there is never more than one.
|
||||
|
||||
The two layers answer different questions and are stored separately. A method
|
||||
that generalizes goes in EXPERIENCES; the story of this particular session —
|
||||
only true for this system / this project / right now — goes in the FACT.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR (EXPERIENCES ONLY) — DO NOT cross this lightly.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
A memory MUST satisfy ALL of:
|
||||
(a) Solves a problem likely to recur for the same user
|
||||
(b) Solution is non-trivial: combines multiple pieces of info / steps, OR
|
||||
carries a decision rationale, OR captures a hard-won workaround, OR is a
|
||||
reusable procedure/recipe for an operational task (e.g. how to analyze &
|
||||
clean up disk usage, how to batch-organize files, how to inspect hardware
|
||||
health, how to set up a scheduled job)
|
||||
(c) A future agent / user would prefer looking it up over re-discovering it
|
||||
|
||||
IMPORTANT — analysis / diagnostic / investigation tasks COUNT:
|
||||
A task does NOT have to make a change or "fix" something to be worth remembering.
|
||||
Read-only / analytical / troubleshooting work is valuable when its PROCEDURE or
|
||||
CONCLUSION is reusable. Explicitly extract:
|
||||
- Analysis / diagnostic recipes: the reusable procedure + what it revealed
|
||||
(e.g. "how to analyze which folders eat C-drive space" — the scan commands,
|
||||
the gotchas hit, and the finding). "It only produced a report, no change"
|
||||
is NOT a reason to skip — the reusable METHOD is the memory.
|
||||
- Investigation outcomes, INCLUDING negative / not-found results
|
||||
(e.g. "searched folder X across C: and D: with rg / fs_search / SQL index —
|
||||
not found anywhere"). A recorded dead-end saves the next agent from repeating
|
||||
the same fruitless search, so it clears the bar.
|
||||
Do NOT reject a memory merely because "the assistant could regenerate the
|
||||
commands" or "nothing was actually modified". If the procedure or the conclusion
|
||||
would save future effort, keep it.
|
||||
|
||||
DO NOT extract:
|
||||
- Truly trivial single-step ops with no reusable procedure ("rename one file",
|
||||
"git status", "run ls once")
|
||||
- One-off issues already resolved with no transferable lesson
|
||||
- Information that is already in the repo/system (don't duplicate package.json)
|
||||
- Per-PR / per-session throwaway tasks unless they encode a reusable pattern
|
||||
|
||||
Heuristic: if the user (or a future agent doing the same kind of task) would
|
||||
want this written down instead of figuring it out again, keep it. A multi-step
|
||||
operational procedure (even a system/ops or read-only analysis/search one, not
|
||||
just code) usually qualifies. Only drop it if it is a single command any user
|
||||
would find via `--help` in 30 seconds AND carries no decision/gotcha worth
|
||||
remembering.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CODE WORK — what a future agent cannot re-derive, so it MUST be captured
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
When the segment is software work on a repository, the reusable method is only
|
||||
half of it. The other half is WHERE the work lives and WHICH VERSION won. Those
|
||||
cannot be recovered by reading the code later — the code shows the current state,
|
||||
not which branch it sits on, nor what was already rejected.
|
||||
|
||||
This section does NOT ask for a longer record. It asks you to SPEND THE SAME
|
||||
BUDGET on higher-value tokens: drop restated goals, narration of what the
|
||||
assistant "then decided to", and pasted code you are only echoing; spend those
|
||||
characters on the four items below instead. A record that omits the branch but
|
||||
quotes 30 lines of diff is a bad trade.
|
||||
|
||||
1. BRANCH / WORKING LOCATION — verbatim, in the fact `statement`. If the segment
|
||||
mentions or checks out `feat/x` / `fix/y`, that token MUST appear. An agent
|
||||
that resumes on the wrong branch produces changes that are silently thrown
|
||||
away, so this one token outranks every other detail.
|
||||
|
||||
2. WHICH VERSION WON — when the user changed their mind, name the winner and mark
|
||||
the loser in one clause: "settled on A; the interim B was dropped". Not both in
|
||||
full — one clause. Keeping only the latest is wrong: an agent that meets the
|
||||
rejected code must recognize it, or it will "fix" things back to the discarded
|
||||
variant. Phrases like `scratch that / go back to / actually, let's / no, not that`
|
||||
mark this: what follows is of record, what precedes is superseded.
|
||||
|
||||
3. EXACT COORDINATES, NOT PASTED CODE — repo-relative path
|
||||
(`qutebrowser/utils/debug.py`, never bare `debug.py`) plus the function /
|
||||
class touched. If a signature changed, give the new signature. If a constant,
|
||||
regex or literal was decided, quote THAT verbatim — but quote only it, not the
|
||||
surrounding block. One accurate line beats a paragraph of approximation.
|
||||
|
||||
4. VERIFIED vs MERELY WRITTEN — four words is enough ("tests run and passing" /
|
||||
"confirmed by git diff only, tests not run"). "the edit is in" and "the edit is in
|
||||
and tested" lead a future agent to do very different things next.
|
||||
|
||||
Anti-pattern — summarizing the goal and dropping the coordinates:
|
||||
bad: "refactored the colour parsing logic to be cleaner."
|
||||
good: "on feat/q4, in qutebrowser/config/configtypes.py QtColor._parse_value:
|
||||
settled on `mult = 360.0 if kind == 'h' else 255.0`; the interim version that
|
||||
used 255.0 for everything was dropped. Tests not run."
|
||||
(the good one is SHORTER than most bad ones — it just spends every character
|
||||
on something that cannot be re-derived)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE FIELDS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — Query-shaped name; phrase as how a user would later ASK.
|
||||
"publish a release to the xx platform", not "publish_release_internal"
|
||||
Self-contained (no "this", "the file", "we").
|
||||
|
||||
search_keys — 0 to 5 alternative phrasings capturing important aspects of
|
||||
solution that `task` alone wouldn't match. Phrase as questions
|
||||
or topics future users would ASK ABOUT. Distinct from task.
|
||||
Omit if task already covers everything.
|
||||
|
||||
solution — Complete, self-contained content the user actually needs:
|
||||
commands, credential locations (NOT credential VALUES),
|
||||
file paths, ordered steps, gotchas, decision rationale.
|
||||
Aggregate multi-turn end-to-end work into ONE solution if it
|
||||
forms a coherent task; split into multiple memories only when
|
||||
the user did unrelated tasks.
|
||||
|
||||
boundary_envs — Concrete pinning that solution depends on, multi-line "key: value":
|
||||
runtime / SDK / library + version
|
||||
API endpoint or method signature
|
||||
external service / middleware version
|
||||
env var NAME (not value)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
If solution is environment-agnostic, leave as "" (empty).
|
||||
Purpose: when the underlying SDK/middleware upgrades, future
|
||||
consumers can inspect this to decide if solution still applies.
|
||||
|
||||
confidence — 0.0 to 1.0
|
||||
1.0 user explicitly stated this as a rule / instruction
|
||||
0.7 inferred with good evidence from tool actions
|
||||
0.5 weakly inferred; one observation; uncertain
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT FIELD — EXACTLY ONE per segment, no value bar
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
You write ONE fact for the whole segment — a recoverable state record for this
|
||||
session, not a polished retrospective and never a list of separate findings. A
|
||||
future agent must be able to resume from it without mistaking a suggestion,
|
||||
inference, or partial search for something verified.
|
||||
|
||||
statement — A compact summary of THIS session. It has two jobs at once, and it
|
||||
must do BOTH: it is the retrieval vector (a future agent finds it by
|
||||
searching with a fresh issue report, phrased the way a user phrases a
|
||||
problem), and it is the payload that agent then acts on. So:
|
||||
|
||||
OPEN with the task and the symptom, in the words a user would use.
|
||||
This is what makes it findable — keep the surface form close to how
|
||||
the problem would be reported. Name the specific goal and object
|
||||
("reclaim disk space under D:\\WXWork", "adjust the hue multiplier
|
||||
in QtColor._parse_value on feat/q4", "locate the new_file_search
|
||||
folder"), never a generic "fix a bug" / "look for a file". Include
|
||||
the concrete symptom: the error string, the failing command, the
|
||||
wrong output.
|
||||
|
||||
THEN give the coordinates and the decision.
|
||||
repo-relative path, symbol, and branch for code work. State what was
|
||||
SETTLED ON, and in the same clause mark what was dropped
|
||||
("settled on X; the interim Y was dropped"). This is the part that
|
||||
cannot be re-derived by reading the code — the code only shows the
|
||||
winner, never that Y was tried and rejected. An agent that meets the
|
||||
rejected variant later must be able to recognize it, or it will
|
||||
"fix" the code back to the discarded version.
|
||||
|
||||
CLOSE with the evidence status.
|
||||
Exactly one of: completed, partially completed, failed, blocked, or
|
||||
still awaiting verification — plus how it was checked ("tests run and
|
||||
passing" / "confirmed by git diff only, tests not run"). Include
|
||||
negative results and dead ends without overstating them.
|
||||
|
||||
⚠️ Never stop at the narrative. "The user tried X and hit error Y; no
|
||||
solution found yet" is a wasted record: it retrieves fine but gives a
|
||||
future agent nothing to act on. If the session genuinely reached no
|
||||
decision, say what was ruled out and what the next unexplored lead is —
|
||||
that is the reusable part.
|
||||
|
||||
The subject of the sentences should be the code and the decision, not
|
||||
"the user". 2 to 4 sentences, flowing prose, not labelled sections.
|
||||
Hard ceiling ~400 latin characters (~200 CJK characters).
|
||||
|
||||
EVIDENCE FIDELITY — mandatory:
|
||||
- Record only actions actually executed and evidence explicitly
|
||||
present in the trace. Preserve material commands, flags,
|
||||
arguments, errors, and numbers verbatim. NEVER complete a
|
||||
command with customary flags or turn advice/plans into actions.
|
||||
- Preserve the distinction among attempted, observed, verified,
|
||||
inferred, and unresolved. Do not add those labels to the prose;
|
||||
express their meaning naturally, e.g. "tried … but was blocked by
|
||||
Access denied" or "suspect … , not yet verified".
|
||||
- Without tool output or explicit confirmation, NEVER claim a fix
|
||||
succeeded, a cause was established, a file/folder does not
|
||||
exist, or tests passed.
|
||||
- Scope every negative finding to what was actually searched and
|
||||
enumerate EVERY explicit coverage limit that could invalidate
|
||||
it, especially permission failures and maximum search depth:
|
||||
"not found within the searched parts of C:\\ and D:\\; some
|
||||
directories were skipped on Access denied, and the search was also
|
||||
depth-limited". Do not omit one limit to make the
|
||||
summary prettier. Never promote a partial search to global
|
||||
absence.
|
||||
- State causality as certain only when the trace directly verifies
|
||||
it. Otherwise retain the hedge ("suspected" / "inferred" / "not yet
|
||||
verified"); do not rewrite an
|
||||
incorrect test assumption as the system's root cause.
|
||||
|
||||
CRITICAL — this text IS the retrieval vector, and it is the only
|
||||
thing a future query gets matched against. A generic summary is
|
||||
worthless, because EVERY session looks like a generic summary: it
|
||||
would match every query and answer none of them. So you MUST name
|
||||
the concrete anchors that make THIS session findable — real file
|
||||
paths, function / symbol names, branch names, commands, error
|
||||
strings, versions, config keys and values, host / service names.
|
||||
If someone later searches for any one of those specifics, this
|
||||
summary has to be the thing that comes back.
|
||||
good: "fixing a failing `npm run build` in CI. Read
|
||||
.github/workflows/ci.yml and package.json; traced it to webpack
|
||||
5.74 hitting ERR_OSSL_EVP_UNSUPPORTED under node 18. Bumping the
|
||||
workflow's node to 20 made the build pass; node 18 still
|
||||
reproduces locally."
|
||||
good: "on branch feat/q0, in qutebrowser/utils/debug.py signal_name:
|
||||
PyQt<5.11 only exposes the name via repr(sig), and repr comes in
|
||||
both a bracketed and a parenthesised form. Settled on two patterns
|
||||
tried in order with fullmatch, breaking on the first hit; the
|
||||
single-regex version was dropped. Confirmed by git diff, tests not
|
||||
run."
|
||||
bad: "user wanted a build bug fixed, the agent looked at some config
|
||||
files, changed some code, and fixed it."
|
||||
(no path, no command, no error string, no version — nothing
|
||||
to retrieve on; every session reads exactly like this)
|
||||
bad: "fixed the signal-name parsing compatibility issue on a feature
|
||||
branch."
|
||||
(which branch? which file? which function? which of the two
|
||||
competing versions won? nothing here survives being
|
||||
retrieved a week later)
|
||||
Fidelity examples (bad → good):
|
||||
"ran Get-ChildItem -Force" → "ran Get-ChildItem"
|
||||
(the trace did not contain `-Force`; do not invent it)
|
||||
"confirmed new_file_search does not exist" →
|
||||
"new_file_search was not found within the searched parts of C:\\ and
|
||||
D:\\; some directories were skipped on Access denied or by the depth
|
||||
limit, so the investigation is only partly done"
|
||||
"identical payloads cause a spurious lock error" →
|
||||
"the verification script assumed the last writer is always a fixed
|
||||
rank; that assumption does not hold, so whether there is a lock bug
|
||||
is still unverified"
|
||||
|
||||
Do NOT record greetings or pure chatter. If the segment established
|
||||
nothing at all, output "fact": null.
|
||||
|
||||
files — 0..N absolute or repo-relative FILE OR DIRECTORY paths actually
|
||||
operated on or investigated during this session. Keep investigated
|
||||
roots such as `C:\\` and `D:\\`. Only include paths that were
|
||||
actually accessed/searched, not paths merely suggested or planned;
|
||||
[] if none apply.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT (given by SDK)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
When no workspace_id is present, the solution cannot rely on unstated
|
||||
project-local context — spell out any repo / path / service it needs inside
|
||||
`solution` or `boundary_envs` so the memory stays usable on its own.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"statement": "...",
|
||||
"files": ["..."]
|
||||
}}
|
||||
}}
|
||||
|
||||
MANDATORY: You MUST output at least ONE experience. Do NOT return an empty list.
|
||||
Even if the task looks simple or is a one-shot success, still capture the
|
||||
reusable essence of what was done (the method / command / how-to). Only the
|
||||
single most valuable experience is required when the session is thin — but never
|
||||
output an empty "experiences" array.
|
||||
|
||||
"fact" is a SINGLE object, never a list — one record per session. Use
|
||||
"fact": null only when the segment is pure chatter that established nothing.
|
||||
"""
|
||||
from ..prompts.en import CODING_EXTRACT_PROMPT_CODING as EXTRACT_PROMPT_CODING # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -839,15 +176,22 @@ class CodingMemoryExtractor:
|
||||
if str(message.content or "").strip()
|
||||
)
|
||||
|
||||
def _select_prompt_template(self, messages: List[ChatMessage]) -> Tuple[str, str]:
|
||||
"""Return (template, language) for this write's new messages."""
|
||||
if not is_chinese(self._language_text(messages)):
|
||||
return self._prompt_tmpl, "en"
|
||||
def _select_prompt_template(
|
||||
self, messages: List[ChatMessage], language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return (template, language code) for this write's new messages.
|
||||
|
||||
from .prompts_zh import EXTRACT_PROMPT_CODING_ZH, EXTRACT_PROMPT_ZH
|
||||
|
||||
prompt = EXTRACT_PROMPT_CODING_ZH if self.scene == "coding" else EXTRACT_PROMPT_ZH
|
||||
return prompt, "zh"
|
||||
Explicit ``language`` wins; otherwise zh/en auto-detection over the new
|
||||
user text (unchanged behaviour).
|
||||
"""
|
||||
code = resolve_language(language, self._language_text(messages))
|
||||
if code == "en":
|
||||
return self._prompt_tmpl, code
|
||||
name = (
|
||||
"CODING_EXTRACT_PROMPT_CODING" if self.scene == "coding"
|
||||
else "CODING_EXTRACT_PROMPT"
|
||||
)
|
||||
return get_prompt(name, code), code
|
||||
|
||||
async def extract(
|
||||
self,
|
||||
@@ -861,16 +205,22 @@ class CodingMemoryExtractor:
|
||||
branch: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
request_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[CodingMemoryDraft], List[WorkingFactDraft]]:
|
||||
"""
|
||||
Args:
|
||||
messages: 本次 write 的完整新消息;只做单条限长,不裁 turn
|
||||
history_messages: 之前 write 的历史消息;仅取最近 5 个 user-turn 作消解上下文
|
||||
user_id / agent_id / workspace_id / branch / session_id: SDK 注入
|
||||
language: 显式 prompt 语言码;None 时按新 user 文本做 zh/en 自动检测
|
||||
|
||||
Returns:
|
||||
(经验草稿, 事实草稿)。两层都可能为空。只有显式启用
|
||||
verbatim_fallback_enabled 时,模型没产出 fact 才调用 _verbatim_fact。
|
||||
|
||||
Raises:
|
||||
CodingWriteError: 未开启原文兜底时,LLM 调用失败(LLM_ERROR)、返回空
|
||||
content(EMPTY_RESPONSE)或回复解析不出 JSON 对象(JSON_PARSE_FAILED)。
|
||||
"""
|
||||
if not messages:
|
||||
return [], []
|
||||
@@ -892,38 +242,39 @@ class CodingMemoryExtractor:
|
||||
|
||||
history_block = self._format_conversation(history_window).strip()
|
||||
new_block = self._format_conversation(new_messages).strip()
|
||||
prompt_tmpl, prompt_language = self._select_prompt_template(new_messages)
|
||||
if prompt_language == "zh":
|
||||
conversation_block = (
|
||||
"最近 5 个用户轮次(仅作上下文,不要重复抽取):\n"
|
||||
+ (history_block or "(无)")
|
||||
+ "\n\n本次新增消息(只从这次完整写入中抽取):\n"
|
||||
+ (new_block or "(无)")
|
||||
)
|
||||
else:
|
||||
conversation_block = (
|
||||
"LAST 5 USER TURNS (CONTEXT ONLY; do not extract them again):\n"
|
||||
+ (history_block or "(none)")
|
||||
+ "\n\nNEW MESSAGES (extract only from this complete write):\n"
|
||||
+ (new_block or "(none)")
|
||||
)
|
||||
explicit_language = normalize_language(language)
|
||||
prompt_tmpl, prompt_language = self._select_prompt_template(
|
||||
new_messages, explicit_language,
|
||||
)
|
||||
empty_block = get_prompt("CODING_EMPTY_BLOCK", prompt_language)
|
||||
conversation_block = get_prompt(
|
||||
"CODING_CONVERSATION_BLOCK", prompt_language,
|
||||
).format(
|
||||
history=history_block or empty_block,
|
||||
new=new_block or empty_block,
|
||||
)
|
||||
|
||||
prompt = prompt_tmpl.format(
|
||||
workspace_present=str(bool(workspace_id)).lower(),
|
||||
branch_present=str(bool(branch)).lower(),
|
||||
conversation_block=conversation_block,
|
||||
)
|
||||
prompt = with_output_language(
|
||||
prompt, prompt_language, explicit=explicit_language is not None,
|
||||
)
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
resp = await self.llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=self.max_tokens,
|
||||
audit_stage="coding_extract",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-extract] LLM call failed: {e}")
|
||||
# 经验层不兜底。事实层原文兜底也默认关闭,避免 assistant/tool 轨迹被
|
||||
# 当作 statement 直接展示;仅在配置显式开启时保留旧行为。
|
||||
# 经验层不兜底。事实层原文兜底默认关闭,避免 assistant/tool 轨迹被
|
||||
# 当作 statement 直接展示;关闭时 LLM 失败抛 CodingWriteError(LLM_ERROR),
|
||||
# 整次写入报失败,而不是「成功、0 条」。仅在配置显式开启时保留旧行为。
|
||||
verbatim = (
|
||||
self._verbatim_fact(messages)
|
||||
if self.verbatim_fallback_enabled else None
|
||||
@@ -950,9 +301,33 @@ class CodingMemoryExtractor:
|
||||
),
|
||||
elapsed_ms=round((time.perf_counter() - _t0) * 1000, 2),
|
||||
)
|
||||
if not self.verbatim_fallback_enabled:
|
||||
raise CodingWriteError(
|
||||
"LLM_ERROR",
|
||||
f"coding extract LLM call failed: {type(e).__name__}: {e}",
|
||||
) from e
|
||||
return [], ([verbatim] if verbatim else [])
|
||||
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||||
|
||||
# 模型答了且没有可记的内容(如 {"experiences": [], "fact": null})是正常的空
|
||||
# 抽取;200 但 content 为空、或回复解析不出 JSON 对象,是模型失败,不能当成
|
||||
# 「成功、0 条」。原文兜底显式开启时沿用旧行为(交给下面的 verbatim 兜底)。
|
||||
failure = self._output_failure(resp.content)
|
||||
if failure is not None and not self.verbatim_fallback_enabled:
|
||||
code, message = failure
|
||||
await _store_coding_log(
|
||||
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id or "", step="CODING_EXTRACT", prompt=prompt,
|
||||
response=resp.content or "",
|
||||
parsed=json.dumps(
|
||||
{"drafts": 0, "error": code, "message": message},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
elapsed_ms=_elapsed, prompt_tokens=resp.prompt_tokens,
|
||||
completion_tokens=resp.completion_tokens, total_tokens=resp.tokens_used,
|
||||
)
|
||||
raise CodingWriteError(code, message)
|
||||
|
||||
raw_experiences, raw_facts = self._parse_output(resp.content)
|
||||
|
||||
# 经验层的 files 走规则抽取(整段轨迹碰过的文件);事实层的 files 由 LLM
|
||||
@@ -1132,12 +507,22 @@ class CodingMemoryExtractor:
|
||||
return "\n".join(rows)
|
||||
|
||||
@classmethod
|
||||
def _parse_output(
|
||||
cls, text: str
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
|
||||
def _output_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||
"""回复本身是失败时返回 (error_code, message);正常(含空抽取)返回 None。"""
|
||||
if not (text or "").strip():
|
||||
return "EMPTY_RESPONSE", "coding extract LLM returned empty response"
|
||||
if cls._load_output_object(text) is None:
|
||||
return (
|
||||
"JSON_PARSE_FAILED",
|
||||
f"coding extract output is not a JSON object ({len(text)} chars)",
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _load_output_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""宽松解析 LLM 输出,返回顶层 JSON 对象;解析不出返回 None。"""
|
||||
if not text:
|
||||
return [], []
|
||||
return None
|
||||
s = text.strip()
|
||||
# 去 markdown fence
|
||||
if s.startswith("```"):
|
||||
@@ -1163,9 +548,21 @@ class CodingMemoryExtractor:
|
||||
obj = json.loads(s[start: end + 1])
|
||||
except Exception:
|
||||
obj = None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning(f"[coding-extract] failed to parse output: {text!r}")
|
||||
@classmethod
|
||||
def _parse_output(
|
||||
cls, text: str
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
|
||||
if not text:
|
||||
return [], []
|
||||
obj = cls._load_output_object(text)
|
||||
if obj is None:
|
||||
logger.warning(
|
||||
f"[coding-extract] failed to parse output ({len(text or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-extract] unparsable output: {text!r}")
|
||||
return [], []
|
||||
|
||||
# "memories" 是双层化之前的 key,老 prompt 缓存 / 旧模型可能还这么回
|
||||
|
||||
+213
-188
@@ -18,17 +18,24 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..utils.lang_detect import is_chinese
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from .store import CodingMemoryStore
|
||||
from .types import (
|
||||
|
||||
|
||||
MAX_FACT_CHAIN_ENTRIES,
|
||||
CodingWriteError,
|
||||
FactReconcileOp,
|
||||
WorkingFact,
|
||||
WorkingFactDraft,
|
||||
@@ -206,143 +213,10 @@ def _subject_conflict(new_subject: str, old_subject: str) -> bool:
|
||||
# General subject-isolation rules belong in the prompt; lookalike examples
|
||||
# encourage pattern matching instead of teaching the rule.
|
||||
|
||||
FACT_RECONCILE_PROMPT = """\
|
||||
You maintain session-level WORKING FACTS. Each statement summarizes one whole
|
||||
trace/session: the user's task, the rough approach, and the result. It is not an
|
||||
atomic claim.
|
||||
|
||||
Every new session fact below WILL be stored; that is not your decision. Your only
|
||||
job is to detect task continuation: is the new session continuing to advance the
|
||||
SAME user task as an existing session fact? If so, SUPERSEDE the older fact so
|
||||
the links form a cross-session progress chain for that task.
|
||||
|
||||
Mark an existing fact as superseded ONLY when there is strong evidence that:
|
||||
- The subjects match: both records are about the SAME entity — the same
|
||||
person, the same file/module, the same host/service. A shared subject is
|
||||
necessary but not sufficient; AND
|
||||
- The user goal is materially the SAME, not merely in the same topic/project;
|
||||
AND
|
||||
- The new trace continues, retries, extends, verifies, or finishes the work
|
||||
recorded by the existing trace.
|
||||
|
||||
Do NOT supersede when:
|
||||
- The two records name different subjects. Two people in one group, two files
|
||||
in one module, two hosts in one cluster are PARALLEL, not successive: one
|
||||
person's state is not a newer version of another's. Where a subject is shown
|
||||
as (unspecified), fall back to judging by the statement alone.
|
||||
- The sessions merely mention the same file, symbol, component, repository,
|
||||
error, or technology but pursue different user goals.
|
||||
- One task only supplies context or a technique for another task.
|
||||
- The relationship is uncertain. Be conservative: INSERT is the safe default;
|
||||
merging unrelated sessions destroys their separate task history.
|
||||
|
||||
Treat each statement as evidence-bounded text. Do not reinterpret an inference
|
||||
inside it as an established cause or use a shared conjecture/error as proof that
|
||||
the user goals are the same. Your decision is only about task continuation; do
|
||||
not "correct", complete, or embellish either session record.
|
||||
|
||||
Examples:
|
||||
existing: "The user needed to fix CI's npm run build; traced it to
|
||||
ERR_OSSL_EVP_UNSUPPORTED under Node 18, workflow not yet changed."
|
||||
new: "The user continued fixing the same CI build; raised Node in
|
||||
.github/workflows/ci.yml to 20 and npm run build passed."
|
||||
→ SUPERSEDE
|
||||
|
||||
existing: "The user needed to fix JWT expiry handling in
|
||||
auth/middleware.ts; refresh logic was added."
|
||||
new: "The user needed performance logging added to
|
||||
auth/middleware.ts; a latency metric was introduced."
|
||||
→ keep both
|
||||
(same file/project, different user goal)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW SESSION FACTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING SESSION FACTS (per new fact, retrieved by statement similarity)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "supersedes": ["fact_xxx"], "reason": "continues the same CI fix task"}},
|
||||
{{"draft_idx": 1, "supersedes": []}}
|
||||
]
|
||||
}}
|
||||
|
||||
Only use fact ids that appear in the candidate list for that same draft_idx.
|
||||
If nothing is superseded anywhere, output {{"ops": []}}.
|
||||
"""
|
||||
from ..prompts.en import CODING_FACT_RECONCILE_PROMPT as FACT_RECONCILE_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
CHAIN_SUMMARY_PROMPT = """\
|
||||
A user task in a working-memory store has progressed across sessions. Below are
|
||||
the session summaries in that task's progress chain, oldest first, ending with
|
||||
the current session.
|
||||
|
||||
Summarize how the SAME task progressed across sessions to its current state.
|
||||
Cover every important stage in chronological order. For each stage, preserve
|
||||
where available: the actual action or change, its direct result, and any
|
||||
unresolved issue or limiting condition. End with an explicit current status.
|
||||
|
||||
Rules:
|
||||
- This is compression only. Report ONLY what the entries say; never add or
|
||||
complete a command, cause, motive, action, test result, success state, or
|
||||
global negative conclusion.
|
||||
- Preserve evidence status exactly: attempted is not completed, observed is
|
||||
not verified, inferred is not established, and unresolved stays unresolved.
|
||||
Keep uncertainty words and search/permission/coverage limits.
|
||||
- State the current ending status faithfully (completed, partially completed,
|
||||
failed, blocked, or still awaiting verification). Never upgrade it based on
|
||||
a plausible causal story or on a later entry that did not verify it.
|
||||
- When entries mention a suspected cause and a later result, connect them
|
||||
causally only if an entry directly says that cause was verified.
|
||||
- Preserve concrete retrieval anchors: file paths, symbols, commands, error
|
||||
strings, versions, config keys/values, and important numeric values that
|
||||
matter to the progress.
|
||||
- For chains of 2-3 generations, do not over-compress the progression into one
|
||||
sentence. Prefer 2-4 compact paragraphs when useful, or readable prose with
|
||||
a clear task goal, stage-by-stage progression, and current status.
|
||||
- Do not mechanically copy the full history. Omit repetition and low-value
|
||||
detail while retaining important actions, direct results, and leftovers.
|
||||
- Do not invent stage headings or dates. Mention a date only when the entries
|
||||
make it meaningful; bare timestamps are noise.
|
||||
- If the entries say older revisions were omitted, explicitly state that
|
||||
earlier history was omitted; do not imply the supplied entries are complete.
|
||||
- Write in the same language the entries are written in.
|
||||
- No preamble or enclosing quotes.
|
||||
|
||||
Example
|
||||
entries:
|
||||
(2026-07-20) The user needed to fix CI's npm run build; traced it to
|
||||
ERR_OSSL_EVP_UNSUPPORTED under Node 18, workflow not yet
|
||||
changed.
|
||||
(2026-07-24) The user continued that CI fix; raised Node in
|
||||
.github/workflows/ci.yml to 20, but the dependency cache
|
||||
still reproduced the old error.
|
||||
(now) The user continued verifying the same fix; after clearing the
|
||||
npm cache, npm run build passed.
|
||||
output:
|
||||
The task goal is fixing CI's npm run build. It was first traced to
|
||||
ERR_OSSL_EVP_UNSUPPORTED under Node 18, with the workflow not yet changed;
|
||||
then .github/workflows/ci.yml was raised to Node 20, but the dependency
|
||||
cache kept the old error present. Finally, after clearing the npm cache,
|
||||
npm run build passed, so this build fix is currently verified as complete.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
ENTRIES
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{chain_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (the summary itself, nothing else)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
from ..prompts.en import CODING_CHAIN_SUMMARY_PROMPT as CHAIN_SUMMARY_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
def _fact_drafts_language_text(drafts: List[WorkingFactDraft]) -> str:
|
||||
@@ -357,37 +231,44 @@ def _fact_drafts_language_text(drafts: List[WorkingFactDraft]) -> str:
|
||||
def build_fact_reconcile_prompt(
|
||||
drafts: List[WorkingFactDraft],
|
||||
candidates: List[List[WorkingFact]],
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return the language-matched fact reconcile prompt and language."""
|
||||
if is_chinese(_fact_drafts_language_text(drafts)):
|
||||
from .prompts_zh import FACT_RECONCILE_PROMPT_ZH
|
||||
"""Return the fact reconcile prompt and its language code.
|
||||
|
||||
template = FACT_RECONCILE_PROMPT_ZH
|
||||
language = "zh"
|
||||
else:
|
||||
template = FACT_RECONCILE_PROMPT
|
||||
language = "en"
|
||||
return template.format(
|
||||
Explicit ``language`` wins; otherwise zh/en auto-detection over the drafts.
|
||||
"""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, _fact_drafts_language_text(drafts))
|
||||
prompt = get_prompt("CODING_FACT_RECONCILE_PROMPT", code).format(
|
||||
drafts_block=WorkingFactReconciler._format_drafts(drafts),
|
||||
candidates_block=WorkingFactReconciler._format_candidates(drafts, candidates),
|
||||
), language
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||
|
||||
|
||||
def build_chain_summary_prompt(
|
||||
chain_block: str,
|
||||
*,
|
||||
current_statement: str,
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return the language-matched cross-session summary prompt and language."""
|
||||
if is_chinese(current_statement or chain_block):
|
||||
from .prompts_zh import CHAIN_SUMMARY_PROMPT_ZH
|
||||
"""Return the cross-session summary prompt and its language code."""
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, current_statement or chain_block)
|
||||
prompt = get_prompt("CODING_CHAIN_SUMMARY_PROMPT", code).format(
|
||||
chain_block=chain_block,
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||
|
||||
template = CHAIN_SUMMARY_PROMPT_ZH
|
||||
language = "zh"
|
||||
else:
|
||||
template = CHAIN_SUMMARY_PROMPT
|
||||
language = "en"
|
||||
return template.format(chain_block=chain_block), language
|
||||
|
||||
@dataclass
|
||||
class FactReconcilePlan:
|
||||
"""plan() 的产物:覆盖判定已完成、尚未落库。apply() 才执行 store 操作。"""
|
||||
|
||||
drafts: List[WorkingFactDraft]
|
||||
candidates: List[List[WorkingFact]] = field(default_factory=list)
|
||||
supersedes: Dict[int, List[str]] = field(default_factory=dict)
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -454,33 +335,70 @@ class WorkingFactReconciler:
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
"""
|
||||
执行 reconcile + 持久化。
|
||||
执行 reconcile + 持久化(plan() 后紧接 apply())。memory_at 是这段对话的
|
||||
时间,记到新事实上。language 为显式 prompt 语言码(None → zh/en 自动检测),
|
||||
同时用于演变总结。
|
||||
|
||||
Returns:
|
||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
||||
宁可多留一条过期事实,也不因为一次调用失败丢结论。
|
||||
(真正落库的新事实, 执行过的 ops)。
|
||||
|
||||
Raises:
|
||||
CodingWriteError(RECONCILE_FAILED):覆盖判定的 LLM 失败 / 空回复 /
|
||||
解析不出。此时既不插入新事实,也不覆盖任何旧事实。
|
||||
"""
|
||||
plan = await self.plan(
|
||||
drafts,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
language=language,
|
||||
)
|
||||
return await self.apply(plan)
|
||||
|
||||
async def plan(
|
||||
self,
|
||||
drafts: List[WorkingFactDraft],
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
session_id: Optional[str] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> FactReconcilePlan:
|
||||
"""检索 candidates + 覆盖判定,不落库。
|
||||
|
||||
没有任何候选时不调 LLM(全部 INSERT 是确定的结论,不是兜底)。调了 LLM 时,
|
||||
有效回复 = JSON 对象且 "ops" 是列表(空列表 = 都不覆盖、只插入)。LLM 调用
|
||||
失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、回复不是
|
||||
上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
|
||||
此前 LLM 失败会退化为全 INSERT 不覆盖,留下本该被接续的旧事实。
|
||||
"""
|
||||
context = dict(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
language=language,
|
||||
)
|
||||
if not drafts:
|
||||
return [], []
|
||||
return FactReconcilePlan(drafts=[], context=context)
|
||||
|
||||
candidates = await self._retrieve_candidates(
|
||||
drafts, user_id=user_id, source=source,
|
||||
)
|
||||
|
||||
try:
|
||||
supersedes = await self._llm_decide(drafts, candidates)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[fact-reconcile] LLM decide failed, insert without supersede: {e}"
|
||||
)
|
||||
supersedes = {}
|
||||
|
||||
facts, ops = await self._execute(
|
||||
drafts,
|
||||
candidates,
|
||||
supersedes,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
@@ -488,8 +406,40 @@ class WorkingFactReconciler:
|
||||
branch=branch,
|
||||
source=source,
|
||||
)
|
||||
|
||||
try:
|
||||
supersedes = await self._llm_decide(drafts, candidates, language=language)
|
||||
except CodingWriteError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"[fact-reconcile] LLM decide failed: {e}")
|
||||
raise CodingWriteError(
|
||||
"RECONCILE_FAILED",
|
||||
f"working fact reconcile LLM call failed: {type(e).__name__}: {e}",
|
||||
cause="LLM_UNAVAILABLE",
|
||||
) from e
|
||||
|
||||
return FactReconcilePlan(
|
||||
drafts=list(drafts),
|
||||
candidates=candidates,
|
||||
supersedes=supersedes,
|
||||
context=context,
|
||||
)
|
||||
|
||||
async def apply(
|
||||
self, plan: FactReconcilePlan,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
"""执行 plan() 的决策(覆盖旧事实 + 插入新事实)。"""
|
||||
if not plan.drafts:
|
||||
return [], []
|
||||
facts, ops = await self._execute(
|
||||
plan.drafts,
|
||||
plan.candidates,
|
||||
plan.supersedes,
|
||||
**plan.context,
|
||||
)
|
||||
logger.info(
|
||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
||||
f"[fact-reconcile] drafts={len(plan.drafts)} inserted={len(facts)} "
|
||||
f"superseded={sum(1 for o in ops if o.action == 'SUPERSEDE')}"
|
||||
)
|
||||
return facts, ops
|
||||
@@ -499,7 +449,15 @@ class WorkingFactReconciler:
|
||||
# ------------------------------------------------------------
|
||||
|
||||
async def _retrieve_candidates(
|
||||
self, drafts: List[WorkingFactDraft], *, user_id: str, source: str
|
||||
self,
|
||||
drafts: List[WorkingFactDraft],
|
||||
*,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: Optional[str],
|
||||
workspace_id: Optional[str],
|
||||
branch: Optional[str],
|
||||
source: str,
|
||||
) -> List[List[WorkingFact]]:
|
||||
"""每条 draft 用 statement 召回同 user 的 top-N active 事实。
|
||||
|
||||
@@ -512,6 +470,10 @@ class WorkingFactReconciler:
|
||||
关掉时整份召回原样交给 LLM。适用于 subject 不是稳定实体名的数据集(自由文本、
|
||||
位置标签),那里精确字符串相等会把同实体判成不同主体,反而拦掉真实更新。
|
||||
"""
|
||||
# Empty/legacy session metadata remains readable, but cannot prove
|
||||
# conversation authority for a destructive SUPERSEDE.
|
||||
if not session_id:
|
||||
return [[] for _ in drafts]
|
||||
try:
|
||||
embeddings = await self.embed_service.embed_batch(
|
||||
[d.statement for d in drafts]
|
||||
@@ -525,6 +487,10 @@ class WorkingFactReconciler:
|
||||
try:
|
||||
hits = await self.store.search_facts(
|
||||
emb, user_id=user_id, top=self.candidates_per_draft,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
# 写入侧同样接词面通道:候选选错了 LLM 就没有机会判对。这里不传
|
||||
# min_score——写入侧要的是"有哪些可能的前身",让 LLM 看更宽的名单,
|
||||
# 阈值过滤是读取侧的事。
|
||||
@@ -546,9 +512,12 @@ class WorkingFactReconciler:
|
||||
kept.append(fact)
|
||||
if blocked:
|
||||
logger.info(
|
||||
f"[fact-reconcile] subject gate blocked {len(blocked)} candidate(s) "
|
||||
f"for subject={draft.subject!r}: "
|
||||
+ ", ".join(f"{f.fact_id}({f.subject!r})" for f in blocked)
|
||||
f"[fact-reconcile] subject gate blocked {len(blocked)} candidate(s): "
|
||||
+ ", ".join(f.fact_id for f in blocked)
|
||||
)
|
||||
logger.debug(
|
||||
f"[fact-reconcile] subject gate: draft subject={draft.subject!r} "
|
||||
f"blocked subjects={[f.subject for f in blocked]!r}"
|
||||
)
|
||||
result.append(kept)
|
||||
return result
|
||||
@@ -561,17 +530,24 @@ class WorkingFactReconciler:
|
||||
self,
|
||||
drafts: List[WorkingFactDraft],
|
||||
candidates: List[List[WorkingFact]],
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[int, List[str]]:
|
||||
"""返回 {draft_idx: [被它覆盖的旧 fact_id, ...]}。"""
|
||||
# 一条候选都没有就没得判,省一次 LLM 调用
|
||||
if not any(candidates):
|
||||
return {}
|
||||
|
||||
prompt, _ = build_fact_reconcile_prompt(drafts, candidates)
|
||||
prompt, _ = build_fact_reconcile_prompt(drafts, candidates, language=language)
|
||||
resp = await self.llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=self.max_tokens,
|
||||
audit_stage="coding_fact_reconcile",
|
||||
)
|
||||
failure = self._reply_failure(resp.content)
|
||||
if failure is not None:
|
||||
code, message = failure
|
||||
logger.warning(f"[fact-reconcile] {code}: {message}")
|
||||
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
|
||||
return self._parse_ops(resp.content)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
@@ -610,7 +586,8 @@ class WorkingFactReconciler:
|
||||
return ordered[-MAX_FACT_CHAIN_ENTRIES:], truncated
|
||||
|
||||
async def _summarize_chain(
|
||||
self, history: List[WorkingFact], current: str, *, truncated: bool = False
|
||||
self, history: List[WorkingFact], current: str, *, truncated: bool = False,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""让 LLM 把同一任务的跨 session 进展写成简短摘要。
|
||||
|
||||
@@ -631,12 +608,14 @@ class WorkingFactReconciler:
|
||||
prompt, _ = build_chain_summary_prompt(
|
||||
"\n".join(rows),
|
||||
current_statement=current,
|
||||
language=language,
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=_CHAIN_SUMMARY_MAX_TOKENS,
|
||||
audit_stage="coding_fact_summary",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[fact-reconcile] chain summary failed: {e}")
|
||||
@@ -695,11 +674,24 @@ class WorkingFactReconciler:
|
||||
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
|
||||
return "\n\n".join(rows) if rows else "(none)"
|
||||
|
||||
@classmethod
|
||||
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
|
||||
if not (text or "").strip():
|
||||
return "EMPTY_RESPONSE", "working fact reconcile LLM returned empty response"
|
||||
obj = cls._load_object(text)
|
||||
if not isinstance(obj, dict) or not isinstance(obj.get("ops"), list):
|
||||
return (
|
||||
"JSON_PARSE_FAILED",
|
||||
f"working fact reconcile output has no ops list ({len(text)} chars)",
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_ops(text: str) -> Dict[int, List[str]]:
|
||||
"""宽松解析 ops,返回 {draft_idx: [fact_id...]}。"""
|
||||
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
|
||||
if not text:
|
||||
return {}
|
||||
return None
|
||||
s = text.strip()
|
||||
# 去 markdown fence
|
||||
if s.startswith("```"):
|
||||
@@ -717,9 +709,19 @@ class WorkingFactReconciler:
|
||||
obj = json.loads(s[start: end + 1])
|
||||
except Exception:
|
||||
obj = None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
@classmethod
|
||||
def _parse_ops(cls, text: str) -> Dict[int, List[str]]:
|
||||
"""宽松解析 ops,返回 {draft_idx: [fact_id...]}。"""
|
||||
if not text:
|
||||
return {}
|
||||
obj = cls._load_object(text)
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning(f"[fact-reconcile] failed to parse ops output: {text!r}")
|
||||
logger.warning(
|
||||
f"[fact-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[fact-reconcile] unparsable ops output: {text!r}")
|
||||
return {}
|
||||
ops_raw = obj.get("ops")
|
||||
if not isinstance(ops_raw, list):
|
||||
@@ -757,6 +759,8 @@ class WorkingFactReconciler:
|
||||
workspace_id: Optional[str],
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
inserted: List[WorkingFact] = []
|
||||
ops: List[FactReconcileOp] = []
|
||||
@@ -777,8 +781,14 @@ class WorkingFactReconciler:
|
||||
subject=draft.subject,
|
||||
details=draft.details,
|
||||
status="active",
|
||||
memory_at=memory_at,
|
||||
)
|
||||
|
||||
# 先确认新事实能向量化,再去覆盖旧事实:否则 embedding 挂掉时旧事实
|
||||
# 已被软删、新事实又落不了库,这条结论就凭空没了。失败抛
|
||||
# CodingWriteError(EMBEDDING_FAILED),整次写入报失败。
|
||||
new_embedding = await self.store.embed_fact(new_fact)
|
||||
|
||||
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
||||
targets = [
|
||||
fid for fid in supersedes.get(idx, [])
|
||||
@@ -792,14 +802,26 @@ class WorkingFactReconciler:
|
||||
old = await self.store.get_fact(old_id, user_id=user_id)
|
||||
if old is None:
|
||||
continue
|
||||
if (
|
||||
old.agent_id != agent_id
|
||||
or old.session_id != session_id
|
||||
or old.workspace_id != workspace_id
|
||||
):
|
||||
logger.warning(
|
||||
f"[fact-reconcile] refused cross-authority SUPERSEDE: {old.fact_id}"
|
||||
)
|
||||
continue
|
||||
# 闸门在候选阶段已经拦过一道,这里再拦一次:候选是按 statement 召回的,
|
||||
# 而 LLM 只能覆盖候选名单里的 id,理论上到不了这儿。真到了就说明前面
|
||||
# 哪一环漏了,宁可让这次覆盖不生效——多留一条旧事实是可恢复的,删掉
|
||||
# 别的主体的状态不是。闸门关掉时这道复查一并跳过,否则闸门就没关干净。
|
||||
if self.subject_gate and _subject_conflict(draft.subject, old.subject):
|
||||
logger.warning(
|
||||
f"[fact-reconcile] refused cross-subject SUPERSEDE: "
|
||||
f"{old.fact_id}({old.subject!r}) by draft subject {draft.subject!r}"
|
||||
f"[fact-reconcile] refused cross-subject SUPERSEDE: {old.fact_id}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[fact-reconcile] cross-subject: old subject={old.subject!r} "
|
||||
f"draft subject={draft.subject!r}"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
@@ -827,7 +849,8 @@ class WorkingFactReconciler:
|
||||
retired, user_id=user_id
|
||||
)
|
||||
new_fact.fact_chain_summary = await self._summarize_chain(
|
||||
history, new_fact.statement, truncated=truncated
|
||||
history, new_fact.statement, truncated=truncated,
|
||||
language=language,
|
||||
)
|
||||
# 摘要之外再把历代自己的 details 原样过继一份。摘要保叙事,过继保数值,
|
||||
# 两者不重复:details 本来就不进摘要(_collect_history 只读 statement)。
|
||||
@@ -846,7 +869,9 @@ class WorkingFactReconciler:
|
||||
)
|
||||
|
||||
try:
|
||||
await self.store.insert_fact(new_fact)
|
||||
await self.store.insert_fact(new_fact, embedding=new_embedding)
|
||||
except CodingWriteError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"[fact-reconcile] INSERT failed: {e}")
|
||||
continue
|
||||
|
||||
+32
-13
@@ -29,6 +29,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_JUDGE_MAX_TURNS = 20
|
||||
_JUDGE_HEAD_TURNS = 5
|
||||
_JUDGE_USER_CONTENT_LIMIT = 1_000
|
||||
_JUDGE_USER_CONTENT_HEAD = 200
|
||||
_JUDGE_USER_CONTENT_TAIL = 200
|
||||
_JUDGE_MAX_QUERY_CHARS = 300
|
||||
_JUDGE_MAX_QUERIES = 20
|
||||
|
||||
@@ -186,13 +189,16 @@ async def classify_messages_scene(
|
||||
prompt=prompt,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
audit_stage="coding_judge",
|
||||
)
|
||||
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||||
result = _parse_scene_json(resp.content)
|
||||
if result is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] failed to parse classify_messages output: {resp.content!r}; defaulting to chat"
|
||||
f"[coding-judge] failed to parse classify_messages output "
|
||||
f"({len(resp.content or '')} chars); defaulting to chat"
|
||||
)
|
||||
logger.debug(f"[coding-judge] unparsable classify_messages output: {resp.content!r}")
|
||||
await _store_coding_log(
|
||||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||||
@@ -203,9 +209,8 @@ async def classify_messages_scene(
|
||||
return "chat"
|
||||
scene = result["scene"]
|
||||
parsed = {**result, "is_coding": scene != "chat"}
|
||||
logger.info(
|
||||
f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}"
|
||||
)
|
||||
logger.info(f"[coding-judge] scene={scene}")
|
||||
logger.debug(f"[coding-judge] scene={scene} reason={result.get('reason', '')!r}")
|
||||
await _store_coding_log(
|
||||
cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id, step="CODING_JUDGE", prompt=prompt,
|
||||
@@ -291,8 +296,10 @@ async def classify_queries_scene(
|
||||
result = _parse_scene_json(resp.content)
|
||||
if result is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] failed to parse classify_queries output: {resp.content!r}; defaulting to chat"
|
||||
f"[coding-judge] failed to parse classify_queries output "
|
||||
f"({len(resp.content or '')} chars); defaulting to chat"
|
||||
)
|
||||
logger.debug(f"[coding-judge] unparsable classify_queries output: {resp.content!r}")
|
||||
return "chat"
|
||||
scene = result["scene"]
|
||||
logger.info(f"[coding-judge] queries scene={scene}")
|
||||
@@ -418,15 +425,18 @@ async def classify_and_rewrite_queries(
|
||||
scene = _parse_scene_label(head)
|
||||
if scene is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] classify_and_rewrite unrecognized head={head!r}; falling back to chat"
|
||||
f"[coding-judge] classify_and_rewrite unrecognized head "
|
||||
f"({len(head)} chars); falling back to chat"
|
||||
)
|
||||
logger.debug(f"[coding-judge] classify_and_rewrite unrecognized head={head!r}")
|
||||
return {"scene": "chat", "is_coding": False, "rewrite_query": rewrite, "ok": False}
|
||||
is_coding = scene != "chat"
|
||||
|
||||
logger.info(
|
||||
f"[coding-judge] classify_and_rewrite scene={scene} "
|
||||
f"rewrite={rewrite!r}"
|
||||
f"rewrite({len(rewrite)} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-judge] classify_and_rewrite rewrite={rewrite!r}")
|
||||
return {"scene": scene, "is_coding": is_coding, "rewrite_query": rewrite, "ok": True}
|
||||
|
||||
|
||||
@@ -565,8 +575,9 @@ async def analyze_queries(
|
||||
parsed_scene = _parse_scene_label(head)
|
||||
if parsed_scene is None:
|
||||
logger.warning(
|
||||
f"[coding-judge] analyze_queries unrecognized head={head!r}; ok=False"
|
||||
f"[coding-judge] analyze_queries unrecognized head ({len(head)} chars); ok=False"
|
||||
)
|
||||
logger.debug(f"[coding-judge] analyze_queries unrecognized head={head!r}")
|
||||
return {**fallback, "since_raw": ""}
|
||||
scene = parsed_scene
|
||||
idx = 1
|
||||
@@ -585,9 +596,10 @@ async def analyze_queries(
|
||||
created_after = _since_to_created_after(since_raw, current_time)
|
||||
|
||||
logger.info(
|
||||
f"[coding-judge] analyze scene={scene} rewrite={rewrite!r} "
|
||||
f"[coding-judge] analyze scene={scene} rewrite({len(rewrite)} chars) "
|
||||
f"since={since_raw!r} created_after={created_after}"
|
||||
)
|
||||
logger.debug(f"[coding-judge] analyze rewrite={rewrite!r}")
|
||||
return {
|
||||
"scene": scene, "is_coding": scene != "chat", "rewrite_query": rewrite,
|
||||
"created_after": created_after, "since_raw": since_raw, "ok": True,
|
||||
@@ -610,7 +622,10 @@ def _since_to_created_after(since_raw: str, current_time: str) -> Optional[float
|
||||
return dt.timestamp()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
logger.warning(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
||||
logger.warning(
|
||||
f"[coding-judge] analyze_queries unparsable SINCE ({len(since_raw)} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-judge] analyze_queries unparsable SINCE={since_raw!r}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -631,9 +646,13 @@ def _format_turns(summary: List[Dict[str, Any]]) -> str:
|
||||
rows = []
|
||||
for t in summary:
|
||||
user = t.get("user", "") or ""
|
||||
# 单 turn 用户文本截到 200 字符
|
||||
if len(user) > 200:
|
||||
user = user[:200] + "..."
|
||||
if len(user) > _JUDGE_USER_CONTENT_LIMIT:
|
||||
omitted = len(user) - _JUDGE_USER_CONTENT_HEAD - _JUDGE_USER_CONTENT_TAIL
|
||||
user = (
|
||||
user[:_JUDGE_USER_CONTENT_HEAD]
|
||||
+ f"\n[...{omitted} chars omitted...]\n"
|
||||
+ user[-_JUDGE_USER_CONTENT_TAIL:]
|
||||
)
|
||||
tools = t.get("tools", []) or []
|
||||
rows.append(
|
||||
f"- turn {t.get('turn', '?')}: user={user!r}, tools={tools}"
|
||||
|
||||
@@ -11,6 +11,7 @@ Coding Memory 预处理工具(规则,零 LLM)
|
||||
使用方式与 scene 路由见 docs/scene_coding_usage.md。
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..pipelines.base import ChatMessage
|
||||
@@ -63,6 +64,13 @@ DEFAULT_MAX_TOOL_RESULT_BYTES = 2048
|
||||
DEFAULT_HEAD_BYTES = 1024
|
||||
DEFAULT_TAIL_BYTES = 512
|
||||
|
||||
# 本模块自己产出的省略标记。截断结果会再次经过截断(落库前一次、读历史时又一次),
|
||||
# 认出自己的标记才能保证幂等:已截断的文本再截一次原样返回,而不是把省略计数改写。
|
||||
_TOOL_OMIT_MARKER_RE = re.compile(r"\n\n\[\.\.\.\d+ bytes omitted by truncate\.\.\.\]\n\n")
|
||||
# 标记本身的字节数上限(数字位数有限),用于判断「已截断且只超出一个标记的长度」。
|
||||
_TOOL_OMIT_MARKER_MAX_BYTES = 128
|
||||
_ASSISTANT_OMIT_MARKER_RE = re.compile(r" \[\.\.\.\d+ chars omitted\.\.\.\] ")
|
||||
|
||||
|
||||
def truncate_tool_result_text(
|
||||
text: str,
|
||||
@@ -81,6 +89,12 @@ def truncate_tool_result_text(
|
||||
raw = text.encode("utf-8")
|
||||
if len(raw) <= max_bytes:
|
||||
return text
|
||||
# 已截断过(max_bytes 很小时标记本身会让结果略超上限):原样返回,保证幂等。
|
||||
if (
|
||||
len(raw) <= max_bytes + _TOOL_OMIT_MARKER_MAX_BYTES
|
||||
and _TOOL_OMIT_MARKER_RE.search(text)
|
||||
):
|
||||
return text
|
||||
# 自定义 max_bytes 可能小于默认 head+tail(1024+512)。按原比例缩放内容窗口,
|
||||
# 给省略标记预留空间,不能因为窗口和过大而把超限原文直接返回。
|
||||
marker_budget = min(96, max(24, max_bytes // 4))
|
||||
@@ -161,6 +175,10 @@ def truncate_assistant_text(
|
||||
text = "" if text is None else str(text)
|
||||
if len(text) <= head_chars + tail_chars:
|
||||
return text
|
||||
# 已截断过:中段恰好是本函数的省略标记 → 原样返回,保证幂等(否则会把
|
||||
# 省略计数改写成标记自身的长度)。
|
||||
if _ASSISTANT_OMIT_MARKER_RE.fullmatch(text[head_chars:len(text) - tail_chars]):
|
||||
return text
|
||||
omitted = len(text) - head_chars - tail_chars
|
||||
return (
|
||||
text[:head_chars]
|
||||
|
||||
@@ -1,745 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""HY Memory working/coding 链路使用的中文提示词模板。"""
|
||||
"""HY Memory working/coding 链路使用的中文提示词模板(兼容入口)。
|
||||
|
||||
|
||||
EXTRACT_PROMPT_ZH = """\
|
||||
你需要从一段任务执行会话中抽取可长期保留的 WORKING 记忆。
|
||||
“working”会话是指用户通过工具操作计算机或系统的任何实操任务——既包括软件/代码
|
||||
工作(编写、调试、重构、构建、部署、API/library 使用),也包括系统与运维工作
|
||||
(运行 shell/PowerShell 命令、磁盘/存储分析或清理、文件与文件夹操作、安装/配置
|
||||
软件、环境/设备设置、硬件/磁盘健康检查、备份、调度/自动化、数据处理)。
|
||||
|
||||
你需要从同一段会话中产出两个层次:
|
||||
|
||||
EXPERIENCES(过程性,“如何做”)——可复用的方法;只有达到下述 VALUE BAR 时才
|
||||
抽取。每条是一个 (task, search_keys, solution, boundary_envs) 组合。
|
||||
|
||||
FACT(情节性,“这次发生了什么”)——关于本次会话的恰好一条记录:用户想做什么、
|
||||
大致如何推进、最终结果如何。它不受价值门槛限制,但绝不能超过一条。
|
||||
|
||||
这两个层次回答不同问题,并分别存储。可泛化的方法放入 EXPERIENCES;只对这个系统、
|
||||
这个项目、此时此刻成立的本次会话经历放入 FACT。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR(仅适用于 EXPERIENCES)——不要轻易越过此门槛。
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
一条记忆必须同时满足以下全部条件:
|
||||
(a) 解决了同一用户以后很可能再次遇到的问题
|
||||
(b) 解决方案并非琐碎操作:它组合了多项信息/多个步骤,或者包含决策理由,或者记录了
|
||||
来之不易的 workaround,或者是某项运维任务的可复用流程/配方(例如如何分析并
|
||||
清理磁盘占用、如何批量整理文件、如何检查硬件健康、如何设置 scheduled job)
|
||||
(c) 未来的 agent / 用户会更愿意查阅它,而不是重新摸索
|
||||
|
||||
重要——分析、诊断、调查任务也算:
|
||||
一项任务不必实际修改或“修复”任何东西才值得记忆。只读、分析、故障排查工作,只要其
|
||||
流程或结论可复用,就有价值。明确抽取:
|
||||
- 分析/诊断配方:可复用的流程以及它揭示的结果(例如“如何分析哪些文件夹占用了
|
||||
C-drive 空间”——扫描命令、遇到的坑和最终发现)。“只生成了报告,没有修改”
|
||||
不是跳过理由——可复用的方法本身就是记忆。
|
||||
- 调查结果,包括否定结果或未找到结果(例如“使用 rg / fs_search / SQL index
|
||||
在 C: 和 D: 中搜索 folder X——任何位置都未找到”)。记录一条死路可以避免下一个
|
||||
agent 重复同样无果的搜索,因此达到了价值门槛。
|
||||
不要仅仅因为“assistant 可以重新生成这些命令”或“实际上没有修改任何东西”就拒绝
|
||||
一条记忆。只要流程或结论能节省未来的工作,就保留它。
|
||||
|
||||
不要抽取:
|
||||
- 没有可复用流程、真正琐碎的单步操作(“rename one file”、“git status”、
|
||||
“run ls once”)
|
||||
- 已经解决且没有可迁移经验的一次性问题
|
||||
- repo/system 中已经存在的信息(不要重复 package.json)
|
||||
- 每个 PR / 每次会话的一次性任务,除非其中编码了可复用模式
|
||||
- 向用户询问缺失信息这一行为。请求参数、列出未知项或引导用户提供必填字段属于
|
||||
对话行为,不是方法:每个 agent 都知道如何提问,因此以后没有任何值得查阅的内容。
|
||||
绝不要产出 task 或 search_keys 形如“how to ask for X”、“gather the missing Y”、
|
||||
“eliciting required fields”、“what to clarify before starting”的 experience。
|
||||
注意,这条限制只适用于 EXPERIENCES——会话最终停在等待用户的状态是真实状态,
|
||||
应写入 FACT。
|
||||
|
||||
如果该段会话因为等待用户而没有取得任何实质进展——assistant 只是询问参数,或者在
|
||||
开展任何实际工作前就被阻塞——输出 "experiences": [],只写 FACT。此时尚未学到任何
|
||||
东西,因此没有方法可存。这一点比看起来更重要:从停滞中提炼出的 experience 在检索时
|
||||
会像一项真正的技能;同一任务的后续片段会匹配它,再次向用户提问而不是继续推进,导致
|
||||
这种停滞在余下会话中不断自我复制。应根据工具实际做了什么判断是否有实质进展,而不是
|
||||
根据该轮是否生成了流畅文本。
|
||||
|
||||
启发式标准:如果用户(或未来执行同类任务的 agent)希望把它记下来,而不是以后再重新
|
||||
摸索,就保留。多步骤运维流程通常符合条件——包括 system/ops 或只读分析/搜索流程,
|
||||
不局限于代码。只有当它只是任何用户都能在 30 秒内通过 `--help` 找到的单条命令,并且
|
||||
不包含值得记忆的决策或坑点时,才丢弃。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE 字段
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — 采用查询式名称;写成用户以后会如何提问。
|
||||
使用“publish a release to the xx platform”,不要使用
|
||||
“publish_release_internal”。
|
||||
必须自包含(不要使用“this”、“the file”、“we”)。
|
||||
|
||||
search_keys — 0 到 5 种替代表述,用来覆盖仅靠 `task` 无法匹配、但对 solution 很
|
||||
重要的方面。写成未来用户会询问的问题或主题,且必须与 task 有区别。
|
||||
如果 task 已覆盖全部内容,则省略。
|
||||
|
||||
solution — 用户实际需要的完整、自包含内容:命令、凭据位置(不是凭据值)、
|
||||
文件路径、有序步骤、坑点、决策理由。如果多轮端到端工作构成一个连贯
|
||||
任务,应聚合成一个 solution;只有用户执行了互不相关的任务时才拆成
|
||||
多条记忆。
|
||||
|
||||
boundary_envs — solution 所依赖的具体限定条件,使用多行“key: value”:
|
||||
runtime / SDK / library + version
|
||||
API endpoint 或 method signature
|
||||
external service / middleware version
|
||||
env var NAME(不是值)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
如果 solution 与环境无关,填写 ""(空字符串)。
|
||||
目的:底层 SDK/middleware 升级后,未来使用者可以据此判断 solution
|
||||
是否仍然适用。
|
||||
|
||||
confidence — 0.0 到 1.0
|
||||
1.0 用户明确将其陈述为规则/指令
|
||||
0.7 根据工具操作及充分证据推断
|
||||
0.5 弱推断;只有一次观察;不确定
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT 字段——每段会话恰好一条,无价值门槛
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
你要为整段会话写一条 FACT——它是本次会话可恢复的状态记录,不是精心润色的回顾,也
|
||||
绝不是分散发现的列表。未来 agent 必须能够从中继续推进,且不会把建议、推断或局部搜索
|
||||
误认为已经验证的事实。
|
||||
|
||||
subject — 本次会话围绕谁或什么展开:工作所针对或服务的单一实体。使用简短名词
|
||||
短语,不要写句子,不要使用动词。选择会话真正围绕的最具体实体,而不是
|
||||
所使用的工具:
|
||||
会话服务于某个具名人员时,使用人名
|
||||
(“Dylan”、“Matthew”)
|
||||
工作针对代码时,使用 file / module / symbol
|
||||
(“auth/middleware.ts”、“parse_plan_text”)
|
||||
运维工作使用 service / host / device / drive
|
||||
(“D:\\WXWork”、“redis-cache-prod”、“ci.yml”)
|
||||
这是 HARD KEY,不是散文:关于不同人员或不同文件的两次会话必须得到不同
|
||||
subject;关于同一实体的两次会话必须得到拼写完全一致的同一字符串。
|
||||
优先使用 trace 中出现的精确标识符,而不是对它的描述。如果该段确实没有
|
||||
单一 subject,输出 ""。
|
||||
|
||||
这很重要:subject 是决定后续会话是否可以按“同一任务的继续”覆盖本条
|
||||
记录的闸门。填错后,一个人的状态可能静默替换另一个人的状态。
|
||||
|
||||
statement — 本次会话的紧凑记录。它分为两部分,而第二部分最容易丢失。
|
||||
保留任务范围内的给定量和具体约束,不要用泛泛的结果评价取代它们。
|
||||
每条规则只写一次:重复的指令文字会挤占记忆真正需要保留的数据。
|
||||
|
||||
PART 1——发生了什么,约 300 个拉丁字符(约 150 个 CJK 字符)。
|
||||
从具体目标和对象开始(“reclaim disk space taken by D:\\WXWork”、
|
||||
“locate the new_file_search folder”),不要写泛泛的“fix a bug”/
|
||||
“find a file”。然后概述检查、尝试或修改了什么。最后说明结束状态,
|
||||
且语义必须精确对应以下之一:completed、partially completed、failed、
|
||||
blocked、still awaiting verification。纳入否定结果和死路,但不要夸大。
|
||||
|
||||
PART 2——TASK-SCOPED GIVENS:整个任务赖以运行的全部给定信息,即使并非
|
||||
本段会话建立,或看起来基础到不值得写。包括绑定关系(每个 name/symbol/
|
||||
label 代表什么)、任务提供而不是推导出的固定值、采用的约定(indexing
|
||||
base、units、timezone、defaults),以及本段自己的结果和值(写“x = 7”,
|
||||
不要只写“the value was computed”)。
|
||||
|
||||
将 PART 2 写成数据,每行一项,值保持给定形式:
|
||||
retry budget = 5; timeout 30s; index starts at 0
|
||||
step 3 input: n=64, tolerance 1e-6
|
||||
slot B: type=private, cost 688.7-721.3 per unit
|
||||
每一行几乎都应由值构成。保留 trace 的 notation——symbols、units、
|
||||
numbers、spellings——删掉其句子外壳:去掉那些只是宣告“这是给定量”的
|
||||
短语,而非给定量本身(“I would prefer”、“it should be somewhere
|
||||
between … and …”)。这些短语会在每项重复;一个 15 项列表会为它们
|
||||
支付 15 次空间成本。
|
||||
每一条 FACT 都要携带这些信息;绝不要写“as above”或“same as the
|
||||
previous session”——检索只会按相似度返回少量 facts,被指代项可能不在
|
||||
其中。
|
||||
|
||||
总长度尽量控制在 1200 字符以内。如果 PART 2 放不下,应优化 notation,
|
||||
绝不能削减覆盖范围:statement 是 retrieval vector,也是后续 segment
|
||||
唯一能读到的字段(details 既不做 embedding,也不会被注入),因此从中
|
||||
删除的内容就永久丢失。压缩给定量周围的措辞,绝不要压缩给定量本身。
|
||||
|
||||
检验标准:如果下一段看不到原始材料,能否仅凭此记录继续任务?只有肯定
|
||||
答案才合格。缺失 binding 不会明显报错——该名称仍看起来有意义,于是会
|
||||
按其通用含义被理解,后续答案可以很流畅,却回答了另一个问题。
|
||||
|
||||
LANGUAGE——使用与 session trace 相同的语言:statement 是 retrieval
|
||||
vector,而查询会使用用户的工作语言;改用另一种语言会让每次检索都付出
|
||||
跨语言匹配损失。
|
||||
|
||||
EVIDENCE FIDELITY——只记录 trace 中实际执行的操作和明确存在的证据。
|
||||
对关键命令、flags、arguments、errors、numbers 保持原样。
|
||||
绝不要按惯例补全命令 flags,也不要把建议或计划写成已经执行的动作:
|
||||
“ran Get-ChildItem -Force” → “ran Get-ChildItem”
|
||||
(trace 中没有 `-Force`,不要杜撰)
|
||||
每个否定发现都必须限定在实际搜索范围内,并逐一列出可能使其失效的所有
|
||||
明确覆盖限制,尤其是 permission failures 和 depth limits:保留当时的
|
||||
roots、filters、depth(“not found under C:\\ and D:\\ at depth 3;
|
||||
some directories were not covered due to Access denied”),绝不要扩大为
|
||||
“does not exist”。不要为了让记录更漂亮而漏掉任何限制。
|
||||
绝不要把局部搜索提升为全局不存在。
|
||||
被阻塞的尝试必须保留其 blocker。
|
||||
保留 attempted、observed、verified、inferred、unresolved 之间的区别;
|
||||
用自然语言表达(“tried … but was blocked by Access denied”、
|
||||
“suspected …, not yet verified”),不要把它们当标签。没有工具输出或
|
||||
明确确认时,绝不要声称修复已成功、原因已确定或路径不存在。
|
||||
|
||||
不要记录问候或纯闲聊——只有这种情况才输出 "fact": null。仅仅陈述了
|
||||
要求,或尚未开展工作就停滞的会话,仍然建立了信息。
|
||||
|
||||
details — 本次会话的具体 OUTPUT,逐字复制。`statement` 是摘要,而摘要必然会
|
||||
丢掉各个具体值;后续检索几乎总是在寻找某个具体值,而非大意。因此,只要
|
||||
会话产出了可寻址的结果,就以原始形式和结构复现在这里:
|
||||
plan / schedule / itinerary → 每一行、每一天、每个 slot、每个 price
|
||||
都严格按原文保留
|
||||
config 或 code block → 保留整个 block
|
||||
command 的关键 output → 保留承载发现的行(sizes、counts、versions、
|
||||
error text)
|
||||
structured result(table、records list)→ 保留各行
|
||||
保留原始布局——line breaks、`Day 1:` / key: value prefixes、units、
|
||||
currency symbols、ids。不要再次概括,不要重排,不要舍入数字,不要翻译。
|
||||
|
||||
只复制 trace 中实际存在的内容。绝不要填补空缺:如果某个 slot 没有填,
|
||||
就保持未填或明确说明——杜撰的值比缺失的值更糟,因为它会被检索并使用。
|
||||
长度控制在约 4000 字符;如果输出更长,保留承载具体值的部分,删去装饰性
|
||||
文字,并注明删掉了什么。
|
||||
|
||||
如果本次会话未产生此类 artifact(尚无发现的纯调查,或等待用户导致的
|
||||
停滞),填写 ""。
|
||||
|
||||
此字段不属于 retrieval vector——`statement` 和 `subject` 负责找到
|
||||
记录,`details` 在找到后提供具体值。因此不要在此堆砌检索诱饵,也不要
|
||||
重复摘要;只写原始内容。
|
||||
|
||||
files — 0..N 个本次会话中实际操作或调查过的绝对或 repo-relative FILE OR
|
||||
DIRECTORY paths。保留调查过的 roots,例如 `C:\\` 和 `D:\\`。只包含
|
||||
实际访问/搜索过的路径,不包含仅建议或计划访问的路径;不适用时填写 []。
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT(由 SDK 提供)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
没有 workspace_id 时,solution 不能依赖未声明的项目本地上下文——必须在 `solution`
|
||||
或 `boundary_envs` 中明确写出其所需的 repo / path / service,使记忆能够独立使用。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"subject": "...",
|
||||
"statement": "...",
|
||||
"files": ["..."],
|
||||
"details": "..."
|
||||
}}
|
||||
}}
|
||||
|
||||
默认应进行抽取。当该段会话确实完成了实际工作时,必须输出至少一条 experience——即使
|
||||
任务看似简单或一次成功,也要记录所做工作的可复用本质(method / command / how-to)。
|
||||
会话内容较少时,只保留最有价值的一条 experience 即可。
|
||||
|
||||
唯一例外是上述停滞情况:如果该段因为缺少信息、被阻塞在向用户提问而没有开展实际工作,
|
||||
输出 "experiences": [],只写 fact。不要为了满足本指令而杜撰 experience——杜撰的
|
||||
“how to ask for the departure city”比没有记忆更糟,因为以后会被检索并照做。只有这个
|
||||
原因允许空数组;实际开展过工作时,绝不能把空数组当捷径。
|
||||
|
||||
"fact" 是单个对象,绝不是列表——每次会话一条记录。
|
||||
|
||||
始终写 fact。fact 的门槛不是“这是否有价值”,而是“是否发生了任何事情”;能够到达
|
||||
你的 segment 已经越过这条门槛。这包括看起来只是 setup 而非工作的片段:仅有一条用户
|
||||
消息陈述 requirements、parameters 或 starting state,即使没有运行工具,也恰恰是后续
|
||||
会话无法自行重建的材料——把需求、参数、起始状态连同情形一起写进 `statement`(紧凑
|
||||
记法,不要照抄对话原句),不要推给 `details`(`details` 不进检索向量,读取侧也不再
|
||||
注入它)。只有完全没有内容的问候和纯闲聊才使用 "fact": null。有疑问时应写 fact:
|
||||
多余记录只多占一行,而漏掉记录会伤害每一次需要它、却无从知道它曾存在的后续会话。
|
||||
文本已迁到 ``hy_memory/prompts/zh_hans.py``(``CODING_*``),这里保留旧名字。
|
||||
"""
|
||||
|
||||
|
||||
EXTRACT_PROMPT_CODING_ZH = """\
|
||||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
║ HARD CONSTRAINT——先阅读本节;它覆盖本提示词后文任何看似要求叙事的措辞。 ║
|
||||
║ ║
|
||||
║ 你不是在撰写某个人做过什么的报告。你要写的是 FUTURE AGENT 为采取行动所需要 ║
|
||||
║ 的信息。该 agent 已有自己的任务;它不关心这个用户想要什么。它需要知道:此处 ║
|
||||
║ 做出了什么决定、位于哪些代码坐标、尝试并否决了什么,以及是否经过验证。 ║
|
||||
║ ║
|
||||
║ 具体来说,每个 `statement` 和每个 `solution` 都必须遵守: ║
|
||||
║ · 语法主语必须是 CODE 或 THE DECISION——绝不能是“the user”,也绝不能是 ║
|
||||
║ “the agent”。不要写“The user attempted to…”、“The user wanted…”、 ║
|
||||
║ “The agent then checked…”。 ║
|
||||
║ · 绝不要用“the session was completed”、“the error was noted”、 ║
|
||||
║ “no solution was found yet”之类流水账短语收尾。结尾应写决定及其验证 ║
|
||||
║ 状态。如果没有做出决定,就以已排除的内容和下一条尚未探索的线索收尾——这才 ║
|
||||
║ 是可复用部分。 ║
|
||||
║ · 保留具体症状(error string、failing command、wrong output)——未来搜索 ║
|
||||
║ 正是通过这些内容命中。 ║
|
||||
║ ║
|
||||
║ bad: “The user attempted to write a QTable to RST with header_rows and ║
|
||||
║ hit a TypeError. The session was completed with the error noted.” ║
|
||||
║ good: “ascii.rst's RST writer rejects header_rows: ║
|
||||
║ `tbl.write(..., format='ascii.rst', header_rows=['name','unit'])` ║
|
||||
║ raises TypeError because RST.__init__ does not forward it, unlike ║
|
||||
║ ascii.fixed_width which accepts it. No fix written — the writer ║
|
||||
║ needs the same header_rows plumbing as fixed_width.” ║
|
||||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
你需要从一段任务执行会话中抽取可长期保留的 WORKING 记忆。
|
||||
“working”会话是指用户通过工具操作计算机或系统的任何实操任务——既包括软件/代码
|
||||
工作(编写、调试、重构、构建、部署、API/library 使用),也包括系统与运维工作
|
||||
(运行 shell/PowerShell 命令、磁盘/存储分析或清理、文件与文件夹操作、安装/配置
|
||||
软件、环境/设备设置、硬件/磁盘健康检查、备份、调度/自动化、数据处理)。
|
||||
|
||||
你需要从同一段会话中产出两个层次:
|
||||
|
||||
EXPERIENCES(过程性,“如何做”)——可复用的方法;只有达到下述 VALUE BAR 时才
|
||||
抽取。每条是一个 (task, search_keys, solution, boundary_envs) 组合。
|
||||
|
||||
FACT(情节性,“这次确立了什么”)——关于本次会话的恰好一条记录。它不是用户做了
|
||||
什么的叙事,而要按以下顺序记录:任务及其具体症状(使它以后能被检索到)、代码坐标、
|
||||
最终采用了什么以及拒绝了什么、验证推进到什么程度。它不受价值门槛限制,但绝不能
|
||||
超过一条。
|
||||
|
||||
这两个层次回答不同问题,并分别存储。可泛化的方法放入 EXPERIENCES;只对这个系统、
|
||||
这个项目、此时此刻成立的本次会话经历放入 FACT。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
VALUE BAR(仅适用于 EXPERIENCES)——不要轻易越过此门槛。
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
一条记忆必须同时满足以下全部条件:
|
||||
(a) 解决了同一用户以后很可能再次遇到的问题
|
||||
(b) 解决方案并非琐碎操作:它组合了多项信息/多个步骤,或者包含决策理由,或者记录了
|
||||
来之不易的 workaround,或者是某项运维任务的可复用流程/配方(例如如何分析并
|
||||
清理磁盘占用、如何批量整理文件、如何检查硬件健康、如何设置 scheduled job)
|
||||
(c) 未来的 agent / 用户会更愿意查阅它,而不是重新摸索
|
||||
|
||||
重要——分析、诊断、调查任务也算:
|
||||
一项任务不必实际修改或“修复”任何东西才值得记忆。只读、分析、故障排查工作,只要其
|
||||
流程或结论可复用,就有价值。明确抽取:
|
||||
- 分析/诊断配方:可复用的流程以及它揭示的结果(例如“如何分析哪些文件夹占用了
|
||||
C-drive 空间”——扫描命令、遇到的坑和最终发现)。“只生成了报告,没有修改”
|
||||
不是跳过理由——可复用的方法本身就是记忆。
|
||||
- 调查结果,包括否定结果或未找到结果(例如“使用 rg / fs_search / SQL index
|
||||
在 C: 和 D: 中搜索 folder X——任何位置都未找到”)。记录一条死路可以避免下一个
|
||||
agent 重复同样无果的搜索,因此达到了价值门槛。
|
||||
不要仅仅因为“assistant 可以重新生成这些命令”或“实际上没有修改任何东西”就拒绝
|
||||
一条记忆。只要流程或结论能节省未来的工作,就保留它。
|
||||
|
||||
不要抽取:
|
||||
- 没有可复用流程、真正琐碎的单步操作(“rename one file”、“git status”、
|
||||
“run ls once”)
|
||||
- 已经解决且没有可迁移经验的一次性问题
|
||||
- repo/system 中已经存在的信息(不要重复 package.json)
|
||||
- 每个 PR / 每次会话的一次性任务,除非其中编码了可复用模式
|
||||
|
||||
启发式标准:如果用户(或未来执行同类任务的 agent)希望把它记下来,而不是以后再重新
|
||||
摸索,就保留。多步骤运维流程通常符合条件——包括 system/ops 或只读分析/搜索流程,
|
||||
不局限于代码。只有当它只是任何用户都能在 30 秒内通过 `--help` 找到的单条命令,并且
|
||||
不包含值得记忆的决策或坑点时,才丢弃。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CODE WORK——未来 agent 无法重新推导的信息,因此必须捕获
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
当该段是针对 repository 的软件工作时,可复用方法只占一半。另一半是工作位于哪里,
|
||||
以及哪个版本胜出。这些无法通过以后阅读代码恢复——代码只能展示当前状态,无法说明它
|
||||
位于哪个 branch,也无法说明哪些方案已被否决。
|
||||
|
||||
本节不是要求写得更长,而是要求把相同预算花在价值更高的 token 上:删掉重复目标、
|
||||
assistant“随后决定”做什么的叙事,以及只是复述的粘贴代码;把这些字符用于下面四项。
|
||||
一条遗漏 branch、却引用 30 行 diff 的记录是糟糕的取舍。
|
||||
|
||||
1. BRANCH / WORKING LOCATION——逐字写入 fact `statement`。如果该段提到或 checkout
|
||||
了 `feat/x` / `fix/y`,这个 token 必须出现。在错误 branch 上恢复工作的 agent
|
||||
会产生随后被静默丢弃的改动,因此这一个 token 的优先级高于其他任何细节。
|
||||
|
||||
2. WHICH VERSION WON——当用户改变主意时,用一个分句指出胜出者并标记落败者:
|
||||
“settled on A; the interim B was dropped”。不要完整保留两个版本,只需一个分句。
|
||||
只保留最新版本是错误的:以后遇到被否决代码的 agent 必须识别它,否则会把代码
|
||||
“修复”回已经丢弃的变体。`scratch that / go back to / actually, let's / no, not that`
|
||||
之类措辞就是标志:后面的内容才是正式结论,前面的内容已被 superseded。
|
||||
|
||||
3. EXACT COORDINATES, NOT PASTED CODE——使用 repo-relative path
|
||||
(`qutebrowser/utils/debug.py`,绝不要只写 `debug.py`)以及触及的 function /
|
||||
class。如果 signature 发生变化,给出新的 signature。如果决定的是 constant、regex
|
||||
或 literal,逐字引用它——但只引用这一项,不要引用周围 block。准确的一行胜过一段
|
||||
近似描述。
|
||||
|
||||
4. VERIFIED vs MERELY WRITTEN——四个词就足够(“tests run and passing”/
|
||||
“confirmed by git diff only, tests not run”)。“the edit is in”和“the edit is in
|
||||
and tested”会让未来 agent 采取完全不同的下一步。
|
||||
|
||||
反例——只总结目标,却丢掉坐标:
|
||||
bad: “refactored the colour parsing logic to be cleaner.”
|
||||
good: “on feat/q4, in qutebrowser/config/configtypes.py QtColor._parse_value:
|
||||
settled on `mult = 360.0 if kind == 'h' else 255.0`; the interim version that
|
||||
used 255.0 for everything was dropped. Tests not run.”
|
||||
(good 往往比 bad 还短——它只是把每个字符都用在无法重新推导的信息上)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXPERIENCE 字段
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
task — 采用查询式名称;写成用户以后会如何提问。
|
||||
使用“publish a release to the xx platform”,不要使用
|
||||
“publish_release_internal”。
|
||||
必须自包含(不要使用“this”、“the file”、“we”)。
|
||||
|
||||
search_keys — 0 到 5 种替代表述,用来覆盖仅靠 `task` 无法匹配、但对 solution 很
|
||||
重要的方面。写成未来用户会询问的问题或主题,且必须与 task 有区别。
|
||||
如果 task 已覆盖全部内容,则省略。
|
||||
|
||||
solution — 用户实际需要的完整、自包含内容:命令、凭据位置(不是凭据值)、
|
||||
文件路径、有序步骤、坑点、决策理由。如果多轮端到端工作构成一个连贯
|
||||
任务,应聚合成一个 solution;只有用户执行了互不相关的任务时才拆成
|
||||
多条记忆。
|
||||
|
||||
boundary_envs — solution 所依赖的具体限定条件,使用多行“key: value”:
|
||||
runtime / SDK / library + version
|
||||
API endpoint 或 method signature
|
||||
external service / middleware version
|
||||
env var NAME(不是值)
|
||||
config file path
|
||||
host / cluster / region identifier
|
||||
如果 solution 与环境无关,填写 ""(空字符串)。
|
||||
目的:底层 SDK/middleware 升级后,未来使用者可以据此判断 solution
|
||||
是否仍然适用。
|
||||
|
||||
confidence — 0.0 到 1.0
|
||||
1.0 用户明确将其陈述为规则/指令
|
||||
0.7 根据工具操作及充分证据推断
|
||||
0.5 弱推断;只有一次观察;不确定
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
FACT 字段——每段会话恰好一条,无价值门槛
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
你要为整段会话写一条 FACT——它是本次会话可恢复的状态记录,不是精心润色的回顾,也
|
||||
绝不是分散发现的列表。未来 agent 必须能够从中继续推进,且不会把建议、推断或局部搜索
|
||||
误认为已经验证的事实。
|
||||
|
||||
statement — 本次会话的紧凑摘要。它同时承担两个职责,且两者都必须完成:它既是
|
||||
retrieval vector(未来 agent 用一份新 issue report 搜索它;措辞方式
|
||||
与用户报告问题的方式一致),也是该 agent 找到后要据以行动的 payload。
|
||||
因此:
|
||||
|
||||
开头写任务和症状,使用用户会使用的措辞。
|
||||
这是保证可检索性的关键——表述应尽量贴近问题被报告时的表面形式。
|
||||
点明具体目标和对象(“reclaim disk space under D:\\WXWork”、
|
||||
“adjust the hue multiplier in QtColor._parse_value on feat/q4”、
|
||||
“locate the new_file_search folder”),绝不要写泛泛的“fix a bug”/
|
||||
“look for a file”。包含具体症状:error string、failing command、
|
||||
wrong output。
|
||||
|
||||
然后给出坐标和决定。
|
||||
对代码工作写 repo-relative path、symbol 和 branch。说明最终 SETTLED ON
|
||||
什么,并在同一分句标记丢弃了什么(“settled on X; the interim Y was
|
||||
dropped”)。这部分无法通过阅读代码重新推导——代码只展示胜出者,从不
|
||||
展示 Y 曾被尝试后否决。以后遇到被否决变体的 agent 必须能识别它,
|
||||
否则会把代码“修复”回已丢弃版本。
|
||||
|
||||
结尾写证据状态。
|
||||
必须精确对应以下之一:completed、partially completed、failed、blocked、
|
||||
still awaiting verification;还要写明如何检查(“tests run and passing”/
|
||||
“confirmed by git diff only, tests not run”)。纳入否定结果和死路,
|
||||
但不要夸大。
|
||||
|
||||
⚠️ 绝不要停留在叙事层。“The user tried X and hit error Y; no solution found
|
||||
yet”是一条浪费的记录:它容易被检索到,却不能指导未来 agent 行动。如果
|
||||
本次确实没有得出决定,就说明排除了什么、下一条尚未探索的线索是什么——
|
||||
这才是可复用部分。
|
||||
|
||||
句子的主语应是 code 和 decision,而不是“the user”。使用 2 到 4 个连贯
|
||||
句子,不要写带标签的小节。硬上限约 400 个拉丁字符(约 200 个 CJK 字符)。
|
||||
|
||||
EVIDENCE FIDELITY——强制要求:
|
||||
- 只记录 trace 中实际执行的操作和明确存在的证据。对关键 commands、flags、
|
||||
arguments、errors、numbers 保持原样。绝不要按惯例补全 command flags,
|
||||
也不要把 advice/plans 写成已执行的动作。
|
||||
- 保留 attempted、observed、verified、inferred、unresolved 之间的区别。
|
||||
不要把这些词作为标签写进正文;自然表达其含义,例如“tried … but was
|
||||
blocked by Access denied”或“suspect …, not yet verified”。
|
||||
- 没有工具输出或明确确认时,绝不要声称修复成功、原因已经确定、file/folder
|
||||
不存在或 tests passed。
|
||||
- 每个否定发现都必须限定在实际搜索范围内,并逐一列出可能使其失效的所有
|
||||
明确覆盖限制,尤其是 permission failures 和 maximum search depth:
|
||||
“not found within the searched parts of C:\\ and D:\\; some directories
|
||||
were skipped on Access denied, and the search was also depth-limited”。
|
||||
不要为了摘要更漂亮而漏掉任何限制。绝不要把局部搜索提升为全局不存在。
|
||||
- 只有 trace 直接验证了因果关系时,才能确定地陈述因果。否则保留限定词
|
||||
(“suspected”/“inferred”/“not yet verified”);不要把错误的测试假设
|
||||
改写成系统的 root cause。
|
||||
|
||||
关键——这段文字就是 retrieval vector,也是未来 query 唯一会匹配的内容。
|
||||
泛泛摘要毫无价值,因为每次会话看起来都像泛泛摘要:它会匹配所有查询,
|
||||
却无法回答任何一个。因此必须写出使本次会话可检索的具体 anchors——真实
|
||||
file paths、function / symbol names、branch names、commands、error strings、
|
||||
versions、config keys and values、host / service names。以后有人搜索其中任一
|
||||
具体项时,本摘要必须能被返回。
|
||||
good: “fixing a failing `npm run build` in CI. Read
|
||||
.github/workflows/ci.yml and package.json; traced it to webpack
|
||||
5.74 hitting ERR_OSSL_EVP_UNSUPPORTED under node 18. Bumping the
|
||||
workflow's node to 20 made the build pass; node 18 still
|
||||
reproduces locally.”
|
||||
good: “on branch feat/q0, in qutebrowser/utils/debug.py signal_name:
|
||||
PyQt<5.11 only exposes the name via repr(sig), and repr comes in
|
||||
both a bracketed and a parenthesised form. Settled on two patterns
|
||||
tried in order with fullmatch, breaking on the first hit; the
|
||||
single-regex version was dropped. Confirmed by git diff, tests not
|
||||
run.”
|
||||
bad: “user wanted a build bug fixed, the agent looked at some config
|
||||
files, changed some code, and fixed it.”
|
||||
(没有 path、command、error string、version——没有任何可检索锚点;
|
||||
每次会话都可以写成这样)
|
||||
bad: “fixed the signal-name parsing compatibility issue on a feature
|
||||
branch.”
|
||||
(哪个 branch?哪个 file?哪个 function?两个竞争版本中谁胜出?
|
||||
一周后检索到它时,没有任何信息仍然有用)
|
||||
忠实度示例(bad → good):
|
||||
“ran Get-ChildItem -Force” → “ran Get-ChildItem”
|
||||
(trace 中没有 `-Force`,不要杜撰)
|
||||
“confirmed new_file_search does not exist” →
|
||||
“new_file_search was not found within the searched parts of C:\\ and
|
||||
D:\\; some directories were skipped on Access denied or by the depth
|
||||
limit, so the investigation is only partly done”
|
||||
“identical payloads cause a spurious lock error” →
|
||||
“the verification script assumed the last writer is always a fixed
|
||||
rank; that assumption does not hold, so whether there is a lock bug
|
||||
is still unverified”
|
||||
|
||||
不要记录问候或纯闲聊。如果该段完全没有建立任何信息,输出 "fact": null。
|
||||
|
||||
files — 0..N 个本次会话中实际操作或调查过的绝对或 repo-relative FILE OR
|
||||
DIRECTORY paths。保留调查过的 roots,例如 `C:\\` 和 `D:\\`。只包含
|
||||
实际访问/搜索过的路径,不包含仅建议或计划访问的路径;不适用时填写 []。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
HOST CONTEXT(由 SDK 提供)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
- workspace_id present: {workspace_present}
|
||||
- branch present: {branch_present}
|
||||
|
||||
没有 workspace_id 时,solution 不能依赖未声明的项目本地上下文——必须在 `solution`
|
||||
或 `boundary_envs` 中明确写出其所需的 repo / path / service,使记忆能够独立使用。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
CONVERSATION
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{conversation_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"experiences": [
|
||||
{{
|
||||
"task": "...",
|
||||
"search_keys": ["...", "..."],
|
||||
"solution": "...",
|
||||
"boundary_envs": "...",
|
||||
"confidence": 0.9
|
||||
}}
|
||||
],
|
||||
"fact": {{
|
||||
"statement": "...",
|
||||
"files": ["..."]
|
||||
}}
|
||||
}}
|
||||
|
||||
强制要求:必须输出至少一条 experience,不要返回空列表。即使任务看似简单或一次成功,
|
||||
也要记录所做工作的可复用本质(method / command / how-to)。会话内容较少时,只需要
|
||||
最有价值的一条 experience——但绝不能输出空的 "experiences" array。
|
||||
|
||||
"fact" 是单个对象,绝不是列表——每次会话一条记录。只有该段是没有建立任何信息的
|
||||
纯闲聊时,才使用 "fact": null。
|
||||
"""
|
||||
|
||||
|
||||
RECONCILE_PROMPT_ZH = """\
|
||||
你需要决定如何将新的 coding memory drafts 与用户已有的 memories 合并。
|
||||
|
||||
对每条新 draft,选择以下一种 action:
|
||||
ADD — 没有已有 memory 描述相同的任务/方面
|
||||
UPDATE — 某条已有 memory 描述相同任务;用新 draft 替换它
|
||||
(solution 可能已经变化,env 可能已经升级,等等)
|
||||
SKIP — 与已有内容重复;没有新信息
|
||||
|
||||
此外,当新内容使已有候选失效或作废时,可以针对已有 candidates 输出 DELETE ops:
|
||||
DELETE — 新 conversation 明确 supersedes/cancels/invalidates 某条已有 memory,
|
||||
且无法通过 UPDATE 修复。示例:
|
||||
* 用户说“don't use X anymore”,而 X 只存在于另一条独立 memory 中
|
||||
(不是本次正在 ADD/UPDATE 的那条)
|
||||
* candidate 的 boundary_envs(service / endpoint / SDK)已经消失,
|
||||
且新 conversation 确认其已 deprecated
|
||||
|
||||
规则:
|
||||
- “Same task”是指已有 memory 和新 draft 都可以作为同一个未来用户查询的有效答案。
|
||||
- 如果 solution 不同,优先 UPDATE(不保留历史版本)。
|
||||
- 如果 task 和 solution 都高度一致,选择 SKIP。
|
||||
- 只有在存在明确证据表明 candidate 已 invalid/obsolete 时才 DELETE。绝不要仅仅因为
|
||||
candidate 看起来“stale”就 DELETE。
|
||||
- 产生 DELETE 时,输出的 ops 数量可以超过 drafts 数量。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW DRAFTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING CANDIDATES(按每条 draft 的 task similarity 检索)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "action": "ADD"}},
|
||||
{{"draft_idx": 1, "action": "UPDATE", "target_experience_id": "exp_xxx", "reason": "..."}},
|
||||
{{"draft_idx": 1, "action": "DELETE", "target_experience_id": "exp_yyy", "reason": "user explicitly deprecated Y"}},
|
||||
{{"draft_idx": 2, "action": "SKIP", "reason": "duplicate"}}
|
||||
]
|
||||
}}
|
||||
|
||||
如果没有 drafts,输出 {{"ops": []}}。
|
||||
"""
|
||||
|
||||
|
||||
FACT_RECONCILE_PROMPT_ZH = """\
|
||||
你负责维护 session-level WORKING FACTS。每条 statement 都概括一整段 trace/session:
|
||||
用户的任务、大致方法和结果。它不是一条 atomic claim。
|
||||
|
||||
下面每条新的 session fact 都一定会被存储;这不是你要决定的事情。你的唯一任务是识别
|
||||
task continuation:新 session 是否在继续推进某条已有 session fact 所记录的同一个用户
|
||||
任务?如果是,就将较旧 fact 标记为 SUPERSEDE,使这些链接为该任务构成跨 session 的
|
||||
进展链。
|
||||
|
||||
只有在存在充分证据同时满足以下条件时,才将已有 fact 标记为 superseded:
|
||||
- subjects 匹配:两条记录围绕同一个实体——同一个 person、同一个 file/module、
|
||||
同一个 host/service。subject 相同是必要条件,但不是充分条件;并且
|
||||
- 用户目标在实质上相同,而不只是处于同一 topic/project;并且
|
||||
- 新 trace 在继续、重试、扩展、验证或完成已有 trace 所记录的工作。
|
||||
|
||||
以下情况不要 supersede:
|
||||
- 两条记录的 subjects 不同。同一 group 中的两个人、同一 module 中的两个 files、
|
||||
同一 cluster 中的两个 hosts 是并行关系,不是先后关系:一个人的状态不是另一个人
|
||||
状态的新版本。如果 subject 显示为 (unspecified),退回到只根据 statement 判断。
|
||||
- 两次 session 只是提到了相同 file、symbol、component、repository、error 或
|
||||
technology,但追求不同的用户目标。
|
||||
- 一个任务只是为另一个任务提供 context 或 technique。
|
||||
- 关系不确定。应保守判断:INSERT 是安全默认值;合并无关 session 会毁掉它们各自
|
||||
独立的任务历史。
|
||||
|
||||
把每条 statement 当作有证据边界的文本。不要把其中的 inference 重新解释为已经确定的
|
||||
cause,也不要把共同的 conjecture/error 当作用户目标相同的证据。你的判断仅涉及 task
|
||||
continuation;不要“纠正”、补全或润色任何一条 session 记录。
|
||||
|
||||
示例:
|
||||
existing: “需要修复 CI 的 npm run build;已追踪到 Node 18 下的
|
||||
ERR_OSSL_EVP_UNSUPPORTED,workflow 尚未修改。”
|
||||
new: “继续修复同一个 CI build;已将 .github/workflows/ci.yml 中的 Node
|
||||
提升到 20,且 npm run build 通过。”
|
||||
→ SUPERSEDE
|
||||
|
||||
existing: “需要修复 auth/middleware.ts 中的 JWT expiry handling;已添加
|
||||
refresh logic。”
|
||||
new: “需要在 auth/middleware.ts 中添加 performance logging;已引入
|
||||
latency metric。”
|
||||
→ 两者都保留
|
||||
(同一 file/project,不同用户目标)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW SESSION FACTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING SESSION FACTS(按每条新 fact 的 statement similarity 检索)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(仅输出严格 JSON,不要 markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "supersedes": ["fact_xxx"], "reason": "continues the same CI fix task"}},
|
||||
{{"draft_idx": 1, "supersedes": []}}
|
||||
]
|
||||
}}
|
||||
|
||||
只能使用同一个 draft_idx 的 candidate list 中实际出现的 fact ids。
|
||||
如果任何位置都没有 fact 被 superseded,输出 {{"ops": []}}。
|
||||
"""
|
||||
|
||||
|
||||
CHAIN_SUMMARY_PROMPT_ZH = """\
|
||||
working-memory store 中的某个用户任务已跨多个 sessions 推进。下面是该任务进展链中的
|
||||
session summaries,按从最早到最新排列,最后一条是当前 session。
|
||||
|
||||
总结同一个任务如何跨 sessions 推进到当前状态。按时间顺序覆盖每个重要阶段。对每个
|
||||
阶段,在有信息时保留:实际 action 或 change、其 direct result,以及任何 unresolved
|
||||
issue 或 limiting condition。最后明确写出当前状态。
|
||||
|
||||
规则:
|
||||
- 这只是压缩。只能报告 entries 实际陈述的内容;绝不要新增或补全 command、cause、
|
||||
motive、action、test result、success state 或 global negative conclusion。
|
||||
- 精确保留证据状态:attempted 不等于 completed,observed 不等于 verified,inferred
|
||||
不等于 established,unresolved 必须保持 unresolved。保留 uncertainty words 以及
|
||||
search/permission/coverage limits。
|
||||
- 忠实陈述当前结束状态(completed、partially completed、failed、blocked 或
|
||||
still awaiting verification)。绝不要因为某个看似合理的因果故事,或后续 entry
|
||||
并未验证的内容而提升状态。
|
||||
- 如果 entries 提到 suspected cause 和后续 result,只有某条 entry 直接说明该 cause
|
||||
已被 verified 时,才可在两者之间建立因果联系。
|
||||
- 保留具体 retrieval anchors:file paths、symbols、commands、error strings、versions、
|
||||
config keys/values,以及对进展重要的 numeric values。
|
||||
- 对 2-3 代的 chains,不要把进展过度压缩成一句话。适合时优先使用 2-4 个紧凑段落,
|
||||
或使用可读性良好的 prose,清楚呈现 task goal、逐阶段 progression 和 current status。
|
||||
- 不要机械复制完整 history。在保留重要 actions、direct results 和 leftovers 的同时,
|
||||
省略重复及低价值细节。
|
||||
- 不要杜撰 stage headings 或 dates。只有 entries 使日期有实际意义时才提及日期;
|
||||
单独的 timestamps 是噪声。
|
||||
- 如果 entries 说明更早 revisions 已被省略,必须明确指出 earlier history was omitted;
|
||||
不要暗示所提供的 entries 是完整历史。
|
||||
- 使用与 entries 相同的语言。
|
||||
- 不要写前言,也不要用引号包裹输出。
|
||||
|
||||
示例
|
||||
entries:
|
||||
(2026-07-20) 需要修复 CI 的 npm run build;已追踪到 Node 18 下的
|
||||
ERR_OSSL_EVP_UNSUPPORTED,workflow 尚未修改。
|
||||
(2026-07-24) 继续修复同一个 CI 问题;已将 .github/workflows/ci.yml 中的 Node
|
||||
提升到 20,但 dependency cache 仍复现旧错误。
|
||||
(now) 继续验证同一修复;清除 npm cache 后,npm run build 通过。
|
||||
output:
|
||||
任务目标是修复 CI 的 npm run build。最初将问题追踪到 Node 18 下的
|
||||
ERR_OSSL_EVP_UNSUPPORTED,当时尚未修改 workflow;随后将
|
||||
.github/workflows/ci.yml 提升到 Node 20,但 dependency cache 仍使旧错误继续
|
||||
出现。最后,清除 npm cache 后 npm run build 通过,因此该 build 修复目前已验证完成。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
ENTRIES
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{chain_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT(只输出 summary 本身,不要其他内容)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
"""
|
||||
from ..prompts.zh_hans import ( # noqa: F401
|
||||
CODING_CHAIN_SUMMARY_PROMPT as CHAIN_SUMMARY_PROMPT_ZH,
|
||||
CODING_EXTRACT_PROMPT as EXTRACT_PROMPT_ZH,
|
||||
CODING_EXTRACT_PROMPT_CODING as EXTRACT_PROMPT_CODING_ZH,
|
||||
CODING_FACT_RECONCILE_PROMPT as FACT_RECONCILE_PROMPT_ZH,
|
||||
CODING_RECONCILE_PROMPT as RECONCILE_PROMPT_ZH,
|
||||
)
|
||||
+206
-98
@@ -7,6 +7,9 @@ Coding Memory Reconciler
|
||||
(DELETE 由 LLM 在 draft 与 candidate 关系中观察到"明确否定/作废"时附带产出)
|
||||
|
||||
执行端:reconcile() 调用 store 完成实际 ADD / UPDATE / DELETE 持久化操作。
|
||||
CodingWriter 分两步用:先 plan()(检索 + LLM 决策,不落库),等经验层与事实层的
|
||||
模型决策都成功后再 apply()(落库)。LLM 失败 / 空回复 / 解析不出时 plan() 抛
|
||||
CodingWriteError(RECONCILE_FAILED),本次写入什么都不存。
|
||||
|
||||
复用 LLMConfig.model(与 chat reconciler 共用)。
|
||||
|
||||
@@ -17,14 +20,20 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..utils.lang_detect import is_chinese
|
||||
from ..prompts import (
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from .judge import _store_coding_log
|
||||
from .store import CodingMemoryStore
|
||||
from .types import CodingMemory, CodingMemoryDraft, ReconcileOp
|
||||
from .types import CodingMemory, CodingMemoryDraft, CodingWriteError, ReconcileOp
|
||||
|
||||
|
||||
|
||||
@@ -50,57 +59,7 @@ def _resolve_configured_temperature(llm_provider) -> Optional[float]:
|
||||
# Prompt
|
||||
# ================================================================
|
||||
|
||||
RECONCILE_PROMPT = """\
|
||||
You decide how to merge new coding memory drafts with the user's existing memories.
|
||||
|
||||
For each new draft, decide one of:
|
||||
ADD — no existing memory describes the same task/aspect
|
||||
UPDATE — an existing memory describes the same task; the new draft replaces it
|
||||
(solution may have changed, env may have upgraded, etc.)
|
||||
SKIP — duplicate of existing; nothing new
|
||||
|
||||
Additionally, you may emit DELETE ops on existing candidates when the new
|
||||
content makes them obsolete or invalid:
|
||||
DELETE — the new conversation EXPLICITLY supersedes/cancels/invalidates an
|
||||
existing memory that cannot be repaired via UPDATE. Examples:
|
||||
* user says "don't use X anymore" and X is only present as a
|
||||
separate memory (not the one being added/updated)
|
||||
* candidate's boundary_envs (service / endpoint / SDK) is gone
|
||||
and the new conversation confirms the deprecation
|
||||
|
||||
Rules:
|
||||
- "Same task" means the existing memory and the new draft would both be valid
|
||||
answers to the same future user query.
|
||||
- If solution differs, prefer UPDATE (we don't keep history).
|
||||
- If both task and solution match closely, SKIP.
|
||||
- DELETE only when there is EXPLICIT evidence the candidate is invalid/obsolete.
|
||||
Never DELETE just because a candidate looks "stale".
|
||||
- Output ops can outnumber drafts when DELETEs are produced.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
NEW DRAFTS
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{drafts_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
EXISTING CANDIDATES (per draft, retrieved by task similarity)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{candidates_block}
|
||||
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
OUTPUT (strict JSON only, no markdown)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
{{
|
||||
"ops": [
|
||||
{{"draft_idx": 0, "action": "ADD"}},
|
||||
{{"draft_idx": 1, "action": "UPDATE", "target_experience_id": "exp_xxx", "reason": "..."}},
|
||||
{{"draft_idx": 1, "action": "DELETE", "target_experience_id": "exp_yyy", "reason": "user explicitly deprecated Y"}},
|
||||
{{"draft_idx": 2, "action": "SKIP", "reason": "duplicate"}}
|
||||
]
|
||||
}}
|
||||
|
||||
If there are no drafts, output {{"ops": []}}.
|
||||
"""
|
||||
from ..prompts.en import CODING_RECONCILE_PROMPT as RECONCILE_PROMPT # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
def _drafts_language_text(drafts: List[CodingMemoryDraft]) -> str:
|
||||
@@ -119,24 +78,32 @@ def _drafts_language_text(drafts: List[CodingMemoryDraft]) -> str:
|
||||
def build_experience_reconcile_prompt(
|
||||
drafts: List[CodingMemoryDraft],
|
||||
candidates_per_draft: List[List[CodingMemory]],
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Return the language-matched experience reconcile prompt and language."""
|
||||
"""Return the experience reconcile prompt and its language code.
|
||||
|
||||
Explicit ``language`` wins; otherwise zh/en auto-detection over the drafts.
|
||||
"""
|
||||
drafts_block = CodingMemoryReconciler._format_drafts(drafts)
|
||||
candidates_block = CodingMemoryReconciler._format_candidates(
|
||||
drafts, candidates_per_draft,
|
||||
)
|
||||
if is_chinese(_drafts_language_text(drafts)):
|
||||
from .prompts_zh import RECONCILE_PROMPT_ZH
|
||||
|
||||
template = RECONCILE_PROMPT_ZH
|
||||
language = "zh"
|
||||
else:
|
||||
template = RECONCILE_PROMPT
|
||||
language = "en"
|
||||
return template.format(
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, _drafts_language_text(drafts))
|
||||
prompt = get_prompt("CODING_RECONCILE_PROMPT", code).format(
|
||||
drafts_block=drafts_block,
|
||||
candidates_block=candidates_block,
|
||||
), language
|
||||
)
|
||||
return with_output_language(prompt, code, explicit=explicit is not None), code
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExperienceReconcilePlan:
|
||||
"""plan() 的产物:模型已决策、尚未落库。apply() 才执行 store 操作。"""
|
||||
|
||||
drafts: List[CodingMemoryDraft]
|
||||
candidates_per_draft: List[List[CodingMemory]] = field(default_factory=list)
|
||||
ops: List[ReconcileOp] = field(default_factory=list)
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -186,23 +153,55 @@ class CodingMemoryReconciler:
|
||||
user_id: str = "",
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> List[ReconcileOp]:
|
||||
"""
|
||||
执行 reconcile + 持久化。返回执行的 ReconcileOp[](含成功执行的 ADD/UPDATE/DELETE/SKIP)。
|
||||
执行 reconcile + 持久化(plan() 后紧接 apply())。返回执行的 ReconcileOp[]
|
||||
(含成功执行的 ADD/UPDATE/DELETE/SKIP)。
|
||||
|
||||
执行错误(如 LLM 失败)会 fail-safe 回退到对每个 draft 直接 ADD。
|
||||
LLM 失败、空回复或解析不出时抛 CodingWriteError(RECONCILE_FAILED),什么都
|
||||
不落库——不再回退成「全部 ADD」。
|
||||
"""
|
||||
plan = await self.plan(
|
||||
drafts,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
language=language,
|
||||
)
|
||||
return await self.apply(plan)
|
||||
|
||||
cache 非空时落 CODING_RECONCILE pipeline log——在 LLM 决策后、执行入库前打点,
|
||||
这样即使 store 入库崩溃,reconcile 决策仍已可见(crash 不丢)。
|
||||
async def plan(
|
||||
self,
|
||||
drafts: List[CodingMemoryDraft],
|
||||
*,
|
||||
request_id: str = "",
|
||||
user_id: str = "",
|
||||
agent_id: str = "default_agent",
|
||||
session_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> ExperienceReconcilePlan:
|
||||
"""检索 candidates + LLM 决策,不落库。
|
||||
|
||||
有效回复 = 一个 JSON 对象,其 "ops" 是列表:
|
||||
- {"ops": []}:模型判定「无需任何操作」(与 chat reconcile 的 [] 同义),
|
||||
成功、本层什么都不存;
|
||||
- 非空列表:至少要有一条可识别的 op(ADD/UPDATE/DELETE/SKIP),否则视为
|
||||
解析失败。漏掉的 draft 仍按原规则补 ADD(那是对有效回复的校验兜底,
|
||||
不是模型失败兜底)。
|
||||
LLM 调用失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、
|
||||
回复不是上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
|
||||
|
||||
cache 非空时落 CODING_RECONCILE pipeline log(成功、失败都落)。
|
||||
"""
|
||||
if not drafts:
|
||||
return []
|
||||
return ExperienceReconcilePlan(drafts=[])
|
||||
|
||||
# 从 draft 回填 trace 身份(writer 通常显式传,这里兜底)
|
||||
if drafts:
|
||||
user_id = user_id or drafts[0].user_id
|
||||
agent_id = agent_id or drafts[0].agent_id
|
||||
session_id = session_id or (drafts[0].session_id or "")
|
||||
user_id = user_id or drafts[0].user_id
|
||||
agent_id = agent_id or drafts[0].agent_id
|
||||
session_id = session_id or (drafts[0].session_id or "")
|
||||
|
||||
# 1. 检索 candidates
|
||||
candidates_per_draft: List[List[CodingMemory]] = await self._retrieve_candidates(drafts)
|
||||
@@ -212,19 +211,20 @@ class CodingMemoryReconciler:
|
||||
_decide_prompt = ""
|
||||
_decide_raw = ""
|
||||
_decide_resp = None
|
||||
failure: Optional[Tuple[str, str]] = None
|
||||
ops: List[ReconcileOp] = []
|
||||
try:
|
||||
ops, _decide_prompt, _decide_raw, _decide_resp = await self._llm_decide(
|
||||
drafts, candidates_per_draft
|
||||
drafts, candidates_per_draft, language=language,
|
||||
)
|
||||
failure = self._reply_failure(_decide_raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-reconcile] LLM decide failed, fallback to ADD-all: {e}")
|
||||
ops = [ReconcileOp(action="ADD", draft_idx=i) for i in range(len(drafts))]
|
||||
logger.warning(f"[coding-reconcile] LLM decide failed: {e}")
|
||||
_decide_raw = f"[LLM error] {type(e).__name__}: {str(e)[:300]}"
|
||||
|
||||
if not ops:
|
||||
# LLM 输出无 ops,但有 drafts → fallback ADD-all
|
||||
logger.info("[coding-reconcile] LLM returned no ops, fallback to ADD-all")
|
||||
ops = [ReconcileOp(action="ADD", draft_idx=i) for i in range(len(drafts))]
|
||||
failure = (
|
||||
"LLM_UNAVAILABLE",
|
||||
f"coding reconcile LLM call failed: {type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
_decide_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||||
# CODING_RECONCILE:LLM 决策完成即打点(执行入库之前,crash 不丢)
|
||||
@@ -232,17 +232,42 @@ class CodingMemoryReconciler:
|
||||
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||||
session_id=session_id, step="CODING_RECONCILE",
|
||||
prompt=_decide_prompt, response=_decide_raw,
|
||||
parsed=json.dumps({"ops": [o.to_dict() for o in ops]}, ensure_ascii=False, default=str),
|
||||
parsed=json.dumps(
|
||||
{"error": failure[0], "message": failure[1], "stored": 0}
|
||||
if failure else {"ops": [o.to_dict() for o in ops]},
|
||||
ensure_ascii=False, default=str,
|
||||
),
|
||||
elapsed_ms=_decide_elapsed,
|
||||
prompt_tokens=getattr(_decide_resp, "prompt_tokens", 0) if _decide_resp else 0,
|
||||
completion_tokens=getattr(_decide_resp, "completion_tokens", 0) if _decide_resp else 0,
|
||||
total_tokens=getattr(_decide_resp, "tokens_used", 0) if _decide_resp else 0,
|
||||
)
|
||||
if failure is not None:
|
||||
code, message = failure
|
||||
logger.warning(f"[coding-reconcile] {code}: {message}")
|
||||
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
|
||||
|
||||
# 3. 校验 + 执行
|
||||
executed = await self._execute(ops, drafts)
|
||||
return ExperienceReconcilePlan(
|
||||
drafts=list(drafts),
|
||||
candidates_per_draft=candidates_per_draft,
|
||||
ops=ops,
|
||||
)
|
||||
|
||||
async def apply(self, plan: ExperienceReconcilePlan) -> List[ReconcileOp]:
|
||||
"""执行 plan() 的决策(store ADD / UPDATE / DELETE)。"""
|
||||
if not plan.drafts:
|
||||
return []
|
||||
if not plan.ops:
|
||||
# 有效的 {"ops": []}:模型判定无需任何操作,本层什么都不存。
|
||||
logger.info(
|
||||
f"[coding-reconcile] drafts={len(plan.drafts)} LLM decided no ops"
|
||||
)
|
||||
return []
|
||||
executed = await self._execute(
|
||||
plan.ops, plan.drafts, plan.candidates_per_draft,
|
||||
)
|
||||
logger.info(
|
||||
f"[coding-reconcile] drafts={len(drafts)} ops={len(executed)} "
|
||||
f"[coding-reconcile] drafts={len(plan.drafts)} ops={len(executed)} "
|
||||
+ ", ".join(f"{a}={sum(1 for o in executed if o.action == a)}"
|
||||
for a in ("ADD", "UPDATE", "DELETE", "SKIP"))
|
||||
)
|
||||
@@ -263,6 +288,12 @@ class CodingMemoryReconciler:
|
||||
"""
|
||||
result: List[List[CodingMemory]] = []
|
||||
for d in drafts:
|
||||
# A coding mutation must be attributable to one conversation.
|
||||
# Empty/legacy session metadata stays readable through ordinary
|
||||
# search, but it cannot authorize UPDATE/DELETE.
|
||||
if not d.session_id:
|
||||
result.append([])
|
||||
continue
|
||||
try:
|
||||
# task 为主入口,search_keys 为附加入口(去重、去空)
|
||||
queries = [d.task] + [k for k in (d.search_keys or []) if k and k != d.task]
|
||||
@@ -274,6 +305,8 @@ class CodingMemoryReconciler:
|
||||
hits = await self.store.search_by_query_embedding(
|
||||
emb,
|
||||
user_id=d.user_id,
|
||||
agent_id=d.agent_id,
|
||||
session_id=d.session_id,
|
||||
workspace_id=d.workspace_id,
|
||||
branch=d.branch,
|
||||
top=self.candidates_per_draft * 4,
|
||||
@@ -303,13 +336,17 @@ class CodingMemoryReconciler:
|
||||
self,
|
||||
drafts: List[CodingMemoryDraft],
|
||||
candidates_per_draft: List[List[CodingMemory]],
|
||||
language: Optional[str] = None,
|
||||
) -> Tuple[List[ReconcileOp], str, str, Any]:
|
||||
"""返回 (ops, prompt, raw_response, llm_resp),供上层打 CODING_RECONCILE log。"""
|
||||
prompt, _ = build_experience_reconcile_prompt(drafts, candidates_per_draft)
|
||||
prompt, _ = build_experience_reconcile_prompt(
|
||||
drafts, candidates_per_draft, language=language,
|
||||
)
|
||||
|
||||
resp = await self.llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=self.max_tokens,
|
||||
audit_stage="coding_reconcile",
|
||||
)
|
||||
return self._parse_ops(resp.content), prompt, resp.content, resp
|
||||
|
||||
@@ -349,11 +386,30 @@ class CodingMemoryReconciler:
|
||||
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
|
||||
return "\n\n".join(rows) if rows else "(none)"
|
||||
|
||||
@classmethod
|
||||
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
|
||||
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
|
||||
if not (text or "").strip():
|
||||
return "EMPTY_RESPONSE", "coding reconcile LLM returned empty response"
|
||||
obj = cls._load_object(text)
|
||||
ops_raw = obj.get("ops") if isinstance(obj, dict) else None
|
||||
if not isinstance(ops_raw, list):
|
||||
return (
|
||||
"JSON_PARSE_FAILED",
|
||||
f"coding reconcile output has no ops list ({len(text)} chars)",
|
||||
)
|
||||
if ops_raw and not cls._parse_ops(text):
|
||||
return (
|
||||
"JSON_PARSE_FAILED",
|
||||
f"coding reconcile output has no recognizable op ({len(text)} chars)",
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_ops(text: str) -> List[ReconcileOp]:
|
||||
"""宽松解析 ops 列表。"""
|
||||
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
|
||||
if not text:
|
||||
return []
|
||||
return None
|
||||
s = text.strip()
|
||||
# 去 markdown fence
|
||||
if s.startswith("```"):
|
||||
@@ -377,9 +433,19 @@ class CodingMemoryReconciler:
|
||||
obj = json.loads(s[start: end + 1])
|
||||
except Exception:
|
||||
obj = None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
if not obj or not isinstance(obj, dict):
|
||||
logger.warning(f"[coding-reconcile] failed to parse ops output: {text!r}")
|
||||
@classmethod
|
||||
def _parse_ops(cls, text: str) -> List[ReconcileOp]:
|
||||
"""宽松解析 ops 列表。"""
|
||||
if not text:
|
||||
return []
|
||||
obj = cls._load_object(text)
|
||||
if not obj:
|
||||
logger.warning(
|
||||
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[coding-reconcile] unparsable ops output: {text!r}")
|
||||
return []
|
||||
ops_raw = obj.get("ops") or []
|
||||
if not isinstance(ops_raw, list):
|
||||
@@ -414,6 +480,7 @@ class CodingMemoryReconciler:
|
||||
self,
|
||||
ops: List[ReconcileOp],
|
||||
drafts: List[CodingMemoryDraft],
|
||||
candidates_per_draft: List[List[CodingMemory]],
|
||||
) -> List[ReconcileOp]:
|
||||
"""
|
||||
校验 + 执行 ops。返回成功执行的 ops(filter 掉无效 op)。
|
||||
@@ -423,6 +490,9 @@ class CodingMemoryReconciler:
|
||||
- UPDATE 需 draft_idx 合法 + target_experience_id 非空
|
||||
- DELETE 需 target_experience_id 非空
|
||||
- SKIP 不执行任何 store 操作
|
||||
|
||||
向量化失败(CodingWriteError(EMBEDDING_FAILED))不吞成「少执行一条」,
|
||||
原样抛给 CodingWriter,整次写入报失败。
|
||||
"""
|
||||
executed: List[ReconcileOp] = []
|
||||
# 已被 UPDATE 占用的 draft_idx → 防止同 draft 被重复 ADD/UPDATE
|
||||
@@ -431,10 +501,10 @@ class CodingMemoryReconciler:
|
||||
for op in ops:
|
||||
if op.action == "ADD":
|
||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
||||
logger.warning(f"[coding-reconcile] drop invalid ADD: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop invalid ADD: {_op_ref(op)}")
|
||||
continue
|
||||
if op.draft_idx in consumed:
|
||||
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {_op_ref(op)}")
|
||||
continue
|
||||
d = drafts[op.draft_idx]
|
||||
memory = self._draft_to_new_memory(d)
|
||||
@@ -443,18 +513,25 @@ class CodingMemoryReconciler:
|
||||
op.target_experience_id = memory.experience_id # 回写 id 供 caller 读
|
||||
executed.append(op)
|
||||
consumed.add(op.draft_idx)
|
||||
except CodingWriteError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-reconcile] ADD failed: {e}")
|
||||
|
||||
elif op.action == "UPDATE":
|
||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
|
||||
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {_op_ref(op)}")
|
||||
continue
|
||||
if not op.target_experience_id:
|
||||
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {_op_ref(op)}")
|
||||
continue
|
||||
if not self._target_was_offered(op, candidates_per_draft):
|
||||
logger.warning(
|
||||
f"[coding-reconcile] drop UPDATE outside authority scope: {_op_ref(op)}"
|
||||
)
|
||||
continue
|
||||
if op.draft_idx in consumed:
|
||||
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {_op_ref(op)}")
|
||||
continue
|
||||
d = drafts[op.draft_idx]
|
||||
memory = self._draft_to_new_memory(d, experience_id=op.target_experience_id)
|
||||
@@ -468,17 +545,24 @@ class CodingMemoryReconciler:
|
||||
await self.store.update(memory)
|
||||
executed.append(op)
|
||||
consumed.add(op.draft_idx)
|
||||
except CodingWriteError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-reconcile] UPDATE failed: {e}")
|
||||
|
||||
elif op.action == "DELETE":
|
||||
if not op.target_experience_id:
|
||||
logger.warning(f"[coding-reconcile] drop DELETE missing target: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop DELETE missing target: {_op_ref(op)}")
|
||||
continue
|
||||
if not self._target_was_offered(op, candidates_per_draft):
|
||||
logger.warning(
|
||||
f"[coding-reconcile] drop DELETE outside authority scope: {_op_ref(op)}"
|
||||
)
|
||||
continue
|
||||
# DELETE 需要知道 user_id;通过 draft_idx(如有)或第一个 draft 推断
|
||||
user_id = self._infer_user_id(op, drafts)
|
||||
if not user_id:
|
||||
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {op.to_dict()}")
|
||||
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {_op_ref(op)}")
|
||||
continue
|
||||
try:
|
||||
ok = await self.store.delete(op.target_experience_id, user_id=user_id)
|
||||
@@ -506,11 +590,26 @@ class CodingMemoryReconciler:
|
||||
target_experience_id=memory.experience_id,
|
||||
reason="fallback ADD (no LLM op)",
|
||||
))
|
||||
except CodingWriteError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-reconcile] fallback ADD failed: {e}")
|
||||
|
||||
return executed
|
||||
|
||||
@staticmethod
|
||||
def _target_was_offered(
|
||||
op: ReconcileOp,
|
||||
candidates_per_draft: List[List[CodingMemory]],
|
||||
) -> bool:
|
||||
"""Only mutate a target frozen into this draft's scoped candidate set."""
|
||||
if op.draft_idx is None or not (0 <= op.draft_idx < len(candidates_per_draft)):
|
||||
return False
|
||||
return any(
|
||||
candidate.experience_id == op.target_experience_id
|
||||
for candidate in candidates_per_draft[op.draft_idx]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _infer_user_id(
|
||||
op: ReconcileOp, drafts: List[CodingMemoryDraft]
|
||||
@@ -544,6 +643,7 @@ class CodingMemoryReconciler:
|
||||
confidence=d.confidence,
|
||||
source=d.source,
|
||||
extraction_source=d.extraction_source,
|
||||
memory_at=d.memory_at,
|
||||
)
|
||||
|
||||
|
||||
@@ -551,6 +651,14 @@ class CodingMemoryReconciler:
|
||||
# Helpers
|
||||
# ================================================================
|
||||
|
||||
def _op_ref(op: ReconcileOp) -> str:
|
||||
"""op 的日志引用:只带动作与编号,不带 LLM 写的 reason。"""
|
||||
return (
|
||||
f"action={op.action} draft_idx={op.draft_idx} "
|
||||
f"target={op.target_experience_id}"
|
||||
)
|
||||
|
||||
|
||||
def _truncate(s: str, n: int) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
|
||||
+447
-52
@@ -44,7 +44,7 @@ from ..data.vector_store import create_vector_store
|
||||
from ..data.vector_store_base import VectorStoreBase
|
||||
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
||||
from ..utils.async_executor import run_in_executor_guarded
|
||||
from .types import CodingMemory, WorkingFact
|
||||
from .types import CodingMemory, CodingWriteError, WorkingFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta (
|
||||
confidence REAL NOT NULL DEFAULT 0.7,
|
||||
source TEXT NOT NULL DEFAULT 'default',
|
||||
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
||||
memory_at INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -109,6 +110,7 @@ CREATE TABLE IF NOT EXISTS working_fact (
|
||||
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
||||
memory_at INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
||||
@@ -164,6 +166,8 @@ _ADDED_COLUMNS = {
|
||||
"coding_memory_meta": {
|
||||
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
||||
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
|
||||
"memory_at": "INTEGER",
|
||||
},
|
||||
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
||||
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
||||
@@ -177,6 +181,7 @@ _ADDED_COLUMNS = {
|
||||
"subject": "TEXT NOT NULL DEFAULT ''",
|
||||
"details": "TEXT NOT NULL DEFAULT ''",
|
||||
"source": "TEXT NOT NULL DEFAULT 'default'",
|
||||
"memory_at": "INTEGER",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -209,6 +214,29 @@ def _scope_where(
|
||||
return sql, params
|
||||
|
||||
|
||||
def _in_clause(column: str, values: Optional[List[str]]) -> Tuple[str, List[Any]]:
|
||||
"""SQL ``column IN (...)`` + parameters; empty values mean no condition."""
|
||||
if not values:
|
||||
return "", []
|
||||
placeholders = ",".join("?" for _ in values)
|
||||
return f"{column} IN ({placeholders})", list(values)
|
||||
|
||||
|
||||
def _source_where(
|
||||
include_sources: Optional[List[str]], exclude_sources: Optional[List[str]],
|
||||
) -> Tuple[str, List[Any]]:
|
||||
"""Caller-source filter as SQL (``1`` when unfiltered); exclusion wins."""
|
||||
clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
if include_sources:
|
||||
clauses.append(f"source IN ({','.join('?' for _ in include_sources)})")
|
||||
params.extend(include_sources)
|
||||
if exclude_sources:
|
||||
clauses.append(f"source NOT IN ({','.join('?' for _ in exclude_sources)})")
|
||||
params.extend(exclude_sources)
|
||||
return (" AND ".join(clauses) or "1"), params
|
||||
|
||||
|
||||
def _scope_matches(
|
||||
row_workspace_id: Optional[str],
|
||||
row_branch: Optional[str],
|
||||
@@ -267,6 +295,29 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def _from_epoch(value: Any) -> Optional[datetime]:
|
||||
"""memory_at 列(秒级 epoch)→ datetime;NULL 或坏值返回 None。"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _to_epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
def _later(a: Optional[datetime], b: Optional[datetime]) -> Optional[datetime]:
|
||||
"""两个对话时间取较晚的;一边缺就取另一边。"""
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return a if _to_epoch(a) >= _to_epoch(b) else b
|
||||
|
||||
|
||||
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||
"""SQLite row → CodingMemory"""
|
||||
return CodingMemory(
|
||||
@@ -285,6 +336,7 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||
confidence=row["confidence"],
|
||||
source=row["source"],
|
||||
extraction_source=row["extraction_source"],
|
||||
memory_at=_from_epoch(row["memory_at"]),
|
||||
created_at=_parse_dt(row["created_at"]),
|
||||
updated_at=_parse_dt(row["updated_at"]),
|
||||
)
|
||||
@@ -311,6 +363,7 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
|
||||
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
||||
fact_chain_summary=(row["fact_chain_summary"] or ""
|
||||
if "fact_chain_summary" in row.keys() else ""),
|
||||
memory_at=_from_epoch(row["memory_at"]),
|
||||
created_at=_parse_dt(row["created_at"]),
|
||||
)
|
||||
|
||||
@@ -408,6 +461,36 @@ class CodingMemoryStore:
|
||||
|
||||
self._initialized = False
|
||||
|
||||
@staticmethod
|
||||
def database_path(
|
||||
config: MemoryConfig,
|
||||
db_path: Optional[str] = None,
|
||||
variant: str = "working",
|
||||
) -> str:
|
||||
"""Return the authoritative SQLite path without creating anything."""
|
||||
if db_path is None:
|
||||
data_dir = (
|
||||
os.path.dirname(config.history.db_path)
|
||||
if config.history.db_path else "./data"
|
||||
)
|
||||
db_path = os.path.join(data_dir, "coding_memory.db")
|
||||
normalized = (
|
||||
"coding"
|
||||
if str(variant or "").strip().lower() == "coding"
|
||||
else "working"
|
||||
)
|
||||
return _variant_db_path(db_path, "_cv" if normalized == "coding" else "")
|
||||
|
||||
@classmethod
|
||||
def database_exists(
|
||||
cls,
|
||||
config: MemoryConfig,
|
||||
db_path: Optional[str] = None,
|
||||
variant: str = "working",
|
||||
) -> bool:
|
||||
"""Whether a coding namespace has an existing SQLite database."""
|
||||
return os.path.isfile(cls.database_path(config, db_path, variant))
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
@@ -516,29 +599,52 @@ class CodingMemoryStore:
|
||||
# ------------------------------------------------------------
|
||||
|
||||
async def insert(self, memory: CodingMemory) -> None:
|
||||
"""新增一条 coding memory(SQLite + VDB 全套)"""
|
||||
"""新增一条 coding memory(SQLite + VDB 全套)。
|
||||
|
||||
先向量化再落 SQLite:向量化失败抛 CodingWriteError(EMBEDDING_FAILED),
|
||||
什么都不写,不留一条没有向量、永远检索不到的经验。
|
||||
"""
|
||||
self._fill_timestamps(memory)
|
||||
embeddings = await self._embed_vec_keys(memory)
|
||||
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||
await self._upsert_vec_keys(memory)
|
||||
await self._upsert_vec_keys(memory, embeddings=embeddings)
|
||||
logger.info(
|
||||
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||
f"task={memory.task!r} keys={1 + len(memory.search_keys)}"
|
||||
f"keys={1 + len(memory.search_keys)}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[coding-store] inserted experience_id={memory.experience_id} "
|
||||
f"task={memory.task!r}"
|
||||
)
|
||||
|
||||
async def update(self, memory: CodingMemory) -> None:
|
||||
"""覆盖式更新一条 coding memory。"""
|
||||
# 拿旧 keys 长度,先删旧 VDB rows
|
||||
prev = await self.get_by_id(memory.experience_id, user_id=memory.user_id)
|
||||
prev_key_count = (1 + len(prev.search_keys)) if prev else 0
|
||||
if prev is None:
|
||||
raise ValueError("coding_memory_update_target_missing")
|
||||
if (
|
||||
not prev.session_id
|
||||
or not memory.session_id
|
||||
or prev.agent_id != memory.agent_id
|
||||
or prev.session_id != memory.session_id
|
||||
or prev.workspace_id != memory.workspace_id
|
||||
):
|
||||
raise ValueError("coding_memory_update_authority_mismatch")
|
||||
prev_key_count = 1 + len(prev.search_keys)
|
||||
# 先向量化:失败时旧经验(SQLite 与向量行)原样保留,不改一个字。
|
||||
embeddings = await self._embed_vec_keys(memory)
|
||||
|
||||
memory.updated_at = datetime.utcnow()
|
||||
if memory.created_at is None and prev is not None:
|
||||
if memory.created_at is None:
|
||||
memory.created_at = prev.created_at
|
||||
# 合并后的经验讲的是两段对话,对话时间取较晚的那段
|
||||
memory.memory_at = _later(prev.memory_at, memory.memory_at)
|
||||
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||
# 删旧 VDB
|
||||
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
||||
# 写新 VDB
|
||||
await self._upsert_vec_keys(memory)
|
||||
await self._upsert_vec_keys(memory, embeddings=embeddings)
|
||||
logger.info(
|
||||
f"[coding-store] updated experience_id={memory.experience_id} "
|
||||
f"old_keys={prev_key_count} new_keys={1 + len(memory.search_keys)}"
|
||||
@@ -555,6 +661,86 @@ class CodingMemoryStore:
|
||||
logger.info(f"[coding-store] deleted experience_id={experience_id}")
|
||||
return True
|
||||
|
||||
async def edit_experience(
|
||||
self,
|
||||
experience_id: str,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
task: Optional[str] = None,
|
||||
solution: Optional[str] = None,
|
||||
search_keys: Optional[List[str]] = None,
|
||||
) -> Optional[CodingMemory]:
|
||||
"""调用方手动改一条经验(client.update 用)。返回改后的经验;不存在返回 None。
|
||||
|
||||
与 update() 的区别:update 是写入链路的覆盖式重写,带写授权校验(同
|
||||
agent/session/workspace 才能改);这里是用户对已有条目的直接编辑,只动
|
||||
给定的字段,其余原样保留,因此不做授权校验。
|
||||
|
||||
向量行只嵌 task + search_keys(solution 不进向量),所以只改 solution 时
|
||||
不重嵌、不碰向量库;改了 task / search_keys 才删旧 key 行重写。
|
||||
updated_at 总是刷新。
|
||||
"""
|
||||
prev = await self.get_by_id(experience_id, user_id=user_id)
|
||||
if prev is None:
|
||||
return None
|
||||
updated = deepcopy(prev)
|
||||
if task is not None:
|
||||
updated.task = task
|
||||
if solution is not None:
|
||||
updated.solution = solution
|
||||
if search_keys is not None:
|
||||
updated.search_keys = list(search_keys)
|
||||
updated.updated_at = datetime.utcnow()
|
||||
await _run_in_pool(self._upsert_sql_sync, updated)
|
||||
if updated.task != prev.task or updated.search_keys != prev.search_keys:
|
||||
await self._delete_vec_keys_by_count(experience_id, 1 + len(prev.search_keys))
|
||||
await self._upsert_vec_keys(updated)
|
||||
logger.info(f"[coding-store] edited experience_id={experience_id}")
|
||||
return updated
|
||||
|
||||
async def edit_fact(
|
||||
self,
|
||||
fact_id: str,
|
||||
*,
|
||||
statement: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[WorkingFact]:
|
||||
"""调用方手动改一条事实的 statement。返回改后的事实;不存在返回 None。
|
||||
|
||||
statement 是事实层的检索文本,所以 active 事实会重嵌并覆盖它那一行向量
|
||||
(node_id 就是 fact_id,upsert 即覆盖)。superseded 事实的向量行早已撤掉,
|
||||
这里不把它加回去,否则会让过期结论重新占召回名额。
|
||||
"""
|
||||
prev = await self.get_fact(fact_id, user_id=user_id)
|
||||
if prev is None:
|
||||
return None
|
||||
updated = deepcopy(prev)
|
||||
updated.statement = statement
|
||||
await _run_in_pool(self._upsert_fact_sql_sync, updated)
|
||||
if updated.status == "active":
|
||||
await self._upsert_fact_vec(updated)
|
||||
logger.info(f"[coding-store] edited fact_id={fact_id}")
|
||||
return updated
|
||||
|
||||
async def delete_fact(self, fact_id: str, user_id: Optional[str] = None) -> bool:
|
||||
"""物理删除一条事实(SQLite 行 + 事实向量行),返回是否真有删到。
|
||||
|
||||
别处留下的引用(经验的 source_fact_ids、后继事实的 fact_link)不清:
|
||||
召回侧本来就按存在性过滤死链(get_many / get_facts 查不到自然掉队)。
|
||||
"""
|
||||
prev = await self.get_fact(fact_id, user_id=user_id)
|
||||
if prev is None:
|
||||
logger.info(f"[coding-store] delete fact skipped (not found): {fact_id}")
|
||||
return False
|
||||
if self._fact_vec is not None:
|
||||
try:
|
||||
await self._fact_vec.delete(fact_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"[coding-store] delete fact vec {fact_id} ignored: {e}")
|
||||
await _run_in_pool(self._delete_fact_sql_sync, fact_id, prev.user_id)
|
||||
logger.info(f"[coding-store] deleted fact_id={fact_id}")
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 读取
|
||||
# ------------------------------------------------------------
|
||||
@@ -639,29 +825,84 @@ class CodingMemoryStore:
|
||||
branch: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> List["CodingMemory"]:
|
||||
"""按 workspace_id(+可选 branch)纯过滤列出该 user 的完整 coding memory。
|
||||
|
||||
作用域规则见 _scope_where(与 search 路径共用同一套)。无语义排序,按
|
||||
updated_at 倒序(最近修改优先)。
|
||||
updated_at 倒序(最近修改优先)。include_sources / exclude_sources 按
|
||||
source 列在 SQL 里过滤(排除优先),所以 LIMIT/OFFSET 在过滤之后生效。
|
||||
"""
|
||||
return await _run_in_pool(
|
||||
self._list_by_workspace_sync, user_id, workspace_id, branch, limit, offset
|
||||
self._list_by_workspace_sync, user_id, workspace_id, branch, limit, offset,
|
||||
include_sources, exclude_sources,
|
||||
)
|
||||
|
||||
def _list_by_workspace_sync(
|
||||
self, user_id: str, workspace_id: str, branch: Optional[str],
|
||||
limit: int, offset: int,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> List["CodingMemory"]:
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
source_sql, source_params = _source_where(include_sources, exclude_sources)
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
sql = (f"SELECT * FROM coding_memory_meta WHERE user_id = ? AND {scope_sql} "
|
||||
f"ORDER BY updated_at DESC LIMIT ? OFFSET ?")
|
||||
params: List[Any] = [user_id] + scope_params + [limit, offset]
|
||||
f"AND {source_sql} "
|
||||
f"ORDER BY updated_at DESC, experience_id ASC LIMIT ? OFFSET ?")
|
||||
params: List[Any] = ([user_id] + scope_params + source_params
|
||||
+ [limit, offset])
|
||||
cur = self._conn.execute(sql, params)
|
||||
return [_row_to_memory(r) for r in cur.fetchall()]
|
||||
|
||||
async def stats(
|
||||
self,
|
||||
user_id: str,
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""经验数、最新创建/修改时间、active 事实数。纯聚合,不读正文。
|
||||
|
||||
作用域与 list_by_workspace 相同:workspace_id 为空表示该 user 全部;
|
||||
来源过滤同样与 list_by_workspace 一致,count 因此恒等于分页可翻到的总数。
|
||||
"""
|
||||
return await _run_in_pool(
|
||||
self._stats_sync, user_id, workspace_id, branch,
|
||||
include_sources, exclude_sources,
|
||||
)
|
||||
|
||||
def _stats_sync(
|
||||
self, user_id: str, workspace_id: Optional[str], branch: Optional[str],
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
source_sql, source_params = _source_where(include_sources, exclude_sources)
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
row = self._conn.execute(
|
||||
f"SELECT COUNT(*) AS n, MAX(created_at) AS created, "
|
||||
f"MAX(updated_at) AS updated FROM coding_memory_meta "
|
||||
f"WHERE user_id = ? AND {scope_sql} AND {source_sql}",
|
||||
[user_id] + scope_params + source_params,
|
||||
).fetchone()
|
||||
facts = self._conn.execute(
|
||||
f"SELECT COUNT(*) AS n FROM working_fact "
|
||||
f"WHERE user_id = ? AND status = 'active' AND {scope_sql} "
|
||||
f"AND {source_sql}",
|
||||
[user_id] + scope_params + source_params,
|
||||
).fetchone()
|
||||
return {
|
||||
"count": int(row["n"] or 0),
|
||||
"latest_created_at": _parse_dt(row["created"]) if row["created"] else None,
|
||||
"latest_updated_at": _parse_dt(row["updated"]) if row["updated"] else None,
|
||||
"facts": int(facts["n"] or 0),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 召回
|
||||
# ------------------------------------------------------------
|
||||
@@ -671,6 +912,10 @@ class CodingMemoryStore:
|
||||
query_embedding: List[float],
|
||||
user_id: str,
|
||||
*,
|
||||
agent_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
top: int = 40,
|
||||
@@ -688,21 +933,34 @@ class CodingMemoryStore:
|
||||
},
|
||||
...
|
||||
]
|
||||
workspace_id / branch 是作用域过滤,规则见 _scope_where:不传则纯语义召回
|
||||
(行为与加过滤前完全一致);传了则只保留 SQLite 里落在作用域内的经验,并按
|
||||
作用域的选择率放大向量取数,避免"先取 top-N 再过滤"把召回吃掉。
|
||||
agent_id / session_id(或 agent_ids / session_ids)是写授权作用域,
|
||||
传入时精确匹配;workspace_id / branch 的规则见 _scope_where。
|
||||
不传任何作用域则维持纯语义召回;传了则只保留 SQLite 里落在作用域内的经验,
|
||||
并按选择率放大向量取数,避免"先取 top-N 再过滤"把召回吃掉。
|
||||
"""
|
||||
if self._vec is None:
|
||||
return []
|
||||
|
||||
effective_agent_ids = list(agent_ids) if agent_ids else (
|
||||
[agent_id] if agent_id else []
|
||||
)
|
||||
effective_session_ids = list(session_ids) if session_ids else (
|
||||
[session_id] if session_id else []
|
||||
)
|
||||
allowed: Optional[Set[str]] = None
|
||||
if workspace_id:
|
||||
if effective_agent_ids or effective_session_ids or workspace_id:
|
||||
allowed, total = await _run_in_pool(
|
||||
self._scoped_experience_ids_sync, user_id, workspace_id, branch
|
||||
self._authority_scoped_experience_ids_sync,
|
||||
user_id,
|
||||
effective_agent_ids,
|
||||
effective_session_ids,
|
||||
workspace_id,
|
||||
branch,
|
||||
)
|
||||
if not allowed:
|
||||
logger.info(
|
||||
f"[coding-store] no experience in scope: "
|
||||
f"agent_id={agent_id!r} session_id={session_id!r} "
|
||||
f"workspace_id={workspace_id!r} branch={branch!r} "
|
||||
f"(user has {total}); skip vdb search"
|
||||
)
|
||||
@@ -749,13 +1007,24 @@ class CodingMemoryStore:
|
||||
# 事实层
|
||||
# ------------------------------------------------------------
|
||||
|
||||
async def insert_fact(self, fact: WorkingFact) -> None:
|
||||
"""新增一条事实(SQLite + 事实 collection 各一行)。"""
|
||||
async def insert_fact(
|
||||
self, fact: WorkingFact, embedding: Optional[List[float]] = None,
|
||||
) -> None:
|
||||
"""新增一条事实(SQLite + 事实 collection 各一行)。
|
||||
|
||||
embedding 可由调用方预先用 embed_fact() 算好(fact reconcile 要在覆盖旧
|
||||
事实之前确认新事实能向量化);不传时这里算。向量化失败抛
|
||||
CodingWriteError(EMBEDDING_FAILED),什么都不写。
|
||||
"""
|
||||
if fact.created_at is None:
|
||||
fact.created_at = datetime.utcnow()
|
||||
# 先向量化再落 SQLite,理由同 insert():不落没有向量的事实。
|
||||
if embedding is None:
|
||||
embedding = await self.embed_fact(fact)
|
||||
await _run_in_pool(self._upsert_fact_sql_sync, fact)
|
||||
await self._upsert_fact_vec(fact)
|
||||
logger.info(
|
||||
await self._upsert_fact_vec(fact, embedding=embedding)
|
||||
logger.info(f"[coding-store] inserted fact_id={fact.fact_id}")
|
||||
logger.debug(
|
||||
f"[coding-store] inserted fact_id={fact.fact_id} "
|
||||
f"statement={fact.statement!r}"
|
||||
)
|
||||
@@ -778,6 +1047,10 @@ class CodingMemoryStore:
|
||||
query_embedding: List[float],
|
||||
user_id: str,
|
||||
*,
|
||||
agent_id: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
session_ids: Optional[List[str]] = None,
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
top: int = 5,
|
||||
@@ -791,8 +1064,9 @@ class CodingMemoryStore:
|
||||
SUPERSEDE 时向量行已被删掉,理论上召不到 superseded;这里再按 SQLite 的
|
||||
status 过一道,避免两边不一致时把过期结论放出去。
|
||||
|
||||
workspace_id / branch 是作用域过滤,规则见 _scope_where,语义与经验层一致:
|
||||
不传不过滤,传了按 SQLite 里的值筛,并按选择率放大向量取数。
|
||||
agent_id / session_id(或 agent_ids / session_ids)是写授权作用域,
|
||||
传入时精确匹配;workspace_id / branch 的规则见 _scope_where。
|
||||
不传任何作用域则不过滤,传了按 SQLite 里的值筛,并按选择率放大向量取数。
|
||||
|
||||
向量库按 top 的 4 倍过采样:向量行和 SQLite 行不是一个事务里写的,中间挂掉
|
||||
就会留下"向量还在、SQLite 没了"的孤儿,软删时删向量失败也会留下 superseded
|
||||
@@ -808,14 +1082,27 @@ class CodingMemoryStore:
|
||||
if self._fact_vec is None:
|
||||
return []
|
||||
|
||||
effective_agent_ids = list(agent_ids) if agent_ids else (
|
||||
[agent_id] if agent_id else []
|
||||
)
|
||||
effective_session_ids = list(session_ids) if session_ids else (
|
||||
[session_id] if session_id else []
|
||||
)
|
||||
fetch = top * 4
|
||||
if workspace_id:
|
||||
if effective_agent_ids or effective_session_ids or workspace_id:
|
||||
scoped, total = await _run_in_pool(
|
||||
self._count_active_facts_sync, user_id, workspace_id, branch
|
||||
self._count_active_facts_sync,
|
||||
user_id,
|
||||
effective_agent_ids,
|
||||
effective_session_ids,
|
||||
workspace_id,
|
||||
branch,
|
||||
)
|
||||
if not scoped:
|
||||
logger.info(
|
||||
f"[coding-store] no active fact in scope: "
|
||||
f"agent_ids={effective_agent_ids!r} "
|
||||
f"session_ids={effective_session_ids!r} "
|
||||
f"workspace_id={workspace_id!r} branch={branch!r} "
|
||||
f"(user has {total}); skip fact vdb search"
|
||||
)
|
||||
@@ -852,6 +1139,8 @@ class CodingMemoryStore:
|
||||
alive = [
|
||||
(f, scores[f.fact_id]) for f in facts
|
||||
if f.status == "active"
|
||||
and (not effective_agent_ids or f.agent_id in effective_agent_ids)
|
||||
and (not effective_session_ids or f.session_id in effective_session_ids)
|
||||
and _scope_matches(f.workspace_id, f.branch, workspace_id, branch)
|
||||
]
|
||||
kept = [(f, s) for f, s in alive if s >= min_score]
|
||||
@@ -964,8 +1253,8 @@ class CodingMemoryStore:
|
||||
experience_id, user_id, agent_id, task, search_keys, solution,
|
||||
boundary_envs, workspace_id, branch, session_id,
|
||||
files, source_fact_ids, confidence, source, extraction_source,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
memory_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(experience_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
agent_id = excluded.agent_id,
|
||||
@@ -981,6 +1270,7 @@ class CodingMemoryStore:
|
||||
confidence = excluded.confidence,
|
||||
source = excluded.source,
|
||||
extraction_source = excluded.extraction_source,
|
||||
memory_at = excluded.memory_at,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
@@ -991,6 +1281,7 @@ class CodingMemoryStore:
|
||||
json.dumps(memory.files, ensure_ascii=False),
|
||||
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
||||
memory.confidence, memory.source, memory.extraction_source,
|
||||
_to_epoch(memory.memory_at),
|
||||
(memory.created_at or datetime.utcnow()).isoformat(),
|
||||
(memory.updated_at or datetime.utcnow()).isoformat(),
|
||||
),
|
||||
@@ -1003,6 +1294,14 @@ class CodingMemoryStore:
|
||||
(experience_id, user_id),
|
||||
)
|
||||
|
||||
def _delete_fact_sql_sync(self, fact_id: str, user_id: str) -> None:
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
self._conn.execute(
|
||||
"DELETE FROM working_fact WHERE fact_id = ? AND user_id = ?",
|
||||
(fact_id, user_id),
|
||||
)
|
||||
|
||||
def _get_by_id_sync(
|
||||
self, experience_id: str, user_id: Optional[str]
|
||||
) -> Optional[CodingMemory]:
|
||||
@@ -1050,14 +1349,28 @@ class CodingMemoryStore:
|
||||
by_id = {r["experience_id"]: _row_to_memory(r) for r in rows}
|
||||
return [by_id[eid] for eid in experience_ids if eid in by_id]
|
||||
|
||||
def _scoped_experience_ids_sync(
|
||||
self, user_id: str, workspace_id: str, branch: Optional[str]
|
||||
def _authority_scoped_experience_ids_sync(
|
||||
self,
|
||||
user_id: str,
|
||||
agent_ids: Optional[List[str]],
|
||||
session_ids: Optional[List[str]],
|
||||
workspace_id: Optional[str],
|
||||
branch: Optional[str],
|
||||
) -> Tuple[Set[str], int]:
|
||||
"""返回 (落在作用域内的 experience_id 集合, 该 user 的经验总数)。
|
||||
"""返回 (落在授权作用域内的 experience_id 集合, 该 user 的经验总数)。
|
||||
|
||||
一次查询同时拿到过滤结果和选择率分母:前者供向量命中做交集,后者定放大倍数。
|
||||
agent_ids / session_ids 为空表示该维度不筛;非空按 IN 精确匹配。
|
||||
"""
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
agent_sql, agent_params = _in_clause("agent_id", agent_ids)
|
||||
if agent_sql:
|
||||
scope_sql = f"({agent_sql}) AND ({scope_sql})"
|
||||
scope_params = [*agent_params, *scope_params]
|
||||
session_sql, session_params = _in_clause("session_id", session_ids)
|
||||
if session_sql:
|
||||
scope_sql = f"({session_sql}) AND ({scope_sql})"
|
||||
scope_params = [*session_params, *scope_params]
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
cur = self._conn.execute(
|
||||
@@ -1069,13 +1382,27 @@ class CodingMemoryStore:
|
||||
return {r["experience_id"] for r in rows if r["in_scope"]}, len(rows)
|
||||
|
||||
def _count_active_facts_sync(
|
||||
self, user_id: str, workspace_id: str, branch: Optional[str]
|
||||
self,
|
||||
user_id: str,
|
||||
agent_ids: Optional[List[str]],
|
||||
session_ids: Optional[List[str]],
|
||||
workspace_id: Optional[str],
|
||||
branch: Optional[str],
|
||||
) -> Tuple[int, int]:
|
||||
"""返回 (作用域内 active 事实数, 该 user 的 active 事实总数)。
|
||||
|
||||
事实层的过滤在 get_facts 拿到对象之后用 _scope_matches 做,这里只要选择率。
|
||||
agent_ids / session_ids 为空表示该维度不筛;非空按 IN 精确匹配。
|
||||
"""
|
||||
scope_sql, scope_params = _scope_where(workspace_id, branch)
|
||||
agent_sql, agent_params = _in_clause("agent_id", agent_ids)
|
||||
if agent_sql:
|
||||
scope_sql = f"({agent_sql}) AND ({scope_sql})"
|
||||
scope_params = [*agent_params, *scope_params]
|
||||
session_sql, session_params = _in_clause("session_id", session_ids)
|
||||
if session_sql:
|
||||
scope_sql = f"({session_sql}) AND ({scope_sql})"
|
||||
scope_params = [*session_params, *scope_params]
|
||||
with self._lock:
|
||||
assert self._conn is not None
|
||||
row = self._conn.execute(
|
||||
@@ -1095,8 +1422,8 @@ class CodingMemoryStore:
|
||||
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
||||
source, statement, files, subject, details,
|
||||
fact_link, derived_experience_ids,
|
||||
status, fact_chain_summary, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
status, fact_chain_summary, memory_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(fact_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
agent_id = excluded.agent_id,
|
||||
@@ -1111,7 +1438,8 @@ class CodingMemoryStore:
|
||||
fact_link = excluded.fact_link,
|
||||
derived_experience_ids = excluded.derived_experience_ids,
|
||||
status = excluded.status,
|
||||
fact_chain_summary = excluded.fact_chain_summary
|
||||
fact_chain_summary = excluded.fact_chain_summary,
|
||||
memory_at = excluded.memory_at
|
||||
""",
|
||||
(
|
||||
fact.fact_id, fact.user_id, fact.agent_id,
|
||||
@@ -1124,6 +1452,7 @@ class CodingMemoryStore:
|
||||
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
||||
fact.status,
|
||||
fact.fact_chain_summary or "",
|
||||
_to_epoch(fact.memory_at),
|
||||
(fact.created_at or datetime.utcnow()).isoformat(),
|
||||
),
|
||||
)
|
||||
@@ -1262,25 +1591,64 @@ class CodingMemoryStore:
|
||||
# VDB 实现
|
||||
# ------------------------------------------------------------
|
||||
|
||||
async def _upsert_vec_keys(self, memory: CodingMemory) -> None:
|
||||
"""把 task + search_keys 写入 VDB(每行一个 MemoryNode)。"""
|
||||
if self._vec is None:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _vec_key_texts(memory: CodingMemory) -> List[Tuple[str, str]]:
|
||||
all_keys: List[Tuple[str, str]] = [("task", memory.task)]
|
||||
for k in memory.search_keys:
|
||||
if k:
|
||||
all_keys.append(("aspect", k))
|
||||
return all_keys
|
||||
|
||||
async def _embed_vec_keys(
|
||||
self, memory: CodingMemory
|
||||
) -> Optional[List[List[float]]]:
|
||||
"""写入链路用:向量化 task + search_keys,失败抛 CodingWriteError。
|
||||
|
||||
无 VDB 时返回 None(不需要向量)。
|
||||
"""
|
||||
if self._vec is None:
|
||||
return None
|
||||
texts = [t for _, t in self._vec_key_texts(memory)]
|
||||
try:
|
||||
embeddings = await self.embed_service.embed_batch(texts)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] embed_batch failed: {e}")
|
||||
raise CodingWriteError(
|
||||
"EMBEDDING_FAILED",
|
||||
f"coding experience embedding failed: {type(e).__name__}: {e}",
|
||||
) from e
|
||||
if len(embeddings or []) != len(texts) or not all(embeddings):
|
||||
raise CodingWriteError(
|
||||
"EMBEDDING_FAILED",
|
||||
"coding experience embedding returned no vector",
|
||||
)
|
||||
return embeddings
|
||||
|
||||
async def _upsert_vec_keys(
|
||||
self,
|
||||
memory: CodingMemory,
|
||||
embeddings: Optional[List[List[float]]] = None,
|
||||
) -> None:
|
||||
"""把 task + search_keys 写入 VDB(每行一个 MemoryNode)。
|
||||
|
||||
embeddings 由写入链路预先算好(见 _embed_vec_keys);不传时现算,
|
||||
失败只记 warning(edit_experience 的手动编辑路径沿用这一行为)。
|
||||
"""
|
||||
if self._vec is None:
|
||||
return
|
||||
|
||||
all_keys = self._vec_key_texts(memory)
|
||||
if not all_keys:
|
||||
return
|
||||
|
||||
# batch embed
|
||||
texts = [t for _, t in all_keys]
|
||||
try:
|
||||
embeddings = await self.embed_service.embed_batch(texts)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] embed_batch failed: {e}")
|
||||
return
|
||||
if embeddings is None:
|
||||
try:
|
||||
embeddings = await self.embed_service.embed_batch(texts)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] embed_batch failed: {e}")
|
||||
return
|
||||
|
||||
nodes: List[MemoryNode] = []
|
||||
for i, ((kind, text), emb) in enumerate(zip(all_keys, embeddings)):
|
||||
@@ -1311,7 +1679,34 @@ class CodingMemoryStore:
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] vdb upsert_batch failed: {e}")
|
||||
|
||||
async def _upsert_fact_vec(self, fact: WorkingFact) -> None:
|
||||
@staticmethod
|
||||
def _fact_vector_text(fact: WorkingFact) -> str:
|
||||
return f"{fact.subject}: {fact.statement}" if fact.subject else fact.statement
|
||||
|
||||
async def embed_fact(self, fact: WorkingFact) -> Optional[List[float]]:
|
||||
"""写入链路用:向量化事实检索文本,失败抛 CodingWriteError。
|
||||
|
||||
无事实 VDB 或 statement 为空时返回 None(不需要向量)。
|
||||
"""
|
||||
if self._fact_vec is None or not fact.statement:
|
||||
return None
|
||||
try:
|
||||
embedding = await self.embed_service.embed(self._fact_vector_text(fact))
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] fact embed failed: {e}")
|
||||
raise CodingWriteError(
|
||||
"EMBEDDING_FAILED",
|
||||
f"working fact embedding failed: {type(e).__name__}: {e}",
|
||||
) from e
|
||||
if not embedding:
|
||||
raise CodingWriteError(
|
||||
"EMBEDDING_FAILED", "working fact embedding returned no vector",
|
||||
)
|
||||
return embedding
|
||||
|
||||
async def _upsert_fact_vec(
|
||||
self, fact: WorkingFact, embedding: Optional[List[float]] = None,
|
||||
) -> None:
|
||||
"""事实入向量库。一条事实一行,node_id 就是 fact_id。
|
||||
|
||||
检索文本是 statement,前面缀上 subject(有的话)。subject 通常已经在
|
||||
@@ -1322,14 +1717,14 @@ class CodingMemoryStore:
|
||||
"""
|
||||
if self._fact_vec is None or not fact.statement:
|
||||
return
|
||||
vector_text = (
|
||||
f"{fact.subject}: {fact.statement}" if fact.subject else fact.statement
|
||||
)
|
||||
try:
|
||||
embedding = await self.embed_service.embed(vector_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] fact embed failed: {e}")
|
||||
return
|
||||
vector_text = self._fact_vector_text(fact)
|
||||
if embedding is None:
|
||||
# edit_fact 的手动编辑路径:现算,失败只记 warning。
|
||||
try:
|
||||
embedding = await self.embed_service.embed(vector_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-store] fact embed failed: {e}")
|
||||
return
|
||||
|
||||
node = MemoryNode(
|
||||
node_id=fact.fact_id,
|
||||
|
||||
@@ -25,6 +25,30 @@ FactStatus = Literal["active", "superseded"]
|
||||
MAX_FACT_CHAIN_ENTRIES = 8
|
||||
|
||||
|
||||
class CodingWriteError(RuntimeError):
|
||||
"""working / coding 写入链路里让整次写入失败的模型错误。
|
||||
|
||||
error_code 是稳定失败码,CodingWriter 原样透传为 agent_error_code:
|
||||
- LLM_ERROR 抽取 LLM 调用失败
|
||||
- EMPTY_RESPONSE 抽取 LLM 返回 200 但 content 为空
|
||||
- JSON_PARSE_FAILED 抽取 LLM 的回复解析不出 JSON 对象
|
||||
- EMBEDDING_FAILED 经验 / 事实向量化失败(不落没有向量的节点)
|
||||
- RECONCILE_FAILED 经验 / 事实 reconcile 的模型决策失败;具体原因在 cause
|
||||
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED),
|
||||
透传为 reconcile_error_code,与 chat 链路一致
|
||||
"""
|
||||
|
||||
def __init__(self, error_code: str, message: str, cause: Optional[str] = None):
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
self.cause = cause
|
||||
|
||||
|
||||
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 草稿(LLM extract 阶段输出)
|
||||
# ================================================================
|
||||
@@ -53,6 +77,8 @@ class CodingMemoryDraft:
|
||||
files: List[str] = field(default_factory=list)
|
||||
source: str = "default"
|
||||
extraction_source: str = "auto_extract"
|
||||
# 对话时间(SDK 注入,同 chat 的 memory_at)
|
||||
memory_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
from ..utils.source_filter import normalize_source
|
||||
@@ -73,6 +99,7 @@ class CodingMemoryDraft:
|
||||
"files": list(self.files),
|
||||
"source": self.source,
|
||||
"extraction_source": self.extraction_source,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +138,9 @@ class CodingMemory:
|
||||
extraction_source: str = "auto_extract"
|
||||
|
||||
# 时间
|
||||
# memory_at 是对话时间(同 chat),created_at / updated_at 是写库时间。
|
||||
# UPDATE 合并时取两者中较晚的那个对话时间,见 store.update。
|
||||
memory_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -135,6 +165,7 @@ class CodingMemory:
|
||||
"confidence": self.confidence,
|
||||
"source": self.source,
|
||||
"extraction_source": self.extraction_source,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -249,6 +280,7 @@ class WorkingFact:
|
||||
fact_chain_summary: str = ""
|
||||
|
||||
# 时间
|
||||
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
|
||||
created_at: Optional[datetime] = None # 产生时间,新的优先
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -272,6 +304,7 @@ class WorkingFact:
|
||||
"derived_experience_ids": list(self.derived_experience_ids),
|
||||
"status": self.status,
|
||||
"fact_chain_summary": self.fact_chain_summary,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
+128
-43
@@ -10,14 +10,16 @@ CodingWriter - 写入端协调器
|
||||
├── recent history: 最近 K 个 user-turn(只作上下文)
|
||||
├── new messages: 本次全部 turn(只做单条限长)
|
||||
├── extractor.extract(...) ← 一次 LLM 产出双层草稿
|
||||
├── reconciler.reconcile(经验草稿) ← ADD/UPDATE/DELETE/SKIP
|
||||
├── fact_reconciler.reconcile(事实草稿) ← INSERT/SUPERSEDE
|
||||
├── reconciler.plan(经验草稿) ← ADD/UPDATE/DELETE/SKIP 决策
|
||||
├── fact_reconciler.plan(事实草稿) ← INSERT/SUPERSEDE 决策
|
||||
├── reconciler.apply / fact_reconciler.apply ← 两层决策都成功后才落库
|
||||
└── _crosslink(...) ← 同批全互链回填
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
@@ -29,7 +31,7 @@ from .judge import classify_messages_is_coding
|
||||
from .preproc import has_any_tool_message, prepare_extractor_messages
|
||||
from .reconciler import CodingMemoryReconciler
|
||||
from .store import CodingMemoryStore
|
||||
from .types import FactReconcileOp, ReconcileOp, WorkingFact
|
||||
from .types import CodingWriteError, FactReconcileOp, ReconcileOp, WorkingFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,6 +52,7 @@ class CodingWriter:
|
||||
embed_service: EmbedService,
|
||||
fact_reconciler: Optional[WorkingFactReconciler] = None,
|
||||
cache: Any = None,
|
||||
variant: str = "working",
|
||||
):
|
||||
self.store = store
|
||||
self.extractor = extractor
|
||||
@@ -59,6 +62,11 @@ class CodingWriter:
|
||||
self.fact_reconciler = fact_reconciler or WorkingFactReconciler(
|
||||
store, embed_service, llm_provider
|
||||
)
|
||||
self.variant = (
|
||||
"coding"
|
||||
if str(variant or "").strip().lower() == "coding"
|
||||
else "working"
|
||||
)
|
||||
# cache 仅用于把 request_id 串给各阶段(各阶段自己打点);writer 不集中打点。
|
||||
self.cache = cache
|
||||
|
||||
@@ -73,9 +81,14 @@ class CodingWriter:
|
||||
session_id: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
|
||||
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
|
||||
事实上;不传则用收到请求的时间(与 chat 链路一致)。language 是显式 prompt
|
||||
语言码,透传给抽取、经验 reconcile、事实 reconcile 与演变总结;None 时各步
|
||||
沿用 zh/en 自动检测。返回响应 dict(兼容 client.add 返回结构):
|
||||
{
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
@@ -92,9 +105,18 @@ class CodingWriter:
|
||||
],
|
||||
"fact_ids": ["..."], # 这趟真正落库的 fact_id
|
||||
}
|
||||
|
||||
模型失败(抽取 LLM 调用失败 / 空回复 / 解析不出)或向量化失败时返回
|
||||
success=False,agent_error_code 为 LLM_ERROR / EMPTY_RESPONSE /
|
||||
JSON_PARSE_FAILED / EMBEDDING_FAILED,error_code=502。经验层 / 事实层
|
||||
reconcile 的模型决策失败时 agent_error_code=RECONCILE_FAILED,原因在
|
||||
reconcile_error_code(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED),
|
||||
本次什么都不落库。模型答了但没有值得记的内容仍是 success=True、0 条。
|
||||
"""
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
t0 = time.perf_counter()
|
||||
if memory_at is None:
|
||||
memory_at = datetime.now()
|
||||
|
||||
if not messages:
|
||||
return self._empty_response(request_id, t0)
|
||||
@@ -129,6 +151,10 @@ class CodingWriter:
|
||||
# 跟库里已有的什么关系是 reconcile 的事。把已有 task 摆到它面前,它会把
|
||||
# 一个其实不同的任务往清单里某个措辞上靠,reconciler 随后判成 UPDATE 合并
|
||||
# 掉,那个区别就没了——措辞对不上顶多多存一条,措辞被带偏是直接写错。
|
||||
#
|
||||
# 模型失败(LLM 调用失败 / 空回复 / 解析不出)抛 CodingWriteError:整次写入
|
||||
# 报失败、什么都不存,连 trace 也不追加——调用方换个 request_id 重跑时,
|
||||
# 历史窗口里不会多出这段本该是「新消息」的对话。
|
||||
try:
|
||||
drafts, fact_drafts = await self.extractor.extract(
|
||||
messages,
|
||||
@@ -140,58 +166,80 @@ class CodingWriter:
|
||||
branch=branch,
|
||||
session_id=session_id,
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
finally:
|
||||
# 历史服务于下一次 write;即使本次没跨过价值门槛,也不能丢掉上下文。
|
||||
try:
|
||||
await self.store.append_trace(
|
||||
current_messages,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id or "default_agent",
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-write] append trace failed: {e}")
|
||||
except CodingWriteError as e:
|
||||
return self._failure_response(request_id, t0, e)
|
||||
|
||||
trace_kwargs = dict(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id or "default_agent",
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
)
|
||||
|
||||
if not drafts and not fact_drafts:
|
||||
# 模型答了、只是没有值得记的:正常的空抽取,仍算成功。历史服务于下一次
|
||||
# write;即使本次没跨过价值门槛,也不能丢掉上下文。
|
||||
logger.info(
|
||||
f"[coding-write] no drafts produced (value bar); request_id={request_id}"
|
||||
)
|
||||
await self._append_trace(current_messages, **trace_kwargs)
|
||||
return self._empty_response(request_id, t0)
|
||||
|
||||
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
||||
for draft in drafts:
|
||||
draft.source = source
|
||||
draft.memory_at = memory_at
|
||||
|
||||
# 3. 经验层 reconcile + 持久化
|
||||
ops: List[ReconcileOp] = (
|
||||
await self.reconciler.reconcile(
|
||||
drafts,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id or "",
|
||||
)
|
||||
if drafts else []
|
||||
)
|
||||
|
||||
# 4. 事实层 reconcile + 持久化(独立链路,不受经验层结果影响)
|
||||
# 3. 两层 reconcile 的模型决策(只检索 + 调 LLM,不落库)
|
||||
# 4. 两层决策都成功后才落库:经验层 apply → 事实层 apply
|
||||
#
|
||||
# 先把所有模型决策做完再写库,任何一层的决策失败(LLM 失败 / 空回复 /
|
||||
# 解析不出 → CodingWriteError(RECONCILE_FAILED))都发生在落库之前,本次
|
||||
# 写入一条经验 / 事实都没存、也没覆盖旧事实,换个 request_id 重跑即干净。
|
||||
# 两层决策互不依赖(各自检索各自的候选),提前做不改变结果。
|
||||
#
|
||||
# 向量化失败抛 CodingWriteError(EMBEDDING_FAILED):不落没有向量的节点,
|
||||
# 整次写入报失败。
|
||||
facts: List[WorkingFact] = []
|
||||
fact_ops: List[FactReconcileOp] = []
|
||||
if fact_drafts:
|
||||
facts, fact_ops = await self.fact_reconciler.reconcile(
|
||||
fact_drafts,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
ops: List[ReconcileOp] = []
|
||||
try:
|
||||
experience_plan = (
|
||||
await self.reconciler.plan(
|
||||
drafts,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id or "",
|
||||
language=language,
|
||||
)
|
||||
if drafts else None
|
||||
)
|
||||
fact_plan = (
|
||||
await self.fact_reconciler.plan(
|
||||
fact_drafts,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
language=language,
|
||||
)
|
||||
if fact_drafts else None
|
||||
)
|
||||
if experience_plan is not None:
|
||||
ops = await self.reconciler.apply(experience_plan)
|
||||
if fact_plan is not None:
|
||||
facts, fact_ops = await self.fact_reconciler.apply(fact_plan)
|
||||
except CodingWriteError as e:
|
||||
return self._failure_response(request_id, t0, e)
|
||||
|
||||
experience_ids = [op.target_experience_id for op in ops
|
||||
if op.action in ("ADD", "UPDATE") and op.target_experience_id]
|
||||
@@ -199,6 +247,7 @@ class CodingWriter:
|
||||
|
||||
# 5. 同批全互链
|
||||
await self._crosslink(experience_ids, facts, user_id=user_id)
|
||||
await self._append_trace(current_messages, **trace_kwargs)
|
||||
|
||||
add_cnt = sum(1 for op in ops if op.action == "ADD")
|
||||
update_cnt = sum(1 for op in ops if op.action == "UPDATE")
|
||||
@@ -217,6 +266,7 @@ class CodingWriter:
|
||||
return {
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
"variant": self.variant,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
"ops": [op.to_dict() for op in ops],
|
||||
@@ -278,11 +328,46 @@ class CodingWriter:
|
||||
f"[coding-write] crosslink fact {f.fact_id} failed: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _empty_response(request_id: str, t0: float) -> Dict[str, Any]:
|
||||
async def _append_trace(self, current_messages: List[ChatMessage], **kwargs) -> None:
|
||||
"""把本次对话追加进 recent trace(下一次 write 的历史上下文),best-effort。"""
|
||||
try:
|
||||
await self.store.append_trace(current_messages, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(f"[coding-write] append trace failed: {e}")
|
||||
|
||||
def _failure_response(
|
||||
self, request_id: str, t0: float, error: CodingWriteError,
|
||||
) -> Dict[str, Any]:
|
||||
"""模型 / 向量化失败:success=False,稳定失败码在 agent_error_code。
|
||||
|
||||
形状与 chat 链路的失败回执一致(error_code=502,error_message 带
|
||||
「[CODE] 」前缀),调用方据 success 判定并重跑。
|
||||
"""
|
||||
code = error.error_code
|
||||
cause = getattr(error, "cause", None)
|
||||
message = f"{cause}: {error}" if cause else str(error)
|
||||
logger.warning(
|
||||
f"[coding-write] failed: code={code} request_id={request_id}: {message}"
|
||||
)
|
||||
resp = self._empty_response(request_id, t0)
|
||||
resp.update({
|
||||
"success": False,
|
||||
"error_code": 502,
|
||||
"error_message": f"[{code}] {message}",
|
||||
"agent_status": "failed",
|
||||
"agent_error_code": code,
|
||||
"agent_error": message,
|
||||
})
|
||||
if cause:
|
||||
# 与 chat 链路一致:reconcile 失败的具体原因
|
||||
resp["reconcile_error_code"] = cause
|
||||
return resp
|
||||
|
||||
def _empty_response(self, request_id: str, t0: float) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
"variant": self.variant,
|
||||
"request_id": request_id,
|
||||
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 2),
|
||||
"ops": [],
|
||||
|
||||
@@ -260,6 +260,7 @@ class EmbedderConfig:
|
||||
max_retries: int = None # 最大重试次数
|
||||
retry_delay: float = None # 重试间隔(秒)
|
||||
timeout: int = None # 请求超时(秒)
|
||||
batch_size: int = None # 单次 HTTP 请求最多带多少条 input(embed_batch 按此切块)
|
||||
|
||||
# 内部平台扩展(extra_headers / extra_body 会透传给 OpenAI client)
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
@@ -294,6 +295,10 @@ class EmbedderConfig:
|
||||
# 请求切断并制造重试风暴。SDK 内层重试已关闭,15s 是每个显式 attempt
|
||||
# 的真实上限;部署可通过 MEMORY_EMBEDDER_TIMEOUT 覆盖。
|
||||
self.timeout = _get_env_int("MEMORY_EMBEDDER_TIMEOUT", 15)
|
||||
if self.batch_size is None:
|
||||
# 大批量(如重新 embed 整个库)不能一次 HTTP 发几千条:多数服务对
|
||||
# input 数组有上限,超了直接 400。按 batch_size 切块逐块发送。
|
||||
self.batch_size = _get_env_int("MEMORY_EMBEDDER_BATCH_SIZE", 64)
|
||||
if self.extra_headers is None:
|
||||
val = os.getenv("MEMORY_EMBEDDER_EXTRA_HEADERS")
|
||||
if val:
|
||||
@@ -427,6 +432,14 @@ class RecallConfig:
|
||||
retrieval_clauses_require_keyword: bool = None # env: MEMORY_RETRIEVAL_CLAUSES_REQUIRE_KEYWORD
|
||||
retrieval_clauses_min_keyword_score: float = None # normalized BM25 floor
|
||||
supplemental_propositions_enabled: bool = None # env: MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED
|
||||
# ── Intention(L7)判重 ──
|
||||
# 开启(默认):召回时同 user+agent+同天 memory_at+同 valid_until 且
|
||||
# cosine≥阈值 的意图视为复本,只返回最早一条,其余异步从 VDB 删除(历史行为)。
|
||||
# 关闭:读侧与写入侧批内都不判重——不隐藏、不丢弃、不删除任何意图。
|
||||
intention_dedup_enabled: bool = None # env: MEMORY_INTENTION_DEDUP_ENABLED
|
||||
# 读侧意图判重 cosine 阈值。env: MEMORY_INTENTION_DEDUP_THRESHOLD,
|
||||
# 未设置时回退到共享的 MEMORY_DEDUP_THRESHOLD(兼容旧部署),默认 0.95。
|
||||
intention_dedup_threshold: float = None
|
||||
|
||||
# 最小分数阈值
|
||||
min_score_threshold: float = 0.0
|
||||
@@ -745,6 +758,15 @@ class RecallConfig:
|
||||
self.supplemental_propositions_enabled = _get_env_bool(
|
||||
"MEMORY_SUPPLEMENTAL_PROPOSITIONS_ENABLED", False
|
||||
)
|
||||
if self.intention_dedup_enabled is None:
|
||||
self.intention_dedup_enabled = _get_env_bool(
|
||||
"MEMORY_INTENTION_DEDUP_ENABLED", True
|
||||
)
|
||||
if self.intention_dedup_threshold is None:
|
||||
self.intention_dedup_threshold = _get_env_float(
|
||||
"MEMORY_INTENTION_DEDUP_THRESHOLD",
|
||||
_get_env_float("MEMORY_DEDUP_THRESHOLD", 0.95),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1136,6 +1158,9 @@ class CodingConfig:
|
||||
# LLM 失败或没有抽出 fact 时,是否把对话原文存成 working fact。
|
||||
# 默认关闭,避免 assistant/tool 轨迹以 statement 形式直接暴露给消费方。
|
||||
verbatim_fallback_enabled: bool = None
|
||||
# search(scene="coding") 是否注入版本演进链块(diff / chain_block)。
|
||||
# 默认关闭(opt-in):中重度 coding 用户可省下大量 prompt token;写入侧仍可 observe。
|
||||
chain_block_enabled: bool = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.enable is None:
|
||||
@@ -1153,6 +1178,10 @@ class CodingConfig:
|
||||
self.verbatim_fallback_enabled = _get_env_bool(
|
||||
"MEMORY_CODING_VERBATIM_FALLBACK_ENABLED", False
|
||||
)
|
||||
if self.chain_block_enabled is None:
|
||||
self.chain_block_enabled = _get_env_bool(
|
||||
"MEMORY_CODING_CHAIN_BLOCK_ENABLED", False
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1252,6 +1281,12 @@ class MemoryConfig:
|
||||
enable_agent: bool = False
|
||||
debug: bool = False
|
||||
metrics_enabled: bool = True
|
||||
# LLM prompt 语言(client 默认值;env: MEMORY_LANGUAGE)。canonical 码见
|
||||
# hy_memory.prompts.SUPPORTED_LANGUAGES(en / zh-Hans / zh-Hant / ja / es / de /
|
||||
# pt / fr / ko / ru / id),也接受别名 zh、zh-CN → zh-Hans,zh-TW、zh-HK →
|
||||
# zh-Hant,pt-BR → pt。add() 等的 per-call language 优先于它。None(默认)=
|
||||
# 沿用 zh/en 自动检测。显式指定时记忆正文用该语言书写。未知码抛 ValueError。
|
||||
language: Optional[str] = None
|
||||
# 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(
|
||||
@@ -1266,6 +1301,11 @@ class MemoryConfig:
|
||||
self.enable_agent = _get_env_bool("MEMORY_ENABLE_AGENT", False)
|
||||
self.debug = _get_env_bool("MEMORY_DEBUG", False)
|
||||
self.metrics_enabled = _get_env_bool("MEMORY_METRICS", True)
|
||||
if self.language is None:
|
||||
self.language = os.getenv("MEMORY_LANGUAGE", "").strip() or None
|
||||
from .prompts import normalize_language
|
||||
|
||||
self.language = normalize_language(self.language)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "MemoryConfig":
|
||||
@@ -1316,6 +1356,10 @@ class MemoryConfig:
|
||||
config.metrics_enabled = config_dict.get(
|
||||
"metrics_enabled", config.metrics_enabled
|
||||
)
|
||||
if config_dict.get("language") is not None:
|
||||
from .prompts import normalize_language
|
||||
|
||||
config.language = normalize_language(config_dict["language"])
|
||||
|
||||
return config
|
||||
|
||||
@@ -1357,6 +1401,8 @@ class MemoryConfig:
|
||||
"supplemental_propositions_enabled": (
|
||||
self.recall.supplemental_propositions_enabled
|
||||
),
|
||||
"intention_dedup_enabled": self.recall.intention_dedup_enabled,
|
||||
"intention_dedup_threshold": self.recall.intention_dedup_threshold,
|
||||
},
|
||||
"pipeline": {
|
||||
"default_version": self.pipeline.default_version,
|
||||
@@ -1374,6 +1420,7 @@ class MemoryConfig:
|
||||
"enable_graph": self.enable_graph,
|
||||
"enable_agent": self.enable_agent,
|
||||
"metrics_enabled": self.metrics_enabled,
|
||||
"language": self.language,
|
||||
}
|
||||
|
||||
def print_config(self) -> None:
|
||||
|
||||
+120
-23
@@ -38,10 +38,12 @@ import importlib.util
|
||||
import json
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ..config import MemoryConfig
|
||||
from ..embedding import EmbeddingDimensionMismatchError
|
||||
from .workload import embedding_slot
|
||||
from ..utils.log_setup import get_request_id
|
||||
|
||||
@@ -80,7 +82,7 @@ def _count_tokens(text: str) -> int:
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
||||
return len(tiktoken.get_encoding("cl100k_base").encode(text, disallowed_special=()))
|
||||
except Exception:
|
||||
return len(text) // _CHARS_PER_TOKEN_FALLBACK + 1
|
||||
|
||||
@@ -98,7 +100,8 @@ def _truncate_for_embedding(text: str, limit: int = _EMBED_MAX_TOKENS) -> str:
|
||||
import tiktoken
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
ids = enc.encode(text)
|
||||
# Input is ordinary user text, not a tokenizer control-token stream.
|
||||
ids = enc.encode(text, disallowed_special=())
|
||||
if len(ids) <= limit:
|
||||
return text
|
||||
dropped = len(ids) - limit
|
||||
@@ -186,6 +189,41 @@ def _redact_headers(headers: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any
|
||||
return out
|
||||
|
||||
|
||||
# Retry-After 的上限:服务端给出超长等待(或恶意值)时不让单次调用挂几十分钟。
|
||||
_RETRY_AFTER_MAX_S = 60.0
|
||||
|
||||
|
||||
def _retry_after_seconds(exc: Exception) -> Optional[float]:
|
||||
"""从 429 异常的响应头里取 Retry-After(秒数或 HTTP 日期),取不到返回 None。"""
|
||||
response = getattr(exc, "response", None)
|
||||
headers = getattr(response, "headers", None)
|
||||
if not headers:
|
||||
return None
|
||||
try:
|
||||
raw = headers.get("retry-after") or headers.get("Retry-After")
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
raw = str(raw).strip()
|
||||
try:
|
||||
seconds = float(raw)
|
||||
except ValueError:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
when = parsedate_to_datetime(raw)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
seconds = (when - datetime.now(timezone.utc)).total_seconds()
|
||||
except Exception:
|
||||
return None
|
||||
if seconds != seconds: # NaN
|
||||
return None
|
||||
return min(max(0.0, seconds), _RETRY_AFTER_MAX_S)
|
||||
|
||||
|
||||
def _embeddings_api_url(base_url: str) -> str:
|
||||
"""OpenAI SDK posts to ``{base_url}/embeddings`` (base_url usually ends with /v1)."""
|
||||
base = (base_url or "").rstrip("/")
|
||||
@@ -204,7 +242,8 @@ class EmbedServiceConfig:
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
embedding_dims: int = 1536
|
||||
batch_size: int = 100
|
||||
# embed_batch 单次 HTTP 最多带多少条 input;超出按块顺序发送
|
||||
batch_size: int = 64
|
||||
enable_cache: bool = True
|
||||
cache_size: int = 10000
|
||||
max_retries: int = 5
|
||||
@@ -286,6 +325,7 @@ class EmbedService:
|
||||
max_retries=embedder.max_retries if embedder.max_retries is not None else 5,
|
||||
retry_delay=embedder.retry_delay if embedder.retry_delay is not None else 3.0,
|
||||
timeout=embedder.timeout if embedder.timeout is not None else 60,
|
||||
batch_size=max(1, int(getattr(embedder, "batch_size", None) or 64)),
|
||||
queue_batch_size=int(os.getenv("MEMORY_EMBED_BATCH_SIZE", "8")),
|
||||
queue_batch_window_ms=float(os.getenv("MEMORY_EMBED_BATCH_WINDOW_MS", "100")),
|
||||
extra_headers=embedder.extra_headers,
|
||||
@@ -639,6 +679,8 @@ class EmbedService:
|
||||
self,
|
||||
texts: List[str],
|
||||
use_cache: bool = True,
|
||||
*,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> List[List[float]]:
|
||||
"""
|
||||
批量向量化
|
||||
@@ -646,17 +688,23 @@ class EmbedService:
|
||||
Args:
|
||||
texts: 文本列表
|
||||
use_cache: 是否使用缓存
|
||||
max_retries: 本次调用的重试次数;None 用配置值。长批量任务(重新
|
||||
embed 整个库)可以给更宽的重试预算,不影响在线路径。
|
||||
|
||||
Returns:
|
||||
向量列表
|
||||
"""
|
||||
vectors, _metas = await self.embed_batch_with_meta(texts, use_cache=use_cache)
|
||||
vectors, _metas = await self.embed_batch_with_meta(
|
||||
texts, use_cache=use_cache, max_retries=max_retries,
|
||||
)
|
||||
return vectors
|
||||
|
||||
async def embed_batch_with_meta(
|
||||
self,
|
||||
texts: List[str],
|
||||
use_cache: bool = True,
|
||||
*,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], List[Dict[str, Any]]]:
|
||||
"""批量向量化,同时返回每条的 provider metadata(非 vector)。"""
|
||||
if not texts:
|
||||
@@ -681,7 +729,12 @@ class EmbedService:
|
||||
indices_to_embed.append(i)
|
||||
|
||||
if texts_to_embed:
|
||||
vectors, batch_metas = await self._embed_texts_with_meta(texts_to_embed)
|
||||
# max_retries 只在显式给出时透传:保持 `_embed_texts_with_meta(texts)`
|
||||
# 这个既有调用形状不变(测试与下游替身按它打桩)。
|
||||
retry_kwargs = {} if max_retries is None else {"max_retries": max_retries}
|
||||
vectors, batch_metas = await self._embed_texts_with_meta(
|
||||
texts_to_embed, **retry_kwargs,
|
||||
)
|
||||
|
||||
for j, vector in enumerate(vectors):
|
||||
idx = indices_to_embed[j]
|
||||
@@ -699,9 +752,9 @@ class EmbedService:
|
||||
return results, metas
|
||||
|
||||
async def _embed_texts_with_meta(
|
||||
self, texts: List[str]
|
||||
self, texts: List[str], *, max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], List[Dict[str, Any]]]:
|
||||
"""实际的向量化调用:**一次 HTTP 带走整批**;返回 (vectors, metas)。
|
||||
"""实际的向量化调用:**一次 HTTP 带走一块**;返回 (vectors, metas)。
|
||||
|
||||
早期实现是 `for text in texts: await _embed_openai([text])` 逐条串行,
|
||||
原意是"避免 batch size 限制",但代价极大:线上一批 8 条 × ~5.6s 串行
|
||||
@@ -718,21 +771,34 @@ class EmbedService:
|
||||
截断在这里做(发送前逐条过 `_truncate_for_embedding`):所有 embedding
|
||||
路径(embed / embed_batch / embed_queued / *_with_meta)最终都落到这个
|
||||
方法 —— 放在各入口会漏。理由与阈值见模块头部 `_EMBED_MAX_TOKENS`。
|
||||
|
||||
切块:一次带走整批只适用于小批。几千条的 input 数组会被多数服务直接
|
||||
400(input 上限),所以按 `batch_size`(默认 64)切块,块之间顺序发送。
|
||||
不超过一块时行为与切块前逐字相同(`batch_mode="single_request"`)。
|
||||
"""
|
||||
if not texts:
|
||||
return [], []
|
||||
|
||||
safe_texts = [_truncate_for_embedding(t) for t in texts]
|
||||
vectors, meta = await self._embed_openai(safe_texts)
|
||||
base_meta = dict(meta or {})
|
||||
base_meta["batch_mode"] = "single_request"
|
||||
base_meta["batch_input_count"] = len(safe_texts)
|
||||
|
||||
chunk = max(1, int(self._embed_config.batch_size or 64))
|
||||
vectors: List[List[float]] = []
|
||||
all_metas: List[Dict[str, Any]] = []
|
||||
for idx in range(len(safe_texts)):
|
||||
m = dict(base_meta)
|
||||
m["batch_index"] = idx
|
||||
all_metas.append(m)
|
||||
chunk_count = (len(safe_texts) + chunk - 1) // chunk
|
||||
for start in range(0, len(safe_texts), chunk):
|
||||
part = safe_texts[start:start + chunk]
|
||||
retry_kwargs = {} if max_retries is None else {"max_retries": max_retries}
|
||||
part_vectors, meta = await self._embed_openai(part, **retry_kwargs)
|
||||
base_meta = dict(meta or {})
|
||||
base_meta["batch_mode"] = "single_request" if chunk_count == 1 else "chunked"
|
||||
base_meta["batch_input_count"] = len(part)
|
||||
if chunk_count > 1:
|
||||
base_meta["batch_chunk_index"] = start // chunk
|
||||
base_meta["batch_chunk_count"] = chunk_count
|
||||
for idx in range(len(part)):
|
||||
m = dict(base_meta)
|
||||
m["batch_index"] = start + idx
|
||||
all_metas.append(m)
|
||||
vectors.extend(part_vectors)
|
||||
|
||||
self._last_embed_meta = all_metas[-1] if all_metas else {}
|
||||
return vectors, all_metas
|
||||
@@ -753,16 +819,22 @@ class EmbedService:
|
||||
"""
|
||||
打一条 INFO 级 embedding 请求日志(含 request_id / provider / model / api / 入参 / 出参)。
|
||||
|
||||
出参只含 vector 维度,不含向量数值。request_id 同时写入 message,
|
||||
出参只含 vector 维度,不含向量数值。入参的 input 是待向量化的原文
|
||||
(对话 / 记忆 / 查询),INFO 只记每条的字符数(input_chars),原文仅在
|
||||
DEBUG 单独输出。request_id 同时写入 message,
|
||||
并依赖 log formatter 的 %(request_id)s(setup_logging 后生效)。
|
||||
"""
|
||||
texts = request_payload.get("input")
|
||||
info_request = {k: v for k, v in request_payload.items() if k != "input"}
|
||||
if isinstance(texts, list):
|
||||
info_request["input_chars"] = [len(str(t)) for t in texts]
|
||||
payload = {
|
||||
"request_id": get_request_id(),
|
||||
"status": status,
|
||||
"provider": self._embed_config.provider,
|
||||
"model": self._embed_config.model,
|
||||
"api": api_url,
|
||||
"request": request_payload,
|
||||
"request": info_request,
|
||||
"response": response_payload,
|
||||
}
|
||||
try:
|
||||
@@ -770,9 +842,13 @@ class EmbedService:
|
||||
except Exception:
|
||||
body = str(payload)
|
||||
logger.info(f"[embed] {body}")
|
||||
if texts is not None and logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
f"[embed] input: {json.dumps(texts, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
async def _embed_openai(
|
||||
self, texts: List[str]
|
||||
self, texts: List[str], *, max_retries: Optional[int] = None,
|
||||
) -> Tuple[List[List[float]], Dict[str, Any]]:
|
||||
"""
|
||||
使用 OpenAI 兼容接口向量化
|
||||
@@ -862,7 +938,9 @@ class EmbedService:
|
||||
"endpoint_overridden": bool(_ov),
|
||||
}
|
||||
|
||||
_max_retries = max(0, int(self._embed_config.max_retries or 0))
|
||||
_max_retries = max(0, int(
|
||||
(self._embed_config.max_retries or 0) if max_retries is None else max_retries
|
||||
))
|
||||
_retry_delay = max(0.0, float(self._embed_config.retry_delay or 0.0))
|
||||
last_exc: Optional[Exception] = None
|
||||
|
||||
@@ -908,6 +986,17 @@ class EmbedService:
|
||||
f"got={len(vectors)}"
|
||||
)
|
||||
dims = [len(v) for v in vectors]
|
||||
expected_dims = self._embed_config.embedding_dims
|
||||
if expected_dims:
|
||||
bad = [d for d in dims if d != int(expected_dims)]
|
||||
if bad:
|
||||
# 宽度不对的向量写进集合会把集合的维度钉死在错误值上(Chroma
|
||||
# 首次 add 定维),或在已有集合上整批失败。确定性错误,不重试。
|
||||
raise EmbeddingDimensionMismatchError(
|
||||
"embedding dimension mismatch: "
|
||||
f"expected={int(expected_dims)} got={bad[0]} "
|
||||
f"model={self._embed_config.model}"
|
||||
)
|
||||
meta = {
|
||||
"http_status": raw.status_code,
|
||||
"response_headers": resp_headers,
|
||||
@@ -943,6 +1032,8 @@ class EmbedService:
|
||||
},
|
||||
)
|
||||
return vectors, meta
|
||||
except EmbeddingDimensionMismatchError:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
_err_name = type(e).__name__
|
||||
@@ -976,12 +1067,18 @@ class EmbedService:
|
||||
if _retryable and attempt < _max_retries:
|
||||
# 逐出可能卡死的 client,下个 attempt 重建干净连接(恢复原防御语义)
|
||||
self._evict_client(_eff_base_url, _eff_api_key, _eff_timeout)
|
||||
_delay = _retry_delay
|
||||
_retry_after = _retry_after_seconds(e) if _status == 429 else None
|
||||
if _retry_after is not None:
|
||||
# 限流时服务端明确告诉了要等多久:按它等,再加一点抖动,
|
||||
# 避免多个 worker 在同一时刻一起醒来再次撞限流。
|
||||
_delay = max(_delay, _retry_after) + random.uniform(0, 0.25 * _retry_after)
|
||||
logger.warning(
|
||||
f"[embed] attempt {attempt + 1}/{_max_retries + 1} failed "
|
||||
f"({_err_name}): {_err_msg[:200]}; retry in {_retry_delay}s"
|
||||
f"({_err_name}): {_err_msg[:200]}; retry in {_delay:.2f}s"
|
||||
)
|
||||
if _retry_delay > 0:
|
||||
await asyncio.sleep(_retry_delay)
|
||||
if _delay > 0:
|
||||
await asyncio.sleep(_delay)
|
||||
continue
|
||||
self._log_embed_call(
|
||||
status="error",
|
||||
|
||||
@@ -114,6 +114,37 @@ class CacheBase(ABC):
|
||||
"""查询 pipeline 中间结果日志"""
|
||||
...
|
||||
|
||||
# ================================================================
|
||||
# Durable write outcomes
|
||||
# ================================================================
|
||||
|
||||
@abstractmethod
|
||||
async def claim_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
operation: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Claim one idempotent write; return the existing row if already claimed."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def finish_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
status: str,
|
||||
result: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Persist the terminal/unknown result for a claimed write."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Read one durable write outcome by exact request id."""
|
||||
...
|
||||
|
||||
# ================================================================
|
||||
# Summary Buffer(rolling summary 的未消费 turn 暂存)
|
||||
#
|
||||
|
||||
+130
-16
@@ -81,6 +81,21 @@ _CREATE_TABLES_SQL = [
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS write_outcomes (
|
||||
request_id VARCHAR(256) NOT NULL,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
agent_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
session_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
result_json LONGTEXT NOT NULL,
|
||||
created_at VARCHAR(64) NOT NULL,
|
||||
updated_at VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (request_id),
|
||||
KEY idx_write_outcomes_owner (user_id, agent_id, session_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS system_metrics (
|
||||
minute_ts VARCHAR(32) NOT NULL,
|
||||
data LONGTEXT NOT NULL,
|
||||
@@ -459,29 +474,128 @@ class MysqlCache(CacheBase):
|
||||
logger.warning(f"get_pipeline_logs failed: {e}")
|
||||
return []
|
||||
|
||||
async def claim_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
operation: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
now = datetime.now().isoformat()
|
||||
inserted = await self._execute(
|
||||
"""
|
||||
INSERT IGNORE INTO write_outcomes (
|
||||
request_id, user_id, agent_id, session_id, operation,
|
||||
status, result_json, created_at, updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, 'running', '{}', %s, %s)
|
||||
""",
|
||||
(request_id, user_id, agent_id, session_id, operation, now, now),
|
||||
)
|
||||
if inserted == 1:
|
||||
return None
|
||||
return await self.get_write_outcome(request_id)
|
||||
|
||||
async def finish_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
status: str,
|
||||
result: Dict[str, Any],
|
||||
) -> bool:
|
||||
updated = await self._execute(
|
||||
"""
|
||||
UPDATE write_outcomes
|
||||
SET status = %s, result_json = %s, updated_at = %s
|
||||
WHERE request_id = %s
|
||||
""",
|
||||
(
|
||||
status,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
datetime.now().isoformat(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
return updated == 1
|
||||
|
||||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
row = await self._fetchone(
|
||||
"SELECT * FROM write_outcomes WHERE request_id = %s",
|
||||
(request_id,),
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
row["result"] = json.loads(row.pop("result_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
row["result"] = {}
|
||||
return row
|
||||
|
||||
# ================================================================
|
||||
# System Metrics(分钟粒度落盘)
|
||||
# ================================================================
|
||||
|
||||
async def store_metrics_minute(self, minute_ts: str, data: dict) -> None:
|
||||
import aiomysql
|
||||
|
||||
try:
|
||||
now = datetime.now().isoformat()
|
||||
# 尝试 UPSERT
|
||||
existing = await self._fetchone(
|
||||
"SELECT data FROM system_metrics WHERE minute_ts = %s", (minute_ts,)
|
||||
)
|
||||
if existing:
|
||||
old_data = json.loads(existing["data"])
|
||||
merged = self._merge_metric_buckets(old_data, data)
|
||||
await self._execute(
|
||||
"UPDATE system_metrics SET data = %s, created_at = %s WHERE minute_ts = %s",
|
||||
(json.dumps(merged, default=str), now, minute_ts),
|
||||
)
|
||||
else:
|
||||
await self._execute(
|
||||
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (%s, %s, %s)",
|
||||
(minute_ts, json.dumps(data, default=str), now),
|
||||
)
|
||||
# Read-merge-write must be one transaction: two instances flushing
|
||||
# the same minute used to overwrite each other's counters, and a
|
||||
# concurrent insert could lose the whole batch to a duplicate key.
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.begin()
|
||||
try:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT data FROM system_metrics "
|
||||
"WHERE minute_ts = %s FOR UPDATE",
|
||||
(minute_ts,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is not None:
|
||||
old_raw = (
|
||||
row["data"]
|
||||
if isinstance(row, dict)
|
||||
else row[0]
|
||||
)
|
||||
merged = self._merge_metric_buckets(
|
||||
json.loads(old_raw), data,
|
||||
)
|
||||
await cur.execute(
|
||||
"UPDATE system_metrics SET data = %s, "
|
||||
"created_at = %s WHERE minute_ts = %s",
|
||||
(
|
||||
json.dumps(merged, default=str),
|
||||
now,
|
||||
minute_ts,
|
||||
),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"INSERT INTO system_metrics "
|
||||
"(minute_ts, data, created_at) "
|
||||
"VALUES (%s, %s, %s)",
|
||||
(
|
||||
minute_ts,
|
||||
json.dumps(data, default=str),
|
||||
now,
|
||||
),
|
||||
)
|
||||
await conn.commit()
|
||||
return
|
||||
except Exception:
|
||||
await conn.rollback()
|
||||
raise
|
||||
except (aiomysql.IntegrityError, aiomysql.OperationalError) as error:
|
||||
code = error.args[0] if error.args else None
|
||||
deadlock = code in (1205, 1213)
|
||||
if attempt or (isinstance(error, aiomysql.OperationalError) and not deadlock):
|
||||
raise
|
||||
# A competing writer inserted the row, or InnoDB chose us
|
||||
# as the deadlock victim. Retry so this batch merges.
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"store_metrics_minute failed: {e}")
|
||||
|
||||
|
||||
+232
-125
@@ -7,7 +7,7 @@ Agent Memory V2 - SqliteCache
|
||||
设计模式跟随 history_store.py:
|
||||
- 标准库 sqlite3 + _run_in_sqlite_pool()
|
||||
- WAL 模式 + check_same_thread=False
|
||||
- threading.Lock 保护写操作
|
||||
- 每实例 threading.RLock 保护共享连接的全部操作与关闭
|
||||
|
||||
三张表:
|
||||
- memory_operations / pipeline_logs / system_metrics: 审计与观测
|
||||
@@ -87,6 +87,20 @@ CREATE TABLE IF NOT EXISTS pipeline_logs_v2 (
|
||||
CREATE INDEX IF NOT EXISTS idx_plog_request ON pipeline_logs_v2(request_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plog_user ON pipeline_logs_v2(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS write_outcomes (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL DEFAULT '',
|
||||
session_id TEXT NOT NULL DEFAULT '',
|
||||
operation TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_write_outcomes_owner
|
||||
ON write_outcomes(user_id, agent_id, session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_metrics (
|
||||
minute_ts TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
@@ -122,7 +136,10 @@ class SqliteCache(CacheBase):
|
||||
def __init__(self, config: MemoryConfig):
|
||||
self.config = config
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._lock = threading.Lock()
|
||||
# One connection is shared by the executor workers. Use the same lock
|
||||
# for reads, writes, initialization and close so close() cannot free
|
||||
# the connection while another worker is inside sqlite3.
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# 读取 db_path
|
||||
cache_cfg = getattr(config, "cache", None)
|
||||
@@ -144,35 +161,37 @@ class SqliteCache(CacheBase):
|
||||
logger.debug(f"SqliteCache initialized: {self._db_path}")
|
||||
|
||||
def _init_sync(self) -> None:
|
||||
db_dir = os.path.dirname(self._db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
self._conn.executescript(_CREATE_TABLES_SQL)
|
||||
self._migrate_pipeline_logs_columns()
|
||||
self._conn.commit()
|
||||
with self._lock:
|
||||
db_dir = os.path.dirname(self._db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
self._conn.executescript(_CREATE_TABLES_SQL)
|
||||
self._migrate_pipeline_logs_columns()
|
||||
self._conn.commit()
|
||||
|
||||
def _migrate_pipeline_logs_columns(self) -> None:
|
||||
"""给旧库的 pipeline_logs_v2 补 namespace/session/event time 列。"""
|
||||
try:
|
||||
cols = {
|
||||
row["name"]
|
||||
for row in self._conn.execute("PRAGMA table_info(pipeline_logs_v2)")
|
||||
}
|
||||
_text_cols = ("namespace", "session_id")
|
||||
for col in _text_cols:
|
||||
if col not in cols:
|
||||
with self._lock:
|
||||
try:
|
||||
cols = {
|
||||
row["name"]
|
||||
for row in self._conn.execute("PRAGMA table_info(pipeline_logs_v2)")
|
||||
}
|
||||
_text_cols = ("namespace", "session_id")
|
||||
for col in _text_cols:
|
||||
if col not in cols:
|
||||
self._conn.execute(
|
||||
f"ALTER TABLE pipeline_logs_v2 ADD COLUMN {col} TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
if "event_at_ms" not in cols:
|
||||
self._conn.execute(
|
||||
f"ALTER TABLE pipeline_logs_v2 ADD COLUMN {col} TEXT NOT NULL DEFAULT ''"
|
||||
"ALTER TABLE pipeline_logs_v2 ADD COLUMN event_at_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "event_at_ms" not in cols:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE pipeline_logs_v2 ADD COLUMN event_at_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[sqlite] pipeline_logs_v2 column migration skipped: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[sqlite] pipeline_logs_v2 column migration skipped: {e}")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
@@ -180,9 +199,10 @@ class SqliteCache(CacheBase):
|
||||
logger.info("SqliteCache closed")
|
||||
|
||||
def _close_sync(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
with self._lock:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
# ================================================================
|
||||
# 统计
|
||||
@@ -255,39 +275,40 @@ class SqliteCache(CacheBase):
|
||||
"""
|
||||
try:
|
||||
def _query():
|
||||
conditions = []
|
||||
params = []
|
||||
if request_id:
|
||||
conditions.append("request_id = ?")
|
||||
params.append(request_id)
|
||||
if memory_id:
|
||||
conditions.append("memory_id = ?")
|
||||
params.append(memory_id)
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
with self._lock:
|
||||
conditions = []
|
||||
params = []
|
||||
if request_id:
|
||||
conditions.append("request_id = ?")
|
||||
params.append(request_id)
|
||||
if memory_id:
|
||||
conditions.append("memory_id = ?")
|
||||
params.append(memory_id)
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if not conditions:
|
||||
return []
|
||||
if not conditions:
|
||||
return []
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
rows = self._conn.execute(
|
||||
f"SELECT * FROM memory_operations_v2 WHERE {where} "
|
||||
f"ORDER BY id DESC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
row = dict(r)
|
||||
# 反序列化 supersedes JSON 字符串 → list
|
||||
import json as _json
|
||||
raw_sup = row.get("supersedes", "")
|
||||
try:
|
||||
row["supersedes"] = _json.loads(raw_sup) if raw_sup else []
|
||||
except Exception:
|
||||
row["supersedes"] = []
|
||||
result.append(row)
|
||||
return result
|
||||
where = " AND ".join(conditions)
|
||||
rows = self._conn.execute(
|
||||
f"SELECT * FROM memory_operations_v2 WHERE {where} "
|
||||
f"ORDER BY id DESC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
row = dict(r)
|
||||
# 反序列化 supersedes JSON 字符串 → list
|
||||
import json as _json
|
||||
raw_sup = row.get("supersedes", "")
|
||||
try:
|
||||
row["supersedes"] = _json.loads(raw_sup) if raw_sup else []
|
||||
except Exception:
|
||||
row["supersedes"] = []
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
return await _run_in_sqlite_pool(_query)
|
||||
except Exception as e:
|
||||
@@ -369,43 +390,126 @@ class SqliteCache(CacheBase):
|
||||
"""
|
||||
try:
|
||||
def _query():
|
||||
conditions = []
|
||||
params = []
|
||||
if request_id:
|
||||
conditions.append("request_id = ?")
|
||||
params.append(request_id)
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
if step:
|
||||
conditions.append("step = ?")
|
||||
params.append(step)
|
||||
with self._lock:
|
||||
conditions = []
|
||||
params = []
|
||||
if request_id:
|
||||
conditions.append("request_id = ?")
|
||||
params.append(request_id)
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
if step:
|
||||
conditions.append("step = ?")
|
||||
params.append(step)
|
||||
|
||||
if not conditions:
|
||||
return []
|
||||
if not conditions:
|
||||
return []
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
rows = self._conn.execute(
|
||||
f"SELECT * FROM pipeline_logs_v2 WHERE {where} "
|
||||
f"ORDER BY id ASC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
out = [dict(r) for r in rows]
|
||||
# 派生可读的 event_at(ISO ms)供查询接口直接用;event_at_ms 为 0 时留空
|
||||
from datetime import datetime as _dt
|
||||
for _r in out:
|
||||
_ms = _r.get("event_at_ms") or 0
|
||||
_r["event_at"] = (
|
||||
_dt.fromtimestamp(_ms / 1000.0).isoformat(timespec="milliseconds")
|
||||
if _ms else ""
|
||||
)
|
||||
return out
|
||||
where = " AND ".join(conditions)
|
||||
rows = self._conn.execute(
|
||||
f"SELECT * FROM pipeline_logs_v2 WHERE {where} "
|
||||
f"ORDER BY id ASC LIMIT ?",
|
||||
params + [limit],
|
||||
).fetchall()
|
||||
out = [dict(r) for r in rows]
|
||||
# 派生可读的 event_at(ISO ms)供查询接口直接用;event_at_ms 为 0 时留空
|
||||
from datetime import datetime as _dt
|
||||
for _r in out:
|
||||
_ms = _r.get("event_at_ms") or 0
|
||||
_r["event_at"] = (
|
||||
_dt.fromtimestamp(_ms / 1000.0).isoformat(timespec="milliseconds")
|
||||
if _ms else ""
|
||||
)
|
||||
return out
|
||||
|
||||
return await _run_in_sqlite_pool(_query)
|
||||
except Exception as e:
|
||||
logger.warning(f"get_pipeline_logs failed: {e}")
|
||||
return []
|
||||
|
||||
# ================================================================
|
||||
# Durable write outcomes
|
||||
# ================================================================
|
||||
|
||||
async def claim_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session_id: str,
|
||||
operation: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
def _claim():
|
||||
with self._lock:
|
||||
now = datetime.now().isoformat()
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO write_outcomes (
|
||||
request_id, user_id, agent_id, session_id, operation,
|
||||
status, result_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'running', '{}', ?, ?)
|
||||
""",
|
||||
(request_id, user_id, agent_id, session_id, operation, now, now),
|
||||
)
|
||||
self._conn.commit()
|
||||
if cursor.rowcount == 1:
|
||||
return None
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM write_outcomes WHERE request_id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
existing = await _run_in_sqlite_pool(_claim)
|
||||
if existing is None:
|
||||
return None
|
||||
return await self.get_write_outcome(request_id)
|
||||
|
||||
async def finish_write_outcome(
|
||||
self,
|
||||
request_id: str,
|
||||
status: str,
|
||||
result: Dict[str, Any],
|
||||
) -> bool:
|
||||
def _finish():
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"""
|
||||
UPDATE write_outcomes
|
||||
SET status = ?, result_json = ?, updated_at = ?
|
||||
WHERE request_id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
datetime.now().isoformat(),
|
||||
request_id,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.rowcount == 1
|
||||
|
||||
return await _run_in_sqlite_pool(_finish)
|
||||
|
||||
async def get_write_outcome(self, request_id: str) -> Optional[Dict[str, Any]]:
|
||||
def _get():
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM write_outcomes WHERE request_id = ?",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
row = await _run_in_sqlite_pool(_get)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
row["result"] = json.loads(row.pop("result_json") or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
row["result"] = {}
|
||||
return row
|
||||
|
||||
# ================================================================
|
||||
# System Metrics(分钟粒度落盘)
|
||||
# ================================================================
|
||||
@@ -414,26 +518,27 @@ class SqliteCache(CacheBase):
|
||||
"""存储一个分钟桶的增量指标数据(UPSERT: 同一分钟多次写入会合并)"""
|
||||
try:
|
||||
def _store():
|
||||
now = datetime.now().isoformat()
|
||||
# UPSERT: 如果同一分钟已存在,合并数据
|
||||
existing = self._conn.execute(
|
||||
"SELECT data FROM system_metrics WHERE minute_ts = ?",
|
||||
(minute_ts,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
import json as _json
|
||||
old_data = _json.loads(existing["data"])
|
||||
merged = self._merge_metric_buckets(old_data, data)
|
||||
self._conn.execute(
|
||||
"UPDATE system_metrics SET data = ?, created_at = ? WHERE minute_ts = ?",
|
||||
(_json.dumps(merged, default=str), now, minute_ts),
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (?, ?, ?)",
|
||||
(minute_ts, json.dumps(data, default=str), now),
|
||||
)
|
||||
self._conn.commit()
|
||||
with self._lock:
|
||||
now = datetime.now().isoformat()
|
||||
# UPSERT: 如果同一分钟已存在,合并数据
|
||||
existing = self._conn.execute(
|
||||
"SELECT data FROM system_metrics WHERE minute_ts = ?",
|
||||
(minute_ts,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
import json as _json
|
||||
old_data = _json.loads(existing["data"])
|
||||
merged = self._merge_metric_buckets(old_data, data)
|
||||
self._conn.execute(
|
||||
"UPDATE system_metrics SET data = ?, created_at = ? WHERE minute_ts = ?",
|
||||
(_json.dumps(merged, default=str), now, minute_ts),
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
"INSERT INTO system_metrics (minute_ts, data, created_at) VALUES (?, ?, ?)",
|
||||
(minute_ts, json.dumps(data, default=str), now),
|
||||
)
|
||||
self._conn.commit()
|
||||
await _run_in_sqlite_pool(_store)
|
||||
except Exception as e:
|
||||
logger.warning(f"store_metrics_minute failed: {e}")
|
||||
@@ -442,17 +547,18 @@ class SqliteCache(CacheBase):
|
||||
"""读取指定时间范围内的分钟指标数据"""
|
||||
try:
|
||||
def _load():
|
||||
rows = self._conn.execute(
|
||||
"SELECT data FROM system_metrics WHERE minute_ts >= ? AND minute_ts <= ? ORDER BY minute_ts",
|
||||
(start_ts, end_ts),
|
||||
).fetchall()
|
||||
results = []
|
||||
for row in rows:
|
||||
try:
|
||||
results.append(json.loads(row["data"]))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return results
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT data FROM system_metrics WHERE minute_ts >= ? AND minute_ts <= ? ORDER BY minute_ts",
|
||||
(start_ts, end_ts),
|
||||
).fetchall()
|
||||
results = []
|
||||
for row in rows:
|
||||
try:
|
||||
results.append(json.loads(row["data"]))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return results
|
||||
return await _run_in_sqlite_pool(_load)
|
||||
except Exception as e:
|
||||
logger.warning(f"load_metrics_range failed: {e}")
|
||||
@@ -462,11 +568,12 @@ class SqliteCache(CacheBase):
|
||||
"""删除指定时间之前的 metrics 数据"""
|
||||
try:
|
||||
def _cleanup():
|
||||
self._conn.execute(
|
||||
"DELETE FROM system_metrics WHERE minute_ts < ?",
|
||||
(before_ts,),
|
||||
)
|
||||
self._conn.commit()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM system_metrics WHERE minute_ts < ?",
|
||||
(before_ts,),
|
||||
)
|
||||
self._conn.commit()
|
||||
await _run_in_sqlite_pool(_cleanup)
|
||||
except Exception as e:
|
||||
logger.warning(f"cleanup_old_metrics failed: {e}")
|
||||
|
||||
@@ -22,6 +22,7 @@ import logging
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
|
||||
from ..models.memory import MemoryNode, MemoryLayer, MemoryStatus
|
||||
from ..config import MemoryConfig
|
||||
@@ -77,6 +78,21 @@ async def _run_in_kuzu_pool(func, *args, **kwargs):
|
||||
return await run_in_executor_guarded(_kuzu_executor, _measured)
|
||||
|
||||
|
||||
# 本进程里已打开、尚未 close 的 store。drop_graph 据此拒绝删掉一个正被
|
||||
# 活着的 client 使用的图库。WeakSet:store 被回收后自动消失。
|
||||
_LIVE_STORES: "weakref.WeakSet[KuzuGraphStore]" = weakref.WeakSet()
|
||||
|
||||
|
||||
def open_graph_paths() -> List[str]:
|
||||
"""Real paths of the graph databases live stores in this process hold open."""
|
||||
import os
|
||||
return [
|
||||
os.path.realpath(str(store._db_path))
|
||||
for store in list(_LIVE_STORES)
|
||||
if store._db is not None
|
||||
]
|
||||
|
||||
|
||||
def kuzu_executor_snapshot() -> Dict[str, Any]:
|
||||
with _kuzu_stats_lock:
|
||||
return {
|
||||
@@ -350,6 +366,7 @@ class KuzuGraphStore(GraphStoreBase):
|
||||
)
|
||||
self._db = db
|
||||
self._conn = conn
|
||||
_LIVE_STORES.add(self)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"GraphStore init timed out after 5s (Kuzu file lock contention). "
|
||||
@@ -2441,6 +2458,7 @@ class KuzuGraphStore(GraphStoreBase):
|
||||
async def close(self) -> None:
|
||||
"""关闭连接"""
|
||||
conn, db = self._conn, self._db
|
||||
_LIVE_STORES.discard(self)
|
||||
self._conn = None
|
||||
self._db = None
|
||||
self._available = False
|
||||
|
||||
+113
-69
@@ -16,6 +16,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
@@ -65,6 +66,11 @@ def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape LIKE wildcards so a user id is matched literally."""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
class HistoryStore:
|
||||
"""
|
||||
SQLite 历史存储 - 记录所有内存操作的审计日志。
|
||||
@@ -89,6 +95,10 @@ class HistoryStore:
|
||||
"""
|
||||
self._db_path = config.history.db_path
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
# sqlite3.Connection.close() must never race with an execute() on the
|
||||
# same connection. The lock also serializes operations on this shared
|
||||
# connection; separate HistoryStore instances remain independent.
|
||||
self._conn_lock = threading.RLock()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""创建表和索引(如果不存在)"""
|
||||
@@ -97,20 +107,21 @@ class HistoryStore:
|
||||
|
||||
def _init_sync(self) -> None:
|
||||
"""同步初始化:创建目录、连接数据库、建表建索引"""
|
||||
# 确保目录存在
|
||||
db_dir = os.path.dirname(self._db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
with self._conn_lock:
|
||||
# 确保目录存在
|
||||
db_dir = os.path.dirname(self._db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
# 开启 WAL 模式,提高并发读写性能
|
||||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
# 开启 WAL 模式,提高并发读写性能
|
||||
self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
|
||||
self._conn.execute(_CREATE_TABLE_SQL)
|
||||
for idx_sql in _CREATE_INDEXES_SQL:
|
||||
self._conn.execute(idx_sql)
|
||||
self._conn.commit()
|
||||
self._conn.execute(_CREATE_TABLE_SQL)
|
||||
for idx_sql in _CREATE_INDEXES_SQL:
|
||||
self._conn.execute(idx_sql)
|
||||
self._conn.commit()
|
||||
|
||||
# ================================================================
|
||||
# 记录操作
|
||||
@@ -218,13 +229,14 @@ class HistoryStore:
|
||||
return await _run_in_sqlite_pool(self._get_history_sync, memory_id)
|
||||
|
||||
def _get_history_sync(self, memory_id: str) -> List[Dict[str, Any]]:
|
||||
if self._conn is None:
|
||||
return []
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY created_at ASC",
|
||||
(memory_id,),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
with self._conn_lock:
|
||||
if self._conn is None:
|
||||
return []
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM memory_history WHERE memory_id = ? ORDER BY created_at ASC",
|
||||
(memory_id,),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
async def get_recent(
|
||||
self,
|
||||
@@ -244,14 +256,43 @@ class HistoryStore:
|
||||
return await _run_in_sqlite_pool(self._get_recent_sync, isolation_key, limit)
|
||||
|
||||
def _get_recent_sync(self, isolation_key: str, limit: int) -> List[Dict[str, Any]]:
|
||||
if self._conn is None:
|
||||
return []
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM memory_history WHERE isolation_key = ? "
|
||||
"ORDER BY created_at DESC LIMIT ?",
|
||||
(isolation_key, limit),
|
||||
with self._conn_lock:
|
||||
if self._conn is None:
|
||||
return []
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM memory_history WHERE isolation_key = ? "
|
||||
"ORDER BY created_at DESC LIMIT ?",
|
||||
(isolation_key, limit),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
async def get_recent_for_user(
|
||||
self,
|
||||
user_id: str,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取某 user 下所有 agent 的最近操作。
|
||||
|
||||
isolation_key 形如 ``"{user_id}:{agent_id}"``。user_id 里的 LIKE
|
||||
通配符会被转义,保证只匹配这个 user,不会漏到 ``u1`` 之外。
|
||||
"""
|
||||
prefix = _escape_like(f"{user_id}:") + "%"
|
||||
return await _run_in_sqlite_pool(
|
||||
self._get_recent_prefix_sync, prefix, limit,
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def _get_recent_prefix_sync(
|
||||
self, pattern: str, limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
with self._conn_lock:
|
||||
if self._conn is None:
|
||||
return []
|
||||
cursor = self._conn.execute(
|
||||
"SELECT * FROM memory_history WHERE isolation_key LIKE ? ESCAPE '\\' "
|
||||
"ORDER BY created_at DESC LIMIT ?",
|
||||
(pattern, limit),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
async def count(self, isolation_key: str = "") -> int:
|
||||
"""
|
||||
@@ -266,16 +307,17 @@ class HistoryStore:
|
||||
return await _run_in_sqlite_pool(self._count_sync, isolation_key)
|
||||
|
||||
def _count_sync(self, isolation_key: str) -> int:
|
||||
if self._conn is None:
|
||||
return 0
|
||||
if isolation_key:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM memory_history WHERE isolation_key = ?",
|
||||
(isolation_key,),
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute("SELECT COUNT(*) FROM memory_history")
|
||||
return cursor.fetchone()[0]
|
||||
with self._conn_lock:
|
||||
if self._conn is None:
|
||||
return 0
|
||||
if isolation_key:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM memory_history WHERE isolation_key = ?",
|
||||
(isolation_key,),
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute("SELECT COUNT(*) FROM memory_history")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭数据库连接"""
|
||||
@@ -284,9 +326,10 @@ class HistoryStore:
|
||||
logger.info("[HistoryStore] closed")
|
||||
|
||||
def _close_sync(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
with self._conn_lock:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
# ================================================================
|
||||
# 内部
|
||||
@@ -339,33 +382,34 @@ class HistoryStore:
|
||||
role: str = "",
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
if self._conn is None:
|
||||
return -1
|
||||
now = _now_iso()
|
||||
extra_json = json.dumps(extra or {}, ensure_ascii=False)
|
||||
cursor = self._conn.execute(
|
||||
"""INSERT INTO memory_history
|
||||
(memory_id, isolation_key, event,
|
||||
old_memory, new_memory, old_status, new_status,
|
||||
change_reason, layer, actor_id, role, extra,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
memory_id,
|
||||
isolation_key,
|
||||
event,
|
||||
old_memory,
|
||||
new_memory,
|
||||
old_status,
|
||||
new_status,
|
||||
change_reason,
|
||||
layer,
|
||||
actor_id,
|
||||
role,
|
||||
extra_json,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.lastrowid
|
||||
with self._conn_lock:
|
||||
if self._conn is None:
|
||||
return -1
|
||||
now = _now_iso()
|
||||
extra_json = json.dumps(extra or {}, ensure_ascii=False)
|
||||
cursor = self._conn.execute(
|
||||
"""INSERT INTO memory_history
|
||||
(memory_id, isolation_key, event,
|
||||
old_memory, new_memory, old_status, new_status,
|
||||
change_reason, layer, actor_id, role, extra,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
memory_id,
|
||||
isolation_key,
|
||||
event,
|
||||
old_memory,
|
||||
new_memory,
|
||||
old_status,
|
||||
new_status,
|
||||
change_reason,
|
||||
layer,
|
||||
actor_id,
|
||||
role,
|
||||
extra_json,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.lastrowid
|
||||
@@ -89,6 +89,17 @@ def passes_created_after(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def physical_collection_name(base_name: Optional[str], dims: Optional[int]) -> str:
|
||||
"""The collection name a store opens for ``base_name`` at ``dims``.
|
||||
|
||||
The dims suffix keeps two embedding widths from sharing one collection.
|
||||
`hy_memory.reembed.vector_space_names` uses this too, so callers never
|
||||
re-derive the rule.
|
||||
"""
|
||||
base = base_name or "agent_memories"
|
||||
return f"{base}_{dims}" if dims else base
|
||||
|
||||
|
||||
class VectorStoreBase(ABC):
|
||||
"""
|
||||
向量存储抽象基类
|
||||
@@ -98,13 +109,10 @@ class VectorStoreBase(ABC):
|
||||
|
||||
def __init__(self, config: MemoryConfig):
|
||||
self.config = config
|
||||
base_name = config.vector_store.collection_name or "agent_memories"
|
||||
dims = config.vector_store.embedding_dims
|
||||
# 自动拼接维度后缀,避免不同 embedding 模型写入同一 collection 导致维度冲突
|
||||
if dims:
|
||||
self._collection_name = f"{base_name}_{dims}"
|
||||
else:
|
||||
self._collection_name = base_name
|
||||
self._collection_name = physical_collection_name(
|
||||
config.vector_store.collection_name, config.vector_store.embedding_dims,
|
||||
)
|
||||
|
||||
_supports_retrieval_clauses: bool = False
|
||||
_supports_narrow_episode_queries: bool = False
|
||||
|
||||
@@ -9,10 +9,12 @@ Agent Memory V2 - VectorStore (Chroma)
|
||||
- 同步 API → _run_in_vdb_pool() 包装为异步
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
import concurrent.futures
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import weakref
|
||||
|
||||
from ..models.memory import (
|
||||
MemoryNode, MemoryLayer, MemoryStatus,
|
||||
@@ -33,6 +35,30 @@ _vdb_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
)
|
||||
|
||||
|
||||
# 本进程里已 initialize、尚未 close 的 store。drop_vector_space 据此拒绝删掉
|
||||
# 一个正被活着的 client 使用的集合。WeakSet:store 被回收后自动消失。
|
||||
_LIVE_STORES: "weakref.WeakSet[ChromaVectorStore]" = weakref.WeakSet()
|
||||
|
||||
|
||||
def _location_key(vs_config) -> Tuple[str, str]:
|
||||
if getattr(vs_config, "host", None):
|
||||
return ("http", f"{vs_config.host}:{vs_config.port or 8000}")
|
||||
return ("local", os.path.realpath(str(vs_config.persist_directory or "")))
|
||||
|
||||
|
||||
def open_collection_names(location: Tuple[str, str]) -> Set[str]:
|
||||
"""Collections that live stores in this process hold open at ``location``."""
|
||||
names: Set[str] = set()
|
||||
for store in list(_LIVE_STORES):
|
||||
if store._client is None or _location_key(store.config.vector_store) != location:
|
||||
continue
|
||||
names.add(store._collection_name)
|
||||
if store._entity_collection is not None:
|
||||
from ..pipelines._retrieval.config import entity_collection_name
|
||||
names.add(entity_collection_name(store._collection_name))
|
||||
return names
|
||||
|
||||
|
||||
async def _run_in_vdb_pool(func, *args, **kwargs):
|
||||
"""Run a synchronous Chroma call in the dedicated VDB executor."""
|
||||
call = functools.partial(func, *args, **kwargs) if args or kwargs else func
|
||||
@@ -88,6 +114,7 @@ class ChromaVectorStore(VectorStoreBase):
|
||||
return client, collection
|
||||
|
||||
self._client, self._collection = await _run_in_vdb_pool(_init)
|
||||
_LIVE_STORES.add(self)
|
||||
logger.debug(f"VectorStore initialized (Chroma), collection={self._collection_name}")
|
||||
|
||||
# ================================================================
|
||||
@@ -561,6 +588,7 @@ class ChromaVectorStore(VectorStoreBase):
|
||||
async def close(self) -> None:
|
||||
"""关闭客户端"""
|
||||
# Chroma PersistentClient 无需显式关闭
|
||||
_LIVE_STORES.discard(self)
|
||||
self._client = None
|
||||
self._collection = None
|
||||
self._entity_collection = None
|
||||
|
||||
@@ -462,14 +462,39 @@ class FaissVectorStore(VectorStoreBase):
|
||||
return None
|
||||
|
||||
async def get_embeddings(self, node_ids: List[str]) -> Dict[str, List[float]]:
|
||||
"""批量取向量(in-memory _data 直接读 embedding),供去重算 cosine。"""
|
||||
out: Dict[str, List[float]] = {}
|
||||
for nid in node_ids:
|
||||
point_id = self._node_id_to_point_id(nid)
|
||||
entry = self._data.get(point_id)
|
||||
if entry and entry.get("embedding"):
|
||||
out[nid] = list(entry["embedding"])
|
||||
return out
|
||||
"""批量取向量,供去重 / episode 摘要计算 cosine。
|
||||
|
||||
``_data`` 只保存 payload 与 FAISS 内部下标;普通 upsert 不额外存一份
|
||||
embedding,向量本体在 FAISS index 里。这里按当前有效下标 reconstruct,
|
||||
并用 ``_idx_to_id`` 校验,避免读到已被替换的旧下标。
|
||||
"""
|
||||
|
||||
def _get() -> Dict[str, List[float]]:
|
||||
out: Dict[str, List[float]] = {}
|
||||
with self._lock:
|
||||
for nid in node_ids:
|
||||
point_id = self._node_id_to_point_id(nid)
|
||||
entry = self._data.get(point_id)
|
||||
if entry is None:
|
||||
continue
|
||||
embedding = entry.get("embedding")
|
||||
if embedding:
|
||||
out[nid] = list(embedding)
|
||||
continue
|
||||
idx = entry.get("idx")
|
||||
if (
|
||||
idx is None
|
||||
or self._index is None
|
||||
or self._idx_to_id.get(int(idx)) != point_id
|
||||
):
|
||||
continue
|
||||
try:
|
||||
out[nid] = self._index.reconstruct(int(idx)).tolist()
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
return await _run_in_vdb_pool(_get)
|
||||
|
||||
# ================================================================
|
||||
# 删除
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Public structural contract for injected embedding services."""
|
||||
|
||||
from typing import Any, Dict, List, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingService(Protocol):
|
||||
"""Embedding service accepted by :class:`HyMemoryClient`.
|
||||
|
||||
Implementations are borrowed by the client: their lifecycle remains owned
|
||||
by the caller and ``HyMemoryClient.close()`` will not close them.
|
||||
"""
|
||||
|
||||
async def embed(self, text: str, use_cache: bool = True) -> List[float]:
|
||||
...
|
||||
|
||||
async def embed_with_meta(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> Tuple[List[float], Dict[str, Any]]:
|
||||
...
|
||||
|
||||
async def embed_batch(
|
||||
self, texts: List[str], use_cache: bool = True
|
||||
) -> List[List[float]]:
|
||||
...
|
||||
|
||||
async def embed_queued(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> List[float]:
|
||||
...
|
||||
|
||||
async def embed_queued_with_meta(
|
||||
self, text: str, use_cache: bool = True
|
||||
) -> Tuple[List[float], Dict[str, Any]]:
|
||||
...
|
||||
|
||||
def get_embedding_dims(self) -> int:
|
||||
...
|
||||
|
||||
|
||||
# Explicit alias for callers that prefer the conventional Protocol suffix.
|
||||
EmbeddingServiceProtocol = EmbeddingService
|
||||
|
||||
|
||||
class EmbeddingDimensionMismatchError(ValueError):
|
||||
"""Raised before initialization when service and store dimensions differ."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EmbeddingService",
|
||||
"EmbeddingServiceProtocol",
|
||||
"EmbeddingDimensionMismatchError",
|
||||
]
|
||||
@@ -64,59 +64,15 @@ def _topic_lock(graph_store, isolation_key: str, topic_id: str) -> asyncio.Lock:
|
||||
return lock
|
||||
|
||||
|
||||
TOPIC_SUMMARY_PROMPT = """You are summarizing everything known about ONE topic.
|
||||
|
||||
## Topic
|
||||
|
||||
{topic_name}
|
||||
|
||||
## Memories under this topic (oldest → newest)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
Write a single dense summary of what is known about the user with respect to this
|
||||
topic.
|
||||
|
||||
Rules:
|
||||
|
||||
* 2–4 sentences, third person, starting with "The user ..." (or the named person
|
||||
if the memories are about someone else).
|
||||
* Cover the topic as a whole — state the current situation, and mention how it
|
||||
developed only when the change matters.
|
||||
* Preserve concrete details: names, places, numbers, dates.
|
||||
* Do NOT invent anything that is not in the memories.
|
||||
* Do NOT enumerate the memories one by one; synthesize them.
|
||||
* Output language must match the language of the memories.
|
||||
|
||||
Return ONLY the summary text, no preamble, no JSON, no quotes."""
|
||||
|
||||
|
||||
TOPIC_SUMMARY_PROMPT_ZH = """你在为**一个主题**总结已知的全部信息。
|
||||
|
||||
## 主题
|
||||
|
||||
{topic_name}
|
||||
|
||||
## 该主题下的记忆(时间正序)
|
||||
|
||||
{memories}
|
||||
|
||||
---
|
||||
|
||||
写一段关于用户在这个主题上的高密度摘要。
|
||||
|
||||
要求:
|
||||
|
||||
* 2–4 句,第三人称,以"用户…"开头(若记忆讲的是别人,则以那个人的名字开头)。
|
||||
* 覆盖整个主题 —— 说明当前状况;只在变化本身重要时才提它是怎么演变过来的。
|
||||
* 保留具体细节:人名、地点、数字、日期。
|
||||
* 绝不编造记忆里没有的内容。
|
||||
* 不要逐条罗列记忆,要综合成一段。
|
||||
* 输出语言与记忆的语言一致。
|
||||
|
||||
只返回摘要正文,不要任何前言、不要 JSON、不要引号。"""
|
||||
# 多语言版本见 hy_memory/prompts/<language>.py;旧名字保留为别名。
|
||||
from ...prompts import ( # noqa: E402
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
resolve_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ...prompts.en import TOPIC_SUMMARY_PROMPT # noqa: E402,F401 (re-export)
|
||||
from ...prompts.zh_hans import TOPIC_SUMMARY_PROMPT as TOPIC_SUMMARY_PROMPT_ZH # noqa: E402,F401 (re-export)
|
||||
|
||||
|
||||
async def find_latest_topic_summary(
|
||||
@@ -193,9 +149,13 @@ async def maybe_summarize_topics(
|
||||
max_concurrency: int = 3,
|
||||
temperature: Optional[float] = None,
|
||||
trace=None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""对本次写入触达的 Topic,按需有界并发生成 / 刷新 summary。
|
||||
|
||||
language 为显式 prompt 语言码(随触发本次写入的请求);None 时按记忆内容
|
||||
做 zh/en 自动检测。
|
||||
|
||||
触发条件:Topic 下 active 记忆数 ≥ `min_memories`,且比上次总结时多了 ≥ `step` 条。
|
||||
这样既不会每写一条就重算(贵),也不会一直不更新(陈旧)。
|
||||
|
||||
@@ -254,6 +214,7 @@ async def maybe_summarize_topics(
|
||||
topic_name=topic_names.get(topic_id) or topic_id,
|
||||
memories=[m.get("content") or "" for m in mems],
|
||||
temperature=temperature,
|
||||
language=language,
|
||||
)
|
||||
if trace is not None:
|
||||
await trace(
|
||||
@@ -317,15 +278,18 @@ async def maybe_summarize_topics(
|
||||
async def _generate(
|
||||
*, llm_provider, topic_name: str, memories: List[str],
|
||||
temperature: Optional[float] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""返回 (摘要文本, 实际 prompt, token 用量) —— 后两个供埋点,线上要能复盘。"""
|
||||
from ...utils.lang_detect import is_chinese
|
||||
|
||||
body = "\n".join(f"- {c}" for c in memories if c)
|
||||
if not body:
|
||||
return "", "", {}
|
||||
tpl = TOPIC_SUMMARY_PROMPT_ZH if is_chinese(body) else TOPIC_SUMMARY_PROMPT
|
||||
prompt = tpl.format(topic_name=topic_name, memories=body)
|
||||
explicit = normalize_language(language)
|
||||
code = resolve_language(explicit, body)
|
||||
prompt = get_prompt("TOPIC_SUMMARY_PROMPT", code).format(
|
||||
topic_name=topic_name, memories=body,
|
||||
)
|
||||
prompt = with_output_language(prompt, code, explicit=explicit is not None)
|
||||
resp = await llm_provider.complete(
|
||||
prompt=prompt,
|
||||
max_tokens=400,
|
||||
|
||||
@@ -160,6 +160,28 @@ def near_dup_demote(
|
||||
return out
|
||||
|
||||
|
||||
# MMR 只在调用方请求的 top-k(search 的 limit)小于这个值时自动启用。
|
||||
# mmr_select 的贪心是 O(k^2 * pool) 次 Jaccard,k 大时代价陡增,而大 k 本身已
|
||||
# 足够容纳多样性,收益很小。权重仍由 MEMORY_HYBRID_V2_MMR_LAMBDA 控制。
|
||||
MMR_MAX_TOP_K = 20
|
||||
|
||||
|
||||
def mmr_applies(top_k: int, lambda_: float) -> bool:
|
||||
"""本次检索是否走 MMR:λ 在有效区间内,且 top_k < MMR_MAX_TOP_K。"""
|
||||
return 0.0 < float(lambda_) < 0.999 and int(top_k) < MMR_MAX_TOP_K
|
||||
|
||||
|
||||
def near_dup_applies(top_k: int) -> bool:
|
||||
"""本次检索是否走近重复降权:与 MMR 同一个 top-k 门槛。
|
||||
|
||||
near_dup_demote 让每个候选和此前所有"非重复"候选两两算 Jaccard,候选
|
||||
互不相似时是 O(pool^2);池子随 limit 放大(约 max(3*limit, 30) + 关键词
|
||||
命中),limit 达到 MMR_MAX_TOP_K 时代价同样陡增。大 k 的调用方(如记忆
|
||||
面板翻页)要的是全部相关项,相似项也应照常出现,降权没有收益。
|
||||
"""
|
||||
return int(top_k) < MMR_MAX_TOP_K
|
||||
|
||||
|
||||
def mmr_select(
|
||||
hits: Sequence[Dict[str, Any]],
|
||||
limit: int,
|
||||
|
||||
@@ -10,11 +10,14 @@ HY Memory - Intention (L7) 召回 + 惰性过期转换 + 复本判定
|
||||
节点的 layer 改写为 l2_fact(持久化 update_payload),并从意图结果里剔除——
|
||||
过期意图变成普通历史事实,下次走 normal 召回;
|
||||
- **判重(重点)**:对幸存意图按下述规则分组,同组只留一条本次返回:
|
||||
同 user_id + 同一天 memory_at + valid_until 相同(都为 None 或都相等)
|
||||
+ cosine 相似度 ≥ MEMORY_DEDUP_THRESHOLD (默认 0.95)
|
||||
同 user_id + 同 agent_id + 同一天 memory_at + valid_until 相同
|
||||
(都为 None 或都相等)+ cosine 相似度 ≥ MEMORY_DEDUP_THRESHOLD (默认 0.95)
|
||||
→ 视为同一个意图的复本(extractor 复读机 / 多 session 重复表达)。
|
||||
保留组里 `gmt_created` 最早的(老的先来先得,稳定 id);
|
||||
- **异步删库**:其余复本 fire-and-forget 从 VDB 删除,不影响本次调用 RT。
|
||||
- **开关**:判重受 `MemoryConfig.recall.intention_dedup_enabled`(默认开)与
|
||||
`recall.intention_dedup_threshold` 控制,由 reader 按 client 配置传入。
|
||||
关闭时完全跳过判重:不隐藏、不删除任何意图(写入侧批内判重同样跳过)。
|
||||
|
||||
Q: 为什么要看 memory_at 和 valid_until?
|
||||
A: 意图是 point-in-time 信号。同一句"我周五要开会",如果一条 memory_at 在
|
||||
@@ -38,8 +41,14 @@ from ...models.memory import MemoryLayer, MemoryStatus
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_dedup_threshold() -> float:
|
||||
"""去重 cosine 阈值:沿用 dedup 内核的 MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
||||
def _get_dedup_threshold(configured: Optional[float] = None) -> float:
|
||||
"""去重 cosine 阈值:优先用 client 配置;否则(或配置非法)沿用 dedup 内核的
|
||||
MEMORY_DEDUP_THRESHOLD,默认 0.95。"""
|
||||
try:
|
||||
if configured is not None:
|
||||
return float(configured)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"[intention] invalid dedup threshold: {configured!r}")
|
||||
try:
|
||||
return float(os.environ.get("MEMORY_DEDUP_THRESHOLD", 0.95))
|
||||
except (TypeError, ValueError):
|
||||
@@ -112,11 +121,19 @@ def _partition_duplicates(
|
||||
if len(hits) < 2:
|
||||
return list(hits), []
|
||||
|
||||
# 1. 按 (memory_at day, valid_until day) 分桶
|
||||
buckets: Dict[Tuple[Any, Any], List[int]] = {}
|
||||
# 1. 按 (user_id, agent_id, memory_at day, valid_until day) 分桶。
|
||||
# user_id / agent_id 必须进键:多用户检索时不同用户的同一天意图可能
|
||||
# 文本、日期都相同,但它们是两个独立的租户数据;只按日期分桶会把其中
|
||||
# 一个判成复本并硬删除。
|
||||
buckets: Dict[Tuple[Any, Any, Any, Any], List[int]] = {}
|
||||
for idx, h in enumerate(hits):
|
||||
node = h.get("node")
|
||||
key = (_memory_at_day(node), _valid_until_key(node))
|
||||
key = (
|
||||
getattr(node, "user_id", None) if node else None,
|
||||
getattr(node, "agent_id", None) if node else None,
|
||||
_memory_at_day(node),
|
||||
_valid_until_key(node),
|
||||
)
|
||||
buckets.setdefault(key, []).append(idx)
|
||||
|
||||
def _gmt(h: Dict[str, Any]) -> float:
|
||||
@@ -229,11 +246,15 @@ async def recall_intentions(
|
||||
*,
|
||||
user_ids: Optional[List[str]] = None,
|
||||
agent_ids: Optional[List[str]] = None,
|
||||
isolation_key: str = "",
|
||||
isolation_keys: Optional[List[str]] = None,
|
||||
limit: int = 10,
|
||||
now: Optional[datetime] = None,
|
||||
stats_out: Optional[Dict[str, Any]] = None,
|
||||
include_sources: Optional[List[str]] = None,
|
||||
exclude_sources: Optional[List[str]] = None,
|
||||
dedup_enabled: bool = True,
|
||||
dedup_threshold: Optional[float] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
召回未过期的 L7_INTENTION 节点(VDB),惰性过期转 L2_FACT,判重并异步删复本。
|
||||
@@ -243,10 +264,18 @@ async def recall_intentions(
|
||||
query_embedding: 查询向量
|
||||
user_ids: 用户隔离(单/多用户)
|
||||
agent_ids: 可选 agent 过滤
|
||||
isolation_key / isolation_keys: 精确的 (user, agent, session) 隔离键。
|
||||
与其它召回通道一致:调用方在按 session 过滤时只生成这两项,
|
||||
user_ids / agent_ids 为 None;不把它们透传下去就会退化成全库检索。
|
||||
limit: 召回上限(返回给上游的条数)
|
||||
now: 当前时间(默认 datetime.now(),测试可注入)
|
||||
stats_out: 可选 dict,本函数把分段耗时与数量**原地回填**进去,供调用方落
|
||||
READ_INTENTION pipeline log。传 None 则完全不统计(零开销)。
|
||||
dedup_enabled: 是否执行读侧判重(及复本异步删库)。False 时跳过判重,
|
||||
所有未过期意图原样返回,不删除任何节点。默认 True(历史行为)。
|
||||
dedup_threshold: 判重 cosine 阈值。None 时回退到 env
|
||||
MEMORY_DEDUP_THRESHOLD(默认 0.95),兼容直接调用本函数的旧代码;
|
||||
reader 总是传入 `recall.intention_dedup_threshold`。
|
||||
|
||||
Returns:
|
||||
[{"node_id", "score", "node", "layer": "l7_intention", "source": "vdb_intention"}, ...]
|
||||
@@ -259,7 +288,7 @@ async def recall_intentions(
|
||||
埋点字段(回填进 stats_out):
|
||||
耗时(ms): vdb_search_ms / expire_ms / backfill_embed_ms / dedup_ms / total_ms
|
||||
数量: fetched / expired / survivors / dedup_dropped / returned
|
||||
配置: fetch_limit / out_limit / dedup_threshold
|
||||
配置: fetch_limit / out_limit / dedup_enabled / dedup_threshold
|
||||
说明: expire_ms、backfill_embed_ms 只有真的发生了对应操作才非 0,
|
||||
借此可判断慢在 VDB 检索、逐条过期转换、补拉向量还是判重计算。
|
||||
"""
|
||||
@@ -283,6 +312,8 @@ async def recall_intentions(
|
||||
query_embedding=query_embedding,
|
||||
user_ids=user_ids or None,
|
||||
agent_ids=agent_ids,
|
||||
isolation_key=isolation_key,
|
||||
isolation_keys=isolation_keys,
|
||||
layers=[MemoryLayer.L7_INTENTION],
|
||||
limit=fetch_limit,
|
||||
score_threshold=0.0, # intention 不设语义门槛
|
||||
@@ -343,7 +374,7 @@ async def recall_intentions(
|
||||
_t = time.perf_counter()
|
||||
_backfilled = 0
|
||||
_missing_cnt = 0
|
||||
if survivors:
|
||||
if survivors and dedup_enabled: # 补拉只为判重;关闭时无需这次 VDB 读
|
||||
missing_ids = [
|
||||
h.get("node_id") for h in survivors
|
||||
if h.get("node_id") and not getattr(h.get("node"), "embedding", None)
|
||||
@@ -376,18 +407,23 @@ async def recall_intentions(
|
||||
)
|
||||
|
||||
# 2. 判重:同 user + 同天 memory_at + valid_until 同日 + cosine≥threshold
|
||||
# dedup_enabled=False → 完全跳过:不隐藏、不删除。
|
||||
_t = time.perf_counter()
|
||||
threshold = _get_dedup_threshold()
|
||||
try:
|
||||
kept, delete_ids = _partition_duplicates(survivors, threshold)
|
||||
except Exception as e:
|
||||
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
|
||||
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
|
||||
threshold = _get_dedup_threshold(dedup_threshold)
|
||||
if dedup_enabled:
|
||||
try:
|
||||
kept, delete_ids = _partition_duplicates(survivors, threshold)
|
||||
except Exception as e:
|
||||
# 任何 dedup 异常 → 降级为不去重(返回全部 survivors 截断 limit)
|
||||
logger.warning(f"[intention] dedup partition failed, degrade to raw: {e}")
|
||||
kept, delete_ids = survivors, []
|
||||
else:
|
||||
kept, delete_ids = survivors, []
|
||||
# 纯 CPU(cosine 两两比较);survivors 多时可能显著
|
||||
_stat(
|
||||
dedup_ms=round((time.perf_counter() - _t) * 1000, 2),
|
||||
dedup_dropped=len(delete_ids),
|
||||
dedup_enabled=bool(dedup_enabled),
|
||||
dedup_threshold=threshold,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,11 +115,16 @@ class ReconcileHybridRetriever:
|
||||
fused_max = max(fused_all) if fused_all else 0.0
|
||||
|
||||
base = (
|
||||
f"[reconcile-hybrid][{req}] pool={pool_size} q_terms={q_terms!r} "
|
||||
f"[reconcile-hybrid][{req}] pool={pool_size} q_terms={len(q_terms)} "
|
||||
f"raw_bm25 max={raw_max:.4f} nonzero={raw_nonzero}/{len(raw_bm25)} "
|
||||
f"bm25_norm max={norm_max:.4f} nonzero={norm_nonzero}/{len(bm25_norms)} "
|
||||
f"sem_max={sem_max:.4f} fused_max={fused_max:.4f} "
|
||||
f"kept={kept}/{len(fused_all)} min_score={self._cfg.min_score} "
|
||||
f"query({len(query_text or '')} chars)"
|
||||
)
|
||||
# query 是待对账的新记忆原文,词项与预览只进 DEBUG
|
||||
logger.debug(
|
||||
f"[reconcile-hybrid][{req}] q_terms={q_terms!r} "
|
||||
f"query={q_preview!r} lemmatized={lem_preview!r}"
|
||||
)
|
||||
|
||||
|
||||
+59
-15
@@ -95,21 +95,48 @@ class ToolCall:
|
||||
# [John]: 我也喜欢茶
|
||||
# 名字单独占一行、整段只出现一次;[user] 标签**不换成名字**,视角信息留在标签里。
|
||||
# extract 用这一行给 L0 basic_info 取 name(用户不必在对话里自报姓名)。
|
||||
# 各语言的模板在 hy_memory/prompts/<language>.py 的 USER_NAME_LINE;这两个旧名字保留。
|
||||
_USER_NAME_LINE_ZH = "用户名叫{name}"
|
||||
_USER_NAME_LINE_EN = "The user's name is {name}"
|
||||
|
||||
_USER_NAME_LINE_RE = re.compile(
|
||||
r"^(?:用户名叫|The user's name is)\s*(.+?)\s*$"
|
||||
)
|
||||
_user_name_line_res: Optional[List["re.Pattern"]] = None
|
||||
|
||||
|
||||
def render_user_name_line(name: str, is_zh: bool) -> str:
|
||||
"""渲染文首的用户名字行;name 为空返回空串。"""
|
||||
def _user_name_line_patterns() -> List["re.Pattern"]:
|
||||
"""每种语言一个匹配名字行的正则(L1_RAW 里可能存着任意语言的名字行)。"""
|
||||
global _user_name_line_res
|
||||
if _user_name_line_res is None:
|
||||
from ..prompts import SUPPORTED_LANGUAGES, get_prompt
|
||||
|
||||
patterns = []
|
||||
for code in SUPPORTED_LANGUAGES:
|
||||
try:
|
||||
tpl = get_prompt("USER_NAME_LINE", code)
|
||||
except (ImportError, AttributeError):
|
||||
continue
|
||||
prefix, _, suffix = tpl.partition("{name}")
|
||||
patterns.append(re.compile(
|
||||
r"^" + re.escape(prefix.strip()) + r"\s*(.+?)\s*"
|
||||
+ re.escape(suffix.strip()) + r"$"
|
||||
))
|
||||
_user_name_line_res = patterns
|
||||
return _user_name_line_res
|
||||
|
||||
|
||||
def render_user_name_line(
|
||||
name: str, is_zh: bool = False, *, language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""渲染文首的用户名字行;name 为空返回空串。
|
||||
|
||||
language(语言码)优先;未给时按 is_zh 在 zh-Hans / en 间选(旧行为)。
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
tpl = _USER_NAME_LINE_ZH if is_zh else _USER_NAME_LINE_EN
|
||||
return tpl.format(name=name)
|
||||
from ..prompts import get_prompt
|
||||
|
||||
code = language or ("zh-Hans" if is_zh else "en")
|
||||
return get_prompt("USER_NAME_LINE", code).format(name=name)
|
||||
|
||||
|
||||
def parse_user_name_line(line: str) -> Optional[str]:
|
||||
@@ -118,8 +145,12 @@ def parse_user_name_line(line: str) -> Optional[str]:
|
||||
L1_RAW 存的就是 get_flat_content() 的产物,回读历史时要靠这个把名字行识别出来,
|
||||
否则它会被当成一条无前缀的裸消息(见 MemoryWriter._get_recent_history)。
|
||||
"""
|
||||
m = _USER_NAME_LINE_RE.match((line or "").strip())
|
||||
return m.group(1) if m else None
|
||||
text = (line or "").strip()
|
||||
for pattern in _user_name_line_patterns():
|
||||
m = pattern.match(text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -257,6 +288,11 @@ class WriteRequest:
|
||||
branch: Optional[str] = None
|
||||
# 分支名。同 workspace_id。
|
||||
|
||||
# 显式 prompt 语言码(canonical,见 hy_memory.prompts.SUPPORTED_LANGUAGES)。
|
||||
# 由 client 入口按「per-call > MemoryConfig.language」解析后注入;None 时
|
||||
# 抽取 / reconcile / 画像等各步沿用 zh/en 自动检测。
|
||||
language: Optional[str] = None
|
||||
|
||||
# ---- 便捷方法 ----
|
||||
|
||||
def has_messages(self) -> bool:
|
||||
@@ -286,8 +322,8 @@ class WriteRequest:
|
||||
[John]: 我也喜欢茶
|
||||
|
||||
无 name 时与历史行为完全一致(`[user]: ...` / `[assistant]: ...`)。
|
||||
名字行只在 role=user 带 name 时出现,且整段只出现一次;语言按消息内容判定
|
||||
(与 extractor 选 zh/en prompt 同一套判据)。
|
||||
名字行只在 role=user 带 name 时出现,且整段只出现一次;语言取显式
|
||||
language,未指定时按消息内容判定(与 extractor 选 zh/en prompt 同一套判据)。
|
||||
"""
|
||||
if not self.messages:
|
||||
return self.content or ""
|
||||
@@ -296,11 +332,14 @@ class WriteRequest:
|
||||
|
||||
name = self.user_name()
|
||||
if name:
|
||||
from ..utils.lang_detect import is_chinese
|
||||
if self.language:
|
||||
name_line = render_user_name_line(name, language=self.language)
|
||||
else:
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
name_line = render_user_name_line(
|
||||
name, is_chinese(messages=self.messages)
|
||||
)
|
||||
name_line = render_user_name_line(
|
||||
name, is_chinese(messages=self.messages)
|
||||
)
|
||||
if name_line:
|
||||
lines.insert(0, name_line)
|
||||
|
||||
@@ -401,6 +440,11 @@ class ReadRequest:
|
||||
intention_limit: int = 0 # Intention 路的 topk,默认 0 关闭
|
||||
|
||||
|
||||
# 重排开关(按次生效)。False 时跳过全部重排阶段:本地 cross-encoder
|
||||
# (recall.local_rerank_enabled) 与记忆关系图重排 (_apply_graph_rerank),
|
||||
# 结果按召回分排序。走请求对象而非环境变量,并发请求互不影响。
|
||||
rerank: bool = True
|
||||
|
||||
# 时间过滤
|
||||
created_after: Optional[float] = None # Unix timestamp (float),只返回 gmt_created >= 此值的记忆
|
||||
|
||||
|
||||
@@ -692,9 +692,10 @@ class CrossDomainSweeper:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[sweeper] Created L6 core {core_id[:12]}: '{core_content[:60]}' "
|
||||
f"[sweeper] Created L6 core {core_id[:12]} "
|
||||
f"from {len(cluster_ids)} basics, evidence_inherited={len(inherited_evidence_ids)}"
|
||||
)
|
||||
logger.debug(f"[sweeper] L6 core {core_id[:12]} content={core_content[:60]!r}")
|
||||
return 1, 0, [core_id]
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -57,7 +57,9 @@ from ._retrieval.ner import (
|
||||
memory_matches_time_hints,
|
||||
)
|
||||
from ._retrieval.diversity import (
|
||||
near_dup_applies,
|
||||
near_dup_demote,
|
||||
mmr_applies,
|
||||
mmr_select,
|
||||
social_boilerplate_demote,
|
||||
pool_df_demote,
|
||||
@@ -1279,7 +1281,10 @@ class BaseReadPipeline(ReadPipeline):
|
||||
SAME_SERIES_TIMELINE_ON = bool(
|
||||
getattr(self.config.recall, "same_series_timeline_enabled", False)
|
||||
)
|
||||
LOCAL_RERANK_ON = bool(
|
||||
# 按次的重排开关(search(rerank=False)):关掉本地 cross-encoder 与图重排,
|
||||
# 结果按召回分排序。只读请求对象,不碰共享状态,并发请求互不影响。
|
||||
RERANK_ON = bool(getattr(request, "rerank", True))
|
||||
LOCAL_RERANK_ON = RERANK_ON and bool(
|
||||
getattr(self.config.recall, "local_rerank_enabled", False)
|
||||
)
|
||||
LOCAL_RERANK_POOL_FACTOR = max(1, int(
|
||||
@@ -1431,6 +1436,11 @@ class BaseReadPipeline(ReadPipeline):
|
||||
MIN_SCORE = _resolve_normal_min_score(request.min_score)
|
||||
|
||||
final_limit = request.limit if request.limit > 0 else 10
|
||||
# MMR 只在调用方的 top-k(final_limit,即 search 的 limit)< MMR_MAX_TOP_K
|
||||
# 时启用;池子(LOCAL_RERANK 下是 final_limit 的若干倍)不参与判定。
|
||||
MMR_ON = mmr_applies(final_limit, MMR_LAMBDA)
|
||||
# 近重复降权同样是候选两两比较,用同一个 top-k 门槛。
|
||||
NEAR_DUP_ON = near_dup_applies(final_limit)
|
||||
vdb_sem_limit = max(final_limit * 3, 30)
|
||||
vdb_kw_limit = max(final_limit * 5, 80)
|
||||
graph_limit = max(
|
||||
@@ -1840,10 +1850,18 @@ class BaseReadPipeline(ReadPipeline):
|
||||
query_embedding,
|
||||
user_ids=isolation_params.get("user_ids"),
|
||||
agent_ids=isolation_params.get("agent_ids"),
|
||||
isolation_key=isolation_params.get("isolation_key") or "",
|
||||
isolation_keys=isolation_params.get("isolation_keys"),
|
||||
limit=request.intention_limit,
|
||||
stats_out=_intent_stats,
|
||||
include_sources=request.include_sources,
|
||||
exclude_sources=request.exclude_sources,
|
||||
dedup_enabled=bool(getattr(
|
||||
self.config.recall, "intention_dedup_enabled", True
|
||||
)),
|
||||
dedup_threshold=getattr(
|
||||
self.config.recall, "intention_dedup_threshold", None
|
||||
),
|
||||
)
|
||||
await trace_log.log_intention(
|
||||
stats=_intent_stats,
|
||||
@@ -2460,7 +2478,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
# 只把已选出的 top-N 换个顺序收益很小。
|
||||
relation_paths: List[Dict[str, Any]] = []
|
||||
_graph_stats: Dict[str, Any] = {}
|
||||
if self._expansion_on():
|
||||
if RERANK_ON and self._expansion_on():
|
||||
_t_graph = datetime.now()
|
||||
_pool_before = len(normal_results)
|
||||
normal_results, relation_paths, _graph_stats = await self._apply_graph_rerank(
|
||||
@@ -2523,7 +2541,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
df_thr=POOL_DF_THR,
|
||||
penalty=POOL_DF_PENALTY,
|
||||
)
|
||||
if NEAR_DUP_THR > 0 and NEAR_DUP_PENALTY > 0:
|
||||
if NEAR_DUP_ON and NEAR_DUP_THR > 0 and NEAR_DUP_PENALTY > 0:
|
||||
normal_results = near_dup_demote(
|
||||
normal_results,
|
||||
jaccard_thr=NEAR_DUP_THR,
|
||||
@@ -2536,7 +2554,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
))
|
||||
if LOCAL_RERANK_ON else final_limit
|
||||
)
|
||||
if 0.0 < MMR_LAMBDA < 0.999 and len(normal_results) > _pre_rerank_limit:
|
||||
if MMR_ON and len(normal_results) > _pre_rerank_limit:
|
||||
normal_results = mmr_select(
|
||||
normal_results,
|
||||
_pre_rerank_limit,
|
||||
@@ -2713,7 +2731,7 @@ class BaseReadPipeline(ReadPipeline):
|
||||
reverse=True,
|
||||
)
|
||||
if (
|
||||
0.0 < MMR_LAMBDA < 0.999
|
||||
MMR_ON
|
||||
and len(multi_seed_pool) > _pre_rerank_limit
|
||||
):
|
||||
multi_seed_pool = mmr_select(
|
||||
@@ -3542,6 +3560,9 @@ class BaseReadPipeline(ReadPipeline):
|
||||
"episode_capsule_render_mode": EPISODE_CAPSULE_RENDER_MODE,
|
||||
"same_series_timeline_enabled": SAME_SERIES_TIMELINE_ON,
|
||||
"local_rerank_enabled": LOCAL_RERANK_ON,
|
||||
"rerank": RERANK_ON,
|
||||
"mmr_applied": MMR_ON,
|
||||
"near_dup_applied": NEAR_DUP_ON,
|
||||
"local_rerank_model": getattr(
|
||||
self.config.recall,
|
||||
"local_rerank_model",
|
||||
@@ -3572,8 +3593,9 @@ class BaseReadPipeline(ReadPipeline):
|
||||
f"hidden_l0={len(hidden_l0_hits)} fwd_l6={len(forward_l6)} rev_l6={len(reverse_l6)} "
|
||||
f"fused_l6={len(fused_l6)} graph_intent={len(graph_intention_hits)} "
|
||||
f"returned={len(final_results)} "
|
||||
f"query='{request.query[:80]}'"
|
||||
f"query({len(request.query or '')} chars)"
|
||||
)
|
||||
logger.debug(f"[read/{self.VERSION}] query={request.query[:80]!r}")
|
||||
|
||||
response.success = True
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import logging
|
||||
from .base import WritePipeline, ReadPipeline
|
||||
from ..config import MemoryConfig
|
||||
from ..core.embed_service import EmbedService
|
||||
from ..embedding import EmbeddingService
|
||||
from ..data.vector_store import create_vector_store
|
||||
from ..data.vector_store_base import VectorStoreBase
|
||||
|
||||
@@ -50,7 +51,12 @@ class ComponentFactory:
|
||||
s2 = await factory.get_system2_writer() # System2Writer (ultra mode)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[MemoryConfig] = None):
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[MemoryConfig] = None,
|
||||
*,
|
||||
embedding_service: Optional[EmbeddingService] = None,
|
||||
):
|
||||
self.config = config or MemoryConfig.from_env()
|
||||
|
||||
# 实例缓存(单例)
|
||||
@@ -62,7 +68,7 @@ class ComponentFactory:
|
||||
self._readers: Dict[str, ReadPipeline] = {}
|
||||
|
||||
# 共享资源
|
||||
self._shared_embed_service: Optional[EmbedService] = None
|
||||
self._shared_embed_service: Optional[EmbeddingService] = embedding_service
|
||||
self._shared_vector_store: Optional[VectorStoreBase] = None
|
||||
self._shared_vector_store_initialized: bool = False
|
||||
self._shared_graph_store = None
|
||||
@@ -72,7 +78,7 @@ class ComponentFactory:
|
||||
# 共享资源
|
||||
# ================================================================
|
||||
|
||||
def _get_shared_embed_service(self) -> EmbedService:
|
||||
def _get_shared_embed_service(self) -> EmbeddingService:
|
||||
if self._shared_embed_service is None:
|
||||
self._shared_embed_service = EmbedService(self.config)
|
||||
return self._shared_embed_service
|
||||
|
||||
@@ -979,171 +979,16 @@ def s2_agent_skip_reason(materials: Dict[str, Any]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
# ====================================================================
|
||||
# System Prompt (dual language)
|
||||
# System Prompt (multilingual: hy_memory/prompts/<language>.py)
|
||||
# ====================================================================
|
||||
|
||||
SYSTEM2_AGENT_PROMPT_ZH = """你是一个认知加工 Agent,负责从用户的记忆数据中演化高层认知结构。
|
||||
|
||||
## 记忆分层架构
|
||||
|
||||
用户的记忆存储在两个系统中:
|
||||
|
||||
### VDB(向量数据库)— 你只能读取,不能修改
|
||||
| 层级 | 说明 |
|
||||
|------|------|
|
||||
| L0 BASIC_INFO | 用户基础属性(姓名、年龄、职业等)|
|
||||
| L1 RAW | 原始对话记录 |
|
||||
| L2 FACT | 原子事实(从对话中提取的客观事实)|
|
||||
| L3 SUMMARY | 会话摘要 |
|
||||
| L4 IDENTITY | 身份画像(性格特征、偏好、习惯)|
|
||||
| L5 KNOWLEDGE | 领域知识 |
|
||||
|
||||
### Graph(知识图谱)— 你是唯一的写入者
|
||||
| 层级 | 说明 |
|
||||
|------|------|
|
||||
| L6 SCHEMA | 行为模式 — 域内、原子化、创建后不可变 |
|
||||
|
||||
## L6 Schema 是什么?
|
||||
|
||||
Schema 是从多次独立经历归纳出的长期行为模式,包含以下三个要素:
|
||||
|
||||
- **Circumstance(场景)**:该模式发生的领域/话题/场景。Schema 必须限定在其场景内,不要跨域泛化。
|
||||
- **Pattern(模式)**:用户在该场景下的惯常行为、思维方式或行动倾向。
|
||||
- **Insight(洞察)**:底层心理驱动力或心智模型。
|
||||
|
||||
### 内容格式
|
||||
行为模式用一句话组合三要素:
|
||||
"当[场景]时,用户[模式]——反映了[洞察]。"
|
||||
|
||||
对与情绪、治疗、压力、哀伤或创伤有关的长期模式,不要把具体人生背景压缩成
|
||||
空泛人格词。优先保留这三部分:
|
||||
"Trigger/background(持久触发背景) → emotional response(反复情绪反应) → observed coping resource(用户已实际使用的应对资源)。"
|
||||
只能写入 user-owned 证据中出现的触发背景、反应和应对方式;助手建议不是用户已观察到的应对资源。
|
||||
|
||||
### 规则
|
||||
- **原子化**:一个 Schema 只包含一个模式。两个不同模式 → 两个 Schema。
|
||||
- **不可变**:Schema 创建后内容永远不变。不要修改已有 Schema 的内容。
|
||||
- **累积证据**:新事实只是在支持已有表述 → 调 `add_evidence`。如果新证据会让旧画像的当前状态产生实质遗漏、错误或误导,使用 `revise_schema` 创建新版本,让旧版本保留审计但退出 active 检索。
|
||||
- **域内约束**:不要跨域。跨域抽象由系统的其他流程处理。
|
||||
- **事实与模式分层**:单条明确身份、健康状态、设备使用、隶属关系、项目或偏好属于 L2 状态,不创建 L6。L6 必须由至少两个独立 raw/session 中的 user-owned 证据支持。
|
||||
- **主体安全**:同一原始写入可能包含他人资料或示例。只有材料明确归因于当前用户时才创建用户 Schema;否则对该材料不执行任何操作。
|
||||
- **候选覆盖**:source-context 审查时,每条候选事实必须二选一:用于 `create_schema`/`add_evidence` 的 evidence,或用 `skip_fact` 明确跳过。原始上下文只用于确认主体或补回该事实遗漏的细节,不得据此创建无关 Schema。
|
||||
- **证据门槛**:创建 L6 必须有 user-owned evidence,且这些证据合计代表至少两个独立 raw/session。
|
||||
一个被 S1 合并的 L2 事实可以代表多次独立写入,应检查它的 `source_raw_memory_ids`,不要只数节点。
|
||||
助手建议不能作为用户模式的独立证据。
|
||||
|
||||
## 工作流程
|
||||
|
||||
输入材料分两部分(仅当已形成聚类时才会调用你):
|
||||
- **聚类结果(Cluster Results)**:主输入,按语义相似度归组的事实
|
||||
- **未聚类事实(补充)**:未进入任一聚类的事实,优先级低于聚类结果,酌情审视即可
|
||||
|
||||
对于每组事实(无论聚类还是未聚类):
|
||||
1. **搜索已有 Schema**:如果已有 Schema 已覆盖该模式,使用 `add_evidence`。只有加入新证据后旧表述会产生实质遗漏、错误或误导时才使用 `revise_schema`。
|
||||
2. **创建新 Schema**:仅当没有已有 Schema 覆盖时。一句话,三要素。
|
||||
3. **建立关系**:两个 Schema 主题相关 → 用 `add_edge`(RELATED_TO)。
|
||||
|
||||
## 原则
|
||||
|
||||
- 所有 Graph 操作必须通过 tools 执行
|
||||
- Schema 内容创建后不可变——绝不重新创建已存在的 Schema
|
||||
- 优先 `add_evidence` 而非创建重复节点
|
||||
- 宁可不创建,也不要创建低质量节点
|
||||
- 一个 Schema = 一个域内原子模式
|
||||
- 打标签时优先使用已有标签列表中的标签"""
|
||||
|
||||
|
||||
SYSTEM2_AGENT_PROMPT_EN = """You are a cognitive processing Agent. Your job is to evolve higher-order cognitive structures from a user's raw memory data.
|
||||
|
||||
## Memory Architecture
|
||||
|
||||
### VDB (Vector Database) — read-only
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| L0 BASIC_INFO | User attributes (name, age, occupation) |
|
||||
| L1 RAW | Original conversation transcripts |
|
||||
| L2 FACT | Atomic facts extracted from conversations |
|
||||
| L3 SUMMARY | Session summaries |
|
||||
| L4 IDENTITY | Identity traits, preferences, habits |
|
||||
| L5 KNOWLEDGE | Domain knowledge |
|
||||
|
||||
### Graph (Knowledge Graph) — you are the sole writer
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| L6 SCHEMA | Behavioral patterns — domain-bound, atomic, immutable once created |
|
||||
|
||||
## What is an L6 Schema?
|
||||
|
||||
A Schema is one cross-event behavioral pattern in the long-term user model and
|
||||
contains the following three components:
|
||||
|
||||
|
||||
- **Circumstance**: The domain/topic/situation where this pattern is observed. A Schema MUST stay within its circumstance — do NOT generalize across domains. Cross-domain abstraction is handled separately by the system.
|
||||
- **Pattern**: The user's habitual behavior, thinking style, or action tendency in this circumstance.
|
||||
- **Insight**: The underlying psychological driver or mental model.
|
||||
|
||||
### Content format
|
||||
Use a single sentence combining all three:
|
||||
"When [circumstance], the user [pattern] — reflecting [insight]."
|
||||
|
||||
For durable emotional, therapy, stress, grief, or trauma-related patterns, do
|
||||
not erase the specific autobiographical background into a generic personality
|
||||
label. Prefer an anchored structure that preserves:
|
||||
"Trigger/background -> recurring emotional response -> observed coping resource."
|
||||
Only include triggers, responses, and coping resources present in user-owned
|
||||
evidence. Assistant advice is not an observed user coping resource.
|
||||
|
||||
### Rules
|
||||
- **Atomic**: One pattern per Schema. Two distinct patterns → two Schemas.
|
||||
- **Immutable**: Once created, content NEVER changes.
|
||||
- **Evidence only**: New facts that merely support existing wording → call
|
||||
`add_evidence`. If new evidence materially changes, corrects, or narrows the
|
||||
current durable state, use `revise_schema` so the old version is retained for
|
||||
audit but removed from active retrieval.
|
||||
- **Domain-bound**: Stay within the observed domain. Cross-domain abstraction is handled by a separate system process.
|
||||
- **Separate facts from patterns**: A single explicit identity, health state,
|
||||
equipment use, affiliation, project, or preference belongs in L2 state memory,
|
||||
not L6. L6 requires user-owned evidence representing at least two independent
|
||||
raw writes or sessions. One reconciled L2 fact may represent multiple writes;
|
||||
inspect its source_raw_memory_ids rather than counting fact nodes.
|
||||
- **Subject safety**: Facts sharing a source chunk may describe other people or
|
||||
examples. Create a user Schema only when the material explicitly attributes
|
||||
the pattern to the user. Otherwise output no operation for that material.
|
||||
- **Candidate coverage**: In a source-context review, account for every listed
|
||||
candidate fact: either use it as evidence in `create_schema`/`add_evidence`,
|
||||
or emit `skip_fact`. Raw context may confirm its subject or restore omitted
|
||||
detail, but must not seed unrelated Schemas.
|
||||
- **Evidence threshold**: Creating L6 requires one or more user-owned evidence
|
||||
facts representing at least two independent raw writes or sessions.
|
||||
Assistant advice does not establish a user pattern by itself.
|
||||
|
||||
### Good vs Bad examples
|
||||
✅ "When cooking, the user strictly follows recipe steps and precisely measures ingredients — reflecting a need for external structure to manage uncertainty."
|
||||
✅ "When gaming, the user always checks walkthroughs to achieve 100% completion — driven by low tolerance for incompleteness."
|
||||
✅ "After an unexpected mentor loss, mortality reminders can bring grief back suddenly; the user pauses to reflect and takes quiet walks to regain steadiness."
|
||||
❌ "The user approaches music as a medium for creative synthesis and cultural bridge-building, consistently blending diverse musical traditions..." (multiple patterns merged, too broad)
|
||||
❌ "The user is passionate about many things and values quality." (no circumstance, too vague)
|
||||
❌ "The user is meaning-driven and resilient." (erases the durable trigger and observed coping evidence)
|
||||
|
||||
## Your Workflow
|
||||
|
||||
Input materials (you are only invoked when at least one cluster exists):
|
||||
- **Cluster Results**: Primary input — facts grouped by semantic similarity
|
||||
- **Unclustered Facts (supplemental)**: Facts not in any cluster; lower priority than cluster results
|
||||
|
||||
For each group of facts (clustered or unclustered):
|
||||
1. **Search existing Schemas**: If an existing Schema already captures this pattern, call `add_evidence`. Use `revise_schema` only when the old wording would become materially incomplete or misleading after adding the new evidence.
|
||||
2. **Create new Schema**: Only if no existing Schema covers this pattern. One sentence, three components.
|
||||
3. **Build relationships**: If two Schemas are thematically related, use `add_edge` (RELATED_TO).
|
||||
|
||||
## Principles
|
||||
|
||||
- All Graph operations MUST go through tools
|
||||
- Schema content is IMMUTABLE — never recreate what already exists
|
||||
- Prefer `add_evidence` over creating duplicates
|
||||
- Prefer not creating over creating low-quality nodes
|
||||
- One Schema = one atomic pattern in one domain
|
||||
- When tagging, prefer reusing tags from the existing tags list"""
|
||||
from ..prompts import ( # noqa: E402
|
||||
get_prompt,
|
||||
normalize_language,
|
||||
with_output_language,
|
||||
)
|
||||
from ..prompts.zh_hans import SYSTEM2_AGENT_PROMPT as SYSTEM2_AGENT_PROMPT_ZH # noqa: E402,F401 (re-export)
|
||||
from ..prompts.en import SYSTEM2_AGENT_PROMPT as SYSTEM2_AGENT_PROMPT_EN # noqa: E402,F401 (re-export)
|
||||
|
||||
# backward compat alias
|
||||
SYSTEM2_AGENT_PROMPT = SYSTEM2_AGENT_PROMPT_ZH
|
||||
@@ -2095,10 +1940,14 @@ async def run_system2_agent(
|
||||
tool_executor: System2ToolExecutor,
|
||||
config: MemoryConfig,
|
||||
max_iterations: int = 10, # kept for backward compat, unused in single-call mode
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Phase 2: System 2 Agent — Single LLM call + JSON output + post-process execution.
|
||||
|
||||
``language``: explicit prompt language code (overrides config.language);
|
||||
None and no config default → zh/en detection over the materials.
|
||||
|
||||
Instead of multi-turn tool-calling loop, this version:
|
||||
1. Puts all materials (facts + existing graph) into the prompt
|
||||
2. Asks LLM to output a JSON array of operations
|
||||
@@ -2120,9 +1969,17 @@ async def run_system2_agent(
|
||||
# Build LLMProvider from config
|
||||
llm_provider = LLMProvider(config)
|
||||
|
||||
# Detect language from materials content
|
||||
lang = _detect_language(materials)
|
||||
system_prompt = _build_single_call_system_prompt(lang)
|
||||
# Prompt language: explicit language (evolve(language=...) or
|
||||
# MemoryConfig.language) wins; otherwise detect zh/en from the materials.
|
||||
explicit_language = normalize_language(language) or normalize_language(
|
||||
getattr(config, "language", None)
|
||||
)
|
||||
lang = explicit_language or _s2_language_code(_detect_language(materials))
|
||||
system_prompt = with_output_language(
|
||||
_build_single_call_system_prompt(lang),
|
||||
lang,
|
||||
explicit=explicit_language is not None,
|
||||
)
|
||||
user_msg = _build_materials_message(materials, lang=lang)
|
||||
|
||||
messages = [
|
||||
@@ -2393,101 +2250,30 @@ async def run_system2_agent(
|
||||
}
|
||||
|
||||
|
||||
def _build_single_call_system_prompt(lang: str) -> str:
|
||||
"""Build system prompt for single-call mode (includes output format instructions)."""
|
||||
base = SYSTEM2_AGENT_PROMPT_ZH if lang == "zh" else SYSTEM2_AGENT_PROMPT_EN
|
||||
|
||||
def _s2_language_code(lang: str) -> str:
|
||||
"""旧的 "zh" / "en" 标记映射到语言码;其余值按语言码处理。"""
|
||||
if lang == "zh":
|
||||
output_instructions = """
|
||||
return "zh-Hans"
|
||||
return lang or "en"
|
||||
|
||||
## 输出格式
|
||||
|
||||
不要调用 tools。直接输出一个 JSON 数组,包含你决定执行的操作。用 ```json 代码块包裹。
|
||||
def _build_single_call_system_prompt(lang: str) -> str:
|
||||
"""Build system prompt for single-call mode (includes output format instructions).
|
||||
|
||||
可用操作:
|
||||
|
||||
1. **create_schema** — 创建新的 L6 行为 Schema
|
||||
```{"op": "create_schema", "schema_kind": "behavior_pattern", "content": "供用户阅读的简洁画像", "retrieval_text": "用于检索的行为与适用场景", "evidence_list": ["fact_node_id_1", "fact_node_id_2"], "tags": ["tag1"]}```
|
||||
|
||||
2. **add_evidence** — 给已有 Schema 追加证据(不修改内容)
|
||||
```{"op": "add_evidence", "node_id": "existing_schema_node_id", "evidence_list": ["new_fact_id_1"]}```
|
||||
|
||||
3. **revise_schema** — 新证据使已有 Schema 的当前状态发生实质变化时,创建新版本并让旧版本退出默认检索。不要用于可共存的细节补充。
|
||||
```{"op": "revise_schema", "node_id": "old_schema_id", "schema_kind": "explicit_state", "content": "更新后的当前画像", "retrieval_text": "更新后的检索文本", "evidence_list": ["new_fact_id_1"], "tags": ["tag1"], "reason": "明确说明旧画像为何会误导"}```
|
||||
|
||||
4. **add_edge** — 建立两个 Schema 之间的关系
|
||||
```{"op": "add_edge", "source_id": "schema_id_1", "target_id": "schema_id_2", "reason": "关系原因"}```
|
||||
|
||||
5. **skip_fact** — 明确标记某条候选事实已审查但不创建 Schema。仅用于他人事实、临时事件、不能证明用户特征的助手建议,或已被现有 Schema 完整覆盖的事实。
|
||||
```{"op": "skip_fact", "node_id": "candidate_fact_id", "reason": "不是稳定的用户模式"}```
|
||||
|
||||
## 输出约定
|
||||
|
||||
1. 输出必须是 JSON 数组,用 ```json 代码块包裹。
|
||||
2. 如果数据不足以得出可靠结论,输出 `[]`。在 source-context review 中,
|
||||
`[]` 表示已审查该组全部候选,且都不足以支持稳定 Schema。
|
||||
3. 代码块外不要有任何其他文字。
|
||||
4. evidence_list 中的 id 必须来自材料中提供的 fact node_id。
|
||||
5. add_evidence / revise_schema 的 node_id 必须来自已有 Graph 节点的 id。
|
||||
6. 对 source-context 审查,必须让每条候选事实出现在 evidence_list 或 skip_fact 中。"""
|
||||
else:
|
||||
output_instructions = """
|
||||
|
||||
## Output Format
|
||||
|
||||
Do NOT call tools. Output a JSON array of operations directly, wrapped in a ```json code block.
|
||||
|
||||
Available operations:
|
||||
|
||||
1. **create_schema** — Create a new L6 behavioral Schema
|
||||
```{"op": "create_schema", "schema_kind": "behavior_pattern", "content": "concise user-facing pattern", "retrieval_text": "behavior plus relevant situations", "evidence_list": ["fact_node_id_1", "fact_node_id_2"], "tags": ["tag1"]}```
|
||||
|
||||
2. **add_evidence** — Add evidence to an existing Schema (content stays immutable)
|
||||
```{"op": "add_evidence", "node_id": "existing_schema_node_id", "evidence_list": ["new_fact_id_1"]}```
|
||||
|
||||
3. **revise_schema** — Create a new version when new evidence materially changes the current state and retire the old version from active retrieval. Do not use it for compatible detail.
|
||||
```{"op": "revise_schema", "node_id": "old_schema_id", "schema_kind": "explicit_state", "content": "updated current profile", "retrieval_text": "updated retrieval text", "evidence_list": ["new_fact_id_1"], "tags": ["tag1"], "reason": "why the old wording would mislead"}```
|
||||
|
||||
4. **add_edge** — Create a relationship between two Schemas
|
||||
```{"op": "add_edge", "source_id": "schema_id_1", "target_id": "schema_id_2", "reason": "relationship reason"}```
|
||||
|
||||
5. **skip_fact** — Explicitly mark a candidate fact as reviewed without creating
|
||||
a Schema. Use only for facts about another person, transient events, assistant
|
||||
advice that does not establish a user trait, or facts already fully covered.
|
||||
```{"op": "skip_fact", "node_id": "candidate_fact_id", "reason": "not a durable user pattern"}```
|
||||
|
||||
## Output Contract
|
||||
|
||||
1. Output MUST be a JSON array wrapped in a ```json code block.
|
||||
2. If data is insufficient for reliable conclusions, output `[]`. In a
|
||||
source-context review, `[]` means every candidate in that review was examined
|
||||
and none supports a durable Schema.
|
||||
3. NO text outside the code block.
|
||||
4. evidence_list IDs must come from the fact node_ids provided in the materials.
|
||||
5. add_evidence / revise_schema node_id must come from existing Graph node IDs.
|
||||
6. For a source-context review, every candidate fact must appear in an evidence_list or skip_fact."""
|
||||
``lang`` is a language code (``en`` / ``zh-Hans`` / ``ja`` ...; legacy ``zh``
|
||||
means zh-Hans). Every fragment comes from hy_memory/prompts/<language>.py.
|
||||
"""
|
||||
code = _s2_language_code(lang)
|
||||
base = get_prompt("SYSTEM2_AGENT_PROMPT", code)
|
||||
output_instructions = get_prompt("SYSTEM2_OUTPUT_INSTRUCTIONS", code)
|
||||
|
||||
if _S2_SCHEMA_EVIDENCE_GATE_ENABLED:
|
||||
if lang == "zh":
|
||||
output_instructions += """
|
||||
output_instructions += get_prompt("SYSTEM2_SCHEMA_EVIDENCE_GATE", code)
|
||||
|
||||
7. L6 只表示跨事件行为模式。create_schema 必须使用 user-owned 证据,且证据合计代表至少两个独立 raw/session。当 `source_raw_memory_ids` 包含多个独立写入时,一个合并后的 L2 事实也可以满足门槛。
|
||||
8. 单条明确身份、健康状态、设备使用、隶属关系、项目或偏好不要创建 L6;这些由 canonical L2 状态通道维护。"""
|
||||
else:
|
||||
output_instructions += """
|
||||
|
||||
7. L6 represents cross-event behavioral patterns only. create_schema must use user-owned evidence representing at least two independent raw writes or sessions. A single reconciled fact can qualify when source_raw_memory_ids contains multiple independent writes.
|
||||
8. Do not create L6 for a single explicit identity, health state, equipment use, affiliation, project, or preference. Canonical L2 state projection owns those facts."""
|
||||
|
||||
# Remove the old "All Graph operations MUST go through tools" principle
|
||||
base = base.replace("- 所有 Graph 操作必须通过 tools 执行\n", "")
|
||||
base = base.replace("- All Graph operations MUST go through tools\n", "")
|
||||
# Remove old tool-calling instructions from workflow
|
||||
base = base.replace("调 `add_evidence` 添加证据", "使用 add_evidence 操作")
|
||||
base = base.replace("call `add_evidence`", "use add_evidence operation")
|
||||
base = base.replace("调 `add_evidence`", "使用 add_evidence")
|
||||
base = base.replace("用 `add_edge`(RELATED_TO)", "使用 add_edge 操作")
|
||||
base = base.replace("use `add_edge` (RELATED_TO)", "use add_edge operation")
|
||||
# Remove the old "All Graph operations MUST go through tools" principle and
|
||||
# the tool-calling wording from the workflow (single-call mode has no tools).
|
||||
for old, new in get_prompt("SYSTEM2_SINGLE_CALL_REPLACEMENTS", code):
|
||||
base = base.replace(old, new)
|
||||
|
||||
return base + output_instructions
|
||||
|
||||
@@ -2516,7 +2302,8 @@ def _parse_operations_json(text: str) -> Optional[List[Dict[str, Any]]]:
|
||||
return result
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"[S2-agent] JSON parse failed: {json_str[:200]}")
|
||||
logger.warning(f"[S2-agent] JSON parse failed ({len(json_str)} chars)")
|
||||
logger.debug(f"[S2-agent] unparsable output: {json_str[:200]!r}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -2553,10 +2340,14 @@ def _detect_language(materials: Dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str:
|
||||
"""将预处理好的 materials 格式化为 Agent 能理解的 user message"""
|
||||
is_zh = (lang == "zh")
|
||||
"""将预处理好的 materials 格式化为 Agent 能理解的 user message
|
||||
|
||||
parts = [("## 预处理材料\n" if is_zh else "## Preprocessed Materials\n")]
|
||||
``lang`` 为语言码(旧值 "zh" 视作 zh-Hans);文案见 prompts/<language>.py 的
|
||||
SYSTEM2_MATERIALS。
|
||||
"""
|
||||
text = get_prompt("SYSTEM2_MATERIALS", _s2_language_code(lang))
|
||||
|
||||
parts = [text["title"]]
|
||||
|
||||
stats = materials.get("stats", {})
|
||||
fresh = stats.get("fresh_facts", 0)
|
||||
@@ -2565,55 +2356,35 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
graph_total = stats.get("graph_total_schemas", 0)
|
||||
recalled_schemas = stats.get("graph_schemas_recalled", 0)
|
||||
|
||||
if is_zh:
|
||||
parts.append(f"### 统计\n- VDB 事实总数: {stats.get('total_facts_all', 0)}")
|
||||
parts.append(f"- 新鲜事实(待处理): {fresh}(已被引用排除={evidenced})")
|
||||
parts.append(f"- 聚类数: {stats.get('clusters_found', 0)}")
|
||||
parts.append(
|
||||
f"- 聚类内事实: {stats.get('clustered_facts_count', 0)},"
|
||||
f"未聚类补充: {stats.get('unclustered_facts_count', 0)}"
|
||||
)
|
||||
parts.append(f"- Graph Schema 总数: {stats.get('graph_total_schemas', 0)}(本次召回={recalled_schemas})")
|
||||
if graph_total == 0:
|
||||
parts.append("- Graph 状态: **空**(首次认知加工)\n")
|
||||
else:
|
||||
parts.append("")
|
||||
parts.append(text["stats"].format(
|
||||
total=stats.get('total_facts_all', 0),
|
||||
fresh=fresh,
|
||||
evidenced=evidenced,
|
||||
clusters=stats.get('clusters_found', 0),
|
||||
clustered=stats.get('clustered_facts_count', 0),
|
||||
unclustered=stats.get('unclustered_facts_count', 0),
|
||||
graph_total=stats.get('graph_total_schemas', 0),
|
||||
recalled=recalled_schemas,
|
||||
))
|
||||
if graph_total == 0:
|
||||
parts.append(text["graph_status_empty"])
|
||||
else:
|
||||
parts.append(f"### Statistics\n- Total VDB facts: {stats.get('total_facts_all', 0)}")
|
||||
parts.append(f"- Fresh facts (to process): {fresh} (evidenced_excluded={evidenced})")
|
||||
parts.append(f"- Clusters found: {stats.get('clusters_found', 0)}")
|
||||
parts.append(
|
||||
f"- Facts in clusters: {stats.get('clustered_facts_count', 0)}, "
|
||||
f"unclustered supplement: {stats.get('unclustered_facts_count', 0)}"
|
||||
)
|
||||
parts.append(f"- Graph Schemas total: {stats.get('graph_total_schemas', 0)} (recalled={recalled_schemas})")
|
||||
if graph_total == 0:
|
||||
parts.append("- Graph status: **EMPTY** (first cognitive processing run)\n")
|
||||
else:
|
||||
parts.append("")
|
||||
parts.append("")
|
||||
|
||||
# Clusters
|
||||
clusters = materials.get("clusters", [])
|
||||
clustered_ids = set()
|
||||
if clusters:
|
||||
parts.append(("### 聚类结果\n" if is_zh else "### Cluster Results\n"))
|
||||
parts.append(text["clusters_header"])
|
||||
for i, c in enumerate(clusters):
|
||||
n_facts = len(c['facts'])
|
||||
group_type = c.get("group_type", "semantic_cluster")
|
||||
if group_type == "holistic_therapy_review":
|
||||
label = (
|
||||
f"{n_facts} 条跨会话用户事实;只归纳有证据的长期情绪/治疗模式"
|
||||
if is_zh else
|
||||
f"{n_facts} cross-session user facts; only evidence-backed durable emotional/therapy patterns"
|
||||
)
|
||||
label = text["label_holistic"].format(n=n_facts)
|
||||
elif group_type == "source_context_review":
|
||||
label = (
|
||||
f"{n_facts} 条候选事实;原始写入仅用于主体与细节核验"
|
||||
if is_zh else
|
||||
f"{n_facts} candidate facts; raw write is only for subject/detail validation"
|
||||
)
|
||||
label = text["label_source_context"].format(n=n_facts)
|
||||
else:
|
||||
label = f"{n_facts} 条,主题" if is_zh else f"{n_facts} facts, topic"
|
||||
label = text["label_topic"].format(n=n_facts)
|
||||
parts.append(f"**Cluster {i}** ({label}: {c['centroid_text']})")
|
||||
for f in c["facts"]:
|
||||
source_ids = list(f.get("source_raw_memory_ids") or [])
|
||||
@@ -2627,8 +2398,7 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
)
|
||||
clustered_ids.add(f['node_id'])
|
||||
if group_type == "source_context_review" and c.get("raw_contexts"):
|
||||
raw_label = "候选事实对应的原始片段" if is_zh else "Relevant raw excerpt per candidate"
|
||||
parts.append(f"\n**{raw_label}:**")
|
||||
parts.append(f"\n**{text['raw_label']}:**")
|
||||
for item in c["raw_contexts"]:
|
||||
parts.append(
|
||||
f"- candidate_id={item.get('node_id')}, "
|
||||
@@ -2637,30 +2407,12 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
)
|
||||
parts.append("")
|
||||
if materials.get("holistic_therapy_review"):
|
||||
parts.append(
|
||||
(
|
||||
"这是一次有边界的夜间全局整理。最多创建 "
|
||||
f"{_S2_HOLISTIC_THERAPY_MAX_SCHEMAS} 条 anchored therapy Schema。"
|
||||
"优先联合不同 raw/session 中的持久 trigger/background、"
|
||||
"反复 emotional response 和用户已实际使用的 coping resource/decision constraint。"
|
||||
"证据不足时输出 []。\n"
|
||||
if is_zh else
|
||||
"This is one bounded nightly holistic consolidation pass. Create at most "
|
||||
f"{_S2_HOLISTIC_THERAPY_MAX_SCHEMAS} anchored therapy Schemas. "
|
||||
"Prefer joining durable trigger/background, recurring emotional response, "
|
||||
"and observed coping resource or decision constraint across different raw writes/sessions. "
|
||||
"Before create_schema, union raw_sources for its evidence IDs and require at least two "
|
||||
"distinct raw source IDs; two facts from one raw write are one event. "
|
||||
"Output [] when evidence is insufficient.\n"
|
||||
)
|
||||
)
|
||||
parts.append(text["holistic_note"].format(
|
||||
max_schemas=_S2_HOLISTIC_THERAPY_MAX_SCHEMAS,
|
||||
))
|
||||
companion_hints = materials.get("independent_source_companions") or {}
|
||||
if companion_hints:
|
||||
parts.append(
|
||||
"**独立来源 companion 提示(只是候选,不是结论):**"
|
||||
if is_zh else
|
||||
"**Independent-source companion hints (candidates, not conclusions):**"
|
||||
)
|
||||
parts.append(text["companion_header"])
|
||||
for node_id, companions in companion_hints.items():
|
||||
parts.append(
|
||||
f"- {node_id} -> "
|
||||
@@ -2676,14 +2428,8 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
all_facts = materials.get("unprocessed_facts", [])
|
||||
unclustered = [f for f in all_facts if f["node_id"] not in clustered_ids]
|
||||
if clusters and unclustered:
|
||||
header = "### 未聚类事实(补充,次要)\n" if is_zh else "### Unclustered Facts (supplemental)\n"
|
||||
hint = (
|
||||
"以下事实未进入上述任一聚类,可在完成聚类主题加工后酌情审视;优先级低于聚类结果。\n"
|
||||
if is_zh
|
||||
else "Facts below were not grouped into any cluster above. Review after cluster themes; lower priority than cluster results.\n"
|
||||
)
|
||||
parts.append(header)
|
||||
parts.append(hint)
|
||||
parts.append(text["unclustered_header"])
|
||||
parts.append(text["unclustered_hint"])
|
||||
for f in unclustered:
|
||||
parts.append(f" - [{f['layer']}] {f['content']} (id={f['node_id']})")
|
||||
parts.append("")
|
||||
@@ -2691,10 +2437,7 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
# Existing graph nodes
|
||||
forward = materials.get("graph_forward", [])
|
||||
if forward:
|
||||
if is_zh:
|
||||
parts.append(f"### 相关 Graph 节点(通过 cluster centroid 向量召回,共 {len(forward)} 条)\n")
|
||||
else:
|
||||
parts.append(f"### Related Graph Nodes (retrieved via cluster centroid search, {len(forward)} hits)\n")
|
||||
parts.append(text["forward_header"].format(count=len(forward)))
|
||||
for g in forward:
|
||||
sim = g.get("similarity", "")
|
||||
sim_str = f", sim={sim}" if sim else ""
|
||||
@@ -2704,30 +2447,17 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
)
|
||||
parts.append("")
|
||||
elif graph_total == 0:
|
||||
if is_zh:
|
||||
parts.append("### 已有 Graph 节点\n当前 Graph 为空,这是首次认知加工。无需搜索 Graph,直接根据聚类结果创建 Schema。\n")
|
||||
else:
|
||||
parts.append("### Existing Graph Nodes\nThe Graph is currently EMPTY — this is the first cognitive processing run. Do NOT search the Graph (it will return nothing). Proceed directly to create Schemas from the cluster results.\n")
|
||||
parts.append(text["graph_empty"])
|
||||
else:
|
||||
# Graph 有节点但 centroid search 没召回(可能 threshold 太高或语义距离远)
|
||||
if is_zh:
|
||||
parts.append(
|
||||
f"### 已有 Graph 节点\n"
|
||||
f"Graph 中共有 {stats.get('graph_total_schemas', 0)} 个 Schema,"
|
||||
f"但 centroid 向量召回未命中相关节点。请根据聚类结果判断是否需要创建新 Schema。\n"
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"### Existing Graph Nodes\n"
|
||||
f"The Graph has {stats.get('graph_total_schemas', 0)} Schemas, "
|
||||
f"but centroid-based vector recall found no matches. Decide based on cluster results whether new Schemas are needed.\n"
|
||||
)
|
||||
parts.append(text["graph_no_recall"].format(
|
||||
graph_total=stats.get('graph_total_schemas', 0),
|
||||
))
|
||||
|
||||
# Reverse hits
|
||||
reverse = materials.get("graph_reverse", [])
|
||||
if reverse:
|
||||
parts.append(("### Graph 反向引用(已有 Schema 引用的 VDB 事实)\n" if is_zh
|
||||
else "### Graph Reverse References (VDB facts referenced by existing Schemas)\n"))
|
||||
parts.append(text["reverse_header"])
|
||||
for r in reverse:
|
||||
parts.append(
|
||||
f" - [{r['layer']}] {r['content']} "
|
||||
@@ -2738,26 +2468,9 @@ def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str
|
||||
# Existing tags(指导 Agent 复用已有 tag,减少发散)
|
||||
existing_tags = materials.get("existing_tags", [])
|
||||
if existing_tags:
|
||||
if is_zh:
|
||||
parts.append("### 已有标签(请优先从中选择,避免创建同义标签)\n")
|
||||
parts.append(f" {', '.join(existing_tags)}\n")
|
||||
else:
|
||||
parts.append("### Existing Tags (prefer reusing these over creating synonyms)\n")
|
||||
parts.append(f" {', '.join(existing_tags)}\n")
|
||||
parts.append(text["tags_header"])
|
||||
parts.append(f" {', '.join(existing_tags)}\n")
|
||||
|
||||
if is_zh:
|
||||
parts.append(
|
||||
"请分析以上材料,输出操作 JSON 数组。"
|
||||
"决定是否需要创建新的 Schema,或为已有 Schema 追加 evidence。"
|
||||
"如果数据不足以得出可靠结论,输出空数组 `[]`。"
|
||||
"打标签时请优先使用已有标签列表中的标签。"
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
"Analyze the materials above and output a JSON array of operations. "
|
||||
"Decide whether to create new Schemas, or add evidence to existing ones. "
|
||||
"If the data is insufficient for reliable conclusions, output an empty array `[]`. "
|
||||
"When tagging, prefer reusing tags from the existing tags list above."
|
||||
)
|
||||
parts.append(text["closing"])
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -243,6 +243,7 @@ class System2Writer(WritePipeline):
|
||||
materials=materials,
|
||||
tool_executor=tool_executor,
|
||||
config=self.config,
|
||||
language=payload.get("language"),
|
||||
)
|
||||
|
||||
# 计算 timing
|
||||
@@ -508,6 +509,7 @@ class System2Writer(WritePipeline):
|
||||
materials=batch,
|
||||
tool_executor=tool_executor,
|
||||
config=self.config,
|
||||
language=payload.get("language"),
|
||||
)
|
||||
batch_record = {
|
||||
"batch_index": batch_index,
|
||||
@@ -632,6 +634,7 @@ class System2Writer(WritePipeline):
|
||||
materials=holistic_materials,
|
||||
tool_executor=tool_executor,
|
||||
config=self.config,
|
||||
language=payload.get("language"),
|
||||
)
|
||||
if holistic_result.get("success"):
|
||||
await self._mark_holistic_therapy_reviewed(
|
||||
@@ -1044,11 +1047,26 @@ class System2Writer(WritePipeline):
|
||||
agent_id: str,
|
||||
llm_call,
|
||||
request_id: str = "",
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""在 System2 Agent 之后运行跨域突破性归纳"""
|
||||
"""在 System2 Agent 之后运行跨域突破性归纳
|
||||
|
||||
sweeper 的 prompt 只有英文版(要求与输入 schema 同语言)。显式 language
|
||||
时在每个 prompt 末尾追加该语言的输出规则,让产出用该语言书写。
|
||||
"""
|
||||
if not _SWEEPER_ENABLED:
|
||||
return {"skipped": True, "reason": "SWEEPER_ENABLED=false"}
|
||||
|
||||
if language:
|
||||
from ..prompts import with_output_language
|
||||
|
||||
_base_call = llm_call
|
||||
|
||||
async def llm_call(prompt: str) -> str:
|
||||
return await _base_call(
|
||||
with_output_language(prompt, language, explicit=True)
|
||||
)
|
||||
|
||||
try:
|
||||
from .cross_domain_sweeper import CrossDomainSweeper
|
||||
|
||||
@@ -1119,6 +1137,7 @@ class System2Writer(WritePipeline):
|
||||
self,
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
手动触发 cross-domain sweeper(同步等待完成)。
|
||||
@@ -1145,6 +1164,7 @@ class System2Writer(WritePipeline):
|
||||
agent_id=agent_id,
|
||||
llm_call=self._get_llm_call(),
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
result["request_id"] = request_id
|
||||
return result
|
||||
@@ -1154,6 +1174,7 @@ class System2Writer(WritePipeline):
|
||||
user_id: str,
|
||||
agent_id: str = "default_agent",
|
||||
max_batches: Optional[int] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
手动触发 System 2 认知加工(由调用方/App 层控制触发时机)。
|
||||
@@ -1186,6 +1207,8 @@ class System2Writer(WritePipeline):
|
||||
"content": "",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"max_batches": max_batches,
|
||||
# 显式 prompt 语言码(None → System 2 按 materials 做 zh/en 检测)
|
||||
"language": language,
|
||||
}
|
||||
results = await self._run_system2_workers(payload)
|
||||
|
||||
@@ -1204,6 +1227,7 @@ class System2Writer(WritePipeline):
|
||||
agent_id=agent_id,
|
||||
llm_call=self._get_llm_call(),
|
||||
request_id=request_id,
|
||||
language=language,
|
||||
)
|
||||
|
||||
agent_success = bool((results.get("system2_agent") or {}).get("success", True))
|
||||
|
||||
+147
-52
@@ -73,6 +73,19 @@ def _write_apply_verify_enabled() -> bool:
|
||||
_RECONCILE_LOOP_USER_LOCKS = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
class ReconcileFailure(str):
|
||||
"""reconcile 失败时 `_reconcile_and_store` 返回的 error:仍是错误信息字符串,
|
||||
另带稳定的失败码 `code`(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED /
|
||||
RECONCILE_EXCEPTION),供 writer 透传给调用方。"""
|
||||
|
||||
code: str = "RECONCILE_FAILED"
|
||||
|
||||
def __new__(cls, message: str, code: str = "RECONCILE_FAILED"):
|
||||
obj = super().__new__(cls, message)
|
||||
obj.code = code
|
||||
return obj
|
||||
|
||||
|
||||
def _reconcile_user_lock(user_id: str) -> asyncio.Lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
locks = _RECONCILE_LOOP_USER_LOCKS.get(loop)
|
||||
@@ -280,6 +293,22 @@ class MemoryWriter(WritePipeline):
|
||||
custom["source_raw_memory_ids"] = source_ids
|
||||
return custom
|
||||
|
||||
def _flat_trimmed_raw(self, request: WriteRequest) -> str:
|
||||
"""把 request.messages 按 raw 统一规则裁剪后拼成 L1_RAW 文本。
|
||||
|
||||
只裁落库副本;request.messages 不动(本次 extractor 输入仍用完整 messages)。
|
||||
"""
|
||||
import copy as _copy
|
||||
from ..utils.raw_trim import trim_raw_messages
|
||||
|
||||
_coding_cfg = getattr(getattr(self, "config", None), "coding", None)
|
||||
_trimmed_req = _copy.copy(request)
|
||||
_trimmed_req.messages = trim_raw_messages(
|
||||
request.messages,
|
||||
tool_max_bytes=getattr(_coding_cfg, "tool_result_max_bytes", None),
|
||||
)
|
||||
return _trimmed_req.get_flat_content()
|
||||
|
||||
@staticmethod
|
||||
def _attach_client_meta(custom: Dict[str, Any], request: Optional[WriteRequest]) -> None:
|
||||
"""给新建节点挂上调用方 meta(单份 list)。
|
||||
@@ -619,6 +648,7 @@ class MemoryWriter(WritePipeline):
|
||||
不查 VDB、不做"新写入 vs 库里已有"的对比,避免给 VDB 增加读压力。
|
||||
库内存量污染由 recall 侧异步删除逐步清理。best-effort:任何异常都
|
||||
回退到"不判重、全写入",绝不影响 upsert 主流程。
|
||||
`recall.intention_dedup_enabled=False` 时跳过批内判重,全部写入。
|
||||
"""
|
||||
if not intentions:
|
||||
return []
|
||||
@@ -644,7 +674,11 @@ class MemoryWriter(WritePipeline):
|
||||
# ============ 批内 dedup(不查 VDB,只对本批 intentions 互斥判重) ============
|
||||
keep_mask: List[bool] = [True] * len(intentions)
|
||||
try:
|
||||
dropped = self._intention_batch_dedup(intentions, embeddings)
|
||||
dropped = (
|
||||
self._intention_batch_dedup(intentions, embeddings)
|
||||
if getattr(self.config.recall, "intention_dedup_enabled", True)
|
||||
else []
|
||||
)
|
||||
for i in dropped:
|
||||
if 0 <= i < len(keep_mask):
|
||||
keep_mask[i] = False
|
||||
@@ -968,7 +1002,7 @@ class MemoryWriter(WritePipeline):
|
||||
async def _run():
|
||||
try:
|
||||
nid = await summarize_closed_episode(
|
||||
vector_store=self.vector_store,
|
||||
vector_store=await self._get_vector_store(),
|
||||
embed_service=self.embed_service,
|
||||
llm_provider=self.llm_provider,
|
||||
episode_id=episode_id,
|
||||
@@ -981,6 +1015,7 @@ class MemoryWriter(WritePipeline):
|
||||
# 这是一次 LLM 调用:prompt / 产出必须能在线上追到,
|
||||
# 否则 badcase 只能看到"摘要内容不对"却拿不到输入
|
||||
trace=self._make_trace_sink(request, "EPISODE_SUMMARY"),
|
||||
language=request.language,
|
||||
)
|
||||
if nid:
|
||||
logger.info(
|
||||
@@ -1194,6 +1229,7 @@ class MemoryWriter(WritePipeline):
|
||||
self._make_trace_sink(request, "TOPIC_SUMMARY")
|
||||
if request is not None else None
|
||||
),
|
||||
language=getattr(request, "language", None),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[topic-summary] failed (non-fatal): {e}")
|
||||
@@ -1239,15 +1275,6 @@ class MemoryWriter(WritePipeline):
|
||||
recon_tokens 含 prompt/completion/total(reconcile 链路 LLM 消耗)
|
||||
"""
|
||||
stored_ids: List[str] = []
|
||||
# Episode:本次写入归属的内部会话 id(跨 write 把"一件事"归到一起)。
|
||||
# 只数 user 侧记忆(与 rolling summary 只数 pending_user_count 一致)——
|
||||
# agent 侧记忆若参与计数会把 episode 提前撑到上限、切得过碎,白付摘要的 token。
|
||||
episode_id = await self._resolve_episode_id(
|
||||
request, _count_user_owned(new_memories_meta)
|
||||
)
|
||||
session_episode_id = self._session_episode_id(request)
|
||||
if session_episode_id:
|
||||
episode_id = session_episode_id
|
||||
# 关系图用:收集本次落库的 (node, op),供 graph 投影建边。
|
||||
# 收 op 是为了拿 op.related(LLM 标注的 typed 关系)。
|
||||
graph_payload: List[Tuple[Any, Any]] = []
|
||||
@@ -1270,10 +1297,36 @@ class MemoryWriter(WritePipeline):
|
||||
graph_store=getattr(self, "_graph_store", None),
|
||||
new_memory_embeddings=precomputed_embeddings,
|
||||
source=request.source,
|
||||
language=request.language,
|
||||
)
|
||||
|
||||
if not recon_result.success:
|
||||
return [], recon_result.error, {"add": 0, "supersede": 0, "update": 0, "total": 0}, {"prompt": 0, "completion": 0, "total": 0}
|
||||
# reconcile 失败:本次一条新记忆都不落库(不做 ADD-all 兜底),由调用方重跑。
|
||||
# episode 状态也还没推进,重跑不会把同一批记忆重复计数。
|
||||
error = ReconcileFailure(
|
||||
recon_result.error or "reconcile failed",
|
||||
code=recon_result.error_code or "RECONCILE_FAILED",
|
||||
)
|
||||
logger.warning(
|
||||
f"TRACE_PERF [{req_id}] S1_RECONCILE_FAILED "
|
||||
f"code={error.code} candidates={len(new_memory_texts)} stored=0"
|
||||
)
|
||||
return [], error, {"add": 0, "supersede": 0, "update": 0, "total": 0}, {
|
||||
"prompt": recon_result.prompt_tokens,
|
||||
"completion": recon_result.completion_tokens,
|
||||
"total": recon_result.total_tokens,
|
||||
}
|
||||
|
||||
# Episode:本次写入归属的内部会话 id(跨 write 把"一件事"归到一起)。
|
||||
# 只数 user 侧记忆(与 rolling summary 只数 pending_user_count 一致)——
|
||||
# agent 侧记忆若参与计数会把 episode 提前撑到上限、切得过碎,白付摘要的 token。
|
||||
# 放在 reconcile 成功之后:失败的写入不能推进 episode 计数。
|
||||
episode_id = await self._resolve_episode_id(
|
||||
request, _count_user_owned(new_memories_meta)
|
||||
)
|
||||
session_episode_id = self._session_episode_id(request)
|
||||
if session_episode_id:
|
||||
episode_id = session_episode_id
|
||||
|
||||
# 分类统计
|
||||
_op_counts = {}
|
||||
@@ -1284,16 +1337,7 @@ class MemoryWriter(WritePipeline):
|
||||
logger.info(
|
||||
f"TRACE_PERF [{req_id}] S1_RECONCILE_DONE "
|
||||
f"ops={len(recon_result.ops)} candidates={len(new_memory_texts)} | {_op_counts_str}"
|
||||
+ (f" | DEGRADED({recon_result.degraded_reason})"
|
||||
if getattr(recon_result, "degraded", False) else "")
|
||||
)
|
||||
if getattr(recon_result, "degraded", False):
|
||||
# 走了 ADD-all 兜底:内容保住了,但没做去重/演化判定。必须可观测,
|
||||
# 否则线上只会看到"记忆变多了"而不知道为什么。
|
||||
logger.warning(
|
||||
f"[write] reconcile degraded → ADD-all "
|
||||
f"({recon_result.degraded_reason}), {len(recon_result.ops)} memories stored as-is"
|
||||
)
|
||||
# 逐条 op 详情由 reconciler 的 "[reconciler] ops detail" 单条 JSON list 输出,
|
||||
# 这里不再逐条打印(避免重复刷屏)。
|
||||
|
||||
@@ -2483,12 +2527,10 @@ class MemoryWriter(WritePipeline):
|
||||
response.extra["extract_tokens"] = {"prompt": 0, "completion": 0, "total": 0}
|
||||
|
||||
if recon_error is not None:
|
||||
response.success = False
|
||||
response.error_code = 502
|
||||
response.error_message = f"[RECONCILE_FAILED] {recon_error}"
|
||||
response.extra["agent_status"] = "failed"
|
||||
response.extra["agent_error"] = recon_error
|
||||
response.extra["agent_nodes"] = 0
|
||||
# 与 write() 一致:reconcile 失败不落任何记忆,报失败供调用方重跑。
|
||||
self._mark_reconcile_failure(response, recon_error)
|
||||
response.extra["agent_mode"] = "extracted"
|
||||
logger.warning(f"[write_extracted] reconcile failed: {recon_error}")
|
||||
return response
|
||||
|
||||
response.success = True
|
||||
@@ -2554,9 +2596,14 @@ class MemoryWriter(WritePipeline):
|
||||
try:
|
||||
# 参数校验
|
||||
content = request.content
|
||||
# raw 在写入时就按统一规则裁好(见 utils/raw_trim.py):工具输出、长
|
||||
# assistant 回复不再原样长期存储;历史拼接直接用存下来的文本,不再二次裁剪。
|
||||
# request.messages 保持原样——本次 extractor 输入仍是完整 messages。
|
||||
_raw_trimmed = False
|
||||
if not content and request.has_messages():
|
||||
content = request.get_flat_content()
|
||||
content = self._flat_trimmed_raw(request)
|
||||
request.content = content
|
||||
_raw_trimmed = True
|
||||
|
||||
if not content:
|
||||
response.error_code = 400
|
||||
@@ -2627,6 +2674,9 @@ class MemoryWriter(WritePipeline):
|
||||
vector_store = await self._get_vector_store()
|
||||
layer_enum = MemoryLayer.from_string(suggested_layer)
|
||||
_raw_custom: Dict[str, Any] = {}
|
||||
if _raw_trimmed:
|
||||
from ..utils.raw_trim import RAW_TRIMMED_KEY
|
||||
_raw_custom[RAW_TRIMMED_KEY] = True
|
||||
self._attach_client_meta(_raw_custom, request)
|
||||
mem_node = MemoryNode(
|
||||
user_id=request.user_id,
|
||||
@@ -2690,6 +2740,7 @@ class MemoryWriter(WritePipeline):
|
||||
vector_store, request.user_id, request.agent_id,
|
||||
exclude_memory_id=memory_id,
|
||||
max_turns=_hist_turns * 2, # k 轮对话 = k*2 条 message
|
||||
language=request.language,
|
||||
)
|
||||
_recent_history_ms = (_t_recent_history and (datetime.now() - _t_recent_history).total_seconds() * 1000) or 0.0
|
||||
_recent_history_count = len([line for line in (_history_context or "").splitlines() if line.strip()])
|
||||
@@ -2770,6 +2821,7 @@ class MemoryWriter(WritePipeline):
|
||||
user_id=request.user_id,
|
||||
agent_id=request.agent_id,
|
||||
session_id=request.session_id or "default_session",
|
||||
language=request.language,
|
||||
)
|
||||
if finalize is finalize_dynamic_episode_capsules:
|
||||
finalize_kwargs["summary_concurrency"] = getattr(
|
||||
@@ -2929,13 +2981,18 @@ class MemoryWriter(WritePipeline):
|
||||
agent_id: Optional[str] = None,
|
||||
exclude_memory_id: str = "",
|
||||
max_turns: int = 20,
|
||||
max_chars_assistant: int = 500,
|
||||
max_chars_assistant: Optional[int] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
获取该用户最近的 L1_RAW 对话记录,按轮次拆分后拼成 history context。
|
||||
language:显式 prompt 语言码,用于渲染名字行;None 时按内容判 zh/en。
|
||||
|
||||
每条 L1_RAW 的 content 是多轮对话([user]: ...\n[assistant]: ...)。
|
||||
拆成单条 message 后,user 消息完整保留,assistant 消息截取前 max_chars_assistant 字符。
|
||||
raw 在写入时已按 utils/raw_trim.py 的规则裁好(custom[RAW_TRIMMED_KEY]),
|
||||
这里拆成单条 message 后直接拼接,不再二次裁剪。该规则之前写入的旧 raw
|
||||
没有这个标记,仍按同一(幂等)规则裁剪后再拼,避免旧的长回复 / 工具输出
|
||||
撑大 prompt。max_chars_assistant 只作用于这些旧 raw,默认同写入规则。
|
||||
最终取最近 max_turns 条 message(注意单位是 message 条数,非对话轮数;
|
||||
调用方按 k 轮 × 2 传入)。
|
||||
|
||||
@@ -2977,23 +3034,33 @@ class MemoryWriter(WritePipeline):
|
||||
# 避免把正文里形如 "[注]: xxx" 的普通文本误判成消息边界。
|
||||
import re as _re
|
||||
from .base import parse_user_name_line
|
||||
_KNOWN_ROLES = {"user", "assistant", "system", "tool"}
|
||||
# 带方括号的任意 label(含对方真人名字)
|
||||
_labeled_prefix = _re.compile(r"^\[([^\]\n]{1,32})\]:\s?")
|
||||
# 无方括号的已知 role(历史数据容错,沿用旧正则的 `\[?...\]?` 宽容度)
|
||||
_bare_role_prefix = _re.compile(r"^(user|assistant|system|tool):\s?")
|
||||
|
||||
# (label, content) 元组列表;label 为 role 或对方真人名字
|
||||
parsed: List[Tuple[str, str]] = []
|
||||
from ..utils.raw_trim import (
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
RAW_TRIMMED_KEY,
|
||||
trim_raw_body,
|
||||
)
|
||||
if max_chars_assistant is None:
|
||||
max_chars_assistant = DEFAULT_RAW_ASSISTANT_MAX_CHARS
|
||||
_coding_cfg = getattr(getattr(self, "config", None), "coding", None)
|
||||
_tool_max_bytes = getattr(_coding_cfg, "tool_result_max_bytes", None)
|
||||
|
||||
# (label, content, 是否旧 raw 需兼容裁剪) 元组列表;label 为 role 或对方真人名字
|
||||
parsed: List[Tuple[str, str, bool]] = []
|
||||
user_name: str = ""
|
||||
for node in nodes:
|
||||
content = node.content or ""
|
||||
_legacy = not bool((node.custom or {}).get(RAW_TRIMMED_KEY))
|
||||
for _line_idx, line in enumerate(content.split("\n")):
|
||||
m = _labeled_prefix.match(line) or _bare_role_prefix.match(line)
|
||||
if m:
|
||||
label = m.group(1)
|
||||
body = line[m.end():]
|
||||
parsed.append((label, body))
|
||||
parsed.append((label, body, _legacy))
|
||||
continue
|
||||
# 名字行只可能是每个 node content 的第一行(get_flat_content 插在文首)。
|
||||
# 逐 node 判断而非用 `not parsed`:parsed 跨 node 累积,第二个 node
|
||||
@@ -3006,36 +3073,41 @@ class MemoryWriter(WritePipeline):
|
||||
continue
|
||||
if parsed:
|
||||
# 续行:拼回上一条 message(保留换行)
|
||||
prev_label, prev_body = parsed[-1]
|
||||
parsed[-1] = (prev_label, prev_body + "\n" + line)
|
||||
prev_label, prev_body, prev_legacy = parsed[-1]
|
||||
parsed[-1] = (prev_label, prev_body + "\n" + line, prev_legacy)
|
||||
else:
|
||||
# 开头就是无前缀行(异常/老数据):当成 user 消息兜底
|
||||
parsed.append(("user", line))
|
||||
parsed.append(("user", line, _legacy))
|
||||
|
||||
# 取最近 max_turns 条 message(按 message 截断,绝不从消息中间切开)
|
||||
recent = parsed[-max_turns:] if len(parsed) > max_turns else parsed
|
||||
if not recent:
|
||||
return ""
|
||||
|
||||
# 格式化:非 user 侧整条截断(含对方真人),user 完整保留
|
||||
# 格式化:新 raw 直接拼接;旧 raw 按写入同一规则兼容裁剪
|
||||
all_messages: List[str] = []
|
||||
if user_name:
|
||||
from .base import render_user_name_line
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
_name_line = render_user_name_line(
|
||||
user_name,
|
||||
is_chinese("\n".join(b for _, b in recent)),
|
||||
_name_line = (
|
||||
render_user_name_line(user_name, language=language)
|
||||
if language else
|
||||
render_user_name_line(
|
||||
user_name,
|
||||
is_chinese("\n".join(b for _, b, _ in recent)),
|
||||
)
|
||||
)
|
||||
if _name_line:
|
||||
all_messages.append(_name_line)
|
||||
for label, body in recent:
|
||||
for label, body, legacy in recent:
|
||||
if legacy:
|
||||
body = trim_raw_body(
|
||||
label, body,
|
||||
assistant_max_chars=max_chars_assistant,
|
||||
tool_max_bytes=_tool_max_bytes,
|
||||
)
|
||||
body = body.strip()
|
||||
# 截断口径:assistant + 对方真人(非已知 role 的 label)。
|
||||
# user 完整保留;system / tool 沿用历史行为(不截断)。
|
||||
_truncate = label == "assistant" or label not in _KNOWN_ROLES
|
||||
if _truncate and len(body) > max_chars_assistant:
|
||||
body = body[:max_chars_assistant] + "..."
|
||||
all_messages.append(f"[{label}]: {body}")
|
||||
|
||||
return "\n".join(all_messages)
|
||||
@@ -3267,6 +3339,7 @@ class MemoryWriter(WritePipeline):
|
||||
existing_tags=existing_tags,
|
||||
history_context=history_context,
|
||||
basic_profile_fields=_basic_profile_fields,
|
||||
language=request.language,
|
||||
)
|
||||
extract_timing["extract_total_ms"] = round(
|
||||
(datetime.now() - _t_extract_total).total_seconds() * 1000, 2
|
||||
@@ -3476,12 +3549,11 @@ class MemoryWriter(WritePipeline):
|
||||
response.extra["reconcile_ops"] = ops_counts
|
||||
response.extra["reconcile_tokens"] = recon_tokens
|
||||
if recon_error is not None:
|
||||
# stored_ids 为空:reconcile 失败时一条新记忆都不落库,write()
|
||||
# 随后回滚 L1_RAW(retry_safe=True),换个 request_id 重跑即可。
|
||||
response.extra["agent_stored_ids"] = stored_ids
|
||||
self._mark_agent_failure(
|
||||
response,
|
||||
error_code="RECONCILE_FAILED",
|
||||
error_message=recon_error,
|
||||
agent_nodes=len(stored_ids),
|
||||
self._mark_reconcile_failure(
|
||||
response, recon_error, agent_nodes=len(stored_ids),
|
||||
)
|
||||
logger.warning(f"[write] reconcile failed: {recon_error}")
|
||||
return
|
||||
@@ -3604,6 +3676,29 @@ class MemoryWriter(WritePipeline):
|
||||
response.error_code = 502
|
||||
response.error_message = f"[{code}] {message}"
|
||||
|
||||
@classmethod
|
||||
def _mark_reconcile_failure(
|
||||
cls,
|
||||
response: WriteResponse,
|
||||
recon_error: str,
|
||||
*,
|
||||
agent_nodes: int = 0,
|
||||
) -> None:
|
||||
"""reconcile 失败:agent_error_code 恒为 RECONCILE_FAILED,具体原因
|
||||
(LLM_UNAVAILABLE / EMPTY_RESPONSE / JSON_PARSE_FAILED / RECONCILE_EXCEPTION)
|
||||
放在 reconcile_error_code。"""
|
||||
reason = getattr(recon_error, "code", "") or "RECONCILE_FAILED"
|
||||
response.extra["reconcile_error_code"] = reason
|
||||
cls._mark_agent_failure(
|
||||
response,
|
||||
error_code="RECONCILE_FAILED",
|
||||
error_message=(
|
||||
f"{reason}: {recon_error}"
|
||||
if reason != "RECONCILE_FAILED" else str(recon_error)
|
||||
),
|
||||
agent_nodes=agent_nodes,
|
||||
)
|
||||
|
||||
async def _handle_agent_failure(self, request, response, agent_result, tracer_span, extract_timing: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""处理 agent extract 失败的情况。"""
|
||||
if agent_result is None:
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Prompt registry: every multilingual LLM prompt, keyed by language code.
|
||||
|
||||
One module per language (``en.py``, ``zh_hans.py``, ``ja.py`` ...) defines the
|
||||
same set of names (``PROMPT_NAMES``). Call sites look prompts up with
|
||||
``get_prompt(name, language)`` instead of importing a language module, so a
|
||||
new language only needs a new module and an entry in ``_MODULES``.
|
||||
|
||||
Language selection (``resolve_language``):
|
||||
|
||||
* explicit ``language`` (per call, else ``MemoryConfig.language`` /
|
||||
``MEMORY_LANGUAGE``) wins and is validated by ``normalize_language``;
|
||||
* otherwise the existing zh/en auto-detection runs unchanged
|
||||
(``utils.lang_detect.is_chinese`` → ``zh-Hans`` or ``en``). Auto-detection
|
||||
never picks any of the other languages.
|
||||
|
||||
When the language is explicit, ``with_output_language`` appends
|
||||
``OUTPUT_LANGUAGE_DIRECTIVE`` so memories are written in that language even
|
||||
if the conversation uses another one. Auto-detected requests keep the
|
||||
"same language as the input" rule and their prompts are byte-identical to
|
||||
earlier releases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# Canonical codes, in documentation order.
|
||||
SUPPORTED_LANGUAGES: Tuple[str, ...] = (
|
||||
"en",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"ja",
|
||||
"es",
|
||||
"de",
|
||||
"pt",
|
||||
"fr",
|
||||
"ko",
|
||||
"ru",
|
||||
"id",
|
||||
)
|
||||
|
||||
# Accepted aliases (matched case-insensitively, "_" treated as "-").
|
||||
LANGUAGE_ALIASES: Dict[str, str] = {
|
||||
"zh": "zh-Hans",
|
||||
"zh-cn": "zh-Hans",
|
||||
"zh-sg": "zh-Hans",
|
||||
"zh-hans-cn": "zh-Hans",
|
||||
"zh-tw": "zh-Hant",
|
||||
"zh-hk": "zh-Hant",
|
||||
"zh-mo": "zh-Hant",
|
||||
"zh-hant-tw": "zh-Hant",
|
||||
"zh-hant-hk": "zh-Hant",
|
||||
"pt-br": "pt",
|
||||
"pt-pt": "pt",
|
||||
"en-us": "en",
|
||||
"en-gb": "en",
|
||||
}
|
||||
|
||||
_MODULES: Dict[str, str] = {
|
||||
"en": "en",
|
||||
"zh-Hans": "zh_hans",
|
||||
"zh-Hant": "zh_hant",
|
||||
"ja": "ja",
|
||||
"es": "es",
|
||||
"de": "de",
|
||||
"pt": "pt",
|
||||
"fr": "fr",
|
||||
"ko": "ko",
|
||||
"ru": "ru",
|
||||
"id": "id",
|
||||
}
|
||||
|
||||
# Every language module must define all of these.
|
||||
PROMPT_NAMES: Tuple[str, ...] = (
|
||||
# chat extraction
|
||||
"EXTRACT_SYSTEM_PROMPT",
|
||||
"STRUCTURE_METADATA_PROMPT",
|
||||
"EXTRACT_FEW_SHOT",
|
||||
"EXTRACT_PROMPT",
|
||||
"EXTRACT_PROMPT_NO_INTENTION",
|
||||
"NO_INTENTION_SECTION_PATTERN",
|
||||
"NO_INTENTION_MEMORY_SECTION",
|
||||
"NO_INTENTION_REPLACEMENTS",
|
||||
"INTENTION_DISABLED_NOTE",
|
||||
"USER_NAME_LINE",
|
||||
# chat reconcile
|
||||
"SEARCH_QUERY_PROMPT",
|
||||
"RECONCILE_PROMPT",
|
||||
"RECONCILE_STRUCTURE_METADATA_PROMPT",
|
||||
"RECONCILE_FEW_SHOT",
|
||||
# working / coding memory
|
||||
"CODING_EXTRACT_PROMPT",
|
||||
"CODING_EXTRACT_PROMPT_CODING",
|
||||
"CODING_CONVERSATION_BLOCK",
|
||||
"CODING_EMPTY_BLOCK",
|
||||
"CODING_RECONCILE_PROMPT",
|
||||
"CODING_FACT_RECONCILE_PROMPT",
|
||||
"CODING_CHAIN_SUMMARY_PROMPT",
|
||||
# summaries
|
||||
"EPISODE_SUMMARY_PROMPT",
|
||||
"TOPIC_SUMMARY_PROMPT",
|
||||
# System 2 evolve
|
||||
"SYSTEM2_AGENT_PROMPT",
|
||||
"SYSTEM2_OUTPUT_INSTRUCTIONS",
|
||||
"SYSTEM2_SCHEMA_EVIDENCE_GATE",
|
||||
"SYSTEM2_SINGLE_CALL_REPLACEMENTS",
|
||||
"SYSTEM2_MATERIALS",
|
||||
# explicit-language output rule
|
||||
"OUTPUT_LANGUAGE_DIRECTIVE",
|
||||
"LANGUAGE_NAME",
|
||||
)
|
||||
|
||||
# Prompts rendered with str.format(); the value lists the expected fields.
|
||||
# The rest are appended verbatim (their braces are literal JSON).
|
||||
FORMAT_FIELDS: Dict[str, Tuple[str, ...]] = {
|
||||
"EXTRACT_PROMPT": ("content", "current_date", "last_messages", "memory_at"),
|
||||
"EXTRACT_PROMPT_NO_INTENTION": ("content", "current_date", "last_messages", "memory_at"),
|
||||
"USER_NAME_LINE": ("name",),
|
||||
"SEARCH_QUERY_PROMPT": ("new_memories",),
|
||||
"RECONCILE_PROMPT": ("current_date", "existing_memories", "few_shot_section", "new_memories"),
|
||||
"CODING_EXTRACT_PROMPT": ("branch_present", "conversation_block", "workspace_present"),
|
||||
"CODING_EXTRACT_PROMPT_CODING": ("branch_present", "conversation_block", "workspace_present"),
|
||||
"CODING_CONVERSATION_BLOCK": ("history", "new"),
|
||||
"CODING_RECONCILE_PROMPT": ("candidates_block", "drafts_block"),
|
||||
"CODING_FACT_RECONCILE_PROMPT": ("candidates_block", "drafts_block"),
|
||||
"CODING_CHAIN_SUMMARY_PROMPT": ("chain_block",),
|
||||
"EPISODE_SUMMARY_PROMPT": ("memories",),
|
||||
"TOPIC_SUMMARY_PROMPT": ("memories", "topic_name"),
|
||||
}
|
||||
|
||||
# Fields of each SYSTEM2_MATERIALS entry (all other entries have none).
|
||||
SYSTEM2_MATERIALS_FIELDS: Dict[str, Tuple[str, ...]] = {
|
||||
"title": (),
|
||||
"stats": ("clustered", "clusters", "evidenced", "fresh", "graph_total", "recalled", "total", "unclustered"),
|
||||
"graph_status_empty": (),
|
||||
"clusters_header": (),
|
||||
"label_holistic": ("n",),
|
||||
"label_source_context": ("n",),
|
||||
"label_topic": ("n",),
|
||||
"raw_label": (),
|
||||
"holistic_note": ("max_schemas",),
|
||||
"companion_header": (),
|
||||
"unclustered_header": (),
|
||||
"unclustered_hint": (),
|
||||
"forward_header": ("count",),
|
||||
"graph_empty": (),
|
||||
"graph_no_recall": ("graph_total",),
|
||||
"reverse_header": (),
|
||||
"tags_header": (),
|
||||
"closing": (),
|
||||
}
|
||||
|
||||
_loaded: Dict[str, ModuleType] = {}
|
||||
|
||||
|
||||
def _supported_text() -> str:
|
||||
aliases = ", ".join(f"{k}->{v}" for k, v in LANGUAGE_ALIASES.items())
|
||||
return f"supported: {', '.join(SUPPORTED_LANGUAGES)} (aliases: {aliases})"
|
||||
|
||||
|
||||
def normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
"""Return the canonical code for ``language``; None/"" stays None.
|
||||
|
||||
Raises ValueError for an unknown code, listing the supported codes.
|
||||
"""
|
||||
if language is None:
|
||||
return None
|
||||
if not isinstance(language, str):
|
||||
raise ValueError(
|
||||
f"language must be a string, got {type(language).__name__}; "
|
||||
+ _supported_text()
|
||||
)
|
||||
raw = language.strip()
|
||||
if not raw:
|
||||
return None
|
||||
key = raw.replace("_", "-").lower()
|
||||
for code in SUPPORTED_LANGUAGES:
|
||||
if code.lower() == key:
|
||||
return code
|
||||
if key in LANGUAGE_ALIASES:
|
||||
return LANGUAGE_ALIASES[key]
|
||||
raise ValueError(f"unsupported language {language!r}; " + _supported_text())
|
||||
|
||||
|
||||
def detect_language_code(content: str = "", messages=None) -> str:
|
||||
"""The legacy automatic choice: ``zh-Hans`` if Chinese, else ``en``."""
|
||||
from ..utils.lang_detect import is_chinese
|
||||
|
||||
return "zh-Hans" if is_chinese(content, messages=messages) else "en"
|
||||
|
||||
|
||||
def resolve_language(
|
||||
language: Optional[str] = None,
|
||||
content: str = "",
|
||||
messages=None,
|
||||
) -> str:
|
||||
"""Explicit language if given (validated), else zh/en auto-detection."""
|
||||
code = normalize_language(language)
|
||||
if code is not None:
|
||||
return code
|
||||
return detect_language_code(content, messages=messages)
|
||||
|
||||
|
||||
def _module(language: str) -> ModuleType:
|
||||
code = normalize_language(language) or "en"
|
||||
mod = _loaded.get(code)
|
||||
if mod is None:
|
||||
mod = importlib.import_module(f"{__name__}.{_MODULES[code]}")
|
||||
_loaded[code] = mod
|
||||
return mod
|
||||
|
||||
|
||||
def get_prompt(name: str, language: Optional[str] = "en") -> Any:
|
||||
"""Look up prompt ``name`` for ``language`` (canonical code or alias)."""
|
||||
if name not in PROMPT_NAMES:
|
||||
raise KeyError(f"unknown prompt {name!r}")
|
||||
return getattr(_module(language or "en"), name)
|
||||
|
||||
|
||||
def with_output_language(
|
||||
prompt: str, language: Optional[str], explicit: bool,
|
||||
) -> str:
|
||||
"""Append the output-language rule when the language was set explicitly."""
|
||||
if not explicit or not language:
|
||||
return prompt
|
||||
return prompt + get_prompt("OUTPUT_LANGUAGE_DIRECTIVE", language)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTED_LANGUAGES",
|
||||
"LANGUAGE_ALIASES",
|
||||
"PROMPT_NAMES",
|
||||
"FORMAT_FIELDS",
|
||||
"SYSTEM2_MATERIALS_FIELDS",
|
||||
"normalize_language",
|
||||
"detect_language_code",
|
||||
"resolve_language",
|
||||
"get_prompt",
|
||||
"with_output_language",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
"""Public types and errors for exact memory-ID lookup."""
|
||||
|
||||
from typing import Literal, Sequence
|
||||
|
||||
|
||||
MemoryKind = Literal["chat", "experience", "fact"]
|
||||
MemoryVariant = Literal["working", "coding"]
|
||||
|
||||
|
||||
class AmbiguousMemoryIdError(LookupError):
|
||||
"""An ID exists in more than one public memory namespace."""
|
||||
|
||||
def __init__(self, memory_id: str, namespaces: Sequence[str]):
|
||||
self.memory_id = memory_id
|
||||
self.namespaces = tuple(namespaces)
|
||||
joined = ", ".join(self.namespaces)
|
||||
super().__init__(
|
||||
f"memory ID {memory_id!r} is ambiguous across namespaces: {joined}; "
|
||||
"pass kind and/or variant to disambiguate"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MemoryKind", "MemoryVariant", "AmbiguousMemoryIdError"]
|
||||
+88
-13
@@ -21,6 +21,11 @@ API Endpoints:
|
||||
GET /healthz — Health check (static, process alive)
|
||||
GET /health — Liveness probe (pings internal event loop; 503 if loop blocked)
|
||||
GET /info — Server info
|
||||
|
||||
/add and /add_extracted accept an optional "language" (en, zh-Hans, zh-Hant, ja,
|
||||
es, de, pt, fr, ko, ru, id, or an alias such as zh-CN / zh-TW / pt-BR). An
|
||||
unknown code returns 400. /search has no language option: the search-time
|
||||
judge / rewrite prompts are English-only and keep the query's own language.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -29,13 +34,32 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .retrieval import AmbiguousMemoryIdError
|
||||
|
||||
logger = logging.getLogger("hy_memory.server")
|
||||
|
||||
|
||||
def _namespace_args(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""kind / variant from a query string (lists) or a JSON body (scalars).
|
||||
|
||||
They disambiguate an id that exists in more than one namespace (chat,
|
||||
experience, fact; working or coding). Absent means "resolve it".
|
||||
"""
|
||||
args: Dict[str, Any] = {}
|
||||
for key in ("kind", "variant"):
|
||||
value = values.get(key)
|
||||
if isinstance(value, list):
|
||||
value = value[0] if value else None
|
||||
if value:
|
||||
args[key] = str(value)
|
||||
return args
|
||||
|
||||
# Lazy-init global client
|
||||
_client = None
|
||||
_client_lock = None
|
||||
@@ -81,6 +105,20 @@ def _get_client():
|
||||
return _client
|
||||
|
||||
|
||||
def _language_arg(handler: BaseHTTPRequestHandler, body: Dict, kwargs: Dict) -> bool:
|
||||
"""Validate optional ``language`` and add it to kwargs; False after a 400 reply."""
|
||||
if body.get("language") in (None, ""):
|
||||
return True
|
||||
from .prompts import normalize_language
|
||||
|
||||
try:
|
||||
kwargs["language"] = normalize_language(body["language"])
|
||||
except ValueError as e:
|
||||
_json_response(handler, 400, {"error": str(e)})
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _json_response(handler: BaseHTTPRequestHandler, status: int, data: Any):
|
||||
"""Send a JSON response."""
|
||||
body = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
||||
@@ -148,7 +186,6 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
if path == "/api/v1/metrics":
|
||||
minutes = 5
|
||||
if "?" in self.path:
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
minutes = int(qs.get("minutes", ["5"])[0])
|
||||
client = _get_client()
|
||||
@@ -161,7 +198,8 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
if m:
|
||||
memory_id = m.group(1)
|
||||
client = _get_client()
|
||||
result = client.get(memory_id)
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
result = client.get(memory_id, **_namespace_args(query))
|
||||
if result is None:
|
||||
_json_response(self, 404, {"error": "not_found", "memory_id": memory_id})
|
||||
else:
|
||||
@@ -170,6 +208,10 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
|
||||
_json_response(self, 404, {"error": "not_found", "path": path})
|
||||
|
||||
except AmbiguousMemoryIdError as e:
|
||||
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
|
||||
except ValueError as e:
|
||||
_json_response(self, 400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"[server] GET {path} error: {e}", exc_info=True)
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
@@ -213,7 +255,7 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
|
||||
def do_PUT(self):
|
||||
path = self.path.rstrip("/")
|
||||
path = self.path.split("?")[0].rstrip("/")
|
||||
body = _read_json_body(self)
|
||||
if body is None:
|
||||
_json_response(self, 400, {"error": "invalid JSON body"})
|
||||
@@ -228,29 +270,43 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
_json_response(self, 400, {"error": "content is required"})
|
||||
return
|
||||
client = _get_client()
|
||||
result = client.update(memory_id, content)
|
||||
result = client.update(
|
||||
memory_id,
|
||||
content,
|
||||
**_namespace_args(body),
|
||||
**({"task": body["task"]} if body.get("task") else {}),
|
||||
)
|
||||
_json_response(self, 200, result)
|
||||
return
|
||||
|
||||
_json_response(self, 404, {"error": "not_found", "path": path})
|
||||
|
||||
except AmbiguousMemoryIdError as e:
|
||||
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
|
||||
except ValueError as e:
|
||||
_json_response(self, 400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"[server] PUT {path} error: {e}", exc_info=True)
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
|
||||
def do_DELETE(self):
|
||||
path = self.path.rstrip("/")
|
||||
path = self.path.split("?")[0].rstrip("/")
|
||||
try:
|
||||
m = re.match(r"^/api/v1/memories/([^/]+)$", path)
|
||||
if m:
|
||||
memory_id = m.group(1)
|
||||
client = _get_client()
|
||||
result = client.delete(memory_id)
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
result = client.delete(memory_id, **_namespace_args(query))
|
||||
_json_response(self, 200, result)
|
||||
return
|
||||
|
||||
_json_response(self, 404, {"error": "not_found", "path": path})
|
||||
|
||||
except AmbiguousMemoryIdError as e:
|
||||
_json_response(self, 409, {"error": "ambiguous_memory_id", "detail": str(e)})
|
||||
except ValueError as e:
|
||||
_json_response(self, 400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"[server] DELETE {path} error: {e}", exc_info=True)
|
||||
_json_response(self, 500, {"error": str(e)})
|
||||
@@ -399,6 +455,9 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
kwargs["memory_at"] = datetime.fromisoformat(body["memory_at"])
|
||||
except Exception:
|
||||
pass
|
||||
# language: explicit prompt / memory language (see HyMemoryClient.add)
|
||||
if not _language_arg(self, body, kwargs):
|
||||
return
|
||||
|
||||
result = client.add(data, **kwargs)
|
||||
_json_response(self, 200, result)
|
||||
@@ -425,6 +484,8 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
kwargs["memory_at"] = datetime.fromisoformat(body["memory_at"])
|
||||
except Exception:
|
||||
pass
|
||||
if not _language_arg(self, body, kwargs):
|
||||
return
|
||||
|
||||
result = client.add_extracted(contents, **kwargs)
|
||||
_json_response(self, 200, result)
|
||||
@@ -455,7 +516,8 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
if key in body:
|
||||
kwargs[key] = body[key]
|
||||
|
||||
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader"):
|
||||
for key in ("limit", "min_score", "schema_min_score", "schema_limit", "reader",
|
||||
"rerank"):
|
||||
if key in body:
|
||||
kwargs[key] = body[key]
|
||||
|
||||
@@ -495,6 +557,16 @@ class MemoryHTTPHandler(BaseHTTPRequestHandler):
|
||||
_json_response(self, 200, result)
|
||||
|
||||
|
||||
def _request_shutdown(server: ThreadingHTTPServer) -> None:
|
||||
"""Ask ``serve_forever`` to stop without blocking the calling thread.
|
||||
|
||||
``BaseServer.shutdown()`` waits for the ``serve_forever`` loop to exit. A
|
||||
signal handler runs on the very thread executing that loop, so calling it
|
||||
there deadlocks forever. Hand the call to a helper thread instead.
|
||||
"""
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
|
||||
def run_server(port: int = 19527, host: str = "127.0.0.1"):
|
||||
"""Start the HTTP server (blocking)."""
|
||||
# Configure logging — basicConfig for root; hy_memory level set after client init
|
||||
@@ -523,13 +595,12 @@ def run_server(port: int = 19527, host: str = "127.0.0.1"):
|
||||
logger.info(f"[server] Listening on http://{host}:{port}")
|
||||
logger.info(f"[server] Health check: http://{host}:{port}/healthz")
|
||||
|
||||
# Graceful shutdown
|
||||
# Graceful shutdown. The signal handler only asks a helper thread to stop
|
||||
# the loop; cleanup runs after serve_forever() returns so it cannot race
|
||||
# the request threads or deadlock the serving thread.
|
||||
def _shutdown(signum, frame):
|
||||
logger.info("[server] Shutting down...")
|
||||
server.shutdown()
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
sys.exit(0)
|
||||
_request_shutdown(server)
|
||||
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
@@ -538,6 +609,10 @@ def run_server(port: int = 19527, host: str = "127.0.0.1"):
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
_shutdown(None, None)
|
||||
finally:
|
||||
server.server_close()
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Public, offline snapshot support for the SDK-owned local data plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DataPlaneSnapshotRejected(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataPlaneSnapshot:
|
||||
source_root: Path
|
||||
snapshot_root: Path
|
||||
files: tuple[dict[str, object], ...]
|
||||
total_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DataPlaneRestore:
|
||||
restored_root: Path
|
||||
previous_root: Path | None
|
||||
files_restored: int
|
||||
total_bytes: int
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def snapshot_local_data_plane(
|
||||
*,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
writers_stopped: bool,
|
||||
) -> DataPlaneSnapshot:
|
||||
"""Copy one quiesced local SDK root and return a verified inventory.
|
||||
|
||||
Remote providers require their own export implementation and must not call
|
||||
this local-path contract.
|
||||
"""
|
||||
if not writers_stopped:
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_writers_running")
|
||||
source = source_root.resolve()
|
||||
target = target_root.resolve()
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_source_invalid")
|
||||
if target.exists():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_target_exists")
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True)
|
||||
for root, directories, files in os.walk(source, followlinks=False):
|
||||
root_path = Path(root)
|
||||
relative = root_path.relative_to(source)
|
||||
target_directory = target / relative
|
||||
target_directory.mkdir(parents=True, exist_ok=True)
|
||||
for name in directories:
|
||||
if (root_path / name).is_symlink():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_symlink")
|
||||
for name in files:
|
||||
item = root_path / name
|
||||
if item.is_symlink():
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_symlink")
|
||||
shutil.copy2(item, target_directory / name)
|
||||
|
||||
inventory: list[dict[str, object]] = []
|
||||
for path in sorted(item for item in target.rglob("*") if item.is_file()):
|
||||
inventory.append(
|
||||
{
|
||||
"path": path.relative_to(target).as_posix(),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
for item in inventory:
|
||||
copied = target / str(item["path"])
|
||||
if copied.stat().st_size != item["bytes"] or _sha256(copied) != item["sha256"]:
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_verify_failed")
|
||||
except DataPlaneSnapshotRejected:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise
|
||||
except OSError as error:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise DataPlaneSnapshotRejected("data_plane_snapshot_failed") from error
|
||||
|
||||
return DataPlaneSnapshot(
|
||||
source_root=source,
|
||||
snapshot_root=target,
|
||||
files=tuple(inventory),
|
||||
total_bytes=sum(int(item["bytes"]) for item in inventory),
|
||||
)
|
||||
|
||||
|
||||
def restore_local_data_plane(
|
||||
*,
|
||||
snapshot_root: Path,
|
||||
target_root: Path,
|
||||
writers_stopped: bool,
|
||||
protective_root: Path | None = None,
|
||||
) -> DataPlaneRestore:
|
||||
"""Replace one local SDK root from a verified snapshot with rollback."""
|
||||
if not writers_stopped:
|
||||
raise DataPlaneSnapshotRejected("data_plane_restore_writers_running")
|
||||
source = snapshot_root.resolve()
|
||||
target = target_root.resolve()
|
||||
protective = (
|
||||
protective_root.resolve()
|
||||
if protective_root is not None
|
||||
else target.parent / f".{target.name}.pre-restore"
|
||||
)
|
||||
staging = target.parent / f".{target.name}.restore.tmp"
|
||||
if target.is_symlink() or protective.exists() or staging.exists():
|
||||
raise DataPlaneSnapshotRejected("data_plane_restore_target_unsafe")
|
||||
|
||||
copied = snapshot_local_data_plane(
|
||||
source_root=source,
|
||||
target_root=staging,
|
||||
writers_stopped=True,
|
||||
)
|
||||
previous: Path | None = None
|
||||
try:
|
||||
if target.exists():
|
||||
target.replace(protective)
|
||||
previous = protective
|
||||
staging.replace(target)
|
||||
except OSError as error:
|
||||
if previous is not None and previous.exists() and not target.exists():
|
||||
previous.replace(target)
|
||||
previous = None
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise DataPlaneSnapshotRejected("data_plane_restore_replace_failed") from error
|
||||
|
||||
return DataPlaneRestore(
|
||||
restored_root=target,
|
||||
previous_root=previous,
|
||||
files_restored=len(copied.files),
|
||||
total_bytes=copied.total_bytes,
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import contextvars
|
||||
import functools
|
||||
from concurrent.futures import Executor
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -39,7 +40,9 @@ async def run_in_executor_guarded(
|
||||
|
||||
async def to_thread_guarded(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
"""Guarded equivalent of ``asyncio.to_thread`` using the default pool."""
|
||||
return await run_in_executor_guarded(None, func, *args, **kwargs)
|
||||
context = contextvars.copy_context()
|
||||
call = functools.partial(func, *args, **kwargs)
|
||||
return await run_in_executor_guarded(None, context.run, call)
|
||||
|
||||
|
||||
def concurrent_result_guarded(
|
||||
|
||||
@@ -8,6 +8,7 @@ Uses a two-tier strategy:
|
||||
This avoids langdetect's known weakness with short Chinese text.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Optional
|
||||
@@ -135,6 +136,30 @@ def detect_language(text: str) -> str:
|
||||
return "zh" if ratio > 0.1 else "en"
|
||||
|
||||
|
||||
def _spoken_text(value: str) -> str:
|
||||
"""Ignore text-block metadata when a message content is structured JSON."""
|
||||
text = value.strip()
|
||||
if text.startswith("["):
|
||||
try:
|
||||
blocks = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(blocks, list)
|
||||
and blocks
|
||||
and all(
|
||||
isinstance(block, dict)
|
||||
and isinstance(block.get("type"), str)
|
||||
and block["type"] in {"text", "input_text", "output_text"}
|
||||
and isinstance(block.get("text"), str)
|
||||
for block in blocks
|
||||
)
|
||||
):
|
||||
return "\n".join(block["text"] for block in blocks)
|
||||
return text
|
||||
|
||||
|
||||
def extract_content_for_detection(
|
||||
content: str = "",
|
||||
messages: Optional[List] = None,
|
||||
@@ -153,20 +178,57 @@ def extract_content_for_detection(
|
||||
Concatenated content text suitable for language detection.
|
||||
"""
|
||||
if content and content.strip():
|
||||
return content.strip()
|
||||
value = content.strip()
|
||||
# Writer serializes message batches to JSON before calling Extractor.
|
||||
# Detect the spoken text, not the leading ASCII keys (role/content),
|
||||
# without changing the raw evidence sent to the model.
|
||||
if value.startswith("["):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
if (
|
||||
isinstance(decoded, list)
|
||||
and decoded
|
||||
and all(
|
||||
isinstance(item, dict)
|
||||
and isinstance(item.get("role"), str)
|
||||
and isinstance(item.get("content"), str)
|
||||
for item in decoded
|
||||
)
|
||||
):
|
||||
# In a mixed batch, the latest user turn is the clearest
|
||||
# instruction for the language; assistant/tool metadata
|
||||
# must not decide it first.
|
||||
user_text = [
|
||||
_spoken_text(item["content"])
|
||||
for item in decoded
|
||||
if item["role"] == "user" and item["content"].strip()
|
||||
]
|
||||
if user_text:
|
||||
return user_text[-1]
|
||||
spoken = "\n".join(_spoken_text(item["content"]) for item in decoded)
|
||||
if spoken.strip():
|
||||
return spoken
|
||||
return _spoken_text(value)
|
||||
|
||||
if messages:
|
||||
parts = []
|
||||
user_parts = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict):
|
||||
c = msg.get("content", "")
|
||||
c, role = msg.get("content", ""), msg.get("role")
|
||||
elif hasattr(msg, "content"):
|
||||
c = msg.content
|
||||
c, role = msg.content, getattr(msg, "role", None)
|
||||
else:
|
||||
continue
|
||||
if c and c.strip():
|
||||
parts.append(c.strip())
|
||||
return "\n".join(parts)
|
||||
if isinstance(c, str) and c.strip():
|
||||
text = _spoken_text(c)
|
||||
parts.append(text)
|
||||
if role == "user":
|
||||
user_parts.append(text)
|
||||
return user_parts[-1] if user_parts else "\n".join(parts)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Context propagated to host-provided HTTP transports for cost accounting."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
_model_call_stage: ContextVar[str] = ContextVar(
|
||||
"hy_memory_model_call_stage",
|
||||
default="other",
|
||||
)
|
||||
|
||||
|
||||
def current_model_call_stage() -> str:
|
||||
return _model_call_stage.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def model_call_stage(stage: str | None) -> Iterator[None]:
|
||||
normalized = (stage or "other").strip().lower() or "other"
|
||||
token: Token[str] = _model_call_stage.set(normalized)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_model_call_stage.reset(token)
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Pipeline Log Writer — 环节级 JSONL 日志(始终开启,与 SQLite Trace 分离)。
|
||||
Pipeline Log Writer — 环节级 JSONL 日志(默认开启,MEMORY_PIPELINE_LOG_ENABLED=false 关闭;
|
||||
与 SQLite Trace 分离)。
|
||||
|
||||
目录: {log_dir}/{subdir}/{date}.log
|
||||
每行: request_id, namespace, user_id, agent_id, step, prompt, content, parsed,
|
||||
@@ -18,12 +19,32 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: A namespace is a single path component, not a path. `\w` keeps Unicode
|
||||
#: names working while excluding separators, drive letters and NUL.
|
||||
_SAFE_SUBDIR = re.compile(r"[\w.-]{1,64}", re.UNICODE)
|
||||
|
||||
|
||||
def _safe_subdir(value: str) -> Optional[str]:
|
||||
"""Return ``value`` if it is safe as one directory name, else ``None``.
|
||||
|
||||
``user_id`` is caller-controlled. Without this check a value such as
|
||||
``"/tmp/evil__u1"`` makes ``Path(base) / "/tmp/evil"`` discard ``base``
|
||||
entirely; ``"../.."`` escapes it the same way.
|
||||
"""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate or candidate in {".", ".."}:
|
||||
return None
|
||||
if not _SAFE_SUBDIR.fullmatch(candidate):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
# 兼容旧 import
|
||||
TraceFileWriter = None # set after class definition
|
||||
|
||||
@@ -40,7 +61,7 @@ class PipelineLogWriter:
|
||||
|
||||
def __init__(self, log_dir: str, default_subdir: str = "default"):
|
||||
self._log_dir = log_dir
|
||||
self._default_subdir = default_subdir or "default"
|
||||
self._default_subdir = _safe_subdir(default_subdir) or "default"
|
||||
try:
|
||||
(Path(self._log_dir) / self._default_subdir).mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
@@ -49,7 +70,7 @@ class PipelineLogWriter:
|
||||
def _resolve_subdir(self, user_id: str) -> str:
|
||||
"""从 user_id 的 "{prefix}__{user_id}" 前缀取 prefix 作为子目录;无前缀则回落。"""
|
||||
if user_id and "__" in user_id:
|
||||
prefix = user_id.split("__", 1)[0].strip()
|
||||
prefix = _safe_subdir(user_id.split("__", 1)[0])
|
||||
if prefix:
|
||||
return prefix
|
||||
return self._default_subdir
|
||||
@@ -80,7 +101,11 @@ class PipelineLogWriter:
|
||||
split_namespace_user,
|
||||
)
|
||||
|
||||
tokens = (prompt_tokens or 0) + (completion_tokens or 0) + (total_tokens or 0)
|
||||
# Providers define total_tokens as prompt+completion. Adding all
|
||||
# three fields doubled every cost estimate in this JSONL.
|
||||
tokens = (total_tokens or 0) or (
|
||||
(prompt_tokens or 0) + (completion_tokens or 0)
|
||||
)
|
||||
namespace, uid = split_namespace_user(user_id or "")
|
||||
if not is_llm_content_logging_enabled():
|
||||
prompt = redact_content(prompt)
|
||||
@@ -111,7 +136,14 @@ class PipelineLogWriter:
|
||||
line = json.dumps(record, ensure_ascii=False, default=str)
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
subdir = self._resolve_subdir(user_id)
|
||||
log_dir = Path(self._log_dir) / subdir
|
||||
base_dir = Path(self._log_dir).resolve()
|
||||
log_dir = (base_dir / subdir).resolve()
|
||||
# Defence in depth: even if a future change lets a suspicious
|
||||
# component through, never write outside the configured root.
|
||||
try:
|
||||
log_dir.relative_to(base_dir)
|
||||
except ValueError:
|
||||
log_dir = base_dir / self._default_subdir
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / f"{date_str}.log"
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Pipeline 可观测性:Log(文件 JSONL,始终开启)与 Trace(SQLite pipeline_logs,可关闭)。
|
||||
Pipeline 可观测性:Log(文件 JSONL,默认开启,可关闭)与 Trace(SQLite pipeline_logs,可关闭)。
|
||||
|
||||
- Log:运维 tail/grep,每个环节即时一行,不依赖后续步骤成功与否。
|
||||
- Trace:Inspector / 调试,落库 pipeline_logs(需 MEMORY_CACHE_BACKEND=sqlite 等持久化后端)。
|
||||
|
||||
环境变量:
|
||||
MEMORY_PIPELINE_LOG_ENABLED — Log 写 JSONL 文件,默认 true;false 时完全不写
|
||||
MEMORY_PIPELINE_TRACE_ENABLED — Trace 落库,默认 true;OpenClaw C 端可设 false
|
||||
MEMORY_MEMORY_OPERATIONS_ENABLED — memory_operations 审计表落库,默认 false
|
||||
MEMORY_LOG_DIR — Log 目录(优先)
|
||||
MEMORY_TRACE_LOG_DIR — Log 目录(兼容旧名)
|
||||
|
||||
Log 与 Trace 都关闭时不包装 store_pipeline_log,而是换成立即返回 True 的
|
||||
空操作:热路径不做任何计时、脱敏或后台任务,也不写库。
|
||||
|
||||
子目录({log_dir}/{subdir}/{date}.log)由 PipelineLogWriter 从每条记录的
|
||||
user_id 前缀(第一个 "__" 左侧)切分;无前缀回落 "default"。该前缀是
|
||||
通用 namespace,可用于项目、部署或数据分桶。
|
||||
@@ -94,6 +98,16 @@ def is_pipeline_trace_enabled() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def is_pipeline_log_enabled() -> bool:
|
||||
"""是否把 pipeline step 写成 JSONL 文件({log_dir}/<subdir>/<date>.log)。默认开启。"""
|
||||
return os.getenv("MEMORY_PIPELINE_LOG_ENABLED", "true").strip().lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def is_memory_operations_enabled() -> bool:
|
||||
"""是否将知识库变动写入 memory_operations 审计表。默认关闭(false)。"""
|
||||
return os.getenv("MEMORY_MEMORY_OPERATIONS_ENABLED", "false").lower() in (
|
||||
@@ -125,7 +139,7 @@ def redact_content(value: str) -> str:
|
||||
|
||||
def resolve_pipeline_log_dir() -> str:
|
||||
"""
|
||||
JSONL pipeline log 根目录(始终使用,有默认值)。
|
||||
JSONL pipeline log 根目录(MEMORY_PIPELINE_LOG_ENABLED 开启时使用,有默认值)。
|
||||
|
||||
优先级: MEMORY_LOG_DIR > MEMORY_TRACE_LOG_DIR > {MEMORY_DATA_DIR}/logs/pipeline
|
||||
"""
|
||||
@@ -228,6 +242,7 @@ def wrap_store_pipeline_log(
|
||||
) -> Callable[..., Any]:
|
||||
"""包装 cache.store_pipeline_log:计时同步、JSONL/DB 异步落盘,不堵主请求。
|
||||
|
||||
- log_writer 为 None 时不写 JSONL(MEMORY_PIPELINE_LOG_ENABLED=false)
|
||||
- step-to-step elapsed_ms / event_at_ms 在调用瞬间打点(与业务墙钟对齐)
|
||||
- event_at_ms:调用方传入非 0 值则直接复用(App 已在自己被调用的那一刻
|
||||
同步打点,比这里更接近真实事件时刻);传 0 / 不传才用本地 time.time()
|
||||
@@ -410,7 +425,8 @@ def wrap_store_pipeline_log(
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# JSONL 用线程池,与 loop 无亲和,留在当前 loop。
|
||||
_track_task(loop.create_task(_persist_jsonl()))
|
||||
if log_writer is not None:
|
||||
_track_task(loop.create_task(_persist_jsonl()))
|
||||
# DB trace 用 cache pool(可能 aiomysql,绑定 owner_loop)。若当前不在
|
||||
# owner_loop(如 search 走独立 loop),派发回 owner_loop 执行,避免跨 loop。
|
||||
if trace_enabled:
|
||||
@@ -446,25 +462,26 @@ def wrap_store_pipeline_log(
|
||||
)
|
||||
except RuntimeError:
|
||||
# 无 running loop(极少见):退化为同步写,保证不丢日志
|
||||
try:
|
||||
log_writer.write_step(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
step=step,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
parsed=parsed,
|
||||
memory_ids=mem_ids,
|
||||
elapsed_ms=step_elapsed_ms,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
event_at_ms=event_at_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if log_writer is not None:
|
||||
try:
|
||||
log_writer.write_step(
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
session_id=session_id,
|
||||
step=step,
|
||||
prompt=prompt,
|
||||
response=response,
|
||||
parsed=parsed,
|
||||
memory_ids=mem_ids,
|
||||
elapsed_ms=step_elapsed_ms,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
event_at_ms=event_at_ms,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if trace_enabled:
|
||||
try:
|
||||
result = original_store(
|
||||
@@ -496,6 +513,11 @@ def wrap_store_pipeline_log(
|
||||
return _store_pipeline_observability
|
||||
|
||||
|
||||
async def _noop_store_pipeline_log(*args: Any, **kwargs: Any) -> bool:
|
||||
"""Log 与 Trace 都关闭时的 store_pipeline_log:不计时、不写文件、不写库。"""
|
||||
return True
|
||||
|
||||
|
||||
def install_pipeline_observability(
|
||||
cache: Any,
|
||||
*,
|
||||
@@ -503,6 +525,9 @@ def install_pipeline_observability(
|
||||
) -> None:
|
||||
"""给 cache 安装 JSONL + Trace 包装(fire-and-forget)。
|
||||
|
||||
MEMORY_PIPELINE_LOG_ENABLED=false 时不写 JSONL;若 Trace 也关闭,
|
||||
store_pipeline_log 被换成空操作(不包装、不写库)。
|
||||
|
||||
在 cache 所属 loop(write loop / _LoopThread)上调用,本函数会捕获该 loop
|
||||
作为 owner_loop 传给包装器:search 走独立 loop 后,从 search loop 触发的 DB
|
||||
落库会被派发回这个 owner_loop,避免跨 loop 用 aiomysql 污染连接池。
|
||||
@@ -514,15 +539,31 @@ def install_pipeline_observability(
|
||||
except RuntimeError:
|
||||
owner_loop = None
|
||||
|
||||
log_dir = resolve_pipeline_log_dir()
|
||||
log_writer = PipelineLogWriter(log_dir)
|
||||
log_enabled = is_pipeline_log_enabled()
|
||||
trace_enabled = is_pipeline_trace_enabled()
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) enabled: {log_dir}/<subdir>/<date>.log "
|
||||
f"(subdir from user_id prefix before '__', else default) ; "
|
||||
f"pipeline trace (DB)={'on' if trace_enabled else 'off'} ; "
|
||||
f"persist=async(fire-and-forget)"
|
||||
)
|
||||
if not log_enabled and not trace_enabled:
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) disabled ; pipeline trace (DB)=off ; "
|
||||
f"store_pipeline_log=no-op"
|
||||
)
|
||||
cache.store_pipeline_log = _noop_store_pipeline_log
|
||||
return
|
||||
|
||||
log_writer = None
|
||||
if log_enabled:
|
||||
log_dir = resolve_pipeline_log_dir()
|
||||
log_writer = PipelineLogWriter(log_dir)
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) enabled: {log_dir}/<subdir>/<date>.log "
|
||||
f"(subdir from user_id prefix before '__', else default) ; "
|
||||
f"pipeline trace (DB)={'on' if trace_enabled else 'off'} ; "
|
||||
f"persist=async(fire-and-forget)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"[{log_label}] Pipeline log (JSONL) disabled ; "
|
||||
f"pipeline trace (DB)=on ; persist=async(fire-and-forget)"
|
||||
)
|
||||
cache.store_pipeline_log = wrap_store_pipeline_log(
|
||||
cache.store_pipeline_log,
|
||||
log_writer=log_writer,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
原始对话(raw)落库前的统一裁剪规则(规则,零 LLM)。
|
||||
|
||||
raw 会被长期保存(MemHub 已取消 raw 的保留期限;lite 模式下 raw 是唯一存储),
|
||||
不裁剪会把工具输出、大段粘贴日志原样存下来。因此 raw 在**写入时**就裁好,
|
||||
读取方(extractor 的「最近 k 条」历史注入)直接拼接,不再二次裁剪。
|
||||
|
||||
规则按说话人标签(ChatMessage.speaker_label() / L1_RAW 里的 ``[label]``):
|
||||
|
||||
- ``user``:完整保留;
|
||||
- ``system``:完整保留(与历史注入原口径一致);
|
||||
- ``tool``:头尾保留、中段替换为 ``[...N bytes omitted by truncate...]``,
|
||||
上限为 ``coding.tool_result_max_bytes``(env ``MEMORY_CODING_TOOL_RESULT_MAX_BYTES``,
|
||||
默认 2048),与 coding 链路同一个函数;
|
||||
- 其余(``assistant``、对方真人名字等非已知 role 的 label):strip 后超过
|
||||
``assistant_max_chars``(默认 500,即历史注入原来的 ``max_chars_assistant``)
|
||||
时截成前 N 个字符 + ``...``。
|
||||
|
||||
所有规则确定且幂等:对已裁剪的文本再裁一次结果不变。
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from ..pipelines.base import ChatMessage
|
||||
|
||||
# 历史注入原 `_get_recent_history(max_chars_assistant=500)` 的默认值,现为唯一来源。
|
||||
DEFAULT_RAW_ASSISTANT_MAX_CHARS = 500
|
||||
|
||||
# L1_RAW 节点 custom 上的标记:内容已按本模块规则在写入时裁剪。
|
||||
# 历史拼接据此区分「新 raw 直接拼接」与「本规则之前写入的旧 raw 需兼容裁剪」。
|
||||
RAW_TRIMMED_KEY = "raw_trimmed"
|
||||
|
||||
# 不裁剪的标签(tool 单独处理)
|
||||
_KEEP_LABELS = frozenset({"user", "system"})
|
||||
|
||||
|
||||
def trim_raw_body(
|
||||
label: str,
|
||||
body: str,
|
||||
*,
|
||||
assistant_max_chars: int = DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
tool_max_bytes: Optional[int] = None,
|
||||
) -> str:
|
||||
"""按说话人标签裁剪单条消息正文。幂等。"""
|
||||
if not isinstance(body, str):
|
||||
body = "" if body is None else str(body)
|
||||
if label in _KEEP_LABELS:
|
||||
return body
|
||||
if label == "tool":
|
||||
from ..coding.preproc import (
|
||||
DEFAULT_MAX_TOOL_RESULT_BYTES,
|
||||
truncate_tool_result_text,
|
||||
)
|
||||
|
||||
return truncate_tool_result_text(
|
||||
body, max_bytes=int(tool_max_bytes or DEFAULT_MAX_TOOL_RESULT_BYTES),
|
||||
)
|
||||
stripped = body.strip()
|
||||
if len(stripped) > assistant_max_chars:
|
||||
return stripped[:assistant_max_chars] + "..."
|
||||
return body
|
||||
|
||||
|
||||
def trim_raw_messages(
|
||||
messages: List[ChatMessage],
|
||||
*,
|
||||
assistant_max_chars: int = DEFAULT_RAW_ASSISTANT_MAX_CHARS,
|
||||
tool_max_bytes: Optional[int] = None,
|
||||
) -> List[ChatMessage]:
|
||||
"""返回裁剪后的新列表;不修改入参(messages 仍要原样给 extractor)。"""
|
||||
out: List[ChatMessage] = []
|
||||
for m in messages:
|
||||
new_content = trim_raw_body(
|
||||
m.speaker_label(),
|
||||
m.content,
|
||||
assistant_max_chars=assistant_max_chars,
|
||||
tool_max_bytes=tool_max_bytes,
|
||||
)
|
||||
if new_content == m.content:
|
||||
out.append(m)
|
||||
continue
|
||||
out.append(ChatMessage(
|
||||
role=m.role,
|
||||
content=new_content,
|
||||
name=m.name,
|
||||
tool_calls=list(m.tool_calls),
|
||||
tool_call_id=m.tool_call_id,
|
||||
tool_name=m.tool_name,
|
||||
))
|
||||
return out
|
||||
@@ -36,7 +36,8 @@ def count_tokens(text: str) -> int:
|
||||
enc = _get_encoder()
|
||||
if enc:
|
||||
try:
|
||||
return len(enc.encode(text))
|
||||
# Quoted control-token spellings are ordinary input text.
|
||||
return len(enc.encode(text, disallowed_special=()))
|
||||
except Exception as e:
|
||||
logger.debug(f"[token_count] encode failed, char fallback: {e}")
|
||||
return max(1, len(text) // 4)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contributing
|
||||
|
||||
感谢你参与 One Memory。
|
||||
感谢你参与 MemHub。
|
||||
|
||||
## 开发环境
|
||||
|
||||
@@ -27,3 +27,10 @@ pnpm check
|
||||
|
||||
涉及外部源码时,必须记录项目、固定版本或 commit、许可证、采用文件和修改说明。不得提交
|
||||
密钥、内部地址、真实对话、个人路径、模型权重或签名证书。
|
||||
|
||||
## 界面翻译
|
||||
|
||||
新增或修改界面语言时遵循
|
||||
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md):使用稳定语义 key,保持所有
|
||||
locale 的 key 与占位符一致,不翻译用户内容、路径、标识符或外部错误正文。语言
|
||||
catalog 与无关产品行为应拆成不同 PR。
|
||||
+115
-182
@@ -1,97 +1,73 @@
|
||||
# Hy Memory 使用与配置
|
||||
|
||||
更新:2026-10-12
|
||||
更新:2026-10-21
|
||||
|
||||
本文说明 MemHub 当前使用的 Hy Memory SDK、配置边界,以及直接使用 SDK 时如何
|
||||
write、search,并把搜索结果格式化为可交给模型的 memory text chunk。
|
||||
架构边界以 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 为准。
|
||||
|
||||
仓库内仍以 One Memory 命名的 Evidence、权限和 Governance 层属于 MemHub 的历史兼容
|
||||
模块;产品代码不应绕过这些层直接调用 SDK。本文中的
|
||||
原生 SDK 示例主要用于开发、调试和独立验证。
|
||||
---
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
1. Memory 读写与存储 backend **只经 SDK**(config / env)。
|
||||
2. MemHub 运行时对 memory 的原子操作 **只经 SDK 公共 Client**;缺正常业务
|
||||
接口先补 SDK。Runtime writers 停止后的 App 启动迁移可直接操作底层存储,
|
||||
但不得把迁移专用方法加入公共 Client。
|
||||
3. MemHub **不存 memory 实体**;**可以存 `memory_id`** 做组织/回执。原文进入 **SDK raw**。
|
||||
4. SDK raw 全部保留,任何模式(lite / pro / ultra)都不删除;一条 raw 对应一个
|
||||
完整 turn,只含真人 user 与 assistant,tool 不持久化。
|
||||
5. `source_raw_ids` 是 SDK 内部溯源关系,不是产品 Evidence 合同;记忆详情不提供
|
||||
“原始依据”或按 raw ID 展开原文的入口。
|
||||
|
||||
生产入口通过共享 Runtime 租约取得公开 `HyMemoryClient`;MemHub 只在调用前执行
|
||||
模型配置、密钥和 harness/model 门禁,不维护第二套 memory 正文权威。
|
||||
|
||||
来源授权、模型授权和宿主召回授权仍是 MemHub 管理面门禁;memory
|
||||
数据平面的权威在 SDK。
|
||||
|
||||
## 当前版本状态
|
||||
|
||||
当前源码固定使用:
|
||||
当前源码固定使用(以仓库 pin / lockfile 为准):
|
||||
|
||||
```text
|
||||
distribution: hy-memory
|
||||
version: 2.0.2
|
||||
version: 见 pyproject / lock(文档数字可能滞后)
|
||||
mode: 按运行时配置选择
|
||||
reader: hybrid
|
||||
```
|
||||
|
||||
仓库 pin、lockfile 和运行时版本守卫均固定到公开的 `hy-memory==2.0.2`。
|
||||
MemHub 在本地 Runtime 内嵌入 SDK,但不绕过自身的来源授权、模型授权、Evidence、
|
||||
Governance 和撤权边界。
|
||||
|
||||
## MemHub 运行时边界
|
||||
|
||||
MemHub 对 SDK 使用以下公开、可复现的本地策略:
|
||||
MemHub 通过公开 `HyMemoryClient` 配置使用可复现的本地策略:
|
||||
|
||||
- vector store 使用受管数据目录中的 Chroma;
|
||||
- graph store 使用受管数据目录中的 Kuzu;
|
||||
- cache 与 history 使用 SQLite;
|
||||
- reader 使用 `hybrid`;
|
||||
- reranker 默认关闭,不引入 Torch、Transformers 或额外模型;
|
||||
- 模型与远程 embedding 参数来自版本化 Model Profile;
|
||||
- API Key 进入系统安全存储,不写入仓库、安装包、日志或 HY metadata;
|
||||
- 服务端数据库、租户队列及宿主 shell 中的 ambient 凭据不会进入 SDK config。
|
||||
- vector / graph / cache / history / coding 等 **一律 SDK config/env**;
|
||||
- 模型与 embedding 来自版本化 Model Profile;密钥进安全存储;
|
||||
- MemHub 管理库可存 harness/model/队列状态与 **`memory_id` 引用**。
|
||||
|
||||
发布包固定应用以下 SDK 行为配置:
|
||||
MemHub 不再通过 import-time 环境变量覆盖 SDK 的提取、reconcile、episode、
|
||||
intention、few-shot、summary 或去重策略。产品只配置模型/密钥/endpoint、本地
|
||||
backend 路径、公开 embedding service,以及每次公开 Client 调用的参数。
|
||||
|
||||
| 环境变量 | 值 |
|
||||
|---|---|
|
||||
| `MEMORY_MODE` | `ultra` |
|
||||
| `MEMORY_ENABLE_GRAPH` | `true` |
|
||||
| `MEMORY_GRAPH_RERANK_ENABLED` | `false` |
|
||||
| `MEMORY_GRAPH_TEMPORAL_SPINE` | `true` |
|
||||
| `MEMORY_TOPIC_SUMMARY_ENABLED` | `false` |
|
||||
| `MEMORY_EPISODE_SUMMARY_ENABLED` | `false` |
|
||||
| `MEMORY_EPISODE_RECALL_ENABLED` | `true` |
|
||||
| `MEMORY_DYNAMIC_EPISODE_ENABLED` | `true` |
|
||||
| `MEMORY_SESSION_EPISODE_ENABLED` | `false` |
|
||||
| `MEMORY_SESSION_EPISODE_CONTEXT_ENABLED` | `false` |
|
||||
| `MEMORY_EPISODE_CAPSULE_CONTEXT_ENABLED` | `true` |
|
||||
| `MEMORY_EPISODE_CAPSULE_QUERY_MIN` | `0.65` |
|
||||
| `MEMORY_EPISODE_CAPSULE_ASSIGNMENT_MIN` | `0.60` |
|
||||
| `MEMORY_EPISODE_CAPSULE_FIRST_RANK_MAX` | `3` |
|
||||
| `MEMORY_EPISODE_CAPSULE_MEMBER_LIMIT` | `3` |
|
||||
| `MEMORY_ENABLE_INTENTION` | `false` |
|
||||
| `MEMORY_ENABLE_QUERY_REWRITE` | `false` |
|
||||
| `MEMORY_FEW_SHOT_ENABLED` | `true` |
|
||||
| `MEMORY_HYBRID_V2_QUERY_EXPAND` | `1` |
|
||||
| `MEMORY_HYBRID_V2_GEO_INTENT_PRIOR` | `0.6` |
|
||||
| `MEMORY_HYBRID_V2_DIVERSITY` | `1` |
|
||||
| `MEMORY_HYBRID_V2_TEMPORAL_ADMIT_PRIOR` | `0.55` |
|
||||
| `MEMORY_HYBRID_V2_TEMPORAL_BOOST` | `0.18` |
|
||||
| `MEMORY_CROSS_DOMAIN_CON_THRESHOLD` | `0.5` |
|
||||
| `MEMORY_READER_ENABLE_SUMMARY` | `false` |
|
||||
| `MEMORY_SUMMARY_ENABLED_IN_SYS2` | `false` |
|
||||
| `MEMORY_LOCAL_RERANK_ENABLED` | `false` |
|
||||
| `RECONCILE_ENABLED` | `true` |
|
||||
| `HY_MEMORY_READER` | `hybrid` |
|
||||
|
||||
这些默认值集中在
|
||||
`apps/core/src/one_memory/hy_runtime_config.py`,并由发布测试和 lockfile 共同约束。
|
||||
独立脚本若绕过 MemHub 直接导入 SDK,应自行显式配置存储目录和模型端点。
|
||||
独立脚本直连 SDK 时须自配存储目录与模型端点。
|
||||
|
||||
## 原生 SDK 初始化
|
||||
|
||||
下面是独立调试示例。产品代码应使用 One Memory 的 `HyMemoryBridge`,因为它额外提供来源
|
||||
授权、模型授权、Evidence 绑定、写后核验、Governance 和撤权保护。
|
||||
下面是独立调试示例。
|
||||
|
||||
产品代码经 `hy_sdk_runtime.SdkClientHandle` 获取已配置的公开 client;memory I/O
|
||||
由 SDK 完成。
|
||||
|
||||
```python
|
||||
from hy_memory import HyMemoryClient, MemoryConfig
|
||||
|
||||
config = MemoryConfig.from_env()
|
||||
|
||||
# 本地优先示例;实际目录应由调用方传入,不能依赖 ~/.hy_memory 默认值。
|
||||
# 本地优先示例;实际目录应由调用方传入。
|
||||
config.vector_store.provider = "chroma"
|
||||
config.vector_store.persist_directory = "/path/to/one-memory/hy-memory/governed-v2/vector"
|
||||
config.vector_store.persist_directory = "/path/to/one-memory/hy-memory/vector"
|
||||
config.graph_store.provider = "kuzu"
|
||||
config.graph_store.db_path = "/path/to/one-memory/hy-memory/governed-v2/graph"
|
||||
config.graph_store.db_path = "/path/to/one-memory/hy-memory/graph"
|
||||
config.cache.backend = "sqlite"
|
||||
config.cache.db_path = "/path/to/one-memory/hy-memory/governed-v2/cache.sqlite3"
|
||||
config.cache.db_path = "/path/to/one-memory/hy-memory/cache.sqlite3"
|
||||
|
||||
config.llm.provider = "openai"
|
||||
config.llm.model = "your-model"
|
||||
@@ -107,12 +83,11 @@ config.embedder.embedding_dims = 1024
|
||||
client = HyMemoryClient(config=config, mode="ultra")
|
||||
```
|
||||
|
||||
`embedding_dims` 必须与模型实际输出宽度以及已有向量/图存储一致。更换 embedding 模型、
|
||||
端点或维度不能直接复用旧 collection;One Memory 会检测图宽度不一致并拒绝静默重建。
|
||||
`embedding_dims` 必须与模型输出及已有向量/图存储一致。
|
||||
|
||||
## Write:写入对话
|
||||
|
||||
推荐把一段连续对话作为 OpenAI messages 写入:
|
||||
推荐把一段连续对话作为 OpenAI messages 写入;含原文时由 SDK 接管 raw:
|
||||
|
||||
```python
|
||||
from datetime import UTC, datetime
|
||||
@@ -131,47 +106,63 @@ result = client.add(
|
||||
request_id=str(uuid4()),
|
||||
metadata={
|
||||
"source_id": "cursor",
|
||||
"evidence_ids": ["evidence-001", "evidence-002"],
|
||||
"workspace_ids": ["workspace-abc"],
|
||||
"input_kind": "raw_conversation",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
参数规则:
|
||||
|
||||
| 参数 | 填法 | 约束 |
|
||||
| --- | --- | --- |
|
||||
| `data` | 字符串或 `[{role, content}]` | 对话优先用 messages;不混入 tool 参数、reasoning、密钥和隐藏系统提示 |
|
||||
| `user_id` | 稳定用户 ID | 一级隔离键;不能为空 |
|
||||
| `agent_id` | 记忆 scope | One Memory 中不是宿主 App 名的简单替代;跨 App scope 由授权和路由决定 |
|
||||
| `session_id` | 稳定会话 ID | 同一会话增量写入时保持一致 |
|
||||
| `memory_at` | 有时区的 `datetime` | 历史导入必须传原始发生时间,不能用导入时间替代 |
|
||||
| `scene` | `"chat"` 或 `"working"` | 普通对话明确传 `chat`;代码/tool 会话才用 `working` |
|
||||
| `request_id` | 每次写入唯一 ID | 用于幂等、追踪和写后核验 |
|
||||
| `metadata` | 来源和 Evidence 引用 | 不放秘密;One Memory 至少保存 source/evidence/workspace/input kind |
|
||||
| `meta` | 业务透传数据 | 不参与 embedding 和过滤;只放可安全返回的数据 |
|
||||
| `embed_override` | 临时 embedding 端点 | 只能换 endpoint/key/header/timeout;模型和维度不能改变 |
|
||||
| `data` | 字符串或 `[{role, content}]` | 对话优先 messages;不混入密钥与隐藏系统提示 |
|
||||
| `user_id` / `agent_id` / `session_id` | 隔离与会话键 | 产品侧授权决定合法 scope |
|
||||
| `memory_at` | 有时区 datetime | 历史导入用原始发生时间 |
|
||||
| `scene` | `chat` / `working` | 明确传入 |
|
||||
| `request_id` | 每次写入唯一 | 幂等与追踪 |
|
||||
| `metadata` / `meta` | 来源与透传 | 不放秘密 |
|
||||
|
||||
返回值至少检查:
|
||||
MemHub Runtime 对应用和 CLI 暴露等价的本机 HTTP 合同:
|
||||
|
||||
```text
|
||||
POST /api/v2/memory-writes
|
||||
GET /api/v2/user-profile
|
||||
|
||||
memhub write submit --messages-file messages.json --session <id>
|
||||
memhub profile
|
||||
```
|
||||
|
||||
`messages.json` 是 OpenAI 文本 messages 数组,当前接受 `user`、
|
||||
`assistant`、`tool`;系统/开发者隐藏提示不得作为记忆输入。Core 将输入冻结进
|
||||
`memory_write_jobs`,worker 对 SDK 执行一次 `add()`,成功后清除队列正文。
|
||||
`user-profile` / `memhub profile` 调用 SDK `get_user_profile()`,返回组装后的
|
||||
用户长期画像,不通过普通 Search 拼装。
|
||||
|
||||
读接口都回显 `user_id`(实际读的用户,未指定时为 `local`):
|
||||
`GET /api/v2/memory-library`、`/memory-library/layers`、`/user-profile`、
|
||||
`/user-profile/versions`、`/home-summary`、`/memories/graph`、`/raw`,
|
||||
`POST /api/v2/context/search`、`/api/v1/recall`、`/api/v2/memories/ask`;
|
||||
`/memories/ask/stream` 写在第一个 `recall` 事件上。
|
||||
`DELETE /api/v2/memories/{id}` 只在删掉调用者自己的记忆时回 204;另一个用户的
|
||||
记忆和不存在的 ID 一律回 404 `memory_not_found`(不暴露是否存在),调用方按
|
||||
「已不存在」处理。
|
||||
|
||||
返回值:
|
||||
|
||||
```python
|
||||
if result.get("success") is not True:
|
||||
raise RuntimeError("HY write failed")
|
||||
|
||||
memory_ids = result.get("memory_ids") or []
|
||||
memory_ids = list(result.get("memory_ids") or [])
|
||||
if result.get("memory_id"):
|
||||
memory_ids.append(result["memory_id"])
|
||||
# 需要组织或回执时,MemHub 只持久化这些 memory_id。
|
||||
```
|
||||
|
||||
不要只相信 `success=True`。One Memory 还会按返回 ID `get()` 回读、核对 request/source/
|
||||
Evidence 支持关系,并在 Governance 层记录版本。生产路径不能用直接 `client.add()` 代替这些
|
||||
检查。
|
||||
以 SDK 合同为准做成功判定;需要写后核验时使用 SDK `get` 回读。缺少所需
|
||||
核验能力时扩展 SDK API,不在 MemHub 新建镜像台账。
|
||||
|
||||
### 写入已抽取的记忆
|
||||
|
||||
只有输入本身就是已有记忆内容、且原始对话不可用时才使用:
|
||||
|
||||
```python
|
||||
result = client.add_extracted(
|
||||
["用户长期偏好简洁、直接的回答。"],
|
||||
@@ -182,18 +173,30 @@ result = client.add_extracted(
|
||||
request_id=str(uuid4()),
|
||||
metadata={
|
||||
"source_id": "memory-export",
|
||||
"evidence_ids": ["evidence-003"],
|
||||
"input_kind": "memory_content_without_raw",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
它跳过 Extractor,但不能跳过 HY reconcile、持久化、One Memory Evidence 绑定和 Governance。
|
||||
该入口仍走 SDK reconcile/持久化;MemHub 只做管理面门禁与可选
|
||||
`memory_id` 记录。
|
||||
|
||||
应用的「添加记忆」、MCP `MemHub_write`(经 broker `POST /memories` 以本机可信身份转发)
|
||||
共用同一个本机 HTTP 合同,直接新增一条记忆:
|
||||
|
||||
```text
|
||||
POST /api/v2/memories {request_id, text, scope: "all-apps"}
|
||||
GET /api/v2/memories/additions/{request_id} # 该次新增的状态
|
||||
```
|
||||
|
||||
两者返回同一回执(`evidence_id`、`text`、`scope`、`created_at`、`state`、`task_id`、
|
||||
`memory_ids`、`error`)。同一 `request_id` 重放返回原回执;内容不同返回 409
|
||||
`memory_add_conflict`,未知 `request_id` 返回 404 `memory_add_not_found`。新增走持久
|
||||
写入队列的优先通道,最终调用 `add_extracted` + reconcile。带外部读取信封的请求
|
||||
一律拒绝(403 `external_endpoint_denied`)。
|
||||
|
||||
## Search:搜索记忆
|
||||
|
||||
普通 chat 搜索:
|
||||
|
||||
```python
|
||||
result = client.search(
|
||||
"用户偏好怎样的回答风格?",
|
||||
@@ -210,117 +213,47 @@ result = client.search(
|
||||
)
|
||||
```
|
||||
|
||||
参数规则:
|
||||
|
||||
| 参数 | 填法 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `query` | 当前问题 | 与 `queries` 二选一 |
|
||||
| `queries` | 多轮查询文本 | 最后一条是目标;只有需要 SDK 判断上下文时使用 |
|
||||
| `user_ids` | 获授权用户列表 | 必填且不能空;不要用空列表扩大范围 |
|
||||
| `agent_ids` | 获授权 scope 列表 | 空列表在 SDK 中表示所有 agent,产品代码必须先完成授权判断 |
|
||||
| `session_ids` | 可选会话范围 | 多 agent 与多 session 不能同时使用 |
|
||||
| `limit` | normal top-k | 默认 10;注入模型前还要受字符预算限制 |
|
||||
| `min_score` | normal 阈值 | One Memory 当前内部语义检索使用 0.25 |
|
||||
| `profile_min_score` | profile 阈值 | One Memory 当前使用 0.25 |
|
||||
| `profile_limit` | profile 数量 | 通常与 limit 一致 |
|
||||
| `intention_limit` | intention 数量 | 当前配置关闭 intention,传 0 |
|
||||
| `created_after` | Unix 秒 | 可选时间下界 |
|
||||
| `scene` | `chat` / `working` | 明确传入可避免额外场景判断模型调用 |
|
||||
| `reader` | `hybrid` | One Memory 固定使用该 reader |
|
||||
| `workspace_id` / `branch` | working scope | 仅 coding/working 路径使用 |
|
||||
| `layers` | 指定层级列表 | 在召回阶段过滤,避免无关层占用 limit |
|
||||
|
||||
chat 返回结构:
|
||||
|
||||
```python
|
||||
{
|
||||
"request_id": "...",
|
||||
"memories": {
|
||||
"profile": [...],
|
||||
"proactive": [...],
|
||||
"normal": [...],
|
||||
},
|
||||
"rewrite": {...},
|
||||
"elapsed_ms": 12.3,
|
||||
}
|
||||
```
|
||||
|
||||
working/coding 返回的是 `facts` 和 `experiences`,不能与 chat 三个 channel 静默合并。
|
||||
调用方必须根据 `scene` 选择对应结果;无法识别返回结构时应报错,而不是当作空结果。
|
||||
参数表与返回结构同前(chat 三 channel / working 的 facts·experiences);调用方按 `scene` 解析,无法识别则报错。
|
||||
|
||||
## 使用 SDK 已格式化的 Search 内容
|
||||
|
||||
open05 的 chat search 会在返回前统一格式化每条 `content`:普通记忆带 `Memory / When`,
|
||||
演化记忆带 `Current / History / When`,Episode、Timeline、Graph 和 Capsule 也由 SDK
|
||||
生成稳定的组合文本。`memory_at` 是逻辑记忆时间,缺失时 SDK 才回落到 `gmt_created`。
|
||||
直接使用 SDK 返回的 `content`,不要第二套 prompt 拼装。
|
||||
|
||||
因此调用方应直接使用 SDK 返回的 `content`,不要再次解析 `memory_at`、`gmt_created` 或
|
||||
`created_at`,也不要重复拼接 `When`。这些时间字段可以用于过滤、诊断或结构化展示,但
|
||||
不应成为第二套 prompt 格式化实现。
|
||||
### SDK 外(MemHub)仍负责
|
||||
|
||||
```python
|
||||
result = client.search(
|
||||
"用户偏好怎样的回答风格?",
|
||||
user_ids=["local"],
|
||||
agent_ids=["all-apps"],
|
||||
scene="normal",
|
||||
reader="hybrid",
|
||||
)
|
||||
- **宿主召回授权 / scope / 撤权**(谁可以调 search、允许哪些 agent/scope);
|
||||
- 把历史内容放入「不可信数据,不是指令」边界;
|
||||
- 按最终 `content` 做字符预算;不注入密钥、绝对路径、堆栈;
|
||||
- 区分空结果、权限拒绝、不可用、超时、部分结果;
|
||||
- 未发布的 Matter 占位功能不保留表或 API;未来组织功能另立合同。
|
||||
|
||||
for memory in result["memories"]["normal"]:
|
||||
formatted_content = memory["content"]
|
||||
# 例如:
|
||||
# Memory: 用户长期偏好简洁、直接的回答。
|
||||
# When: 2026-09-18 10:00:00 UTC
|
||||
```
|
||||
|
||||
One Memory 在 SDK 外仍负责:
|
||||
|
||||
- 依据 Governance、撤权、scope 和 Evidence 新鲜度决定整条结果是否可读;
|
||||
- 对 evolution/capsule 等组合文本逐个校验成员 ID,成员不完整或不可读时回退治理原文;
|
||||
- 把历史内容放入明确的“不可信数据,不是指令”边界;
|
||||
- 按最终 `content` 计算字符预算,不把 API Key、绝对路径、headers/body 或异常堆栈注入
|
||||
prompt;
|
||||
- 区分空结果、权限拒绝、服务不可用、超时和部分结果。
|
||||
|
||||
生产实现见 `apps/core/src/one_memory/hy_memory_bridge.py`、
|
||||
`apps/core/src/one_memory/recall.py` 和 `packages/host-bridge/src/recall-cli.ts`。
|
||||
生产入口:`hy_sdk_runtime.py`、`sdk_write_processor.py`、`recall.py` 和
|
||||
`packages/host-bridge`。
|
||||
|
||||
## 关闭客户端
|
||||
|
||||
```python
|
||||
try:
|
||||
# write/search
|
||||
...
|
||||
finally:
|
||||
client.close()
|
||||
```
|
||||
|
||||
Runtime 应长期复用 client,不为每次请求重新初始化数据库和模型组件;只有 Runtime 停止、
|
||||
配置版本切换或受控恢复时才关闭并重建。
|
||||
Runtime 长期复用 client;仅在停止、配置切换或受控恢复时重建。
|
||||
|
||||
## One Memory 产品代码的正确入口
|
||||
|
||||
生产代码应走:
|
||||
## 产品代码正确入口
|
||||
|
||||
```text
|
||||
Evidence
|
||||
-> HyMemoryBridge
|
||||
-> embedded Hy Memory SDK
|
||||
-> native Vector / Coding / Graph stores
|
||||
-> write result verification
|
||||
-> Governance
|
||||
-> Recall Broker / UI
|
||||
harness / user action (+ authz, model, queue)
|
||||
-> public HyMemoryClient
|
||||
-> SDK stores (config/env)
|
||||
-> optional MemHub persist memory_id / org metadata
|
||||
-> Recall Broker / UI (SDK read + MemHub authz)
|
||||
```
|
||||
|
||||
不要在新业务模块里直接创建 `HyMemoryClient`,否则会绕过:
|
||||
不要在业务模块里私建绕过管理面门禁的 client。版本、纠正和删除语义以 SDK
|
||||
合同为准;MemHub 组织层只引用 `memory_id`。
|
||||
|
||||
- 来源读取授权;
|
||||
- 共享 Runtime、模型配置、API Key 安全存储与请求预算;
|
||||
- Evidence 引用与作者归属;
|
||||
- 写后回读验证;
|
||||
- Governance 状态、版本、更正和删除级联。
|
||||
|
||||
Hy Memory SDK 是 MemHub 的内置可信组件,可自由访问和管理本机的全部
|
||||
Hy Memory 数据。来源授权仍决定哪些宿主数据可以进入本机;宿主 Recall Grant
|
||||
仍决定哪些外部 AI 应用可以读取治理后的记忆。这两类边界不限制内置 SDK 的本地 CRUD。
|
||||
Hy Memory SDK 是 MemHub 内置可信引擎,管理本机 Hy Memory 数据。
|
||||
来源授权决定哪些宿主数据可进入写入路径;宿主 Recall Grant 决定外部应用可读范围。
|
||||
这两类边界 **不** 意味着 MemHub 再存一套 memory 实体。
|
||||
+6
-4
@@ -29,10 +29,12 @@ prompt/response、密钥、访问令牌或完整本地路径。结构化日志
|
||||
文件。详细合同见
|
||||
[`docs/contracts/diagnostic-logging.md`](docs/contracts/diagnostic-logging.md)。
|
||||
|
||||
用户显式把诊断级别切换为 `debug` 时,Core 会启用 HY pipeline trace,并将
|
||||
`MEMORY_LOG_LLM_CONTENT` 设为 `true`。该模式会把完整模型 prompt/response 写入本地调试
|
||||
日志,其中可能包含获授权的对话正文和模型输出;它只应用于有意识的故障排查,不应长期
|
||||
开启,也不应在未经检查和脱敏时分享相关日志。切回 `info` 会停止后续正文记录,但不会
|
||||
HY pipeline 日志与 trace 在任何级别下都关闭。HY SDK 的文本日志只写入
|
||||
`hy-memory/governed-v2/logs/hy_memory.log`,其中的 WARNING 及以上同时写入 `core.log`;
|
||||
SDK 不在 INFO 及以上级别记录对话或记忆正文。用户显式把诊断级别切换为 `debug` 时,Core 将
|
||||
`MEMORY_LOG_LLM_CONTENT` 设为 `true`。该模式会把完整模型 prompt/response 写入
|
||||
`hy_memory.log` 的 DEBUG 行,其中可能包含获授权的对话正文和模型输出;它只应用于有意识的
|
||||
故障排查,不应长期开启,也不应在未经检查和脱敏时分享相关日志。切回 `info` 会停止后续正文记录,但不会
|
||||
自动删除此前已写入的调试文件。
|
||||
|
||||
具体行为以当前源码、网络请求实现和发布说明为准。发现实现与本说明不一致时,请按
|
||||
|
||||
+30
-13
@@ -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.2`。MemHub 源码版本为 `1.0.2`
|
||||
(build 6),GitHub 仓库为
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.14`。MemHub 源码版本为 `1.0.6`
|
||||
(build 43),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.2` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
`v1.0.6` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
@@ -25,23 +37,27 @@ Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命
|
||||
## 核心原则
|
||||
|
||||
- 一次安装、一个 Runtime、一份数据、多种客户端。
|
||||
- Python Core 是唯一记忆数据面和唯一业务数据库写入者。
|
||||
- Hy Memory SDK 是 memory/raw/vector/graph/coding 数据面的唯一权威;Python Core
|
||||
管理授权、模型、异步队列和 Runtime 状态。
|
||||
- 来源读取、模型处理和宿主读取是相互独立的授权边界。
|
||||
- 正式记忆链路保持 `Evidence -> Hy Memory -> Governance -> Matters/Wiki`。
|
||||
- 正式写链路为 `memory_write_jobs -> SDK add() -> raw/memory ID receipt`。
|
||||
- 产品不包含屏幕、Accessibility / AXTree、OCR、键鼠、剪贴板或后台录音采集。
|
||||
|
||||
## 仓库结构
|
||||
|
||||
- `apps/core`:Python / FastAPI Kernel,负责 Evidence、Hy Memory、治理、检索与 API。
|
||||
- `apps/core`:Python / FastAPI Kernel,负责授权、模型、队列、SDK Runtime 边界与 API。
|
||||
- `apps/cli`:安装、控制和诊断共享 Runtime 的 `one-memory` CLI。
|
||||
- `apps/web`:React / TypeScript 管理界面。
|
||||
- `apps/desktop`:Electron 桌面壳与图形安装入口。
|
||||
- `packages/contracts`:公共 API 类型。
|
||||
- `packages/i18n`:Web 与 Electron 共用的界面语言合同和 catalog。
|
||||
- `packages/host-bridge`:AI App 的 Skill、Hook、插件和 MCP 接入。
|
||||
- `packages/runtime-control`:Core、Host Gateway 和 Runtime 生命周期控制。
|
||||
|
||||
架构详情见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) 和
|
||||
[`docs/contracts/runtime-unification.md`](docs/contracts/runtime-unification.md)。
|
||||
界面语言架构与社区翻译规则见
|
||||
[`docs/LOCALIZATION.md`](docs/LOCALIZATION.md)。
|
||||
产品、测试和一线支持排查导入与记忆沉淀时,使用
|
||||
[`docs/PRODUCT_LOG_TROUBLESHOOTING.md`](docs/PRODUCT_LOG_TROUBLESHOOTING.md)。
|
||||
|
||||
@@ -93,15 +109,16 @@ Node、Host Bridge 或模型。用户显式执行 `memhub install` 后,CLI 才
|
||||
Python Core 和运行所需的 Python 依赖位于 MemHub 受管 Runtime,不写入系统 Python。
|
||||
MemHub 的 SQLite 数据默认位于 `~/.one-memory`,不在 npm 包或 Runtime 版本目录中。
|
||||
`npm uninstall` 只移除 npm CLI;`memhub uninstall` 移除服务、受管 Runtime 和 CLI 链接,
|
||||
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止且二次确认精确路径后
|
||||
才会删除数据。
|
||||
默认保留 Memory。只有独立的 `memhub delete-data` 在 Runtime 停止、显式加 `--yes` 并用
|
||||
`--confirm <数据目录>` 再写一遍同一个目录后才会删除数据(不加参数只预览要删除的目录)。`memhub stop` 停止受管服务时也会关闭
|
||||
登录自动启动,再次 `memhub start` 会恢复。
|
||||
|
||||
当前平台能力和安装边界以源码、CI 与发布说明为准。未完成真机验收的平台不会仅凭跨平台
|
||||
测试宣称可用。
|
||||
|
||||
## Hy Memory
|
||||
|
||||
当前开发依赖固定为公开 PyPI 包 `hy-memory==2.0.2`。SDK 配置、权限边界及 search
|
||||
SDK 依赖固定为公开 PyPI 正式版 `hy-memory==2.0.14`。SDK 配置、权限边界及 search
|
||||
返回格式见 [`MEMORY.md`](MEMORY.md)。HY search 返回的 `content` 已包含 SDK 统一生成的
|
||||
逻辑时间和演化上下文;调用方不应再次转换时间戳或重复拼接 `When`。
|
||||
|
||||
|
||||
+25
-3
@@ -3,9 +3,9 @@
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": "one-memory",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-15T17:26:47Z",
|
||||
"documentNamespace": "https://one-memory.local/spdx/2026-10-22T13:18:16Z",
|
||||
"creationInfo": {
|
||||
"created": "2026-10-15T17:26:47Z",
|
||||
"created": "2026-10-22T13:18:16Z",
|
||||
"creators": [
|
||||
"Tool: one-memory-generate-sbom"
|
||||
]
|
||||
@@ -88,6 +88,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-npm-i18next",
|
||||
"name": "i18next",
|
||||
"versionInfo": "25.10.10",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": "pkg:npm/i18next@25.10.10"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-npm-lucide-react",
|
||||
"name": "lucide-react",
|
||||
@@ -2335,7 +2352,7 @@
|
||||
{
|
||||
"SPDXID": "SPDXRef-Package-hy-memory",
|
||||
"name": "hy-memory",
|
||||
"versionInfo": "2.0.2",
|
||||
"versionInfo": "2.0.14",
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": false,
|
||||
"licenseConcluded": "Apache-2.0",
|
||||
@@ -2380,6 +2397,11 @@
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-npm-https-proxy-agent"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": "SPDXRef-Package-npm-i18next"
|
||||
},
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Package-one-memory",
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
MemHub itself is licensed under Apache-2.0 (see `LICENSE`).
|
||||
It bundles the runtime dependencies listed below.
|
||||
Generated from the installed dependency tree on 2026-10-15T17:26:47Z
|
||||
Generated from the installed dependency tree on 2026-10-22T13:18:16Z
|
||||
by `python3 scripts/generate_sbom.py`. Do not edit by hand.
|
||||
|
||||
Per-package license texts are not reproduced here yet; the license
|
||||
column is filled in as part of release review, and the SBOM records
|
||||
`NOASSERTION` until then rather than guessing.
|
||||
|
||||
## hy-memory 2.0.2
|
||||
## hy-memory 2.0.14
|
||||
|
||||
License: Apache-2.0
|
||||
|
||||
@@ -112,12 +112,13 @@ Adaptation boundary:
|
||||
|
||||
No Memmy brand, icon, font or mascot assets are distributed.
|
||||
|
||||
## JavaScript (npm) — 10 packages
|
||||
## JavaScript (npm) — 11 packages
|
||||
|
||||
- @tanstack/react-query 5.103.2
|
||||
- axios 1.20.0
|
||||
- fflate 0.8.3
|
||||
- https-proxy-agent 7.0.0
|
||||
- i18next 25.10.10
|
||||
- lucide-react 1.43.0
|
||||
- proxy-from-env 2.1.0
|
||||
- react 19.2.8
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@one-memory/cli",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.6",
|
||||
"description": "MemHub bootstrap CLI for installing and managing the local-first shared memory runtime",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* It is generated rather than hand-written so the two cannot drift: this reads
|
||||
* 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 { dirname, resolve } from "node:path";
|
||||
@@ -20,12 +19,14 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, "..");
|
||||
const repositoryRoot = resolve(root, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveBuildVersion } = require("../../../scripts/release-version.cjs");
|
||||
const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
|
||||
const { assertSemver } = require("../../../scripts/release-version.cjs");
|
||||
const pkgPath = resolve(root, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const publishVersion = assertSemver(
|
||||
process.env.MEMHUB_NPM_VERSION?.trim() || pkg.version,
|
||||
"MEMHUB_NPM_VERSION",
|
||||
);
|
||||
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
|
||||
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
|
||||
throw new Error(
|
||||
@@ -53,7 +54,7 @@ mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const published = { ...pkg };
|
||||
published.name = publishName;
|
||||
published.version = releaseVersion;
|
||||
published.version = publishVersion;
|
||||
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
if (!published[section]) continue;
|
||||
published[section] = Object.fromEntries(
|
||||
|
||||
@@ -21,18 +21,21 @@ const TOKEN = "probe-token-not-a-real-credential";
|
||||
|
||||
const MEMORY = {
|
||||
memory_id: "probe-1",
|
||||
user_id: "local",
|
||||
kind: "chat",
|
||||
status: "confirmed",
|
||||
content: "部署脚本要先跑 verify_merge_plan。",
|
||||
scopes: ["all-apps"],
|
||||
evidence_ids: ["probe-e1"],
|
||||
origin: "extracted",
|
||||
layer: "l2_fact",
|
||||
status: "active",
|
||||
is_latest: true,
|
||||
supersedes: [],
|
||||
superseded_by: [],
|
||||
tags: [],
|
||||
revision: 2,
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:00Z",
|
||||
deleted_at: null,
|
||||
owner: null,
|
||||
source_raw_ids: ["probe-raw-1"],
|
||||
user_id: "local",
|
||||
agent_id: "codex",
|
||||
session_id: "probe-session",
|
||||
source: "codex",
|
||||
memory_at: 1758240000,
|
||||
gmt_created: 1758240000,
|
||||
};
|
||||
|
||||
const ROUTES = new Map([
|
||||
@@ -124,26 +127,6 @@ const ROUTES = new Map([
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"/api/v1/memory-tasks",
|
||||
[
|
||||
{
|
||||
task_id: "probe-task",
|
||||
request_id: "probe-request",
|
||||
user_id: "local",
|
||||
kind: "extract",
|
||||
input_ref: "probe",
|
||||
status: "waiting_for_model",
|
||||
profile_id: "probe-profile",
|
||||
profile_revision: 1,
|
||||
profile_snapshot: {},
|
||||
attempts: 0,
|
||||
last_error_code: "model_missing",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
updated_at: "2026-09-19T00:00:01Z",
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
"/api/v1/model-profiles",
|
||||
[
|
||||
@@ -159,28 +142,20 @@ const ROUTES = new Map([
|
||||
},
|
||||
],
|
||||
],
|
||||
["/api/v1/home-summary", { memory_count: 1, latest: null, observed_at: "T" }],
|
||||
[
|
||||
"/api/v1/memory-library",
|
||||
{ total: 1, offset: 0, limit: 100, items: [MEMORY] },
|
||||
],
|
||||
[
|
||||
"/api/v1/memories/probe-1",
|
||||
"/api/v2/home-summary",
|
||||
{
|
||||
memory: MEMORY,
|
||||
evidence: [
|
||||
{
|
||||
evidence_id: "probe-e1",
|
||||
available: true,
|
||||
source_id: "codex",
|
||||
written_by: "user",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
verbatim: "部署前先跑 verify_merge_plan,别靠读 diff 猜冲突。",
|
||||
},
|
||||
],
|
||||
history: [{ memory_id: "probe-1", revision: 2, action: "confirm" }],
|
||||
schema_version: 2,
|
||||
memory_count: 1,
|
||||
latest: MEMORY,
|
||||
observed_at: "2026-09-19T00:00:01Z",
|
||||
},
|
||||
],
|
||||
[
|
||||
"/api/v2/memory-library",
|
||||
{ memories: [MEMORY], total: 1, offset: 0, limit: 100 },
|
||||
],
|
||||
["/api/v2/memories/probe-1", MEMORY],
|
||||
]);
|
||||
|
||||
const seen = [];
|
||||
|
||||
@@ -29,11 +29,8 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"sources",
|
||||
"sync",
|
||||
"memories",
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"extraction",
|
||||
"graph",
|
||||
"profile",
|
||||
"graph-width",
|
||||
"models",
|
||||
"host",
|
||||
"doctor",
|
||||
@@ -51,20 +48,17 @@ const REQUIRED_COMMAND_SURFACE = [
|
||||
"uninstall",
|
||||
"delete-data",
|
||||
"recall",
|
||||
"setup",
|
||||
"scan",
|
||||
"progress",
|
||||
"jobs",
|
||||
"processing",
|
||||
"path",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"write",
|
||||
"task",
|
||||
"serve",
|
||||
];
|
||||
|
||||
const REQUIRED_MARKERS = [
|
||||
"backup_restore_confirmation_required",
|
||||
"backup_restore_confirmation_mismatch",
|
||||
"backup_restore_runtime_state_unknown",
|
||||
"backup_restore_maintenance_identity_invalid",
|
||||
"runtime_service_activation_unverified",
|
||||
@@ -81,17 +75,18 @@ const REQUIRED_MARKERS = [
|
||||
* exact literal the catalog usage string contains.
|
||||
*/
|
||||
const REQUIRED_SUBCOMMANDS = [
|
||||
{ command: "calls", marker: "memhub calls clear" },
|
||||
{ command: "import-file", marker: "memhub import-file upload" },
|
||||
{ command: "import-file", marker: "memhub import-file commit" },
|
||||
{ command: "import-file", marker: "memhub import-file discard" },
|
||||
{ command: "notes", marker: "memhub notes add" },
|
||||
{ command: "notes", marker: "memhub notes process" },
|
||||
{ command: "extraction", marker: "memhub extraction set" },
|
||||
{ command: "graph", marker: "memhub graph width" },
|
||||
{ command: "memories", marker: "memhub memories add" },
|
||||
{ command: "memories", marker: "memhub memories add-status" },
|
||||
{ command: "memories", marker: "memhub memories update" },
|
||||
{ command: "memories", marker: "memhub memories delete" },
|
||||
{ command: "write", marker: "memhub write submit" },
|
||||
{ command: "write", marker: "memhub write status" },
|
||||
{ command: "graph-width", marker: "memhub graph-width --decision" },
|
||||
{ command: "models", marker: "memhub models add" },
|
||||
{ command: "models", marker: "memhub models test" },
|
||||
{ command: "models", marker: "memhub models edit" },
|
||||
{ command: "models", marker: "memhub models activate" },
|
||||
{ command: "processing", marker: "memhub processing extract" },
|
||||
{ command: "sources", marker: "memhub sources connect" },
|
||||
{ command: "jobs", marker: "memhub jobs start" },
|
||||
{ command: "jobs", marker: "memhub jobs <pause|resume|cancel>" },
|
||||
];
|
||||
@@ -103,83 +98,30 @@ const REQUIRED_SUBCOMMANDS = [
|
||||
* counterexamples on the packaged Runtime") had no machine-checkable probe.
|
||||
* These run without `--lifecycle`, so they are safe against any package.
|
||||
*/
|
||||
const PROFILE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const STOPPED_DESTRUCTIVE_PROBES = [
|
||||
{ name: "memories_add", args: ["memories", "add", "synthetic memory"] },
|
||||
{
|
||||
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: "memories_delete_apply",
|
||||
args: ["memories", "delete", "m-1", "--yes"],
|
||||
},
|
||||
{
|
||||
name: "graph_width_apply",
|
||||
args: ["graph", "width", "--decision", "keep_existing", "--confirm"],
|
||||
args: ["graph-width", "--decision", "keep_existing"],
|
||||
},
|
||||
{
|
||||
name: "processing_extract_apply",
|
||||
args: ["processing", "extract", "--confirm"],
|
||||
name: "graph_width_rebuild_apply",
|
||||
args: ["graph-width", "--decision", "rebuild_with_new", "--yes"],
|
||||
},
|
||||
{ name: "models_activate_apply", args: ["models", "activate", PROFILE_ID] },
|
||||
{ name: "models_test", args: ["models", "test", PROFILE_ID] },
|
||||
{
|
||||
name: "models_remove_apply",
|
||||
args: ["models", "remove", PROFILE_ID, "--yes"],
|
||||
},
|
||||
{
|
||||
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: "sources_connect_import",
|
||||
args: ["sources", "connect", "codex", "--import"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
// Exactly one request for two sources — the batch endpoint, per contract.
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
@@ -165,6 +166,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(document.state).toBe("partial");
|
||||
const byId = Object.fromEntries(
|
||||
@@ -206,6 +208,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
[],
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(document.sources.every((row) => row.status === "failed")).toBe(true);
|
||||
expect(
|
||||
@@ -249,6 +252,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
[],
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
const busy = document.sources.find((row) => row.source_id === "cursor");
|
||||
expect(document.state).toBe("partial");
|
||||
@@ -288,6 +292,7 @@ describe("A19 batch execution", () => {
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
const notFoundRow = document.sources.find(
|
||||
(row) => row.source_id === "cursor",
|
||||
@@ -296,3 +301,82 @@ describe("A19 batch execution", () => {
|
||||
expect(document.not_found_source_ids).toContain("cursor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment-override sources need --confirm-overrides", () => {
|
||||
it("leaves them out and names them unless confirmed", async () => {
|
||||
const planned = await planBatchImport(
|
||||
readerFor([
|
||||
found("codex"),
|
||||
found("dsh", {
|
||||
candidate_kind: "environment_override",
|
||||
requires_override_confirmation: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
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: body.source_ids,
|
||||
failed_sources: {},
|
||||
jobs: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const operations = { post } as unknown as CoreOperations;
|
||||
const skipped = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(post.mock.calls[0]![1]).toMatchObject({
|
||||
source_ids: ["codex"],
|
||||
confirm_environment_overrides: false,
|
||||
});
|
||||
expect(skipped.override_required_source_ids).toEqual(["dsh"]);
|
||||
expect(renderBatchScanText(skipped)).toContain("--confirm-overrides");
|
||||
|
||||
const confirmed = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
operations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: true },
|
||||
);
|
||||
expect(post.mock.calls[1]![1]).toMatchObject({
|
||||
source_ids: ["codex", "dsh"],
|
||||
confirm_environment_overrides: true,
|
||||
});
|
||||
expect(confirmed.override_required_source_ids).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses without a request when every detected source needs confirmation", async () => {
|
||||
const planned = await planBatchImport(
|
||||
readerFor([
|
||||
found("dsh", {
|
||||
candidate_kind: "environment_override",
|
||||
requires_override_confirmation: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
if (planned.state !== "preview") throw new Error("expected preview");
|
||||
const post = vi.fn();
|
||||
const document = await executeBatchImport(
|
||||
planned.candidates,
|
||||
readerFor([]),
|
||||
{ post } as unknown as CoreOperations,
|
||||
planned.notFound,
|
||||
{ confirmOverrides: false },
|
||||
);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(document).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "environment_override_confirmation_required",
|
||||
});
|
||||
expect(batchScanExitCode(document)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
/** A19: one-shot scan/import of every *detected* source.
|
||||
/** A19: one-shot connect/import of every *detected* source
|
||||
* (`sources connect --all-detected --import`).
|
||||
*
|
||||
* The engine already takes many sources in a single `POST /api/v1/sources/connect`
|
||||
* (`source_ids` is a 1..32 tuple) and answers per source. The CLI used to reject
|
||||
@@ -37,13 +38,16 @@ export interface BatchSourceRow {
|
||||
export interface BatchScanDocument {
|
||||
schema_version: 1;
|
||||
product: string;
|
||||
command: "scan" | "init";
|
||||
command: "sources connect";
|
||||
state: "committed" | "partial" | "rejected" | "unknown" | "no_sources";
|
||||
request_id: string | null;
|
||||
accepted_source_ids: string[];
|
||||
sources: BatchSourceRow[];
|
||||
/** `location_state != found` rows are listed so the user can see what was skipped. */
|
||||
not_found_source_ids: string[];
|
||||
/** Detected sources whose location comes from an environment override and
|
||||
* that were left out because `--confirm-overrides` was not given. */
|
||||
override_required_source_ids?: string[];
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -118,12 +122,39 @@ export async function planBatchImport(reader: CoreReader): Promise<
|
||||
|
||||
/** Run the batch connect once and fold the per-source receipt into rows. */
|
||||
export async function executeBatchImport(
|
||||
candidates: readonly SourceConnectionCandidate[],
|
||||
detected: readonly SourceConnectionCandidate[],
|
||||
reader: CoreReader,
|
||||
operations: CoreOperations,
|
||||
notFound: readonly string[],
|
||||
options: { confirmOverrides: boolean },
|
||||
): Promise<BatchScanDocument> {
|
||||
const requestId = randomUUID();
|
||||
// An environment-override location is a choice the user has to confirm:
|
||||
// without `--confirm-overrides` those sources are left out and named.
|
||||
const skipped = options.confirmOverrides
|
||||
? []
|
||||
: detected
|
||||
.filter((candidate) => candidate.requires_override_confirmation)
|
||||
.map((candidate) => candidate.source_id);
|
||||
const overrideFields =
|
||||
skipped.length > 0 ? { override_required_source_ids: skipped } : {};
|
||||
const candidates = detected.filter(
|
||||
(candidate) => !skipped.includes(candidate.source_id),
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "sources connect",
|
||||
state: "rejected",
|
||||
request_id: null,
|
||||
accepted_source_ids: [],
|
||||
sources: [],
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
code: "environment_override_confirmation_required",
|
||||
};
|
||||
}
|
||||
// Same shared builder as the single-source import and the Web console, so a
|
||||
// batch connect cannot drift from either.
|
||||
const request = buildSourceImportRequest({
|
||||
@@ -141,9 +172,9 @@ export async function executeBatchImport(
|
||||
: [[candidate.source_id, candidate.candidate_revision]],
|
||||
),
|
||||
),
|
||||
confirmEnvironmentOverrides: candidates.some(
|
||||
(candidate) => candidate.requires_override_confirmation,
|
||||
),
|
||||
confirmEnvironmentOverrides:
|
||||
options.confirmOverrides &&
|
||||
candidates.some((candidate) => candidate.requires_override_confirmation),
|
||||
fullRescan: false,
|
||||
onboarding: true,
|
||||
enableSync: true,
|
||||
@@ -154,7 +185,7 @@ export async function executeBatchImport(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "scan",
|
||||
command: "sources connect",
|
||||
state: result.state,
|
||||
request_id: requestId,
|
||||
accepted_source_ids: [],
|
||||
@@ -168,6 +199,7 @@ export async function executeBatchImport(
|
||||
failure_reason: result.code,
|
||||
})),
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
code: result.code,
|
||||
};
|
||||
}
|
||||
@@ -254,7 +286,7 @@ export async function executeBatchImport(
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: PRODUCT,
|
||||
command: "scan",
|
||||
command: "sources connect",
|
||||
state:
|
||||
failedCount === 0
|
||||
? "committed"
|
||||
@@ -270,6 +302,7 @@ export async function executeBatchImport(
|
||||
accepted_source_ids: accepted,
|
||||
sources: rows,
|
||||
not_found_source_ids: [...notFound],
|
||||
...overrideFields,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,5 +334,10 @@ export function renderBatchScanText(document: BatchScanDocument): string {
|
||||
: document.state === "partial"
|
||||
? `部分完成:${document.accepted_source_ids.length} 个成功,其余见上方`
|
||||
: `未完成:${document.code ?? "unknown"}`;
|
||||
return `${summary}\n${lines.join("\n")}\n`;
|
||||
const overrides = document.override_required_source_ids ?? [];
|
||||
const hint =
|
||||
overrides.length > 0
|
||||
? `\n未接入(目录来自环境变量覆盖,需确认):${overrides.join("、")}\n确认目录无误后加 --confirm-overrides 重新运行。`
|
||||
: "";
|
||||
return `${summary}\n${lines.join("\n")}${hint}\n`;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Contract fixes from the CLI review: exit codes, where JSON goes, the global
|
||||
// `--data-dir`, and the write idempotency key.
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { run } from "./cli.js";
|
||||
import { COMMANDS } from "./command-catalog.js";
|
||||
|
||||
const transport = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
create: vi.fn(() => transport),
|
||||
get: transport.get,
|
||||
post: transport.post,
|
||||
request: transport.request,
|
||||
isAxiosError: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
inspectRuntimeStatus: vi.fn(),
|
||||
discoverRuntimeAttachment: vi.fn(),
|
||||
inspectBackgroundHealth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@one-memory/runtime-control", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@one-memory/runtime-control")>()),
|
||||
...runtime,
|
||||
}));
|
||||
|
||||
const ATTACHED = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const method of Object.values(transport)) method.mockReset();
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "running",
|
||||
code: "runtime_verified",
|
||||
instanceId: "instance-1",
|
||||
host: "127.0.0.1",
|
||||
port: 8765,
|
||||
version: "1.2.3",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue(ATTACHED);
|
||||
runtime.inspectBackgroundHealth.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("--data-dir is a global option", () => {
|
||||
it("points a Runtime-attached command at that data directory", async () => {
|
||||
transport.get.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { request_id: "r", profile: null, elapsed_ms: 1 },
|
||||
});
|
||||
const result = await capturedRun(
|
||||
["profile", "--data-dir", "/synthetic/other", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: "/synthetic/default" },
|
||||
);
|
||||
expect(result.code).toBe(0);
|
||||
expect(runtime.inspectRuntimeStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir: "/synthetic/other" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is accepted by every command the catalog used to advertise it on", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
runtime.discoverRuntimeAttachment.mockResolvedValue(null);
|
||||
for (const argv of [
|
||||
["profile"],
|
||||
["scan-roots", "list"],
|
||||
["adapter", "list"],
|
||||
["scan", "--source", "codex"],
|
||||
["write", "status", "r-1"],
|
||||
["backup", "create", "--target", "/synthetic/data/backups/a"],
|
||||
["memories", "list"],
|
||||
]) {
|
||||
const result = await capturedRun([
|
||||
...argv,
|
||||
"--data-dir",
|
||||
"/synthetic/other",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code, argv.join(" ")).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a relative or repeated --data-dir", async () => {
|
||||
for (const argv of [
|
||||
["profile", "--data-dir", "relative"],
|
||||
["profile", "--data-dir", "/a", "--data-dir", "/b"],
|
||||
["profile", "--data-dir"],
|
||||
]) {
|
||||
expect((await capturedRun(argv)).code).toBe(2);
|
||||
}
|
||||
expect(runtime.inspectRuntimeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core failures are exit 1, not usage errors", () => {
|
||||
it("adapter probe reports a Core refusal with its code on stdout", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "source_not_authorized" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"adapter",
|
||||
"probe",
|
||||
"--source",
|
||||
"codex",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "adapter probe",
|
||||
state: "failed",
|
||||
code: "source_not_authorized",
|
||||
exit_code: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("adapter probe prints readable text without --json", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { source: "codex", available: true },
|
||||
});
|
||||
const result = await capturedRun(["adapter", "probe", "--source", "codex"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("来源 codex 的探测结果");
|
||||
expect(result.stdout).toContain("available: true");
|
||||
});
|
||||
|
||||
it("scan-roots add posts directly and reports a refusal as exit 1", async () => {
|
||||
transport.request.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "scan_root_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"scan-roots",
|
||||
"add",
|
||||
"--root",
|
||||
"/synthetic/root",
|
||||
"--source",
|
||||
"codex",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(transport.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "post",
|
||||
url: "http://127.0.0.1:8765/api/v1/scan-roots",
|
||||
data: { root: "/synthetic/root", source: "codex", recursive: true },
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(result.stdout).code).toBe("scan_root_conflict");
|
||||
});
|
||||
|
||||
it("the old `path add` spelling still works", async () => {
|
||||
transport.request.mockResolvedValue({ status: 200, data: { ok: true } });
|
||||
const result = await capturedRun([
|
||||
"path",
|
||||
"remove",
|
||||
"--root",
|
||||
"/synthetic/root",
|
||||
"--yes",
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(transport.request.mock.calls[0][0]).toMatchObject({
|
||||
method: "delete",
|
||||
data: { root: "/synthetic/root" },
|
||||
});
|
||||
});
|
||||
|
||||
it("scan --source runs directly", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { job_id: "j1", source_id: "codex", status: "pending" },
|
||||
});
|
||||
const result = await capturedRun(["scan", "--source", "codex", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "scan",
|
||||
state: "committed",
|
||||
scan: { job_id: "j1" },
|
||||
});
|
||||
// A scan without a source is a usage error, not a preview.
|
||||
expect((await capturedRun(["scan"])).code).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write submit keeps its idempotency key", () => {
|
||||
async function messagesFile(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
|
||||
const file = path.join(directory, "messages.json");
|
||||
await writeFile(file, JSON.stringify([{ role: "user", content: "hi" }]));
|
||||
return file;
|
||||
}
|
||||
|
||||
it("generates a request id, sends it and prints it with a retry hint", async () => {
|
||||
transport.post.mockImplementation(
|
||||
async (_url: string, body: { request_id: string }) => ({
|
||||
status: 200,
|
||||
data: {
|
||||
request_id: body.request_id,
|
||||
state: "accepted",
|
||||
created_at: "T",
|
||||
updated_at: "T",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--messages-file",
|
||||
await messagesFile(),
|
||||
]);
|
||||
expect(result.code).toBe(0);
|
||||
const sent = transport.post.mock.calls[0][1].request_id as string;
|
||||
expect(sent).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(result.stdout).toContain(`request_id: ${sent}`);
|
||||
expect(result.stdout).toContain(`--request-id ${sent}`);
|
||||
});
|
||||
|
||||
it("reports a 409 conflict as JSON on stdout with the request id", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 409,
|
||||
data: { detail: "memory_write_conflict" },
|
||||
});
|
||||
const result = await capturedRun([
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s1",
|
||||
"--messages-file",
|
||||
await messagesFile(),
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--json",
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
command: "write submit",
|
||||
state: "rejected",
|
||||
request_id: "req-1",
|
||||
code: "memory_write_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("write status of an unknown id is exit 1 with state not_found", async () => {
|
||||
transport.get.mockResolvedValue({ status: 404, data: {} });
|
||||
const error = Object.assign(new Error("404"), {
|
||||
response: { status: 404 },
|
||||
});
|
||||
transport.get.mockRejectedValue(error);
|
||||
const result = await capturedRun(["task", "--id", "nope", "--json"]);
|
||||
// `axios.isAxiosError` is mocked false, so the read error surfaces as a
|
||||
// failed read; either way it is exit 1, never a silent exit 0.
|
||||
expect(result.code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor and ui use exit 3 for a stopped Runtime", () => {
|
||||
it("doctor exits 3 when the Runtime is stopped", async () => {
|
||||
runtime.inspectRuntimeStatus.mockResolvedValue({
|
||||
state: "stopped",
|
||||
code: "runtime_not_running",
|
||||
});
|
||||
const result = await capturedRun(["doctor", "--json"]);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse(result.stdout).schema_version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog exit codes", () => {
|
||||
it("lists exit 2 for every command, since every parser can reject", () => {
|
||||
for (const command of COMMANDS) {
|
||||
expect(command.exit_codes, command.name).toContain(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists exit 3 for every command that attaches to the Runtime", () => {
|
||||
for (const name of [
|
||||
"status",
|
||||
"doctor",
|
||||
"init",
|
||||
"sources",
|
||||
"sync",
|
||||
"progress",
|
||||
"jobs",
|
||||
"models",
|
||||
"memories",
|
||||
"profile",
|
||||
"settings",
|
||||
"evolve",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"scan",
|
||||
"write",
|
||||
"backup",
|
||||
"ui",
|
||||
"onboarding",
|
||||
]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command?.exit_codes, name).toContain(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not claim exit 3 or 4 for lifecycle commands that never return them", () => {
|
||||
for (const name of ["start", "stop", "restart"]) {
|
||||
const command = COMMANDS.find((entry) => entry.name === name);
|
||||
expect(command?.exit_codes).toEqual([0, 1, 2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("says stop turns login auto-start off for a managed service", () => {
|
||||
const stop = COMMANDS.find((entry) => entry.name === "stop");
|
||||
expect(stop?.summary).toContain("登录时也不会自动启动");
|
||||
});
|
||||
});
|
||||
|
||||
async function capturedRun(
|
||||
arguments_: string[],
|
||||
environment: NodeJS.ProcessEnv = { ONE_MEMORY_DATA_DIR: "/synthetic/data" },
|
||||
) {
|
||||
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_, environment), stdout, stderr };
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import { DiagnosticLogger } from "@one-memory/runtime-control";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -29,6 +30,27 @@ async function fixture() {
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function readZipEntry(archive: Buffer, expected: string): string {
|
||||
let offset = 0;
|
||||
while (archive.readUInt32LE(offset) === 0x04034b50) {
|
||||
const method = archive.readUInt16LE(offset + 8);
|
||||
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||
const nameLength = archive.readUInt16LE(offset + 26);
|
||||
const extraLength = archive.readUInt16LE(offset + 28);
|
||||
const nameStart = offset + 30;
|
||||
const dataStart = nameStart + nameLength + extraLength;
|
||||
const name = archive.subarray(nameStart, nameStart + nameLength).toString();
|
||||
const compressed = archive.subarray(dataStart, dataStart + compressedSize);
|
||||
if (name === expected) {
|
||||
if (method === 0) return compressed.toString();
|
||||
if (method === 8) return inflateRawSync(compressed).toString();
|
||||
throw new Error("unsupported zip compression");
|
||||
}
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
throw new Error(`missing ${expected}`);
|
||||
}
|
||||
|
||||
it("prints matching diagnostic events as NDJSON without starting Runtime", async () => {
|
||||
const dataDir = await fixture();
|
||||
let stdout = "";
|
||||
@@ -74,6 +96,25 @@ it("exports one support zip to the requested downloads directory", async () => {
|
||||
});
|
||||
expect(receipt.path).toMatch(/MemHub-日志-3800-\d{8}-\d{4}\.zip$/);
|
||||
expect(await readdir(downloads)).toEqual([path.basename(receipt.path)]);
|
||||
const archive = await readFile(receipt.path);
|
||||
expect(JSON.parse(readZipEntry(archive, "summary.json")).commit).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed for an explicitly supplied invalid commit", async () => {
|
||||
const dataDir = await fixture();
|
||||
const downloads = await mkdtemp(path.join(os.tmpdir(), "om-cli-downloads-"));
|
||||
roots.push(downloads);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
await expect(
|
||||
run(["logs", "export", "--json"], {
|
||||
ONE_MEMORY_DATA_DIR: dataDir,
|
||||
ONE_MEMORY_EXPORT_DIR: downloads,
|
||||
ONE_MEMORY_BUILD_NUMBER: "3800",
|
||||
ONE_MEMORY_COMMIT: "unknown",
|
||||
}),
|
||||
).rejects.toThrow("diagnostic_commit_invalid");
|
||||
expect(await readdir(downloads)).toEqual([]);
|
||||
});
|
||||
|
||||
it("never emits a secret from a malformed manually written event", async () => {
|
||||
@@ -121,10 +162,54 @@ it("never emits a secret from a malformed manually written event", async () => {
|
||||
command: "logs",
|
||||
state: "empty",
|
||||
events: 0,
|
||||
invalid_lines: 1,
|
||||
invalid_lines: 0,
|
||||
unrecognized_lines: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// After an upgrade the files still hold an older build's records. They are
|
||||
// whole JSON lines the reader leaves out, not damage, so the text output must
|
||||
// not warn about them; only a line that is not JSON is called damaged.
|
||||
it("warns only about lines that are not JSON, not about older records", async () => {
|
||||
const dataDir = await fixture();
|
||||
const older = {
|
||||
schema_version: 0,
|
||||
service: "desktop",
|
||||
component: "desktop.lifecycle",
|
||||
event: "desktop.started",
|
||||
};
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-desktop.jsonl"),
|
||||
`${JSON.stringify(older)}\n${JSON.stringify({ ...older, schema_version: 1 })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((value) => {
|
||||
stdout += String(value);
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((value) => {
|
||||
stderr += String(value);
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(await run(["logs"], { ONE_MEMORY_DATA_DIR: dataDir })).toBe(0);
|
||||
expect(stdout).toContain("core.start_failed");
|
||||
expect(stdout).not.toContain("损坏");
|
||||
expect(stderr).toBe("");
|
||||
|
||||
await writeFile(
|
||||
path.join(dataDir, "logs", "diagnostic-cli.jsonl"),
|
||||
'{"schema_version": 1, "event": \n',
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
stdout = "";
|
||||
expect(await run(["logs"], { ONE_MEMORY_DATA_DIR: dataDir })).toBe(0);
|
||||
expect(stdout).toContain("跳过 1 行损坏的诊断记录。");
|
||||
expect(stderr).toBe("memhub: 跳过 1 行损坏的诊断记录。\n");
|
||||
});
|
||||
|
||||
it("rejects invalid filters without touching the log directory", async () => {
|
||||
const dataDir = path.join(
|
||||
await mkdtemp(path.join(os.tmpdir(), "om-cli-no-log-")),
|
||||
|
||||
+726
-60
@@ -1,9 +1,18 @@
|
||||
import { mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
claimRuntimeServiceForApp,
|
||||
clearSdkMigrationBlock,
|
||||
readRuntimeServiceOwner,
|
||||
recordSdkMigrationBlock,
|
||||
resolveRuntimeCliConfig,
|
||||
} from "@one-memory/runtime-control";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -57,6 +66,11 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
uninstallUserService: vi.fn(async (..._args: unknown[]) => "removed"),
|
||||
restartUserService: vi.fn(async () => undefined),
|
||||
runSdkMigration: vi.fn(
|
||||
async (): Promise<{ state: string; code?: string }> => ({
|
||||
state: "not_required",
|
||||
}),
|
||||
),
|
||||
rollbackLocalInstall: vi.fn(async () => undefined),
|
||||
planCliLink: vi.fn(async ({ home, installRoot }) => ({
|
||||
schema_version: 1,
|
||||
@@ -126,6 +140,7 @@ vi.mock("@one-memory/runtime-control", async (importOriginal) => {
|
||||
installUserService: runtimeMocks.installUserService,
|
||||
uninstallUserService: runtimeMocks.uninstallUserService,
|
||||
restartUserService: runtimeMocks.restartUserService,
|
||||
runSdkMigration: runtimeMocks.runSdkMigration,
|
||||
rollbackLocalInstall: runtimeMocks.rollbackLocalInstall,
|
||||
planCliLink: runtimeMocks.planCliLink,
|
||||
installCliLink: runtimeMocks.installCliLink,
|
||||
@@ -784,7 +799,7 @@ describe("public One Memory CLI", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("upgrade failure restarts the restored artifact with the same data identity", async () => {
|
||||
it("upgrade restart failure keeps the new version and never restarts the old one", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
@@ -826,25 +841,27 @@ describe("public One Memory CLI", () => {
|
||||
command: "upgrade",
|
||||
code: "restart failed",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
// The CLI manages the objective aliases the package does not own
|
||||
// (`memhub` + `memhub-cli`); both are rolled back.
|
||||
expect(aliasMocks.rollbackManagedCliLink).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledTimes(2);
|
||||
for (const call of runtimeMocks.restartUserService.mock
|
||||
.calls as unknown[][]) {
|
||||
expect(call[0]).toMatchObject({
|
||||
dataDir: "/tmp/custom data",
|
||||
installRoot: "/tmp/install/current",
|
||||
});
|
||||
}
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
rolled_back: false,
|
||||
});
|
||||
// Product rule: the previous Runtime may predate the SDK migration gate,
|
||||
// so it is never restored after the migration was handed the home.
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(aliasMocks.rollbackManagedCliLink).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
(runtimeMocks.restartUserService.mock.calls as unknown[][])[0]?.[0],
|
||||
).toMatchObject({
|
||||
dataDir: "/tmp/custom data",
|
||||
installRoot: "/tmp/install/current",
|
||||
});
|
||||
});
|
||||
|
||||
// O-22 (2026-09-29 C8 H17): `upgrade --yes` on a manifest-valid but
|
||||
// crash-looping version reported exit 0 and left `current` on the bad
|
||||
// version. A restart that merely returns is not proof the new Runtime came
|
||||
// up, so the CLI now verifies health and rolls back if it never does.
|
||||
it("rolls back and fails when the upgraded Runtime never becomes healthy", async () => {
|
||||
it("fails without rolling back when the upgraded Runtime never becomes healthy", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
@@ -884,9 +901,219 @@ describe("public One Memory CLI", () => {
|
||||
command: "upgrade",
|
||||
code: "upgrade_health_check_failed",
|
||||
});
|
||||
// Rolled back to the previous version and restarted it.
|
||||
// Kept on the new version; the old Runtime is not restarted.
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const upgradeArguments = [
|
||||
"upgrade",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--data-dir",
|
||||
"/tmp/custom data",
|
||||
"--yes",
|
||||
];
|
||||
const presentCliLink = {
|
||||
schema_version: 1 as const,
|
||||
platform: "linux" as const,
|
||||
home: "/tmp/home",
|
||||
install_root: "/tmp/install",
|
||||
bin_directory: "/tmp/bin",
|
||||
status: "present" as const,
|
||||
blocked_reason: null,
|
||||
link_path: "/tmp/bin/one-memory",
|
||||
target_path: "/tmp/install/current/bin/one-memory",
|
||||
};
|
||||
|
||||
it("says the data was restored when a failed migration put it back", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
dataRestored: true,
|
||||
} as never);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
data_restored: true,
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
});
|
||||
expect(failure.message).toContain("记忆数据已恢复到升级前的状态。");
|
||||
expect(failure.message).toContain(
|
||||
"/tmp/custom data/logs/sdk-migration.log",
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates the home with the new Core before restarting the Runtime", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
const order: string[] = [];
|
||||
runtimeMocks.runSdkMigration.mockImplementationOnce(async () => {
|
||||
order.push("migrate");
|
||||
return { state: "cutover" };
|
||||
});
|
||||
runtimeMocks.restartUserService.mockImplementationOnce(async () => {
|
||||
order.push("restart");
|
||||
});
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(order).toEqual(["migrate", "restart"]);
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir: "/tmp/custom data" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays on the new version, not serving, when the migration does not finish", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
});
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
// The one file support needs, and where to send it.
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(failure.message).toContain(
|
||||
"/tmp/custom data/logs/sdk-migration.log",
|
||||
);
|
||||
expect(failure.message).toContain("GitHub");
|
||||
expect(failure.message).not.toContain("回滚到上一版本");
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-upgrade" }),
|
||||
);
|
||||
// No rollback: the old Runtime has no migration gate. The new service is
|
||||
// (re)started so its gate, not the old Runtime, holds the data dir.
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(aliasMocks.rollbackManagedCliLink).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
(runtimeMocks.restartUserService.mock.calls as unknown[][])[0]?.[0],
|
||||
).toMatchObject({
|
||||
installRoot: "/tmp/install/current",
|
||||
});
|
||||
});
|
||||
|
||||
it("stays on the new version when the migration itself throws", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockRejectedValueOnce(
|
||||
new Error("sdk_migration_fence_failed"),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "sdk_migration_fence_failed",
|
||||
log_file: "/tmp/custom data/logs/sdk-migration.log",
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rolls back a failure before the migration touched the home", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
aliasMocks.installManagedCliLink.mockRejectedValueOnce(
|
||||
new Error("cli_link_failed"),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "cli_link_failed",
|
||||
});
|
||||
expect(runtimeMocks.runSdkMigration).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.restartUserService).toHaveBeenCalledTimes(2);
|
||||
// The previous Runtime was never stopped; nothing is (re)started.
|
||||
expect(runtimeMocks.restartUserService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the new version when a migrated home is slow to come up", async () => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce(presentCliLink);
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({ state: "cutover" });
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
upgradeArguments,
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => false },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "upgrade",
|
||||
code: "runtime_not_ready_after_sdk_migration",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports success once the upgraded Runtime is healthy", async () => {
|
||||
@@ -1384,7 +1611,7 @@ describe("public One Memory CLI", () => {
|
||||
expect(JSON.parse(output.value()).apply).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back the distribution when service installation fails", async () => {
|
||||
it("keeps the distribution when service installation fails after the migration ran", async () => {
|
||||
runtimeMocks.applyLocalInstall.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
source: "/tmp/distribution",
|
||||
@@ -1425,13 +1652,14 @@ describe("public One Memory CLI", () => {
|
||||
command: "install",
|
||||
status: "error",
|
||||
code: "service failed",
|
||||
rolled_back: false,
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.rollbackCliLink).toHaveBeenCalledOnce();
|
||||
expect(aliasMocks.rollbackManagedCliLink).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackCliLink).not.toHaveBeenCalled();
|
||||
expect(aliasMocks.rollbackManagedCliLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("upgrades only an existing managed CLI and rolls back a failed restart", async () => {
|
||||
it("upgrades only an existing managed CLI and keeps it after a failed restart", async () => {
|
||||
runtimeMocks.planCliLink.mockResolvedValueOnce({
|
||||
schema_version: 1,
|
||||
platform: "linux",
|
||||
@@ -1471,7 +1699,7 @@ describe("public One Memory CLI", () => {
|
||||
status: "error",
|
||||
code: "restart failed",
|
||||
});
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.installCliLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1783,19 +2011,11 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
["path", "list", "--json"],
|
||||
["adapter", "list", "--json"],
|
||||
["task", "--id", "nope", "--json"],
|
||||
[
|
||||
"write",
|
||||
"--session",
|
||||
"s",
|
||||
"--from",
|
||||
"1",
|
||||
"--to",
|
||||
"1",
|
||||
"--evidence",
|
||||
"e",
|
||||
"--json",
|
||||
],
|
||||
["backup", "create", "--target", "/tmp/x", "--yes", "--json"],
|
||||
["write", "status", "nope", "--json"],
|
||||
["backup", "create", "--target", "/tmp/x", "--json"],
|
||||
["scan-roots", "add", "--root", "/tmp/x", "--json"],
|
||||
["scan", "--source", "codex", "--json"],
|
||||
["onboarding", "reset", "--json"],
|
||||
])("%s ... --json exits 3 with runtime_not_running", async (...argv) => {
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
@@ -1805,7 +2025,9 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(3);
|
||||
const document = JSON.parse(streams.stderr());
|
||||
// A result, not an error: on stdout like every other --json document.
|
||||
expect(streams.stderr()).toBe("");
|
||||
const document = JSON.parse(streams.stdout());
|
||||
expect(document).toMatchObject({
|
||||
code: "runtime_not_running",
|
||||
exit_code: 3,
|
||||
@@ -1814,6 +2036,48 @@ describe("stopped Runtime is exit 3, not a usage error", () => {
|
||||
expect(document.status).not.toBe("usage_error");
|
||||
});
|
||||
|
||||
it("write submit validates its messages file before attaching, then exits 3", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "memhub-write-"));
|
||||
const file = path.join(directory, "messages.json");
|
||||
await import("node:fs/promises").then(({ writeFile }) =>
|
||||
writeFile(file, JSON.stringify([{ role: "user", content: "hi" }])),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let missing: number;
|
||||
let code: number;
|
||||
try {
|
||||
missing = await run(
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
path.join(directory, "absent.json"),
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
);
|
||||
code = await run(
|
||||
[
|
||||
"write",
|
||||
"submit",
|
||||
"--session",
|
||||
"s",
|
||||
"--messages-file",
|
||||
file,
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
expect(missing!).toBe(2);
|
||||
expect(code!).toBe(3);
|
||||
});
|
||||
|
||||
it.each(["pause", "resume"] as const)(
|
||||
"parses sync %s as the direct sync-set command",
|
||||
async (action) => {
|
||||
@@ -1898,6 +2162,189 @@ describe("lifecycle commands accept --json", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A Runtime service that refuses to serve an unmigrated home must be reported
|
||||
// as that, with the migration log and where to send it, not as a generic
|
||||
// "not ready within budget".
|
||||
describe("lifecycle commands report a migration block", () => {
|
||||
async function blockedHome() {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-blocked-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_disk_space_insufficient",
|
||||
"runtime-service",
|
||||
);
|
||||
return dataDir;
|
||||
}
|
||||
// darwin: kickstart "restarts" but never becomes ready. Elsewhere: no
|
||||
// service, so the direct spawn fails. Both must surface the block.
|
||||
const blockedControls = () => ({
|
||||
restartManagedRuntime: vi.fn(async () => "restarted" as const),
|
||||
waitForRuntimeReady: vi.fn(async () => false),
|
||||
stopRuntime: vi.fn(async () => "stopped" as const),
|
||||
ensureRuntime: vi.fn(async () => {
|
||||
throw new Error("Timed out waiting for MemHub Runtime");
|
||||
}),
|
||||
});
|
||||
|
||||
it("restart --json prints the block code, log file and issues URL", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
blockedControls(),
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const lines = output.value().trim().split("\n");
|
||||
const document = JSON.parse(lines[lines.length - 1]!);
|
||||
expect(document).toMatchObject({
|
||||
command: "restart",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
log_file: path.join(dataDir, "logs", "sdk-migration.log"),
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(output.value()).not.toContain("runtime_not_ready_within_budget");
|
||||
});
|
||||
|
||||
it("restart on a frozen home retries the migration in the foreground first", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_memory_unreadable",
|
||||
"desktop",
|
||||
{ frozen: { service_was_enabled: true }, data_restored: true },
|
||||
);
|
||||
const controls = {
|
||||
...blockedControls(),
|
||||
migrateSdkData: vi.fn(async () => ({
|
||||
state: "deferred" as const,
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
dataRestored: true,
|
||||
})),
|
||||
};
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
controls,
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(controls.migrateSdkData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dataDir, caller: "cli-restart" }),
|
||||
);
|
||||
// Still frozen: the service is not started to retry on its own.
|
||||
expect(controls.restartManagedRuntime).not.toHaveBeenCalled();
|
||||
expect(controls.ensureRuntime).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "restart",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
data_restored: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("restart on a frozen home restarts the Runtime once the retry succeeds", async () => {
|
||||
const dataDir = await mkdtemp(path.join(tmpdir(), "memhub-frozen-"));
|
||||
await recordSdkMigrationBlock(
|
||||
dataDir,
|
||||
"sdk_migration_memory_unreadable",
|
||||
"desktop",
|
||||
{ frozen: { service_was_enabled: false } },
|
||||
);
|
||||
const order: string[] = [];
|
||||
const migrateSdkData = vi.fn(async () => {
|
||||
order.push("migrate");
|
||||
await clearSdkMigrationBlock(dataDir);
|
||||
return { state: "cutover" as const };
|
||||
});
|
||||
const output = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["restart", "--json"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
{
|
||||
migrateSdkData,
|
||||
waitForRuntimeReady: vi.fn(async () => true),
|
||||
restartManagedRuntime: vi.fn(async () => {
|
||||
order.push("restart");
|
||||
return "restarted" as const;
|
||||
}),
|
||||
stopRuntime: vi.fn(async () => "stopped" as const),
|
||||
ensureRuntime: vi.fn(async () => {
|
||||
order.push("restart");
|
||||
return { pid: 1 } as never;
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(migrateSdkData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-restart" }),
|
||||
);
|
||||
expect(order).toEqual(["migrate", "restart"]);
|
||||
});
|
||||
|
||||
it("restart in text mode names the log file and GitHub issues", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
try {
|
||||
expect(
|
||||
await run(
|
||||
["restart"],
|
||||
{ ONE_MEMORY_DATA_DIR: dataDir },
|
||||
blockedControls(),
|
||||
),
|
||||
).toBe(1);
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(output.value()).toContain("sdk_migration_disk_space_insufficient");
|
||||
expect(output.value()).toContain(
|
||||
path.join(dataDir, "logs", "sdk-migration.log"),
|
||||
);
|
||||
expect(output.value()).toContain(
|
||||
"https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
);
|
||||
});
|
||||
|
||||
it("ui --json reports the block instead of runtime_not_running", async () => {
|
||||
const dataDir = await blockedHome();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(["ui", "--json"], { ONE_MEMORY_DATA_DIR: dataDir });
|
||||
} finally {
|
||||
output.restore();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
expect(code!).not.toBe(0);
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "ui",
|
||||
state: "blocked",
|
||||
code: "sdk_migration_disk_space_insufficient",
|
||||
log_file: path.join(dataDir, "logs", "sdk-migration.log"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The objective names `memhub host install <host>` (host as a positional). Both
|
||||
// that and the `--host <id>` flag form must reach the same command; naming both
|
||||
// at once is ambiguous and refused rather than guessed.
|
||||
@@ -2007,7 +2454,7 @@ describe("install gates on Runtime readiness", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("fails and rolls back when the installed Runtime never becomes ready", async () => {
|
||||
it("fails without rolling back when the installed Runtime never becomes ready", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
const streams = captureStreams();
|
||||
@@ -2036,10 +2483,10 @@ describe("install gates on Runtime readiness", () => {
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(streams.stderr()).toContain("install_runtime_not_ready");
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(
|
||||
lifecycleMocks.rollbackPreparedServiceInstall,
|
||||
).toHaveBeenCalledOnce();
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes CLI ownership after a successful install over an App-owned Runtime", async () => {
|
||||
@@ -2084,7 +2531,7 @@ describe("install gates on Runtime readiness", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("restores App ownership before relaunching the old Runtime after install failure", async () => {
|
||||
it("never hands the home back to the App Runtime after the migration ran", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
const root = await mkdtemp(path.join(tmpdir(), "memhub-cli-owner-"));
|
||||
@@ -2094,14 +2541,6 @@ describe("install gates on Runtime readiness", () => {
|
||||
appBundle: "/Applications/MemHub.app",
|
||||
appExecutable: "/Applications/MemHub.app/Contents/MacOS/MemHub",
|
||||
});
|
||||
lifecycleMocks.rollbackPreparedServiceInstall.mockImplementationOnce(
|
||||
async (_prepared: unknown, rollback: () => Promise<void>) => {
|
||||
await rollback();
|
||||
await expect(readRuntimeServiceOwner(dataDir)).resolves.toMatchObject({
|
||||
owner: "app",
|
||||
});
|
||||
},
|
||||
);
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
@@ -2124,15 +2563,114 @@ describe("install gates on Runtime readiness", () => {
|
||||
{ waitForRuntimeReady: async () => false },
|
||||
),
|
||||
).toBe(1);
|
||||
// The App Runtime may predate the SDK migration gate: CLI keeps it.
|
||||
await expect(readRuntimeServiceOwner(dataDir)).resolves.toMatchObject({
|
||||
owner: "app",
|
||||
owner: "cli",
|
||||
});
|
||||
expect(
|
||||
lifecycleMocks.rollbackPreparedServiceInstall,
|
||||
).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
streams.restore();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the install, not serving, when its migration is deferred", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
runtimeMocks.runSdkMigration.mockResolvedValueOnce({
|
||||
state: "deferred",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
});
|
||||
const waitForRuntimeReady = vi.fn(async () => true);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"install",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--bin-dir",
|
||||
"/tmp/bin",
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"--yes",
|
||||
],
|
||||
{},
|
||||
{ waitForRuntimeReady },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
const failure = JSON.parse(streams.stderr());
|
||||
expect(failure).toMatchObject({
|
||||
command: "install",
|
||||
code: "sdk_migration_memory_unreadable",
|
||||
log_file: "/tmp/data/logs/sdk-migration.log",
|
||||
issues_url: "https://github.com/Tencent-Hunyuan/Hy-Memory/issues/new",
|
||||
});
|
||||
expect(failure.message).toContain("/tmp/data/logs/sdk-migration.log");
|
||||
expect(runtimeMocks.runSdkMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ caller: "cli-install" }),
|
||||
);
|
||||
// The new (gated) service owns the data dir; the previous registration is
|
||||
// never re-bootstrapped and nothing is rolled back.
|
||||
expect(lifecycleMocks.applyPreparedServiceInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ restorePreviousOnFailure: false }),
|
||||
);
|
||||
expect(runtimeMocks.rollbackLocalInstall).not.toHaveBeenCalled();
|
||||
expect(
|
||||
lifecycleMocks.rollbackPreparedServiceInstall,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(waitForRuntimeReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rolls back an install that failed before its migration ran", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
runtimeMocks.installCliLink.mockRejectedValueOnce(
|
||||
new Error("cli_link_failed"),
|
||||
);
|
||||
const streams = captureStreams();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"install",
|
||||
"--source",
|
||||
"/tmp/dist",
|
||||
"--home",
|
||||
"/tmp/home",
|
||||
"--install-root",
|
||||
"/tmp/install",
|
||||
"--bin-dir",
|
||||
"/tmp/bin",
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"--yes",
|
||||
],
|
||||
{},
|
||||
{ waitForRuntimeReady: async () => true },
|
||||
);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(code!).toBe(1);
|
||||
expect(JSON.parse(streams.stderr())).toMatchObject({
|
||||
command: "install",
|
||||
code: "cli_link_failed",
|
||||
});
|
||||
expect(runtimeMocks.runSdkMigration).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.rollbackLocalInstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("succeeds when the installed Runtime becomes ready", async () => {
|
||||
vi.clearAllMocks();
|
||||
stubInstallPlan();
|
||||
@@ -2212,7 +2750,8 @@ describe("ui wires the Core session contract without token leakage", () => {
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(4);
|
||||
// A stopped Runtime is exit 3, like every other command.
|
||||
expect(code!).toBe(3);
|
||||
const document = JSON.parse(output.value());
|
||||
expect(document).toMatchObject({
|
||||
schema_version: 1,
|
||||
@@ -2241,6 +2780,120 @@ describe("ui wires the Core session contract without token leakage", () => {
|
||||
// refuse while the Runtime owns the data dir, stop+call Core from a
|
||||
// maintenance process, and never report success from a malformed response.
|
||||
describe("backup restore drives the Core managed-restore contract", () => {
|
||||
it("previews without --yes, exits 0 and starts nothing", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "restore", "--source", "/tmp/x", "--json"],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(maintenance).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
command: "backup restore",
|
||||
state: "preview",
|
||||
target: "/tmp/x",
|
||||
applied: false,
|
||||
next_step: "memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes data only when --confirm repeats the data directory", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "memhub-delete-home-"));
|
||||
const dataDir = path.join(home, ".one-memory");
|
||||
await mkdir(path.join(dataDir, "logs"), { recursive: true });
|
||||
const deleteData = async (...extra: string[]) => {
|
||||
const output = captureStdout();
|
||||
try {
|
||||
const code = await run(
|
||||
["delete-data", "--data-dir", dataDir, ...extra, "--json"],
|
||||
{ HOME: home },
|
||||
);
|
||||
return { code, document: JSON.parse(output.value()) };
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
};
|
||||
try {
|
||||
const refused = await deleteData("--yes");
|
||||
expect(refused.code).toBe(4);
|
||||
expect(refused.document).toMatchObject({
|
||||
state: "blocked",
|
||||
code: "data_deletion_confirmation_required",
|
||||
});
|
||||
expect(await readdir(dataDir)).toEqual(["logs"]);
|
||||
|
||||
const deleted = await deleteData("--yes", "--confirm", dataDir);
|
||||
expect(deleted.code).toBe(0);
|
||||
expect(await readdir(dataDir).catch(() => [])).toEqual([]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses --yes without repeating the backup path", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
["backup", "restore", "--source", "/tmp/x", "--yes", "--json"],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(4);
|
||||
expect(maintenance).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
state: "rejected",
|
||||
code: "backup_restore_confirmation_required",
|
||||
next_step:
|
||||
"恢复需要再写一遍备份路径:memhub backup restore --source /tmp/x --yes --confirm /tmp/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores with --yes and the repeated backup path", async () => {
|
||||
const maintenance = vi.fn(async () => ({
|
||||
state: "restored" as const,
|
||||
result: {
|
||||
state: "restored" as const,
|
||||
restoredBytes: 1,
|
||||
previousBackupPath: "/tmp/pre.sqlite3",
|
||||
sourceSchemaVersion: 34,
|
||||
},
|
||||
}));
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
try {
|
||||
code = await run(
|
||||
[
|
||||
"backup",
|
||||
"restore",
|
||||
"--source",
|
||||
"/tmp/x",
|
||||
"--yes",
|
||||
"--confirm",
|
||||
"/tmp/x",
|
||||
"--json",
|
||||
],
|
||||
{},
|
||||
{ runMaintenanceRestore: maintenance },
|
||||
);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
expect(code!).toBe(0);
|
||||
expect(maintenance).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects while the Runtime is running, with a distinct exit code", async () => {
|
||||
runtimeMocks.inspectRuntimeStatus.mockResolvedValueOnce({
|
||||
state: "running",
|
||||
@@ -2357,7 +3010,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires --confirm to repeat the exact source path", async () => {
|
||||
it("refuses a legacy --confirm that does not repeat the source path", async () => {
|
||||
const maintenance = vi.fn();
|
||||
const output = captureStdout();
|
||||
let code: number;
|
||||
@@ -2385,7 +3038,7 @@ describe("backup restore drives the Core managed-restore contract", () => {
|
||||
command: "backup",
|
||||
verb: "restore",
|
||||
state: "rejected",
|
||||
code: "backup_restore_confirmation_required",
|
||||
code: "backup_restore_confirmation_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2537,7 +3190,7 @@ describe("init onboarding", () => {
|
||||
|
||||
// Boolean flags were idempotent, but accepting them twice hid a duplicated
|
||||
// argv fragment that could also have duplicated a value-bearing flag.
|
||||
it.each(["--import", "--full-rescan", "--confirm-overrides", "--dry-run"])(
|
||||
it.each(["--import", "--confirm-overrides"])(
|
||||
"refuses a repeated init %s flag",
|
||||
async (flag) => {
|
||||
expect(await run(["init", "--source", "codex", flag, flag], {})).toBe(2);
|
||||
@@ -2610,12 +3263,25 @@ describe("init onboarding", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts --dry-run together with --import", async () => {
|
||||
// Parsing must accept the pair; it stops at the Runtime check, which is
|
||||
// not a usage failure.
|
||||
expect(
|
||||
await run(["init", "--source", "codex", "--import", "--dry-run"], {}),
|
||||
).not.toBe(2);
|
||||
// `--dry-run` and `--full-rescan` never did anything (P0-2/P0-3): they are
|
||||
// gone rather than accepted as no-ops.
|
||||
it.each(["--dry-run", "--full-rescan"])(
|
||||
"refuses the removed no-op %s flag",
|
||||
async (flag) => {
|
||||
expect(
|
||||
await run(["init", "--source", "codex", "--import", flag], {}),
|
||||
).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("still parses the old `init --source --import` spelling", async () => {
|
||||
// It stops at the Runtime check (exit 3), which is not a usage failure.
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["init", "--source", "codex", "--import"], {})).toBe(3);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2718
-2154
File diff suppressed because it is too large.
Load diff
@@ -58,10 +58,22 @@ const CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--yes",
|
||||
summary: "确认执行;不加该参数时只输出计划,不做任何改动。",
|
||||
};
|
||||
/** The one confirmation convention, for irreversible operations only. */
|
||||
const DESTRUCTIVE_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--yes",
|
||||
summary:
|
||||
"确认这次不可逆操作;不加时只说明将要做什么,不做任何改动,exit 0。(旧的 --confirm 仍作为别名接受。)",
|
||||
};
|
||||
const JSON_OPTION: CommandOption = {
|
||||
flag: "--json",
|
||||
summary: "以 JSON 输出到 stdout,供脚本与 Agent 消费。",
|
||||
};
|
||||
/** Global to the memory commands: whose memories are read or written. */
|
||||
const USER_ID_OPTION: CommandOption = {
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"读写哪个用户的记忆,默认 local(App 用的就是 local)。1–64 位字母、数字、点、下划线或连字符;其他用户的记忆来自用 sources connect hermes --user-id 接入的 Hermes profile。",
|
||||
};
|
||||
const WATCH_OPTION: CommandOption = {
|
||||
flag: "--watch",
|
||||
summary: "串行持续刷新;Ctrl-C 只停止观察,不停止 Runtime。",
|
||||
@@ -70,21 +82,6 @@ const INTERVAL_OPTION: CommandOption = {
|
||||
flag: "--interval <seconds>",
|
||||
summary: "刷新间隔,默认 5 秒,范围 1..300;必须与 --watch 一起使用。",
|
||||
};
|
||||
const PLAN_FILE_OPTION: CommandOption = {
|
||||
flag: "--plan-file <path>",
|
||||
summary: "读取先前 --json 保存的有界计划文件。",
|
||||
};
|
||||
const EXPLICIT_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--confirm",
|
||||
summary: "确认执行计划文件逐字描述的操作;缺少时绝不写入。",
|
||||
};
|
||||
// Same flag, but for commands that name their target directly instead of
|
||||
// going through a saved plan file; the plan-file wording would be wrong here.
|
||||
const DIRECT_CONFIRM_OPTION: CommandOption = {
|
||||
flag: "--confirm",
|
||||
summary: "确认执行上面预览的这次操作;缺少时绝不写入。",
|
||||
};
|
||||
|
||||
const SERVICE_OPTIONS: readonly CommandOption[] = [
|
||||
PLATFORM_OPTION,
|
||||
HOME_OPTION,
|
||||
@@ -154,55 +151,77 @@ const INSTALL_OPTIONS: readonly CommandOption[] = [
|
||||
const INSTALL_USAGE =
|
||||
"[--source <path> | --release-manifest-url <https-url>] [--channel <stable|prerelease>] [--install-root <path>] [--home <path>] [--bin-dir <path>] [--data-dir <path>] [--uid <id>] [--xdg-config-home <path>] [--no-shell-path] [--shell-profile <path>] [--preserve-data-dir] [--allow-downgrade] [--yes]";
|
||||
|
||||
const USAGE_EXITS = [EXIT_OK, EXIT_FAILED, EXIT_USAGE] as const;
|
||||
const RUNTIME_EXITS = [
|
||||
EXIT_OK,
|
||||
EXIT_FAILED,
|
||||
EXIT_USAGE,
|
||||
EXIT_RUNTIME_STOPPED,
|
||||
] as const;
|
||||
|
||||
const KIND_OPTION: CommandOption = {
|
||||
flag: "--kind <chat|experience|fact>",
|
||||
summary:
|
||||
"记忆所在的命名空间;工作/编码场景的经验和事实需要同时给 --variant。list 输出里每条都带 kind/variant。",
|
||||
};
|
||||
const VARIANT_OPTION: CommandOption = {
|
||||
flag: "--variant <working|coding>",
|
||||
summary:
|
||||
"经验或事实属于工作还是编码场景;必须和 --kind experience|fact 一起用。",
|
||||
};
|
||||
|
||||
export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "serve",
|
||||
summary: "在前台启动完整 Runtime(Core 与 Host Gateway),不注册系统服务。",
|
||||
usage: "memhub serve",
|
||||
options: [],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
summary: "启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。",
|
||||
summary:
|
||||
"启动本机 Runtime;已有通过身份校验的实例时只连接,不重复启动。受管服务会同时恢复登录时自动启动。",
|
||||
usage: "memhub start [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
summary: "停止通过身份校验的本机 Runtime;身份不明确时不发送信号。",
|
||||
summary:
|
||||
"停止通过身份校验的本机 Runtime;身份不明确时不发送信号。受管服务(launchd/systemd、App)停止后登录时也不会自动启动,直到再次运行 memhub start。",
|
||||
usage: "memhub stop [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "restart",
|
||||
summary: "先停止再启动本机 Runtime,两步串行执行。",
|
||||
usage: "memhub restart [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
summary: "只读查询 Runtime 运行状态;不启动服务、不迁移数据、不改配置。",
|
||||
usage: "memhub status [--watch] [--interval <seconds>] [--json]",
|
||||
options: [WATCH_OPTION, INTERVAL_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "doctor",
|
||||
summary: "按诊断口径做同样的只读检查;默认不修复任何东西。",
|
||||
summary:
|
||||
"按诊断口径做只读检查并给出修复建议;默认不修复任何东西。Runtime 未运行时 exit 3。",
|
||||
usage: "memhub doctor [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "logs",
|
||||
summary:
|
||||
"读取本机脱敏诊断日志;logs export 会把排查包直接存到下载目录。默认显示最近一次会话的 200 条事件。",
|
||||
"读取本机脱敏诊断日志;logs export 会把排查包(含系统崩溃报告)直接存到下载目录。默认显示最近一次会话的 200 条事件。",
|
||||
usage:
|
||||
"memhub logs export [--json]\nmemhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
|
||||
"memhub logs export [--json]\n memhub logs [--since <15m|2h|7d|RFC3339>] [--session <id>] [--trace <id>] [--incident <id>] [--operation <id>] [--request <id>] [--job <id>] [--task <id>] [--component <name>] [--event <name>] [--errors-only] [--tail <1..5000>] [--follow] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--since <time>",
|
||||
@@ -226,64 +245,56 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"以 NDJSON 输出到 stdout(每行一个脱敏事件;非单 JSON 文档),供脚本与 Agent 消费。",
|
||||
},
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE],
|
||||
exit_codes: USAGE_EXITS,
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
summary:
|
||||
"首次接入:选择来源 → 选择是否导入历史。接入即开启该 Harness 的处理;后续可用 sync pause/resume 冻结或恢复。在终端且不带 --source 时进入交互引导(默认全选);非终端或加 --json 时不读 stdin、只列出可用来源和下一步。--all-detected 一次接入所有已检测来源。",
|
||||
"首次接入引导。在终端里(未加 --json)进入交互向导:选择来源 → 是否开启处理 → 配置模型。非终端或加 --json 时不读 stdin,只列出可接入的来源和下一步命令。脚本接入请用 sources connect。",
|
||||
usage: "memhub init [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
summary:
|
||||
"列出来源,直接接入来源,或按 request id 查接入回执。connect 不加 --import 只授权(处理保持冻结:不导入历史、不扫描新会话);加 --import 同时导入历史并开启持续同步,返回 request id。目录来自环境变量覆盖的来源必须加 --confirm-overrides。Hermes 的每个 profile 是一个独立用户:用 --user-id 和 --root 接入,授权并导入历史(任何层级的 profiles/ 目录都会被排除,各用户互不读取)。--root 不能是 profiles 目录本身,也不能与另一个用户的 Hermes 目录相同或互相包含(~/.hermes 与 ~/.hermes/profiles/<name> 除外)。",
|
||||
usage:
|
||||
"memhub init [--json]\n memhub init --source <id> [--source <id> ...] [--import] [--dry-run] [--full-rescan] [--confirm-overrides] [--json]\n memhub init --all-detected [--import] [--dry-run] [--json]",
|
||||
"memhub sources list [--json]\n memhub sources connect <id> [<id> ...] [--import] [--confirm-overrides] [--request-id <uuid>] [--json]\n memhub sources connect hermes --user-id <id> --root <path> [--import] [--request-id <uuid>] [--json]\n memhub sources connect --all-detected [--import] [--confirm-overrides] [--json]\n memhub sources receipt <request-id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "要接入的来源,可重复;终端下不给则进入交互引导。",
|
||||
flag: "--import",
|
||||
summary:
|
||||
"接入后立即导入历史会话并开启持续同步;不加时只授权,处理保持冻结。",
|
||||
},
|
||||
{
|
||||
flag: "--user-id <id>",
|
||||
summary:
|
||||
"只用于 hermes:这条接入属于哪个用户(默认 local)。和 --root 一起接入一个 Hermes profile,例如 --user-id wife --root ~/.hermes/profiles/wife;用户 ID 不必等于 profile 名,例如 --user-id wife-work --root ~/.hermes/profiles/wife:work。其他来源只能是 local。",
|
||||
},
|
||||
{
|
||||
flag: "--root <path>",
|
||||
summary:
|
||||
"只用于 hermes:读取的根目录(~ 会展开为主目录)。默认是 Hermes 自己的目录 ~/.hermes。",
|
||||
},
|
||||
{
|
||||
flag: "--all-detected",
|
||||
summary:
|
||||
"接入并(加 --import 时)导入全部 location_state=found 的来源。",
|
||||
},
|
||||
{
|
||||
flag: "--import",
|
||||
summary: "接入后立即导入该来源的历史会话(默认只接入不导入)。",
|
||||
},
|
||||
{
|
||||
flag: "--dry-run",
|
||||
summary: "只出计划,不接入也不导入;用于先看会做什么。",
|
||||
},
|
||||
{
|
||||
flag: "--full-rescan",
|
||||
summary: "导入时忽略既有游标做全量重扫。",
|
||||
"接入全部已检测到(location_state=found)的来源;与具体来源互斥。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm-overrides",
|
||||
summary: "确认接受环境覆盖类来源(如 DSH_HOME 指定的目录)。",
|
||||
summary:
|
||||
"确认接受目录来自环境变量覆盖的来源(如 DSH_HOME 指定的目录);不加时这些来源被拒绝(指定来源时)或跳过并列出(--all-detected 时)。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
summary: "生成无头首次导入计划;只读取已有来源,不探测目录、不写授权。",
|
||||
usage: "memhub setup plan --source <id> [--source <id> ...] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "计划导入的来源,可重复 1..32 次。",
|
||||
flag: "--request-id <uuid>",
|
||||
summary:
|
||||
"--import 的幂等键;用同一个 id 重试只会读回第一次的回执,不会重复导入。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "sources",
|
||||
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],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
@@ -297,7 +308,7 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "progress",
|
||||
@@ -318,17 +329,17 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
INTERVAL_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "jobs",
|
||||
summary:
|
||||
"启动或控制单个导入任务:start 创建 history/sync 作业;pause/resume/cancel 控制已有作业。所有写操作先出计划,必须显式 --confirm;list/show 为只读。",
|
||||
"查看或控制导入任务:start 创建 history/sync 任务;pause/resume/cancel 控制已有任务。都直接执行。",
|
||||
usage:
|
||||
"memhub jobs list [--json]\n" +
|
||||
" memhub jobs show <job-id> [--json]\n" +
|
||||
" memhub jobs start --source <id> [--operation <history|sync>] [--confirm] [--json]\n" +
|
||||
" memhub jobs <pause|resume|cancel> --job <id> [--confirm] [--json]",
|
||||
" memhub jobs start --source <id> [--operation <history|sync>] [--json]\n" +
|
||||
" memhub jobs <pause|resume|cancel> --job <id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
@@ -336,192 +347,193 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
{
|
||||
flag: "--operation <history|sync>",
|
||||
summary: "start 的导入类型,默认 history。",
|
||||
summary:
|
||||
"start 的导入类型,默认 history;sync 与 scan --source 相同,立即增量同步一次。",
|
||||
},
|
||||
{
|
||||
flag: "--job <id>",
|
||||
summary: "pause/resume/cancel 的目标导入任务。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "processing",
|
||||
summary: "手动推进待处理证据;默认只预览,确认后执行。",
|
||||
usage:
|
||||
"memhub processing extract [--evidence <id> ...] [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--evidence <id>",
|
||||
summary:
|
||||
"processing extract 要提前处理的证据 ID,可重复;不给则推进下一批。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
summary:
|
||||
"交互配置模型,或生成配置/探测计划、执行已确认操作与查询回执;脚本模式的凭据只从受控文件读取。",
|
||||
"管理生成模型配置。add/test 直接提交并返回操作 ID(异步,用 receipt 查结果);edit 直接执行;activate 换了向量模型(模型或维度)时先显示切换计划,需 --yes 确认后重新生成全部向量,switch 查看或取消、spaces 查看或清理旧向量数据;remove 不可逆,需 --yes。<profile-id>/<operation-id> 都是 UUID;脚本模式的凭据只从受控文件读取。",
|
||||
usage:
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models plan --config-file <path> [--json]\n memhub models configure --plan-file <path> --confirm [--api-key-file <path>] [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--confirm] [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--confirm] [--json]\n memhub models probe-plan <profile-id> [--json]\n memhub models probe --plan-file <path> --confirm [--json]\n memhub models probe-receipt <operation-id> [--json]\n memhub models remove <profile-id> [--confirm] [--json]",
|
||||
"memhub models list [--json]\n memhub models show <profile-id> [--json]\n memhub models add --config-file <path> [--api-key-file <path>] [--activate [--yes] [--wait|--no-wait]] [--json]\n memhub models test <profile-id> [--json]\n memhub models receipt <operation-id> [--json]\n memhub models edit <profile-id> --config-file <path> [--api-key-file <path>] [--json]\n memhub models activate <profile-id> [--yes] [--wait|--no-wait] [--json]\n memhub models switch <status|cancel> [--json]\n memhub models spaces [--cleanup <space-id> --yes] [--json]\n memhub models remove <profile-id> [--yes] [--json]",
|
||||
alternative_usage: "memhub models setup",
|
||||
options: [
|
||||
{
|
||||
flag: "--config-file <path>",
|
||||
summary: "不含 api_key 的有界 JSON 模型配置。",
|
||||
summary:
|
||||
"有界 JSON 模型配置,不能含 api_key;edit 的补丁必须带 expected_revision。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
EXPLICIT_CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--api-key-file <path>",
|
||||
summary: "从 regular file 读取凭据;内容不进入参数、输出或错误。",
|
||||
summary: "从属主专用的普通文件读取凭据;内容不进入参数、输出或错误。",
|
||||
},
|
||||
{
|
||||
flag: "--activate",
|
||||
summary:
|
||||
"add 成功后先探测,再按 activate 的规则设为当前使用(向量模型变化时同样要确认切换)。",
|
||||
},
|
||||
{
|
||||
flag: "--wait | --no-wait",
|
||||
summary:
|
||||
"activate 开始向量模型切换后是否跟随进度;终端里默认跟随,Ctrl-C 只停止显示。",
|
||||
},
|
||||
{
|
||||
flag: "--cleanup <space-id>",
|
||||
summary: "spaces:立即删除一个已停用的向量空间(不可恢复,需 --yes)。",
|
||||
},
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "memories",
|
||||
summary:
|
||||
"列出/查看记忆,或生成并显式确认带版本与 request identity 的治理操作计划。",
|
||||
"列出、搜索、查看、添加、修改或物理删除记忆(对话、工作、编码三个场景)。add 和 App 的「添加记忆」是同一个入口;delete 不可逆,需 --yes;要删的记忆已不存在(或不属于该用户)时提示未找到,按已删除处理并退出 0。",
|
||||
usage:
|
||||
"memhub memories list [--query <text>] [--query-file <path>] [--status <state>] [--source <id>] [--offset <n>] [--limit <n>] [--json]\n memhub memories show <memory-id> [--json]\n memhub memories plan add --content-file <path> --scope <scope> --evidence <id> [--kind <kind>] [--json]\n memhub memories plan <correct|delete|scope> <memory-id> [--content-file <path>] [--scope <scope>] [--json]\n memhub memories apply --plan-file <path> --confirm [--content-file <path>] [--json]",
|
||||
"memhub memories list [--scene <all|chat|working|coding>] [--source <id>] [--query <text>] [--status <state>] [--offset <n>] [--limit <n>] [--user-id <id>] [--json]\n memhub memories show <memory-id> [--kind <kind>] [--variant <variant>] [--user-id <id>] [--json]\n memhub memories add <text> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add --content-file <path> [--request-id <uuid>] [--user-id <id>] [--json]\n memhub memories add-status <request-id> [--user-id <id>] [--json]\n memhub memories update <memory-id> --content-file <path> [--kind <kind>] [--variant <variant>] [--task <text>] [--user-id <id>] [--json]\n memhub memories delete <memory-id> [--kind <kind>] [--variant <variant>] [--yes] [--user-id <id>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--scene <all|chat|working|coding>",
|
||||
summary: "只列一个场景,默认 all。working/coding 列的是该场景的经验。",
|
||||
},
|
||||
{ flag: "--source <id>", summary: "只列某个来源写入的记忆。" },
|
||||
{
|
||||
flag: "--query <text>",
|
||||
summary:
|
||||
"按内容过滤;会出现在 shell 历史里,敏感词请改用 --query-file。",
|
||||
},
|
||||
{
|
||||
flag: "--query-file <path>",
|
||||
summary: "从文件读取过滤词,避免查询内容进入命令行历史。",
|
||||
"关键词搜索(只搜 active 记忆,在最相关的一批里匹配所有关键词,输出 search_window 时表示可能少计)。",
|
||||
},
|
||||
{
|
||||
flag: "--status <state>",
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")}。`,
|
||||
summary: `记忆状态,取值 ${MEMORY_STATUSES.join("、")},默认 active。`,
|
||||
},
|
||||
{ flag: "--source <id>", summary: "只看某个来源产生的记忆。" },
|
||||
{ flag: "--offset <n>", summary: "跳过前 n 条,默认 0。" },
|
||||
{
|
||||
flag: "--limit <n>",
|
||||
summary: "本页最多返回多少条,默认 100,上限 500。",
|
||||
},
|
||||
KIND_OPTION,
|
||||
VARIANT_OPTION,
|
||||
{
|
||||
flag: "--content-file <path>",
|
||||
summary:
|
||||
"从有界普通文件读取新增/纠正正文;计划只保存摘要,执行时再次校验。",
|
||||
"从有界普通文件读取正文:add 的新记忆(≤12000 字),或 update 的新内容(经验为 solution)。",
|
||||
},
|
||||
{
|
||||
flag: "--kind <chat|working|coding|schema|intention>",
|
||||
summary: "新增记忆的类型,默认 chat。",
|
||||
flag: "--task <text>",
|
||||
summary: "update 经验(--kind experience)时同时改写 task。",
|
||||
},
|
||||
{
|
||||
flag: "--scope <scope>",
|
||||
summary: "记忆使用范围,可重复;private 必须独占。",
|
||||
},
|
||||
{
|
||||
flag: "--evidence <id>",
|
||||
summary: "新增记忆引用的 Evidence ID,可重复。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
EXPLICIT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "calls",
|
||||
summary:
|
||||
"清除本机调用记录;不可逆删除,默认只出计划,必须显式 --confirm 才执行。",
|
||||
usage:
|
||||
"memhub calls clear --event <id> [--event <id> ...] [--confirm] [--json]\n memhub calls clear --all [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--event <id>",
|
||||
summary: "要清除的调用记录事件 ID,可重复,最多 100 条。",
|
||||
},
|
||||
{
|
||||
flag: "--all",
|
||||
summary: "清除全部调用记录;与 --event 互斥。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
},
|
||||
{
|
||||
name: "import-file",
|
||||
summary:
|
||||
"上传、提交或丢弃一份历史导入文件;提交与丢弃默认只出计划,需 --confirm。",
|
||||
usage:
|
||||
"memhub import-file upload --file <path> [--kind raw_conversation|memory_content] [--confirm] [--json]\n memhub import-file commit <upload-id> [--confirm] [--json]\n memhub import-file discard <upload-id> [--confirm] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--file <path>",
|
||||
flag: "--request-id <uuid>",
|
||||
summary:
|
||||
"要上传的普通文件(非符号链接,<=128 MiB,.zip/.json/.md/.txt)。",
|
||||
"add 的幂等键;用同一个 id 重试只会读回第一次的结果,不会重复添加。不给时自动生成并输出。",
|
||||
},
|
||||
{
|
||||
flag: "--kind <kind>",
|
||||
summary:
|
||||
"输入类型,默认 raw_conversation;memory_content 用于记忆正文。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
DESTRUCTIVE_CONFIRM_OPTION,
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "notes",
|
||||
name: "profile",
|
||||
summary:
|
||||
"端内新增记忆(memory-notes):从普通文件新增,或显式确认后处理已有笔记。",
|
||||
usage:
|
||||
"memhub notes add --content-file <path> [--json]\n memhub notes apply --plan-file <path> --content-file <path> --confirm [--json]\n memhub notes process <note-id> [--confirm] [--json]",
|
||||
"读取 SDK 组装后的用户长期画像(L0 + 已启用的长期画像补充);--versions 列出 L0 画像的版本链(最新在前,* 为当前版本)。",
|
||||
usage: "memhub profile [--versions] [--user-id <id>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--content-file <path>",
|
||||
summary: "从有界普通文件读取新增正文;计划只保存摘要,执行时重新校验。",
|
||||
flag: "--versions",
|
||||
summary:
|
||||
"列出 L0 基本信息的版本链(get_user_basic_info),用于找出覆盖了正确值的错误版本。",
|
||||
},
|
||||
PLAN_FILE_OPTION,
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "extraction",
|
||||
summary: "设置提取窗口;默认只出计划,--confirm 后才写入。",
|
||||
usage: "memhub extraction set --window <3m|6m|1y|all> [--confirm] [--json]",
|
||||
name: "settings",
|
||||
summary:
|
||||
"查看或切换记忆模式(lite / pro / ultra)。lite 不调用模型,只在原始对话里检索;pro 由模型提取记忆(System 1);ultra 在 pro 之上每晚自动进化(System 2,使用知识图谱)。任何模式都保留全部原始对话。切换不迁移数据:已处理的不会重做,排队中的和新的按新模式处理;需 --yes,不加时只显示计划。环境变量 ONE_MEMORY_GRAPH_STORE 会强制 ultra。settings features 查看或开关 SDK 的可选检索与提取功能(全局设置,默认全部保持 SDK 默认值):graph_rerank 图谱重排(仅 ultra)、same_series_timeline 同系列时间线(需 graph_rerank)、episode_recall 片段召回(pro / ultra,需先开 session_episodes 写入片段)、session_episodes 片段写入、episode_summary 片段摘要(每个片段一次模型调用)、intention 意图提取(默认开)、intention_dedup 意图判重(默认开;关闭则检索与写入都不判重、不删除意图)、few_shot 提取示例,以及数值 intention_dedup_threshold 意图判重阈值(默认 0.95,可设 0.8–1.0);set 需 --yes,不加时只显示计划;reset 恢复默认值。settings language 查看或设置界面语言(App 切换语言时会自动设置):简体中文(zh-Hans)和 system 不指定 SDK 语言,SDK 照旧自动识别中文或英文;其余语言(en、zh-Hant、ja、es、de、pt、fr、ko、ru、id)会让提取等 SDK 提示使用该语言,立即生效。",
|
||||
usage:
|
||||
"memhub settings mode [lite|pro|ultra] [--yes] [--json]\n" +
|
||||
" memhub settings features [--json]\n" +
|
||||
" memhub settings features set <name>=<true|false> [...] [--yes] [--json]\n" +
|
||||
" memhub settings features set intention_dedup_threshold=<0.8-1.0> [--yes] [--json]\n" +
|
||||
" memhub settings features reset [<name> ...] [--json]\n" +
|
||||
" memhub settings language [<code>|system] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--window <preset>",
|
||||
summary: "提取窗口预设:3m、6m、1y 或 all。",
|
||||
flag: "mode [lite|pro|ultra]",
|
||||
summary: "不带模式只显示当前模式;带模式时先显示切换计划。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
{
|
||||
flag: "features set <name>=<true|false>",
|
||||
summary:
|
||||
"开关一个或多个 SDK 功能;功能在当前模式下不生效时会注明原因(例如 graph_rerank 需要 ultra)。intention_dedup_threshold 取 0.8–1.0 的数值。",
|
||||
},
|
||||
{
|
||||
flag: "features reset [<name> ...]",
|
||||
summary: "把指定功能(不写则全部)恢复为 SDK 默认值。",
|
||||
},
|
||||
{
|
||||
flag: "language [<code>|system]",
|
||||
summary:
|
||||
"不带参数显示界面语言和 SDK 提示语言;带语言代码(en、zh-Hans、zh-Hant、ja、es、de、pt、fr、ko、ru、id)或 system 时设置。",
|
||||
},
|
||||
CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "graph",
|
||||
summary: "图谱宽度不匹配时的用户决定;默认只出计划,--confirm 后才执行。",
|
||||
name: "evolve",
|
||||
summary:
|
||||
"立即为一个用户运行记忆进化(System 2,仅 ultra 模式)。在后台运行并返回运行 ID;--wait 等待结果。同一用户同时只运行一次;每晚的自动进化由 Runtime 按设置安排。",
|
||||
usage:
|
||||
"memhub graph width --decision <keep_existing|rebuild_with_new|export_first> [--confirm] [--json]",
|
||||
"memhub evolve [--user-id <id>] [--agent-id <id>] [--max-batches <n>] [--wait] [--json]",
|
||||
options: [
|
||||
USER_ID_OPTION,
|
||||
{
|
||||
flag: "--agent-id <id>",
|
||||
summary:
|
||||
"进化哪个 agent 的记忆,默认 all-apps(MemHub 写入记忆用的 agent)。",
|
||||
},
|
||||
{
|
||||
flag: "--max-batches <n>",
|
||||
summary: "每轮最多处理的批次数(1–1000),默认由 SDK 决定。",
|
||||
},
|
||||
{ flag: "--wait", summary: "等待这次进化结束再返回。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "graph-width",
|
||||
summary:
|
||||
"嵌入维度变化、图谱宽度不匹配时的处置决定。keep_existing 与 export_first 直接执行;rebuild_with_new 会归档旧图谱并重建,需 --yes。",
|
||||
usage:
|
||||
"memhub graph-width --decision <keep_existing|export_first|rebuild_with_new> [--yes] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--decision <decision>",
|
||||
summary:
|
||||
"keep_existing(保持)、export_first(先导出)、rebuild_with_new(导出后重建)。",
|
||||
"keep_existing(保持现有图谱)、export_first(先导出)、rebuild_with_new(导出后归档旧图谱并按新维度重建)。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary: "rebuild_with_new 必须加;其它决定不需要。",
|
||||
},
|
||||
DIRECT_CONFIRM_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
@@ -549,14 +561,14 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
summary:
|
||||
"从当前授权的原文与治理记忆统一检索上下文;本地只读,不调用模型、不触发补索引。",
|
||||
"从原始对话与已沉淀记忆统一检索上下文;本地只读,不调用模型、不触发补索引。文本输出标明每条是「原始对话摘录」还是「记忆」(JSON 里是 kind: raw|memory)。",
|
||||
usage:
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
"memhub recall <query> [--kind <raw|memory>] [--source <id>] [--limit <n>] [--character-budget <n>] [--timeout-ms <n>] [--user-id <id>] [--json]\n memhub recall --query-file <path> [...]\n memhub recall --stdin [...]",
|
||||
options: [
|
||||
{
|
||||
flag: "--query-file <path>",
|
||||
@@ -583,23 +595,24 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
flag: "--timeout-ms <n>",
|
||||
summary: "Core 查询时限 50..30000 毫秒,默认 2000。",
|
||||
},
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_RUNTIME_STOPPED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "service-plan",
|
||||
summary: "生成用户级服务计划;只输出计划,不写文件也不执行命令。",
|
||||
usage: `memhub service-plan ${SERVICE_USAGE}`,
|
||||
options: SERVICE_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "service-install",
|
||||
summary: "预览用户级服务安装;只有 --yes 才写入服务文件并注册。",
|
||||
usage: `memhub service-install ${SERVICE_USAGE} [--yes]`,
|
||||
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "service-uninstall",
|
||||
@@ -607,15 +620,15 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
"预览用户级服务卸载;只有 --yes 才注销并删除逐字匹配的受管服务文件,不删数据。",
|
||||
usage: `memhub service-uninstall ${SERVICE_USAGE} [--yes]`,
|
||||
options: [...SERVICE_OPTIONS, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "install",
|
||||
summary:
|
||||
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行并在失败时回滚。",
|
||||
"预览发行目录安装、memhub/memhub-cli/one-memory 短链接、PATH 条目与用户服务计划;只有 --yes 才执行。记忆数据迁移开始前失败会回滚;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址。",
|
||||
usage: `memhub install ${INSTALL_USAGE}`,
|
||||
options: INSTALL_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "uninstall",
|
||||
@@ -628,134 +641,186 @@ export const COMMANDS: readonly CommandDescriptor[] = [
|
||||
{
|
||||
name: "upgrade",
|
||||
summary:
|
||||
"预览升级;只有 --yes 才切换受管版本并重启服务,失败时恢复旧版本,不删数据。",
|
||||
"预览升级;只有 --yes 才切换受管版本并重启服务,不删数据。记忆数据迁移开始前失败会恢复旧版本;迁移开始后失败保留新版本、拒绝启动,并输出迁移日志与反馈地址(不会回滚到旧版本)。",
|
||||
usage: `memhub upgrade ${INSTALL_USAGE}`,
|
||||
options: INSTALL_OPTIONS,
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "delete-data",
|
||||
summary:
|
||||
"独立、二次确认地删除 One Memory Home(数据)。这是唯一会删除数据的命令;uninstall 不会。默认只出预览;需 Runtime 已停止,并显式 --confirm <path> 复述该目录。",
|
||||
"删除 MemHub 数据目录(记忆、来源、配置),不可逆。这是唯一会删除数据的命令;uninstall 不会。需要 Runtime 已停止;不加参数时只预览要删除的目录;删除需要 --yes 并用 --confirm 再写一遍同一个目录。",
|
||||
usage:
|
||||
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>] [--json]",
|
||||
options: [
|
||||
DATA_DIR_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--data-dir <path>",
|
||||
summary: "要删除的数据目录绝对路径;默认当前使用的数据目录。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary:
|
||||
"删除时必须加,且要同时给出 --confirm <path>;不加时只预览,exit 0。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm <path>",
|
||||
summary:
|
||||
"第二次确认:逐字复述预览里的数据目录;缺少它时即使 --yes 也不删除。",
|
||||
"删除时必须加:再写一遍要删除的数据目录,与预览中的目录完全一致才执行。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
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: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
summary: "显示总帮助或单个命令的用法。",
|
||||
usage: "memhub help [command] [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
summary: "显示 CLI 版本。",
|
||||
usage: "memhub version [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE],
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
name: "scan-roots",
|
||||
summary:
|
||||
"查看或登记被扫描的目录(scan roots)。list 只读;add/remove 预览后加 --yes 才提交;均已接入 Runtime 的 scan-roots 端点。",
|
||||
"查看或登记被扫描的目录(scan roots)。add/remove 直接执行。(旧名 path 仍可用。)",
|
||||
usage:
|
||||
"memhub path list [--json]\n" +
|
||||
" memhub path add --root <path> [--source <id>] [--no-recursive] [--yes]\n" +
|
||||
" memhub path remove --root <path> [--yes]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION, CONFIRM_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
"memhub scan-roots list [--json]\n" +
|
||||
" memhub scan-roots add --root <path> [--source <id>] [--no-recursive] [--json]\n" +
|
||||
" memhub scan-roots remove --root <path> [--json]",
|
||||
options: [
|
||||
{ flag: "--root <path>", summary: "要登记或移除的目录。" },
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "把目录绑定到某个来源;不给时由 Runtime 自动识别。",
|
||||
},
|
||||
{ flag: "--no-recursive", summary: "只扫描该目录本身,不进入子目录。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "adapter",
|
||||
summary:
|
||||
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读,已接入 Runtime 的 adapters/探测端点。",
|
||||
"查看 Harness Adapter 的就绪状态与最近一次探测结果(list),或现场探测某个来源(probe --source <id>);两者均为只读。",
|
||||
usage:
|
||||
"memhub adapter list [--json]\n" +
|
||||
" memhub adapter probe --source <id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
options: [
|
||||
{ flag: "--source <id>", summary: "probe 的目标来源。" },
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "scan",
|
||||
summary:
|
||||
"立即扫描;默认只输出计划,加 --yes 才真正执行。指定 --source 扫描单个来源,--all 一次扫描/导入全部已检测来源(Runtime 按来源寻址,不接受裸路径)。",
|
||||
usage:
|
||||
"memhub scan [--source <id>] [--full] [--yes] [--json]\n memhub scan --all [--yes] [--json]",
|
||||
"立即增量扫描一个已连接并开启同步的来源,返回导入任务(与 jobs start --operation sync 相同)。接入全部来源请用 sources connect --all-detected --import。",
|
||||
usage: "memhub scan --source <id> [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--source <id>",
|
||||
summary: "按单个来源扫描;与 --all 互斥。",
|
||||
summary: "要扫描的来源。",
|
||||
},
|
||||
{
|
||||
flag: "--all",
|
||||
summary:
|
||||
"处理全部 location_state=found 的来源,按来源逐条回执;部分成功返回非 0。",
|
||||
},
|
||||
{
|
||||
flag: "--full",
|
||||
summary: "忽略既有游标做全量重扫;默认只读取增量。",
|
||||
},
|
||||
DATA_DIR_OPTION,
|
||||
JSON_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
summary:
|
||||
"提交一次记忆写入,返回可查询的 request id;写入异步完成,不可重复提交。",
|
||||
"提交一段 OpenAI 格式的对话(messages)到写入队列,异步加工成记忆;或按 request id 查询状态。request id 是幂等键:不给时自动生成并输出,重试请带上同一个。新增一条结论式记忆请用 memories add。",
|
||||
usage:
|
||||
"memhub write --session <id> --from <round> --to <round> --evidence <id> [--evidence <id>...] [--live] [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
summary: "查询写入任务状态;unknown 表示结果待定,不可据此重发。",
|
||||
usage: "memhub task --id <request-id> [--json]",
|
||||
options: [DATA_DIR_OPTION, JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED],
|
||||
"memhub write submit --messages-file <path> --session <id> [--source <id>] [--request-id <id>] [--memory-at <RFC3339>] [--live] [--user-id <id>] [--json]\n memhub write status <request-id> [--user-id <id>] [--json]",
|
||||
options: [
|
||||
{
|
||||
flag: "--messages-file <path>",
|
||||
summary:
|
||||
"OpenAI messages 数组(或 {messages:[…]}),1..200 条,role 为 user/assistant/tool,至少一条 user。",
|
||||
},
|
||||
{ flag: "--session <id>", summary: "会话 ID。" },
|
||||
{ flag: "--source <id>", summary: "来源应用,默认 memhub-cli。" },
|
||||
{
|
||||
flag: "--request-id <id>",
|
||||
summary:
|
||||
"幂等键:同 id 同内容不会重复入队,同 id 不同内容被拒绝(409)。",
|
||||
},
|
||||
{ flag: "--memory-at <RFC3339>", summary: "对话发生时间,必须带时区。" },
|
||||
{ flag: "--live", summary: "优先处理。" },
|
||||
USER_ID_OPTION,
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: RUNTIME_EXITS,
|
||||
},
|
||||
{
|
||||
name: "backup",
|
||||
summary:
|
||||
"备份与恢复 MemHub 数据(两阶段)。create 调用 Runtime 的备份端点;restore 会覆盖现有数据,要求 Runtime 已停止且 `--confirm` 逐字重复 `--source`,随后由非 Runtime 维护态 Core 完成受管校验、保护性备份和原子替换。",
|
||||
"备份与恢复主库(来源、写入队列、配置)。注意:不包含记忆本身——SDK 记忆库 hy-memory/ 不在备份里,restore 也不会替换它。create 的目标要放在 <数据目录>/backups/ 下才能被 restore 使用;restore 覆盖现有主库,需 Runtime 已停止,并加 --yes 与 --confirm <同一个备份路径>。",
|
||||
usage:
|
||||
"memhub backup create --target <path> [--yes] [--json]\n" +
|
||||
" memhub backup restore --source <path> --yes --confirm <path> [--json]",
|
||||
"memhub backup create --target <path> [--json]\n" +
|
||||
" memhub backup restore --source <path> [--yes --confirm <path>] [--json]",
|
||||
options: [
|
||||
DATA_DIR_OPTION,
|
||||
JSON_OPTION,
|
||||
CONFIRM_OPTION,
|
||||
{
|
||||
flag: "--target <path>",
|
||||
summary: "create 的备份文件路径,必须在数据目录内。",
|
||||
},
|
||||
{
|
||||
flag: "--source <path>",
|
||||
summary:
|
||||
"restore 的备份文件,必须是 <数据目录>/backups/ 下的受管备份。",
|
||||
},
|
||||
{
|
||||
...DESTRUCTIVE_CONFIRM_OPTION,
|
||||
summary:
|
||||
"restore 必须加,且要同时给出 --confirm <path>;create 不需要。",
|
||||
},
|
||||
{
|
||||
flag: "--confirm <path>",
|
||||
summary: "restore 要求逐字重复 --source 路径;不一致时不做任何改动。",
|
||||
summary:
|
||||
"restore 必须加:再写一遍 --source 的备份路径,完全一致才执行。",
|
||||
},
|
||||
JSON_OPTION,
|
||||
],
|
||||
exit_codes: [EXIT_OK, EXIT_USAGE, EXIT_FAILED, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
{
|
||||
name: "ui",
|
||||
summary:
|
||||
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
|
||||
"用系统默认浏览器打开 Runtime 托管的 Web Console:先向 Core 换取一次性 loopback 登录码,再交给系统浏览器由 Core 置 HttpOnly/SameSite=Strict session;CLI 不向浏览器暴露裸 Core token。Runtime 未运行时 exit 3;Core 未提供该合同时如实返回 ui_session_contract_unavailable(exit 4)。",
|
||||
usage: "memhub ui [--json]",
|
||||
options: [JSON_OPTION],
|
||||
exit_codes: [EXIT_OK, EXIT_FAILED, EXIT_USAGE, EXIT_PLAN_BLOCKED],
|
||||
exit_codes: [...RUNTIME_EXITS, EXIT_PLAN_BLOCKED],
|
||||
},
|
||||
];
|
||||
|
||||
/** Old names that still run but are not listed: `path` → `scan-roots`,
|
||||
* `task` → `write status`, `graph width` → `graph-width`. */
|
||||
export const HIDDEN_COMMAND_ALIASES: Readonly<Record<string, string>> = {
|
||||
path: "scan-roots",
|
||||
task: "write",
|
||||
graph: "graph-width",
|
||||
};
|
||||
|
||||
export function findCommand(name: string): CommandDescriptor | undefined {
|
||||
return COMMANDS.find((command) => command.name === name);
|
||||
}
|
||||
|
||||
/** A listed command, or the command a hidden alias now runs as. */
|
||||
export function findCommandOrAlias(
|
||||
name: string,
|
||||
): CommandDescriptor | undefined {
|
||||
return findCommand(HIDDEN_COMMAND_ALIASES[name] ?? name);
|
||||
}
|
||||
@@ -69,20 +69,16 @@ const COMMANDS = [
|
||||
"doctor",
|
||||
"logs",
|
||||
"init",
|
||||
"setup",
|
||||
"sources",
|
||||
"sync",
|
||||
"progress",
|
||||
"jobs",
|
||||
"processing",
|
||||
"models",
|
||||
"memories",
|
||||
// C6 App BLOCK (25f4ecf): UI write operations that had no CLI equivalent.
|
||||
"calls",
|
||||
"import-file",
|
||||
"notes",
|
||||
"extraction",
|
||||
"graph",
|
||||
"profile",
|
||||
"settings",
|
||||
"evolve",
|
||||
"graph-width",
|
||||
"host",
|
||||
"recall",
|
||||
"service-plan",
|
||||
@@ -92,21 +88,23 @@ const COMMANDS = [
|
||||
"uninstall",
|
||||
"upgrade",
|
||||
"delete-data",
|
||||
"onboarding",
|
||||
"help",
|
||||
"version",
|
||||
// A6 product commands. Appended, not inserted: the catalog is a stable
|
||||
// contract, so existing names must not shift position.
|
||||
"path",
|
||||
"scan-roots",
|
||||
"adapter",
|
||||
"scan",
|
||||
"write",
|
||||
"task",
|
||||
"backup",
|
||||
// `ui` is declared but blocked on a Core session contract; it is listed so
|
||||
// `help` documents it and the blocked shape is machine-readable.
|
||||
"ui",
|
||||
] as const;
|
||||
|
||||
/** Old names that still run and still answer `--help`, but are not listed. */
|
||||
const HIDDEN_ALIASES = [
|
||||
["path", "scan-roots"],
|
||||
["task", "write"],
|
||||
] as const;
|
||||
|
||||
describe("CLI-22 shared output contract", () => {
|
||||
describe("help", () => {
|
||||
it.each([["help"], ["--help"], ["-h"]])(
|
||||
@@ -147,6 +145,56 @@ describe("CLI-22 shared output contract", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(HIDDEN_ALIASES)(
|
||||
"documents the hidden alias %s as %s and keeps it out of the listing",
|
||||
async (alias, target) => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", alias], {})).toBe(0);
|
||||
expect(await run([alias, "--help"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(`${PRODUCT} ${target}`);
|
||||
const listing = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
listing.restore();
|
||||
}
|
||||
const names = JSON.parse(listing.stdout()).commands.map(
|
||||
(entry: { name: string }) => entry.name,
|
||||
);
|
||||
expect(names).not.toContain(alias);
|
||||
},
|
||||
);
|
||||
|
||||
it("lists no removed command", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "--json"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const catalog = streams.stdout();
|
||||
for (const removed of [
|
||||
'"name":"setup"',
|
||||
'"name":"notes"',
|
||||
'"name":"calls"',
|
||||
'"name":"graph"',
|
||||
"--plan-file",
|
||||
"probe-plan",
|
||||
"probe-receipt",
|
||||
"models configure",
|
||||
"sources import",
|
||||
"--full-rescan",
|
||||
"--dry-run",
|
||||
"--full]",
|
||||
]) {
|
||||
expect(catalog).not.toContain(removed);
|
||||
}
|
||||
});
|
||||
|
||||
it("never executes the documented command", async () => {
|
||||
for (const command of COMMANDS) {
|
||||
for (const argv of [
|
||||
@@ -202,27 +250,63 @@ describe("CLI-22 shared output contract", () => {
|
||||
expect(payload.commands).toBeUndefined();
|
||||
});
|
||||
|
||||
it("documents the destructive restore confirmation explicitly", async () => {
|
||||
it("documents the one --yes convention for destructive commands", async () => {
|
||||
const general = captureStreams();
|
||||
try {
|
||||
expect(await run(["help"], {})).toBe(0);
|
||||
} finally {
|
||||
general.restore();
|
||||
}
|
||||
expect(general.stdout()).toContain("确认约定");
|
||||
for (const [command, usage] of [
|
||||
[
|
||||
"backup",
|
||||
"memhub backup restore --source <path> [--yes --confirm <path>]",
|
||||
],
|
||||
["memories", "memhub memories delete <memory-id>"],
|
||||
["models", "memhub models remove <profile-id> [--yes]"],
|
||||
[
|
||||
"delete-data",
|
||||
"memhub delete-data [--data-dir <path>] [--yes --confirm <path>]",
|
||||
],
|
||||
["graph-width", "[--yes]"],
|
||||
] as const) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", command], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(usage);
|
||||
// Only the two commands with no way back repeat the target path.
|
||||
if (command !== "backup" && command !== "delete-data") {
|
||||
expect(streams.stdout()).not.toContain("--confirm <path>");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("says backup does not include the memories themselves", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "backup"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain(
|
||||
"memhub backup restore --source <path> --yes --confirm <path>",
|
||||
);
|
||||
expect(streams.stdout()).toContain("逐字重复 --source");
|
||||
expect(streams.stdout()).toContain("不包含记忆本身");
|
||||
});
|
||||
|
||||
it("does not attach restore's confirmation flag to path", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", "path"], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
it("does not promise a rollback after the migration started", async () => {
|
||||
for (const command of ["install", "upgrade"]) {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["help", command], {})).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(streams.stdout()).toContain("迁移开始后失败保留新版本");
|
||||
expect(streams.stdout()).not.toContain("失败时恢复旧版本");
|
||||
expect(streams.stdout()).not.toContain("失败时回滚");
|
||||
}
|
||||
expect(streams.stdout()).not.toContain("--confirm <path>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -546,22 +630,25 @@ describe("documented commands", () => {
|
||||
models: [
|
||||
"list",
|
||||
"show",
|
||||
"plan",
|
||||
"configure",
|
||||
"add",
|
||||
"test",
|
||||
"receipt",
|
||||
"probe-plan",
|
||||
"probe",
|
||||
"probe-receipt",
|
||||
"edit",
|
||||
"activate",
|
||||
"switch",
|
||||
"spaces",
|
||||
"remove",
|
||||
],
|
||||
// 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"],
|
||||
sources: ["list", "connect", "receipt"],
|
||||
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>"],
|
||||
memories: ["list", "show", "plan", "apply"],
|
||||
memories: ["list", "show", "add", "add-status", "update", "delete"],
|
||||
"scan-roots": ["list", "add", "remove"],
|
||||
write: ["submit", "status"],
|
||||
host: ["list", "status", "install", "uninstall", "sync"],
|
||||
};
|
||||
|
||||
|
||||
@@ -43,61 +43,35 @@ const ATTACHED = {
|
||||
};
|
||||
|
||||
const RESPONSE = {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
request_id: "context-request-1",
|
||||
state: "grounded",
|
||||
paths: {
|
||||
raw: {
|
||||
status: "ok",
|
||||
reason: null,
|
||||
elapsed_ms: 2,
|
||||
returned_count: 1,
|
||||
truncated: false,
|
||||
truncated_by: [],
|
||||
},
|
||||
memory: {
|
||||
status: "ok",
|
||||
reason: null,
|
||||
elapsed_ms: 3,
|
||||
returned_count: 1,
|
||||
truncated: false,
|
||||
truncated_by: [],
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{
|
||||
kind: "raw",
|
||||
source_id: "codex",
|
||||
conversation_id: "conversation-1",
|
||||
anchor_evidence_id: "evidence-1",
|
||||
messages: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
revision: 1,
|
||||
role: "user",
|
||||
written_by: "user",
|
||||
created_at: "2026-09-19T00:00:00Z",
|
||||
text: "Synthetic raw citation.",
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
raw_id: "raw-1",
|
||||
content: "Synthetic raw citation.",
|
||||
source: "codex",
|
||||
session_id: "session-1",
|
||||
memory_at: "2026-09-19T00:00:00Z",
|
||||
score: 0.8,
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
kind: "memory",
|
||||
memory_id: "memory-1",
|
||||
revision: 2,
|
||||
content: "Synthetic governed memory.",
|
||||
memory_kind: "working",
|
||||
content: "Synthetic SDK memory.",
|
||||
layer: "l2_fact",
|
||||
status: "active",
|
||||
source: "codex",
|
||||
updated_at: "2026-09-19T00:00:01Z",
|
||||
evidence_ids: ["evidence-1"],
|
||||
score: 0.7,
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
used_characters: 49,
|
||||
used_characters: 44,
|
||||
truncated: false,
|
||||
total_ms: 5,
|
||||
adoption: "unknown",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -115,42 +89,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("unified context search", () => {
|
||||
it("accepts an empty tool message in a valid partial context response", async () => {
|
||||
it("accepts an empty raw item in a valid partial context response", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
state: "partial",
|
||||
paths: {
|
||||
raw: {
|
||||
...RESPONSE.paths.raw,
|
||||
status: "degraded",
|
||||
reason: "lexical_only",
|
||||
returned_count: 1,
|
||||
},
|
||||
memory: {
|
||||
...RESPONSE.paths.memory,
|
||||
status: "empty",
|
||||
returned_count: 0,
|
||||
},
|
||||
},
|
||||
items: [
|
||||
{
|
||||
kind: "raw",
|
||||
source_id: "workbuddy",
|
||||
conversation_id: "conversation-1",
|
||||
anchor_evidence_id: "evidence-1",
|
||||
messages: [
|
||||
{
|
||||
evidence_id: "evidence-1",
|
||||
revision: 1,
|
||||
role: "tool",
|
||||
written_by: "tool",
|
||||
created_at: "2026-10-05T20:48:12Z",
|
||||
text: "",
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
...RESPONSE.items[0],
|
||||
source: "workbuddy",
|
||||
content: "",
|
||||
truncated: true,
|
||||
},
|
||||
],
|
||||
@@ -165,8 +114,9 @@ describe("unified context search", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "partial",
|
||||
items: [{ kind: "raw", messages: [{ text: "" }] }],
|
||||
items: [{ kind: "raw", content: "" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,12 +128,26 @@ describe("unified context search", () => {
|
||||
streams.restore();
|
||||
}
|
||||
const output = streams.stdout();
|
||||
expect(output).toContain("Synthetic governed memory.");
|
||||
expect(output).toContain("Synthetic SDK memory.");
|
||||
expect(output).toContain("Synthetic raw citation.");
|
||||
expect(output).not.toMatch(/^- raw |memory-1|evidence-1|采用状态/m);
|
||||
expect(output).not.toMatch(/^- raw |memory-1|raw-1|采用状态/m);
|
||||
});
|
||||
|
||||
it("queries the current read-only context endpoint and preserves citations", async () => {
|
||||
it("labels raw hits as original-conversation excerpts, not memories", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "synthetic question"])).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
const output = streams.stdout();
|
||||
expect(output).toContain(
|
||||
"- [原始对话摘录 · codex] Synthetic raw citation.",
|
||||
);
|
||||
expect(output).toContain("- [记忆] Synthetic SDK memory.");
|
||||
});
|
||||
|
||||
it("queries the SDK context endpoint directly and preserves item ids", async () => {
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(
|
||||
@@ -209,7 +173,7 @@ describe("unified context search", () => {
|
||||
streams.restore();
|
||||
}
|
||||
expect(transport.post).toHaveBeenCalledWith(
|
||||
"/api/v1/context/search",
|
||||
"/api/v2/context/search",
|
||||
{
|
||||
query: "synthetic question",
|
||||
kinds: ["raw", "memory"],
|
||||
@@ -222,12 +186,18 @@ describe("unified context search", () => {
|
||||
);
|
||||
const output = JSON.parse(streams.stdout());
|
||||
expect(output).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "grounded",
|
||||
request_id: "context-request-1",
|
||||
adoption: "unknown",
|
||||
});
|
||||
expect(output.items[0].messages[0].evidence_id).toBe("evidence-1");
|
||||
expect(output.items[1].evidence_ids).toEqual(["evidence-1"]);
|
||||
expect(output).not.toHaveProperty("paths");
|
||||
expect(output.items[0]).toMatchObject({ kind: "raw", raw_id: "raw-1" });
|
||||
expect(output.items[1]).toMatchObject({
|
||||
kind: "memory",
|
||||
memory_id: "memory-1",
|
||||
layer: "l2_fact",
|
||||
});
|
||||
// No authority pre-query: released Core always serves the SDK data plane.
|
||||
expect(transport.get).not.toHaveBeenCalled();
|
||||
expect(transport.put).not.toHaveBeenCalled();
|
||||
expect(transport.patch).not.toHaveBeenCalled();
|
||||
@@ -242,19 +212,8 @@ describe("unified context search", () => {
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
paths: {
|
||||
...RESPONSE.paths,
|
||||
raw: {
|
||||
status: "not_requested",
|
||||
reason: "not_requested",
|
||||
elapsed_ms: 0,
|
||||
returned_count: 0,
|
||||
truncated: false,
|
||||
truncated_by: [],
|
||||
},
|
||||
},
|
||||
items: [RESPONSE.items[1]],
|
||||
used_characters: 26,
|
||||
used_characters: 21,
|
||||
},
|
||||
});
|
||||
const streams = captureStreams();
|
||||
@@ -384,6 +343,91 @@ describe("unified context search", () => {
|
||||
expect(streams.stdout()).not.toContain("must-not-print");
|
||||
});
|
||||
|
||||
it("counts characters as code points so an emoji hit stays grounded", async () => {
|
||||
// Core reports used_characters with Python's len(), which counts code
|
||||
// points: this text is 10 characters there and 12 UTF-16 units here.
|
||||
const text = "消费保 📍 扩展 📡";
|
||||
expect(text.length).toBe(12);
|
||||
expect(Array.from(text).length).toBe(10);
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
items: [{ ...RESPONSE.items[0], content: text }],
|
||||
used_characters: 10,
|
||||
},
|
||||
});
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "test", "--json"])).toBe(0);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
state: "grounded",
|
||||
used_characters: 10,
|
||||
items: [{ kind: "raw", content: text }],
|
||||
});
|
||||
});
|
||||
|
||||
it("still rejects a response whose character total does not match", async () => {
|
||||
// The same text counted as UTF-16 units is exactly what Core does not mean,
|
||||
// so the cross-check must keep failing instead of accepting both totals.
|
||||
const text = "消费保 📍 扩展 📡";
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
items: [{ ...RESPONSE.items[0], content: text }],
|
||||
used_characters: 12,
|
||||
},
|
||||
});
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "test", "--json"])).toBe(1);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
state: "invalid",
|
||||
code: "response_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a raw item from a source that was not asked for",
|
||||
["--source", "codex"],
|
||||
{ ...RESPONSE.items[0], source: "cursor" },
|
||||
],
|
||||
[
|
||||
"a raw item when only memory was asked for",
|
||||
["--kind", "memory"],
|
||||
RESPONSE.items[0],
|
||||
],
|
||||
["a score outside 0..1", [], { ...RESPONSE.items[1], score: 1.5 }],
|
||||
])("rejects %s as an invalid response", async (_label, flags, item) => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
...RESPONSE,
|
||||
items: [item],
|
||||
used_characters: Array.from(String(item.content)).length,
|
||||
},
|
||||
});
|
||||
const streams = captureStreams();
|
||||
try {
|
||||
expect(await run(["recall", "query", ...flags, "--json"])).toBe(1);
|
||||
} finally {
|
||||
streams.restore();
|
||||
}
|
||||
expect(JSON.parse(streams.stdout())).toMatchObject({
|
||||
schema_version: 2,
|
||||
state: "invalid",
|
||||
code: "response_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports endpoint denial without exposing the response body", async () => {
|
||||
transport.post.mockResolvedValue({
|
||||
status: 403,
|
||||
|
||||
@@ -3,31 +3,6 @@ import { PRODUCT } from "./version.js";
|
||||
|
||||
export const CONTEXT_QUERY_MAX_CHARACTERS = 2_000;
|
||||
const CONTEXT_KINDS = ["raw", "memory"] as const;
|
||||
const PATH_STATES = [
|
||||
"ok",
|
||||
"empty",
|
||||
"not_requested",
|
||||
"not_authorized",
|
||||
"unavailable",
|
||||
"degraded",
|
||||
] as const;
|
||||
const PATH_REASONS = [
|
||||
"not_requested",
|
||||
"permission_required",
|
||||
"source_revoked",
|
||||
"read_unavailable",
|
||||
"timeout_budget",
|
||||
"evidence_changed",
|
||||
"memory_changed",
|
||||
"lexical_only",
|
||||
] as const;
|
||||
const LIMIT_REASONS = [
|
||||
"count_budget",
|
||||
"character_budget",
|
||||
"timeout_budget",
|
||||
"metadata_budget",
|
||||
] as const;
|
||||
|
||||
export interface ContextSearchInput {
|
||||
query: string;
|
||||
kinds: Array<(typeof CONTEXT_KINDS)[number]>;
|
||||
@@ -35,10 +10,13 @@ export interface ContextSearchInput {
|
||||
limit: number;
|
||||
character_budget: number;
|
||||
timeout_ms: number;
|
||||
/** Whose memories to search. Omitted for the default user (`local`). */
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/** The CLI's JSON document mirrors Core's SDK-native context schema v2. */
|
||||
export interface ContextSearchDocument {
|
||||
schema_version: 1;
|
||||
schema_version: 2;
|
||||
product: string;
|
||||
state:
|
||||
| "grounded"
|
||||
@@ -47,15 +25,14 @@ export interface ContextSearchDocument {
|
||||
| "unavailable"
|
||||
| "denied"
|
||||
| "not_found"
|
||||
| "rejected"
|
||||
| "invalid";
|
||||
code?: string;
|
||||
request_id?: string;
|
||||
paths?: Record<string, unknown>;
|
||||
items: Array<Record<string, unknown>>;
|
||||
used_characters?: number;
|
||||
truncated?: boolean;
|
||||
total_ms?: number;
|
||||
adoption?: "unknown";
|
||||
}
|
||||
|
||||
export async function searchContext(
|
||||
@@ -67,7 +44,7 @@ export async function searchContext(
|
||||
const validated = validateContextResponse(outcome.data, input);
|
||||
return (
|
||||
validated ?? {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: "invalid",
|
||||
code: "response_invalid",
|
||||
@@ -120,9 +97,17 @@ export function contextExitCode(document: ContextSearchDocument): number {
|
||||
}
|
||||
|
||||
export function renderContextText(document: ContextSearchDocument): string {
|
||||
if (document.state === "unavailable") return "暂时无法查记忆,请稍后重试。\n";
|
||||
if (document.state === "unavailable") {
|
||||
// No lexical fallback: without the embedding service nothing is searched.
|
||||
return document.code === "embedding_unavailable"
|
||||
? "向量服务不可用,无法检索;请检查向量模型配置。\n"
|
||||
: "暂时无法查记忆,请稍后重试。\n";
|
||||
}
|
||||
if (document.state === "denied") return "当前应用没有查记忆权限。\n";
|
||||
if (document.state === "invalid") return "查记忆返回了无法识别的结果。\n";
|
||||
if (document.state === "rejected") {
|
||||
return `查记忆被拒绝(${document.code}),请检查参数。\n`;
|
||||
}
|
||||
const lines = [
|
||||
document.state === "empty"
|
||||
? "没有查到相关记忆。"
|
||||
@@ -130,14 +115,14 @@ export function renderContextText(document: ContextSearchDocument): string {
|
||||
? "查到以下内容,部分内容还在整理中:"
|
||||
: "查到以下相关内容:",
|
||||
];
|
||||
// A raw hit is an excerpt of an original conversation, not a distilled
|
||||
// memory; say which one each line is so it is not read as a conclusion.
|
||||
for (const item of document.items) {
|
||||
if (item.kind === "memory") {
|
||||
lines.push(`- ${String(item.content)}`);
|
||||
} else {
|
||||
for (const message of item.messages as Array<Record<string, unknown>>) {
|
||||
lines.push(`- ${String(message.text)}`);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
item.kind === "raw"
|
||||
? `[原始对话摘录 · ${String(item.source)}]`
|
||||
: "[记忆]";
|
||||
lines.push(`- ${label} ${String(item.content)}`);
|
||||
}
|
||||
if (document.truncated) lines.push("结果已按请求预算截断。");
|
||||
return `${lines.join("\n")}\n`;
|
||||
@@ -149,84 +134,37 @@ function validateContextResponse(
|
||||
): ContextSearchDocument | null {
|
||||
const response = record(value);
|
||||
if (
|
||||
response?.schema_version !== 1 ||
|
||||
response?.schema_version !== 2 ||
|
||||
!oneOf(response.state, ["grounded", "empty", "partial", "unavailable"]) ||
|
||||
!boundedText(response.request_id, 64) ||
|
||||
!record(response.paths) ||
|
||||
!Array.isArray(response.items) ||
|
||||
response.items.length > request.limit ||
|
||||
!integer(response.used_characters, 0, request.character_budget) ||
|
||||
typeof response.truncated !== "boolean" ||
|
||||
!number(response.total_ms, 0) ||
|
||||
response.adoption !== "unknown"
|
||||
!number(response.total_ms, 0)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const paths = response.paths as Record<string, unknown>;
|
||||
const rawPath = validatePath(paths.raw);
|
||||
const memoryPath = validatePath(paths.memory);
|
||||
if (!rawPath || !memoryPath) return null;
|
||||
const requested = new Set(request.kinds);
|
||||
if (
|
||||
(!requested.has("raw") && rawPath.status !== "not_requested") ||
|
||||
(!requested.has("memory") && memoryPath.status !== "not_requested")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let characters = 0;
|
||||
for (const itemValue of response.items) {
|
||||
const item = validateItem(itemValue, request);
|
||||
if (!item || !requested.has(item.kind as "raw" | "memory")) return null;
|
||||
items.push(item);
|
||||
characters +=
|
||||
item.kind === "memory"
|
||||
? String(item.content).length
|
||||
: (item.messages as Array<Record<string, unknown>>).reduce(
|
||||
(sum, message) => sum + String(message.text).length,
|
||||
0,
|
||||
);
|
||||
characters += characterCount(item.content);
|
||||
}
|
||||
if (characters !== response.used_characters) return null;
|
||||
if (response.state === "empty" && items.length !== 0) return null;
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: response.state,
|
||||
request_id: response.request_id as string,
|
||||
paths: { raw: rawPath, memory: memoryPath },
|
||||
items,
|
||||
used_characters: response.used_characters as number,
|
||||
truncated: response.truncated,
|
||||
total_ms: response.total_ms as number,
|
||||
adoption: "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
function validatePath(value: unknown): Record<string, unknown> | null {
|
||||
const path = record(value);
|
||||
if (
|
||||
!path ||
|
||||
!oneOf(path.status, PATH_STATES) ||
|
||||
!number(path.elapsed_ms, 0) ||
|
||||
!integer(path.returned_count, 0, 20) ||
|
||||
typeof path.truncated !== "boolean" ||
|
||||
!enumList(path.truncated_by, LIMIT_REASONS) ||
|
||||
!(
|
||||
path.reason === null ||
|
||||
path.reason === undefined ||
|
||||
oneOf(path.reason, PATH_REASONS)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
status: path.status,
|
||||
reason: path.reason ?? null,
|
||||
elapsed_ms: path.elapsed_ms,
|
||||
returned_count: path.returned_count,
|
||||
truncated: path.truncated,
|
||||
truncated_by: path.truncated_by,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,88 +173,65 @@ function validateItem(
|
||||
request: ContextSearchInput,
|
||||
): Record<string, unknown> | null {
|
||||
const item = record(value);
|
||||
if (!item || !oneOf(item.kind, CONTEXT_KINDS)) return null;
|
||||
if (
|
||||
!item ||
|
||||
!oneOf(item.kind, CONTEXT_KINDS) ||
|
||||
boundedText(item.content, 20_000, true) === null ||
|
||||
!number(item.score, 0) ||
|
||||
(item.score as number) > 1 ||
|
||||
typeof item.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "memory") {
|
||||
if (
|
||||
!boundedText(item.memory_id, 512) ||
|
||||
!integer(item.revision, 1) ||
|
||||
boundedText(item.content, 20_000, true) === null ||
|
||||
boundedText(item.memory_kind, 64, true) === null ||
|
||||
!boundedText(item.updated_at, 64) ||
|
||||
!textList(item.evidence_ids, 128, 512) ||
|
||||
typeof item.truncated !== "boolean"
|
||||
boundedText(item.layer, 64, true) === null ||
|
||||
boundedText(item.status, 32, true) === null ||
|
||||
!optionalText(item.source, 128) ||
|
||||
!optionalText(item.updated_at, 64)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "memory",
|
||||
memory_id: item.memory_id,
|
||||
revision: item.revision,
|
||||
content: item.content,
|
||||
memory_kind: item.memory_kind,
|
||||
updated_at: item.updated_at,
|
||||
evidence_ids: item.evidence_ids,
|
||||
layer: item.layer,
|
||||
status: item.status,
|
||||
source: item.source ?? null,
|
||||
updated_at: item.updated_at ?? null,
|
||||
score: item.score,
|
||||
truncated: item.truncated,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!boundedText(item.source_id, 128) ||
|
||||
!boundedText(item.raw_id, 512) ||
|
||||
!boundedText(item.source, 128) ||
|
||||
(request.raw_source_ids !== undefined &&
|
||||
!request.raw_source_ids.includes(item.source_id as string)) ||
|
||||
!boundedText(item.conversation_id, 512) ||
|
||||
!boundedText(item.anchor_evidence_id, 512) ||
|
||||
!Array.isArray(item.messages) ||
|
||||
item.messages.length < 1 ||
|
||||
item.messages.length > 3 ||
|
||||
typeof item.truncated !== "boolean"
|
||||
!request.raw_source_ids.includes(item.source as string)) ||
|
||||
!boundedText(item.session_id, 512) ||
|
||||
!optionalText(item.memory_at, 64)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const messages = item.messages.map(validateMessage);
|
||||
if (messages.some((message) => message === null)) return null;
|
||||
return {
|
||||
kind: "raw",
|
||||
source_id: item.source_id,
|
||||
conversation_id: item.conversation_id,
|
||||
anchor_evidence_id: item.anchor_evidence_id,
|
||||
messages,
|
||||
raw_id: item.raw_id,
|
||||
content: item.content,
|
||||
source: item.source,
|
||||
session_id: item.session_id,
|
||||
memory_at: item.memory_at ?? null,
|
||||
score: item.score,
|
||||
truncated: item.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function validateMessage(value: unknown): Record<string, unknown> | null {
|
||||
const message = record(value);
|
||||
if (
|
||||
!message ||
|
||||
!boundedText(message.evidence_id, 512) ||
|
||||
!integer(message.revision, 1) ||
|
||||
boundedText(message.role, 32, true) === null ||
|
||||
boundedText(message.written_by, 32, true) === null ||
|
||||
!(
|
||||
message.created_at === null ||
|
||||
boundedText(message.created_at, 64) !== null
|
||||
) ||
|
||||
boundedText(message.text, 20_000, true) === null ||
|
||||
typeof message.truncated !== "boolean"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
evidence_id: message.evidence_id,
|
||||
revision: message.revision,
|
||||
role: message.role,
|
||||
written_by: message.written_by,
|
||||
created_at: message.created_at,
|
||||
text: message.text,
|
||||
truncated: message.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
function failed(
|
||||
outcome: Exclude<ReadOutcome<unknown>, { state: "ok" }>,
|
||||
): ContextSearchDocument {
|
||||
return {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
product: PRODUCT,
|
||||
state: outcome.state,
|
||||
code: outcome.code,
|
||||
@@ -330,6 +245,17 @@ function record(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count characters the way Core does. Python's len() counts Unicode code
|
||||
* points; String.prototype.length counts UTF-16 code units, so an emoji outside
|
||||
* the BMP is one character to Core and two here. Comparing the two totals made
|
||||
* every response containing an emoji fail the used_characters cross-check and
|
||||
* surface as response_invalid.
|
||||
*/
|
||||
function characterCount(value: unknown): number {
|
||||
return Array.from(String(value)).length;
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
@@ -363,27 +289,10 @@ function oneOf<const T extends readonly string[]>(
|
||||
return typeof value === "string" && values.includes(value as T[number]);
|
||||
}
|
||||
|
||||
function enumList(
|
||||
value: unknown,
|
||||
values: readonly string[],
|
||||
): value is string[] {
|
||||
function optionalText(value: unknown, maximum: number): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length <= 4 &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((item) => oneOf(item, values))
|
||||
);
|
||||
}
|
||||
|
||||
function textList(
|
||||
value: unknown,
|
||||
maximumItems: number,
|
||||
maximumText: number,
|
||||
): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length <= maximumItems &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every((item) => boundedText(item, maximumText) !== null)
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
boundedText(value, maximum) !== null
|
||||
);
|
||||
}
|
||||
Loaded 100 of 1081 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user