compound-memory 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {compound_memory-0.1.0/src/compound_memory.egg-info → compound_memory-0.2.0}/PKG-INFO +2 -2
- {compound_memory-0.1.0 → compound_memory-0.2.0}/README.md +1 -1
- {compound_memory-0.1.0 → compound_memory-0.2.0}/pyproject.toml +1 -1
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/__init__.py +1 -1
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/cli.py +26 -7
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/server.py +35 -16
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/storage.py +103 -8
- {compound_memory-0.1.0 → compound_memory-0.2.0/src/compound_memory.egg-info}/PKG-INFO +2 -2
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/SOURCES.txt +1 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_mcp_tools.py +79 -2
- compound_memory-0.2.0/tests/test_ns_isolation.py +265 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/LICENSE +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/setup.cfg +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/embedding.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/index.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/model.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/review_queue.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/scoring.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory/vector_index.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/dependency_links.txt +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/entry_points.txt +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/requires.txt +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/top_level.txt +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_distill.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_embedding.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_index.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_lifecycle.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_model.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_scoring.py +0 -0
- {compound_memory-0.1.0 → compound_memory-0.2.0}/tests/test_vector_index.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compound-memory
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Local multi-agent shared memory with compounding (MCP server + CLI)
|
|
5
5
|
Author: chinwe
|
|
6
6
|
License-Expression: MIT
|
|
@@ -147,4 +147,4 @@ PyPI 版本不可重传,tag 必须与 `pyproject.toml` 的 `version` 一致(
|
|
|
147
147
|
|
|
148
148
|
1. **一次性配置**(PyPI → 项目 → Publishing):owner `chinwe`、repo `compound-memory`、workflow `release.yml`、environment `pypi`。首次发布时项目尚不存在,在 pypi.org 用"pending publisher"预注册即可。
|
|
149
149
|
2. **发布**:`git tag v0.1.0 && git push origin v0.1.0` → `release.yml` 自动 build + `uv publish`。
|
|
150
|
-
3. 发布后 `uvx compound-memory-server`
|
|
150
|
+
3. 发布后 `uvx --from compound-memory compound-memory-server` 即为通用安装形态(`uvx` 的参数是包名,script 名不同须用 `--from`;MCP 配置里的 `command` 换成 uvx 后不再依赖仓库克隆路径)。
|
|
@@ -118,4 +118,4 @@ PyPI 版本不可重传,tag 必须与 `pyproject.toml` 的 `version` 一致(
|
|
|
118
118
|
|
|
119
119
|
1. **一次性配置**(PyPI → 项目 → Publishing):owner `chinwe`、repo `compound-memory`、workflow `release.yml`、environment `pypi`。首次发布时项目尚不存在,在 pypi.org 用"pending publisher"预注册即可。
|
|
120
120
|
2. **发布**:`git tag v0.1.0 && git push origin v0.1.0` → `release.yml` 自动 build + `uv publish`。
|
|
121
|
-
3. 发布后 `uvx compound-memory-server`
|
|
121
|
+
3. 发布后 `uvx --from compound-memory compound-memory-server` 即为通用安装形态(`uvx` 的参数是包名,script 名不同须用 `--from`;MCP 配置里的 `command` 换成 uvx 后不再依赖仓库克隆路径)。
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "compound-memory"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Local multi-agent shared memory with compounding (MCP server + CLI)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
# PEP 639 SPDX 表达式;与 License:: classifier 互斥,故后者已移除
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import datetime as dt
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
8
9
|
import sys
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Any
|
|
@@ -19,8 +20,13 @@ def _emit(payload: Any) -> None:
|
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def _open_store(args: argparse.Namespace) -> MemoryStore:
|
|
22
|
-
# CLI 与 server 同权:vec extra +
|
|
23
|
-
|
|
23
|
+
# CLI 与 server 同权:vec extra + 模型就绪即启用向量路,否则自动降级纯词面;
|
|
24
|
+
# 环境注入的进程身份(若有)同样生效——未设置时保持自报身份模式
|
|
25
|
+
return MemoryStore(
|
|
26
|
+
Path(args.root),
|
|
27
|
+
embedder=auto_encoder(),
|
|
28
|
+
agent_id=os.environ.get("COMPOUND_MEMORY_AGENT_ID") or None,
|
|
29
|
+
)
|
|
24
30
|
|
|
25
31
|
|
|
26
32
|
def cmd_init(args: argparse.Namespace) -> None:
|
|
@@ -35,13 +41,17 @@ def cmd_write(args: argparse.Namespace) -> None:
|
|
|
35
41
|
def cmd_search(args: argparse.Namespace) -> None:
|
|
36
42
|
_emit(
|
|
37
43
|
_open_store(args).search(
|
|
38
|
-
query=args.query,
|
|
44
|
+
query=args.query,
|
|
45
|
+
ns=args.ns,
|
|
46
|
+
top_k=args.top_k,
|
|
47
|
+
include_neighbors=args.include_neighbors,
|
|
48
|
+
reader=args.reader,
|
|
39
49
|
)
|
|
40
50
|
)
|
|
41
51
|
|
|
42
52
|
|
|
43
53
|
def cmd_get(args: argparse.Namespace) -> None:
|
|
44
|
-
_emit(_open_store(args).get(args.id))
|
|
54
|
+
_emit(_open_store(args).get(args.id, reader=args.reader))
|
|
45
55
|
|
|
46
56
|
|
|
47
57
|
def cmd_link(args: argparse.Namespace) -> None:
|
|
@@ -62,7 +72,7 @@ def cmd_decay(args: argparse.Namespace) -> None:
|
|
|
62
72
|
|
|
63
73
|
|
|
64
74
|
def cmd_revive(args: argparse.Namespace) -> None:
|
|
65
|
-
_emit(_open_store(args).revive(args.id))
|
|
75
|
+
_emit(_open_store(args).revive(args.id, reader=args.reader))
|
|
66
76
|
|
|
67
77
|
|
|
68
78
|
def cmd_stats(args: argparse.Namespace) -> None:
|
|
@@ -88,6 +98,7 @@ def cmd_distill_plan(args: argparse.Namespace) -> None:
|
|
|
88
98
|
min_uses=args.min_uses,
|
|
89
99
|
min_confidence=args.min_confidence,
|
|
90
100
|
ns=args.ns,
|
|
101
|
+
reader=args.reader,
|
|
91
102
|
)
|
|
92
103
|
)
|
|
93
104
|
|
|
@@ -125,18 +136,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
125
136
|
|
|
126
137
|
p = sub.add_parser("search")
|
|
127
138
|
p.add_argument("query"); p.add_argument("--ns", default="_shared"); p.add_argument("--top-k", type=int, default=5)
|
|
139
|
+
p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
|
|
128
140
|
p.add_argument("--no-neighbors", dest="include_neighbors", action="store_false",
|
|
129
141
|
help="omit embedded one-hop neighbors from hits")
|
|
130
142
|
p.set_defaults(func=cmd_search)
|
|
131
143
|
|
|
132
|
-
p = sub.add_parser("get")
|
|
144
|
+
p = sub.add_parser("get")
|
|
145
|
+
p.add_argument("id")
|
|
146
|
+
p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
|
|
147
|
+
p.set_defaults(func=cmd_get)
|
|
133
148
|
p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b"); p.set_defaults(func=cmd_link)
|
|
134
149
|
p = sub.add_parser("feedback"); p.add_argument("id"); p.add_argument("agent"); p.set_defaults(func=cmd_feedback)
|
|
135
150
|
|
|
136
151
|
p = sub.add_parser("decay"); p.add_argument("--now", default=None, help="ISO date override (testing)")
|
|
137
152
|
p.set_defaults(func=cmd_decay)
|
|
138
153
|
|
|
139
|
-
p = sub.add_parser("revive")
|
|
154
|
+
p = sub.add_parser("revive")
|
|
155
|
+
p.add_argument("id")
|
|
156
|
+
p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
|
|
157
|
+
p.set_defaults(func=cmd_revive)
|
|
140
158
|
p = sub.add_parser(
|
|
141
159
|
"distill-plan",
|
|
142
160
|
help="scan distillation candidates and print a signal-annotated list",
|
|
@@ -148,6 +166,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
148
166
|
p.add_argument("--min-uses", type=int, default=1, help="activity gate: uses >= this")
|
|
149
167
|
p.add_argument("--min-confidence", type=float, default=0.5, help="activity gate: confidence >= this")
|
|
150
168
|
p.add_argument("--ns", default="_shared")
|
|
169
|
+
p.add_argument("--reader", default=None, help="caller identity, required for private agent-* namespaces")
|
|
151
170
|
p.set_defaults(func=cmd_distill_plan)
|
|
152
171
|
p = sub.add_parser("distill-apply")
|
|
153
172
|
p.add_argument("content"); p.add_argument("type", choices=MEMORY_TYPES); p.add_argument("source")
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
恰好 5 个 tool:memory_write / memory_search / memory_get / memory_link / memory_feedback。
|
|
4
4
|
memory_feedback 是一等公民——复利闭环依赖它。
|
|
5
|
+
|
|
6
|
+
所有 tool 声明 structured_output=False(单份序列化):mcp 2.x 会从 `dict[str, Any]`
|
|
7
|
+
注解推断 outputSchema,结构化载荷与文本回退同时下发双份,撑大宿主上下文;
|
|
8
|
+
关闭后只走 text 一份 JSON,形状不变。
|
|
5
9
|
"""
|
|
6
10
|
|
|
7
11
|
from __future__ import annotations
|
|
8
12
|
|
|
13
|
+
import os
|
|
9
14
|
from pathlib import Path
|
|
10
15
|
from typing import Any, Callable
|
|
11
16
|
|
|
@@ -24,10 +29,15 @@ def configure(
|
|
|
24
29
|
git: bool = True,
|
|
25
30
|
git_probe: Callable[[], bool] | None = None,
|
|
26
31
|
embedder: Callable[[list[str]], list[list[float]]] | None = None,
|
|
32
|
+
agent_id: str | None = None,
|
|
27
33
|
) -> MemoryStore:
|
|
28
34
|
global _store
|
|
29
35
|
_store = MemoryStore(
|
|
30
|
-
Path(root) if root is not None else default_root(),
|
|
36
|
+
Path(root) if root is not None else default_root(),
|
|
37
|
+
git=git,
|
|
38
|
+
git_probe=git_probe,
|
|
39
|
+
embedder=embedder,
|
|
40
|
+
agent_id=agent_id,
|
|
31
41
|
)
|
|
32
42
|
return _store
|
|
33
43
|
|
|
@@ -39,7 +49,7 @@ def _store_or_configure() -> MemoryStore:
|
|
|
39
49
|
return _store
|
|
40
50
|
|
|
41
51
|
|
|
42
|
-
@mcp.tool()
|
|
52
|
+
@mcp.tool(structured_output=False)
|
|
43
53
|
def memory_write(
|
|
44
54
|
content: str,
|
|
45
55
|
type: str,
|
|
@@ -52,35 +62,44 @@ def memory_write(
|
|
|
52
62
|
return _store_or_configure().write(content=content, type=type, source=source, ns=ns, key=key, links=links)
|
|
53
63
|
|
|
54
64
|
|
|
55
|
-
@mcp.tool()
|
|
56
|
-
def memory_search(
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
@mcp.tool(structured_output=False)
|
|
66
|
+
def memory_search(
|
|
67
|
+
query: str,
|
|
68
|
+
ns: str = "_shared",
|
|
69
|
+
top_k: int = 5,
|
|
70
|
+
include_neighbors: bool = True,
|
|
71
|
+
reader: str | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Search memories. Fuses lexical (BM25) and, when the vec extra + model are installed, vector (BGE) recall via RRF; otherwise falls back to lexical only. Confidence/recency/type act only as a small tie-break. Default namespace is _shared. Each hit embeds up to 3 trimmed one-hop neighbors (active only) unless include_neighbors=False. reader: your own source agent id — REQUIRED when ns is 'agent-<name>' (private namespace, readable only by its owner host); ignored for _shared. Returns {'hits': [...]} sorted by score. Compounding rule: after actually adopting a hit, call memory_feedback (agent = your source id) — skipped feedbacks leave the store static."""
|
|
74
|
+
hits = _store_or_configure().search(
|
|
75
|
+
query=query, ns=ns, top_k=top_k, include_neighbors=include_neighbors, reader=reader
|
|
76
|
+
)
|
|
59
77
|
return {"hits": hits, "count": len(hits)}
|
|
60
78
|
|
|
61
79
|
|
|
62
|
-
@mcp.tool()
|
|
63
|
-
def memory_get(mem_id: str, include_neighbors: bool = True) -> dict[str, Any]:
|
|
64
|
-
"""Fetch a memory by id; one-hop link neighbors are included by default. After adopting it, call memory_feedback (agent = your source id)."""
|
|
65
|
-
return _store_or_configure().get(mem_id, include_neighbors=include_neighbors)
|
|
80
|
+
@mcp.tool(structured_output=False)
|
|
81
|
+
def memory_get(mem_id: str, include_neighbors: bool = True, reader: str | None = None) -> dict[str, Any]:
|
|
82
|
+
"""Fetch a memory by id; one-hop link neighbors are included by default. reader: your own source agent id — required when the memory lives in a private 'agent-<name>' namespace (readable only by its owner host). After adopting it, call memory_feedback (agent = your source id)."""
|
|
83
|
+
return _store_or_configure().get(mem_id, include_neighbors=include_neighbors, reader=reader)
|
|
66
84
|
|
|
67
85
|
|
|
68
|
-
@mcp.tool()
|
|
86
|
+
@mcp.tool(structured_output=False)
|
|
69
87
|
def memory_link(id_a: str, id_b: str) -> dict[str, Any]:
|
|
70
|
-
"""Create a bidirectional link between two memories (compounding source #2: association)."""
|
|
88
|
+
"""Create a bidirectional link between two memories (compounding source #2: association). Both memories must live in the same namespace; cross-namespace links are rejected."""
|
|
71
89
|
return _store_or_configure().link(id_a, id_b)
|
|
72
90
|
|
|
73
91
|
|
|
74
|
-
@mcp.tool()
|
|
92
|
+
@mcp.tool(structured_output=False)
|
|
75
93
|
def memory_feedback(mem_id: str, agent: str) -> dict[str, Any]:
|
|
76
|
-
"""Report that a memory was actually used. Increments uses, raises confidence (+0.1; extra +0.15 when a different agent validates). agent must be your own source agent id. Archiving is reversed on feedback. MUST be called after a memory is adopted — this closes the compounding loop."""
|
|
94
|
+
"""Report that a memory was actually used. Increments uses, raises confidence (+0.1; extra +0.15 when a different agent validates). agent must be your own source agent id. Memories in a private 'agent-<name>' namespace accept feedback only from the owner (agent = 'agent-<name>' or '<name>'). Archiving is reversed on feedback. MUST be called after a memory is adopted — this closes the compounding loop."""
|
|
77
95
|
return _store_or_configure().feedback(mem_id, agent)
|
|
78
96
|
|
|
79
97
|
|
|
80
98
|
def main() -> None:
|
|
81
99
|
if _store is None:
|
|
82
|
-
# 生产入口自动挂向量路(vec extra +
|
|
83
|
-
|
|
100
|
+
# 生产入口自动挂向量路(vec extra + 模型就绪才生效,否则静默降级纯词面);
|
|
101
|
+
# 宿主经 COMPOUND_MEMORY_AGENT_ID 注入进程身份,未设置则保持自报身份模式
|
|
102
|
+
configure(embedder=auto_encoder(), agent_id=os.environ.get("COMPOUND_MEMORY_AGENT_ID") or None)
|
|
84
103
|
mcp.run()
|
|
85
104
|
|
|
86
105
|
|
|
@@ -18,7 +18,7 @@ import subprocess
|
|
|
18
18
|
import uuid
|
|
19
19
|
from dataclasses import asdict
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Callable
|
|
21
|
+
from typing import Any, Callable, overload
|
|
22
22
|
|
|
23
23
|
import yaml
|
|
24
24
|
|
|
@@ -115,6 +115,7 @@ class MemoryStore:
|
|
|
115
115
|
remover: Callable[[Path], None] | None = None,
|
|
116
116
|
git_probe: Callable[[], bool] | None = None,
|
|
117
117
|
embedder: Callable[[list[str]], list[list[float]]] | None = None,
|
|
118
|
+
agent_id: str | None = None,
|
|
118
119
|
) -> None:
|
|
119
120
|
self.root = Path(root)
|
|
120
121
|
self.ns_root = self.root / "namespaces"
|
|
@@ -126,6 +127,10 @@ class MemoryStore:
|
|
|
126
127
|
self.git_enabled = git and (git_probe or _git_available)()
|
|
127
128
|
self._clock = clock
|
|
128
129
|
self._remover = remover or _unlink_file
|
|
130
|
+
# 进程侧身份证明:agent_id 非空时(宿主经 COMPOUND_MEMORY_AGENT_ID 注入),
|
|
131
|
+
# 所有调用方自报身份(source/reader/agent)必须与其一致,缺省 reader 自动补真值。
|
|
132
|
+
# 只由 server/cli 入口显式传入,store 自身不读环境变量(测试与库调用保持确定性)。
|
|
133
|
+
self.agent_id = agent_id
|
|
129
134
|
self._ensure_layout()
|
|
130
135
|
if self.git_enabled and not (self.root / ".git").exists():
|
|
131
136
|
# init commit 仅限首次创建:__init__ 在每次 CLI/MCP 启动都会执行,
|
|
@@ -232,6 +237,55 @@ class MemoryStore:
|
|
|
232
237
|
if ns != "_shared" and not ns.startswith("agent-"):
|
|
233
238
|
raise ValueError("ns must be '_shared' or start with 'agent-'")
|
|
234
239
|
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _check_ns_owner(ns: str, identity: str | None, role: str = "reader") -> None:
|
|
242
|
+
"""读/反馈侧 owner 校验:agent-* 私有 ns 只有属主宿主可读、可反馈。
|
|
243
|
+
|
|
244
|
+
与 write 的 `_check_ns + PermissionError` 对称——写侧已保证非属主写不进
|
|
245
|
+
私有 ns,读侧若不校验则任何宿主显式传 ns=agent-<别人> 即可越权读全量
|
|
246
|
+
(2026-10-03 实测:search 签名原本无调用方身份参数,跨宿主零阻力);
|
|
247
|
+
feedback 侧不校验则外来 agent 可刷 uses/confidence 或复活归档。
|
|
248
|
+
|
|
249
|
+
identity 缺省时对 _shared 放行、对 agent-* 拒绝:宁可不读,不猜身份。
|
|
250
|
+
role 只是让报错指引对得上调用方的参数名(reader / agent)。
|
|
251
|
+
"""
|
|
252
|
+
if not ns.startswith("agent-"):
|
|
253
|
+
return
|
|
254
|
+
owner = ns[len("agent-"):]
|
|
255
|
+
if identity in (ns, owner):
|
|
256
|
+
return
|
|
257
|
+
raise PermissionError(
|
|
258
|
+
f"namespace {ns!r} is private to {owner!r}; {role} is {identity!r}. "
|
|
259
|
+
f"Pass {role}={ns!r} or {role}={owner!r} if you are that host."
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@overload
|
|
263
|
+
def _resolve_identity(self, value: str, role: str) -> str: ...
|
|
264
|
+
|
|
265
|
+
@overload
|
|
266
|
+
def _resolve_identity(self, value: None, role: str) -> str | None: ...
|
|
267
|
+
|
|
268
|
+
def _resolve_identity(self, value: str | None, role: str) -> str | None:
|
|
269
|
+
"""身份裁决:进程注入(agent_id)优先于调用方自报。
|
|
270
|
+
|
|
271
|
+
- 未启用 attestation(agent_id 为空)⇒ 原样放行,行为同旧版(自报身份)。
|
|
272
|
+
- 调用方缺省 ⇒ 自动补进程身份(诚实缺省,如 search 私有 ns 忘带 reader)。
|
|
273
|
+
- 调用方与进程身份等价(agent-x / x 两种形式)⇒ 归一化为 agent_id,
|
|
274
|
+
保证 validated_by 等记录字段去重一致。
|
|
275
|
+
- 调用方与进程身份矛盾 ⇒ 响亮报错(伪造/配错宿主都该炸,不该静默改写)。
|
|
276
|
+
"""
|
|
277
|
+
if self.agent_id is None:
|
|
278
|
+
return value
|
|
279
|
+
if value is None:
|
|
280
|
+
return self.agent_id
|
|
281
|
+
accepted = {self.agent_id, self.agent_id.removeprefix("agent-")}
|
|
282
|
+
if value in accepted:
|
|
283
|
+
return self.agent_id
|
|
284
|
+
raise PermissionError(
|
|
285
|
+
f"{role} {value!r} contradicts attested agent {self.agent_id!r} "
|
|
286
|
+
f"(COMPOUND_MEMORY_AGENT_ID); the process identity wins"
|
|
287
|
+
)
|
|
288
|
+
|
|
235
289
|
def write(
|
|
236
290
|
self,
|
|
237
291
|
content: str,
|
|
@@ -244,6 +298,7 @@ class MemoryStore:
|
|
|
244
298
|
confidence: float | None = None,
|
|
245
299
|
origin: str | None = None,
|
|
246
300
|
) -> dict[str, Any]:
|
|
301
|
+
source = self._resolve_identity(source, "source")
|
|
247
302
|
mem, conflict_with = self._write_new(
|
|
248
303
|
content,
|
|
249
304
|
type=type,
|
|
@@ -306,21 +361,33 @@ class MemoryStore:
|
|
|
306
361
|
result["conflicts_with"] = conflict_with.id
|
|
307
362
|
return result
|
|
308
363
|
|
|
309
|
-
def get(self, mem_id: str, include_neighbors: bool = True) -> dict[str, Any]:
|
|
364
|
+
def get(self, mem_id: str, include_neighbors: bool = True, reader: str | None = None) -> dict[str, Any]:
|
|
365
|
+
reader = self._resolve_identity(reader, "reader")
|
|
310
366
|
mem = self.find(mem_id)
|
|
311
367
|
if mem is None:
|
|
312
368
|
return {"found": False}
|
|
369
|
+
self._check_ns_owner(mem.ns, reader)
|
|
313
370
|
result = asdict(mem)
|
|
314
371
|
result["found"] = True
|
|
372
|
+
# links 输出同 ns 脱敏:跨 ns 遗留链不把对侧 id 暴露给本侧读者(与邻居召回同规则)
|
|
373
|
+
same_ns_links: list[str] = []
|
|
374
|
+
for l in mem.links:
|
|
375
|
+
t = self.find(l)
|
|
376
|
+
if t is None or t.ns == mem.ns:
|
|
377
|
+
same_ns_links.append(l)
|
|
378
|
+
result["links"] = same_ns_links
|
|
315
379
|
if include_neighbors and mem.links:
|
|
316
|
-
neighbors = [asdict(n) for n in (self.find(l) for l in
|
|
380
|
+
neighbors = [asdict(n) for n in (self.find(l) for l in same_ns_links) if n is not None]
|
|
317
381
|
result["neighbors"] = neighbors
|
|
318
382
|
return result
|
|
319
383
|
|
|
320
384
|
def feedback(self, mem_id: str, agent: str) -> dict[str, Any]:
|
|
385
|
+
agent = self._resolve_identity(agent, "agent")
|
|
321
386
|
mem = self.find(mem_id)
|
|
322
387
|
if mem is None:
|
|
323
388
|
return {"found": False}
|
|
389
|
+
# 私有记忆只有属主可反馈:防外来 agent 刷 uses/confidence、混入 validated_by 或复活归档
|
|
390
|
+
self._check_ns_owner(mem.ns, agent, role="agent")
|
|
324
391
|
if mem.archived:
|
|
325
392
|
self._move_to_active(mem)
|
|
326
393
|
mem.uses += 1
|
|
@@ -346,6 +413,10 @@ class MemoryStore:
|
|
|
346
413
|
if missing:
|
|
347
414
|
return {"found": False, "missing": missing}
|
|
348
415
|
assert mem_a is not None and mem_b is not None
|
|
416
|
+
# 跨 ns 链会把对侧 id 写进本侧文件 frontmatter,成为私有 id 的泄漏源;
|
|
417
|
+
# 且邻居召回本就同 ns 过滤,跨 ns 链对复利无贡献——创建侧直接禁止
|
|
418
|
+
if mem_a.ns != mem_b.ns:
|
|
419
|
+
raise ValueError(f"cannot link memories across namespaces: {mem_a.ns!r} vs {mem_b.ns!r}")
|
|
349
420
|
if id_b not in mem_a.links:
|
|
350
421
|
mem_a.links.append(id_b)
|
|
351
422
|
if id_a not in mem_b.links:
|
|
@@ -361,13 +432,19 @@ class MemoryStore:
|
|
|
361
432
|
ns: str = "_shared",
|
|
362
433
|
top_k: int = 5,
|
|
363
434
|
include_neighbors: bool = True,
|
|
435
|
+
reader: str | None = None,
|
|
364
436
|
) -> list[dict[str, Any]]:
|
|
365
437
|
"""检索 = 选候选(store 的 layout 职责)+ 排序(scoring.rank 单一定义点)。
|
|
366
438
|
|
|
367
439
|
embedder 可用时叠加向量召回:候选 = 词面命中 ∪ 向量 KNN(ns/活性过滤),
|
|
368
440
|
两路 rank 在 rank 内 RRF 融合;向量路任何故障都降级纯词面(宁缺勿炸)。
|
|
441
|
+
|
|
442
|
+
reader 是调用方身份,ns=agent-* 时必填且须为属主(读侧 owner 校验,
|
|
443
|
+
与 write 的越权抛 PermissionError 对称);ns=_shared 时忽略。
|
|
369
444
|
"""
|
|
370
445
|
self._check_ns(ns)
|
|
446
|
+
reader = self._resolve_identity(reader, "reader")
|
|
447
|
+
self._check_ns_owner(ns, reader)
|
|
371
448
|
now = self._clock()
|
|
372
449
|
q_tokens = tokenize(query)
|
|
373
450
|
if not q_tokens:
|
|
@@ -378,7 +455,7 @@ class MemoryStore:
|
|
|
378
455
|
self._candidates(q_tokens, ns, vec_rels),
|
|
379
456
|
now=now,
|
|
380
457
|
top_k=top_k,
|
|
381
|
-
neighbor_lookup=self._active_neighbors if include_neighbors else None,
|
|
458
|
+
neighbor_lookup=(lambda mid: self._active_neighbors(mid, ns)) if include_neighbors else None,
|
|
382
459
|
vec_sims=vec_sims,
|
|
383
460
|
)
|
|
384
461
|
|
|
@@ -414,15 +491,19 @@ class MemoryStore:
|
|
|
414
491
|
except Exception:
|
|
415
492
|
return None, []
|
|
416
493
|
|
|
417
|
-
def _active_neighbors(self, mem_id: str) -> list[Memory]:
|
|
418
|
-
"""邻居召回的数据源:hit 的一度 links,归档邻居不召回(截断/上限/去环归 rank)。
|
|
494
|
+
def _active_neighbors(self, mem_id: str, ns: str = "_shared") -> list[Memory]:
|
|
495
|
+
"""邻居召回的数据源:hit 的一度 links,归档邻居不召回(截断/上限/去环归 rank)。
|
|
496
|
+
|
|
497
|
+
ns 过滤是访问控制的一部分,不可省:_shared 记忆若链到agent-* 私有记忆,
|
|
498
|
+
邻居会把私有正文带进 _shared 的检索结果(2026-10-03 实测泄漏)。
|
|
499
|
+
"""
|
|
419
500
|
mem = self.find(mem_id)
|
|
420
501
|
if mem is None:
|
|
421
502
|
return []
|
|
422
503
|
out: list[Memory] = []
|
|
423
504
|
for link_id in mem.links:
|
|
424
505
|
neighbor = self.find(link_id)
|
|
425
|
-
if neighbor is not None and not neighbor.archived:
|
|
506
|
+
if neighbor is not None and not neighbor.archived and neighbor.ns == ns:
|
|
426
507
|
out.append(neighbor)
|
|
427
508
|
return out
|
|
428
509
|
|
|
@@ -445,10 +526,13 @@ class MemoryStore:
|
|
|
445
526
|
self._commit("decay: archive " + ", ".join(archived))
|
|
446
527
|
return archived
|
|
447
528
|
|
|
448
|
-
def revive(self, mem_id: str) -> dict[str, Any]:
|
|
529
|
+
def revive(self, mem_id: str, reader: str | None = None) -> dict[str, Any]:
|
|
530
|
+
reader = self._resolve_identity(reader, "reader")
|
|
449
531
|
mem = self.find(mem_id)
|
|
450
532
|
if mem is None:
|
|
451
533
|
return {"found": False}
|
|
534
|
+
# revive 返回全文,与 get 同属按 id 读路径:私有 ns 仅属主可复活
|
|
535
|
+
self._check_ns_owner(mem.ns, reader)
|
|
452
536
|
if mem.archived:
|
|
453
537
|
self._move_to_active(mem)
|
|
454
538
|
self._save(mem)
|
|
@@ -480,6 +564,7 @@ class MemoryStore:
|
|
|
480
564
|
min_uses: int = 1,
|
|
481
565
|
min_confidence: float = 0.5,
|
|
482
566
|
ns: str = "_shared",
|
|
567
|
+
reader: str | None = None,
|
|
483
568
|
) -> dict[str, Any]:
|
|
484
569
|
"""蒸馏候选扫描:窗口 + 活性门过滤,产出带信号标注的建议清单(只标注不合并)。
|
|
485
570
|
|
|
@@ -487,8 +572,12 @@ class MemoryStore:
|
|
|
487
572
|
(BM25 normalized_similarity ≥ DISTILL_DUP_SIM_THRESHOLD,弱信号)、
|
|
488
573
|
promotion_candidate(episode 高活性,晋升建议——判断后置,#6)。
|
|
489
574
|
归档区不参与;坏日期记忆按宁缺勿滥跳过。
|
|
575
|
+
|
|
576
|
+
reader:候选带正文返回,扫私有 ns 须属主(与 get/search 同规则)。
|
|
490
577
|
"""
|
|
491
578
|
self._check_ns(ns)
|
|
579
|
+
reader = self._resolve_identity(reader, "reader")
|
|
580
|
+
self._check_ns_owner(ns, reader)
|
|
492
581
|
now = self._clock()
|
|
493
582
|
cands: list[Memory] = []
|
|
494
583
|
for path in sorted((self.ns_root / ns).rglob("*.md")):
|
|
@@ -548,11 +637,17 @@ class MemoryStore:
|
|
|
548
637
|
源批量归档,收进一次 commit。源任一不存在 ⇒ 整体不落库(found: False)。
|
|
549
638
|
产物与现存 fact/insight 的 key 冲突走既有 review 队列机制,不特殊对待。
|
|
550
639
|
"""
|
|
640
|
+
source = self._resolve_identity(source, "source")
|
|
551
641
|
source_ids = list(dict.fromkeys(source_ids)) # 去重保序:重复源只归档一次
|
|
552
642
|
sources = [self.find(mid) for mid in source_ids]
|
|
553
643
|
missing = [mid for mid, mem in zip(source_ids, sources) if mem is None]
|
|
554
644
|
if missing:
|
|
555
645
|
return {"found": False, "missing": missing}
|
|
646
|
+
# 蒸馏不跨 ns:私有记忆被当源蒸进 _shared 是正文泄漏通道;
|
|
647
|
+
# distill_plan 本就按单 ns 扫描,源与产物同 ns 是既定流程
|
|
648
|
+
foreign_ns = sorted({s.ns for s in sources if s is not None and s.ns != ns})
|
|
649
|
+
if foreign_ns:
|
|
650
|
+
raise ValueError(f"distill sources must live in target ns {ns!r}; found in: {foreign_ns}")
|
|
556
651
|
mem, conflict_with = self._write_new(
|
|
557
652
|
content,
|
|
558
653
|
type=type,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compound-memory
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Local multi-agent shared memory with compounding (MCP server + CLI)
|
|
5
5
|
Author: chinwe
|
|
6
6
|
License-Expression: MIT
|
|
@@ -147,4 +147,4 @@ PyPI 版本不可重传,tag 必须与 `pyproject.toml` 的 `version` 一致(
|
|
|
147
147
|
|
|
148
148
|
1. **一次性配置**(PyPI → 项目 → Publishing):owner `chinwe`、repo `compound-memory`、workflow `release.yml`、environment `pypi`。首次发布时项目尚不存在,在 pypi.org 用"pending publisher"预注册即可。
|
|
149
149
|
2. **发布**:`git tag v0.1.0 && git push origin v0.1.0` → `release.yml` 自动 build + `uv publish`。
|
|
150
|
-
3. 发布后 `uvx compound-memory-server`
|
|
150
|
+
3. 发布后 `uvx --from compound-memory compound-memory-server` 即为通用安装形态(`uvx` 的参数是包名,script 名不同须用 `--from`;MCP 配置里的 `command` 换成 uvx 后不再依赖仓库克隆路径)。
|
|
@@ -34,10 +34,12 @@ current_store: MemoryStore
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
@asynccontextmanager
|
|
37
|
-
async def make_client(root: Path, git_off: bool = False):
|
|
37
|
+
async def make_client(root: Path, git_off: bool = False, agent_id: str | None = None):
|
|
38
38
|
"""配置 server store 并开内存 client;无 git 场景经 configure 注入探测 adapter。"""
|
|
39
39
|
global current_store
|
|
40
|
-
current_store = cm_server.configure(
|
|
40
|
+
current_store = cm_server.configure(
|
|
41
|
+
root, git=True, git_probe=(lambda: False) if git_off else None, agent_id=agent_id
|
|
42
|
+
)
|
|
41
43
|
async with mcp.Client(cm_server.mcp) as c:
|
|
42
44
|
yield c
|
|
43
45
|
|
|
@@ -269,6 +271,81 @@ class TestNamespacePermissions:
|
|
|
269
271
|
}))
|
|
270
272
|
assert res["ns"] == "agent-tars"
|
|
271
273
|
|
|
274
|
+
async def test_private_read_requires_reader_param(self, memroot):
|
|
275
|
+
"""search/get 的 reader 透传:缺身份 is_error,属主身份放行."""
|
|
276
|
+
async with make_client(memroot) as client:
|
|
277
|
+
priv = call(await client.call_tool("memory_write", {
|
|
278
|
+
"content": "私有检索草稿", "type": "fact", "source": "agent-tars", "ns": "agent-tars",
|
|
279
|
+
}))
|
|
280
|
+
no_reader = await client.call_tool(
|
|
281
|
+
"memory_search", {"query": "私有检索", "ns": "agent-tars"}
|
|
282
|
+
)
|
|
283
|
+
assert no_reader.is_error
|
|
284
|
+
assert (await client.call_tool("memory_get", {"mem_id": priv["id"]})).is_error
|
|
285
|
+
hits = call(await client.call_tool(
|
|
286
|
+
"memory_search", {"query": "私有检索", "ns": "agent-tars", "reader": "agent-tars"}
|
|
287
|
+
))
|
|
288
|
+
assert [h["id"] for h in hits["hits"]] == [priv["id"]]
|
|
289
|
+
got = call(await client.call_tool(
|
|
290
|
+
"memory_get", {"mem_id": priv["id"], "reader": "tars"}
|
|
291
|
+
))
|
|
292
|
+
assert got["found"] is True
|
|
293
|
+
|
|
294
|
+
async def test_cross_ns_link_rejected(self, memroot):
|
|
295
|
+
async with make_client(memroot) as client:
|
|
296
|
+
shared = call(await client.call_tool("memory_write", {
|
|
297
|
+
"content": "共享记忆", "type": "episode", "source": "agent-a",
|
|
298
|
+
}))
|
|
299
|
+
priv = call(await client.call_tool("memory_write", {
|
|
300
|
+
"content": "私有记忆", "type": "episode", "source": "agent-tars", "ns": "agent-tars",
|
|
301
|
+
}))
|
|
302
|
+
res = await client.call_tool("memory_link", {"id_a": shared["id"], "id_b": priv["id"]})
|
|
303
|
+
assert res.is_error
|
|
304
|
+
|
|
305
|
+
async def test_private_feedback_rejects_foreign_agent(self, memroot):
|
|
306
|
+
async with make_client(memroot) as client:
|
|
307
|
+
priv = call(await client.call_tool("memory_write", {
|
|
308
|
+
"content": "私有反馈目标", "type": "fact", "source": "agent-tars", "ns": "agent-tars",
|
|
309
|
+
}))
|
|
310
|
+
res = await client.call_tool("memory_feedback", {"mem_id": priv["id"], "agent": "agent-a"})
|
|
311
|
+
assert res.is_error
|
|
312
|
+
ok = call(await client.call_tool("memory_feedback", {"mem_id": priv["id"], "agent": "tars"}))
|
|
313
|
+
assert ok["uses"] == 1
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class TestSingleCopySerialization:
|
|
317
|
+
"""structured_output=False:载荷只走 text 一份,无 structuredContent 双份下发。"""
|
|
318
|
+
|
|
319
|
+
async def test_no_structured_content_duplication(self, memroot):
|
|
320
|
+
async with make_client(memroot) as client:
|
|
321
|
+
res = await client.call_tool("memory_search", {"query": "anything"})
|
|
322
|
+
assert res.structured_content is None
|
|
323
|
+
assert len(res.content) == 1
|
|
324
|
+
assert res.content[0].type == "text"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class TestAttestationWiring:
|
|
328
|
+
"""进程身份注入(configure 的 agent_id,生产来自 COMPOUND_MEMORY_AGENT_ID)经 tool 缝生效。"""
|
|
329
|
+
|
|
330
|
+
async def test_forged_identity_rejected(self, memroot):
|
|
331
|
+
async with make_client(memroot, agent_id="agent-zcode") as client:
|
|
332
|
+
res = await client.call_tool("memory_write", {
|
|
333
|
+
"content": "x", "type": "fact", "source": "agent-a",
|
|
334
|
+
})
|
|
335
|
+
assert res.is_error
|
|
336
|
+
ok = call(await client.call_tool("memory_write", {
|
|
337
|
+
"content": "x", "type": "fact", "source": "agent-zcode",
|
|
338
|
+
}))
|
|
339
|
+
assert ok["source"] == "agent-zcode"
|
|
340
|
+
|
|
341
|
+
async def test_reader_autofilled_from_process_identity(self, memroot):
|
|
342
|
+
async with make_client(memroot, agent_id="agent-zcode") as client:
|
|
343
|
+
priv = call(await client.call_tool("memory_write", {
|
|
344
|
+
"content": "私有自动补 reader", "type": "fact", "source": "agent-zcode", "ns": "agent-zcode",
|
|
345
|
+
}))
|
|
346
|
+
hits = call(await client.call_tool("memory_search", {"query": "私有自动补", "ns": "agent-zcode"}))
|
|
347
|
+
assert [h["id"] for h in hits["hits"]] == [priv["id"]]
|
|
348
|
+
|
|
272
349
|
|
|
273
350
|
class TestWithoutGit:
|
|
274
351
|
async def test_tools_work_without_git(self, memroot2):
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""ns 隔离行为测试(store 缝):读写双侧属主边界 + 旁路通道(link/feedback/distill)。
|
|
2
|
+
|
|
3
|
+
spec 原为「读不隔离」,2026-10-03 修订为双侧属主校验(fail-closed,缺身份即拒绝)。
|
|
4
|
+
默认检索的候选圈定已挡住直读;这里钉的是显式 ns 与旁路的拒绝/放行,
|
|
5
|
+
以及 get 输出对跨 ns 遗留链的脱敏。MCP 参数透传在 test_mcp_tools.py。
|
|
6
|
+
|
|
7
|
+
为何要钉旁路:私有内容的主路(search/get)封死后,剩余攻击面是按 id
|
|
8
|
+
直操作的三条旁路——共同前提是拿到私有 id,所以 links 脱敏也是边界的一部分。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from compound_memory.cli import main as cli_main
|
|
19
|
+
from compound_memory.storage import MemoryStore
|
|
20
|
+
from conftest import CLOCK_DATE
|
|
21
|
+
|
|
22
|
+
OWNER = "agent-zcode"
|
|
23
|
+
OWNER_BARE = "zcode"
|
|
24
|
+
FOREIGN = "agent-workbuddy"
|
|
25
|
+
PRIVATE_NS = "agent-zcode"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _days_ago(n: int) -> str:
|
|
29
|
+
"""相对测试固定"今天"(CLOCK_DATE)推算——store 的 clock 已注入同一日期。"""
|
|
30
|
+
return (CLOCK_DATE - dt.timedelta(days=n)).isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _seed(store: MemoryStore) -> tuple[str, str]:
|
|
34
|
+
"""预置一对最简数据:属主私有记忆 + 外来 _shared 公开记忆。"""
|
|
35
|
+
priv = store.write("zcode private draft", type="fact", source=OWNER, ns=PRIVATE_NS, key="priv")
|
|
36
|
+
pub = store.write("shared public note", type="fact", source=FOREIGN, ns="_shared", key="pub")
|
|
37
|
+
return priv["id"], pub["id"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_legacy_link(store: MemoryStore, mem_id: str, target_id: str) -> None:
|
|
41
|
+
"""伪造存量跨 ns 链(规则生效前的遗留数据):改文件 links 后重建索引。
|
|
42
|
+
|
|
43
|
+
正路 link() 现已拒绝跨 ns,跨 ns 链只能这样造出来——这正是测试目的。
|
|
44
|
+
"""
|
|
45
|
+
mem = store.find(mem_id)
|
|
46
|
+
assert mem is not None
|
|
47
|
+
mem.links.append(target_id)
|
|
48
|
+
store._save(mem)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestReadIsolation:
|
|
52
|
+
def test_default_search_never_sees_private(self, store: MemoryStore):
|
|
53
|
+
"""主路隔离:_shared 检索即便用私有记忆的原词也命中不了私有 ns。"""
|
|
54
|
+
_seed(store)
|
|
55
|
+
hits = store.search("zcode private draft")
|
|
56
|
+
assert all(h["ns"] != PRIVATE_NS for h in hits)
|
|
57
|
+
|
|
58
|
+
def test_private_search_requires_owner_reader(self, store: MemoryStore):
|
|
59
|
+
priv_id, _ = _seed(store)
|
|
60
|
+
with pytest.raises(PermissionError):
|
|
61
|
+
store.search("private draft", ns=PRIVATE_NS)
|
|
62
|
+
with pytest.raises(PermissionError):
|
|
63
|
+
store.search("private draft", ns=PRIVATE_NS, reader=FOREIGN)
|
|
64
|
+
# 属主两种身份形式(全称/短名)都放行
|
|
65
|
+
assert [h["id"] for h in store.search("private draft", ns=PRIVATE_NS, reader=OWNER)] == [priv_id]
|
|
66
|
+
assert [h["id"] for h in store.search("private draft", ns=PRIVATE_NS, reader=OWNER_BARE)] == [priv_id]
|
|
67
|
+
|
|
68
|
+
def test_shared_search_ignores_reader(self, store: MemoryStore):
|
|
69
|
+
_seed(store)
|
|
70
|
+
hits = store.search("public note", reader=FOREIGN)
|
|
71
|
+
assert [h["ns"] for h in hits] == ["_shared"]
|
|
72
|
+
|
|
73
|
+
def test_private_get_requires_owner_reader(self, store: MemoryStore):
|
|
74
|
+
priv_id, _ = _seed(store)
|
|
75
|
+
with pytest.raises(PermissionError):
|
|
76
|
+
store.get(priv_id)
|
|
77
|
+
with pytest.raises(PermissionError):
|
|
78
|
+
store.get(priv_id, reader=FOREIGN)
|
|
79
|
+
assert store.get(priv_id, reader=OWNER)["found"] is True
|
|
80
|
+
assert store.get(priv_id, reader=OWNER_BARE)["found"] is True
|
|
81
|
+
|
|
82
|
+
def test_legacy_cross_ns_link_redacted_from_get(self, store: MemoryStore):
|
|
83
|
+
"""存量跨 ns 链的双向脱敏:links 输出与邻居召回都不暴露对侧 id。"""
|
|
84
|
+
priv_id, _ = _seed(store)
|
|
85
|
+
bridge = store.write("bridging note", type="episode", source=FOREIGN)
|
|
86
|
+
_add_legacy_link(store, bridge["id"], priv_id)
|
|
87
|
+
_add_legacy_link(store, priv_id, bridge["id"])
|
|
88
|
+
store.rebuild_index()
|
|
89
|
+
|
|
90
|
+
got_pub = store.get(bridge["id"])
|
|
91
|
+
assert got_pub["links"] == []
|
|
92
|
+
assert got_pub.get("neighbors", []) == []
|
|
93
|
+
got_priv = store.get(priv_id, reader=OWNER)
|
|
94
|
+
assert got_priv["links"] == []
|
|
95
|
+
|
|
96
|
+
hits = store.search("bridging")
|
|
97
|
+
assert hits and hits[0]["neighbors"] == []
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestLinkNamespaceRule:
|
|
101
|
+
def test_cross_ns_link_rejected(self, store: MemoryStore):
|
|
102
|
+
priv_id, pub_id = _seed(store)
|
|
103
|
+
with pytest.raises(ValueError, match="across namespaces"):
|
|
104
|
+
store.link(pub_id, priv_id)
|
|
105
|
+
# 原子拒绝:两侧文件都不留下链
|
|
106
|
+
assert store.find(pub_id) is not None and store.find(pub_id).links == [] # type: ignore[union-attr]
|
|
107
|
+
assert store.find(priv_id) is not None and store.find(priv_id).links == [] # type: ignore[union-attr]
|
|
108
|
+
|
|
109
|
+
def test_same_ns_link_ok(self, store: MemoryStore):
|
|
110
|
+
_, pub_id = _seed(store)
|
|
111
|
+
other = store.write("another shared note", type="fact", source=FOREIGN, ns="_shared")
|
|
112
|
+
res = store.link(pub_id, other["id"])
|
|
113
|
+
assert res["found"] is True
|
|
114
|
+
assert other["id"] in store.find(pub_id).links # type: ignore[union-attr]
|
|
115
|
+
|
|
116
|
+
def test_self_link_still_rejected(self, store: MemoryStore):
|
|
117
|
+
_, pub_id = _seed(store)
|
|
118
|
+
with pytest.raises(ValueError, match="itself"):
|
|
119
|
+
store.link(pub_id, pub_id)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestFeedbackNamespaceRule:
|
|
123
|
+
def test_foreign_feedback_on_private_denied(self, store: MemoryStore):
|
|
124
|
+
priv_id, _ = _seed(store)
|
|
125
|
+
with pytest.raises(PermissionError):
|
|
126
|
+
store.feedback(priv_id, FOREIGN)
|
|
127
|
+
mem = store.find(priv_id)
|
|
128
|
+
assert mem is not None and mem.uses == 0 and mem.confidence == 0.5
|
|
129
|
+
|
|
130
|
+
def test_owner_feedback_on_private_ok(self, store: MemoryStore):
|
|
131
|
+
priv_id, _ = _seed(store)
|
|
132
|
+
res = store.feedback(priv_id, OWNER)
|
|
133
|
+
assert res["uses"] == 1 and res["confidence"] == 0.6
|
|
134
|
+
|
|
135
|
+
def test_shared_feedback_unaffected(self, store: MemoryStore):
|
|
136
|
+
_, pub_id = _seed(store)
|
|
137
|
+
assert store.feedback(pub_id, FOREIGN)["found"] is True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestDistillNamespaceRule:
|
|
141
|
+
def test_cross_ns_sources_rejected_atomically(self, store: MemoryStore):
|
|
142
|
+
priv_id, pub_id = _seed(store)
|
|
143
|
+
# 私有 → _shared(正文外泄方向)与 _shared → 私有(越权并源)都拒绝
|
|
144
|
+
with pytest.raises(ValueError, match="must live in target ns"):
|
|
145
|
+
store.distill_apply("合并产物", type="insight", source=OWNER, source_ids=[priv_id], ns="_shared")
|
|
146
|
+
with pytest.raises(ValueError, match="must live in target ns"):
|
|
147
|
+
store.distill_apply("合并产物", type="insight", source=OWNER, source_ids=[pub_id], ns=PRIVATE_NS)
|
|
148
|
+
# 原子:源未被归档、产物未落库
|
|
149
|
+
assert store.find(priv_id) is not None and not store.find(priv_id).archived # type: ignore[union-attr]
|
|
150
|
+
assert store.find(pub_id) is not None and not store.find(pub_id).archived # type: ignore[union-attr]
|
|
151
|
+
|
|
152
|
+
def test_same_ns_distill_by_owner_ok(self, store: MemoryStore):
|
|
153
|
+
priv_id, _ = _seed(store)
|
|
154
|
+
res = store.distill_apply(
|
|
155
|
+
"蒸馏产物内容", type="insight", source=OWNER, source_ids=[priv_id], ns=PRIVATE_NS
|
|
156
|
+
)
|
|
157
|
+
assert res["ns"] == PRIVATE_NS and priv_id in res["links"]
|
|
158
|
+
assert store.find(priv_id) is not None and store.find(priv_id).archived # type: ignore[union-attr]
|
|
159
|
+
|
|
160
|
+
def test_distill_plan_private_requires_owner_reader(self, store: MemoryStore):
|
|
161
|
+
"""distill_plan 候选带正文返回:扫私有 ns 与 get/search 同规则。"""
|
|
162
|
+
priv_id, _ = _seed(store)
|
|
163
|
+
store.feedback(priv_id, OWNER) # 过活性门(uses >= 1)
|
|
164
|
+
with pytest.raises(PermissionError):
|
|
165
|
+
store.distill_plan(ns=PRIVATE_NS)
|
|
166
|
+
plan = store.distill_plan(ns=PRIVATE_NS, reader=OWNER)
|
|
167
|
+
assert [c["id"] for c in plan["candidates"]] == [priv_id]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestReviveNamespaceRule:
|
|
171
|
+
def test_revive_private_requires_owner_reader(self, store: MemoryStore):
|
|
172
|
+
"""revive 返回全文,与 get 同属按 id 读路径:私有 ns 仅属主可复活。"""
|
|
173
|
+
old = store.write(
|
|
174
|
+
"zcode old private episode", type="episode", source=OWNER, ns=PRIVATE_NS, created=_days_ago(120)
|
|
175
|
+
)
|
|
176
|
+
assert old["id"] in store.decay_sweep()
|
|
177
|
+
with pytest.raises(PermissionError):
|
|
178
|
+
store.revive(old["id"])
|
|
179
|
+
assert store.revive(old["id"], reader=OWNER)["archived"] is False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TestAttestation:
|
|
183
|
+
"""进程侧身份证明:身份等于进程(COMPOUND_MEMORY_AGENT_ID),不等于自称。
|
|
184
|
+
|
|
185
|
+
三条裁决规则:缺省自动补进程身份(诚实缺省)、等价形式归一化、
|
|
186
|
+
矛盾响亮报错——连带关闭「_shared 伪造 source 污染跨 Agent 验证」的口子。
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
@pytest.fixture
|
|
190
|
+
def attested(self, tmp_path) -> MemoryStore:
|
|
191
|
+
from conftest import sandbox_safe_remove
|
|
192
|
+
|
|
193
|
+
return MemoryStore(
|
|
194
|
+
tmp_path / "attested", clock=lambda: CLOCK_DATE, remover=sandbox_safe_remove, agent_id=OWNER
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def test_reader_auto_filled_for_owner_ns(self, attested: MemoryStore):
|
|
198
|
+
priv = attested.write("私有草稿", type="fact", source=OWNER, ns=PRIVATE_NS)
|
|
199
|
+
hits = attested.search("私有草稿", ns=PRIVATE_NS) # 忘带 reader:自动补进程身份
|
|
200
|
+
assert [h["id"] for h in hits] == [priv["id"]]
|
|
201
|
+
|
|
202
|
+
def test_forged_reader_rejected(self, attested: MemoryStore):
|
|
203
|
+
attested.write("私有草稿", type="fact", source=OWNER, ns=PRIVATE_NS)
|
|
204
|
+
with pytest.raises(PermissionError, match="attested agent"):
|
|
205
|
+
attested.search("私有草稿", ns=PRIVATE_NS, reader=FOREIGN)
|
|
206
|
+
|
|
207
|
+
def test_foreign_ns_denied_even_with_autofill(self, attested: MemoryStore):
|
|
208
|
+
"""自动补的是进程身份:读别人的私有 ns 依旧被属主门挡住。"""
|
|
209
|
+
mem, _ = attested._write_new(
|
|
210
|
+
"workbuddy 私密",
|
|
211
|
+
type="fact",
|
|
212
|
+
source=FOREIGN,
|
|
213
|
+
ns="agent-workbuddy",
|
|
214
|
+
key=None,
|
|
215
|
+
links=None,
|
|
216
|
+
created=None,
|
|
217
|
+
confidence=None,
|
|
218
|
+
origin=None,
|
|
219
|
+
)
|
|
220
|
+
with pytest.raises(PermissionError):
|
|
221
|
+
attested.search("workbuddy 私密", ns="agent-workbuddy")
|
|
222
|
+
with pytest.raises(PermissionError):
|
|
223
|
+
attested.get(mem.id)
|
|
224
|
+
|
|
225
|
+
def test_write_source_must_match_process(self, attested: MemoryStore):
|
|
226
|
+
"""attestation 连带关闭 _shared 伪造 source 的口子;等价形式归一化。"""
|
|
227
|
+
with pytest.raises(PermissionError, match="attested agent"):
|
|
228
|
+
attested.write("x", type="fact", source=FOREIGN)
|
|
229
|
+
res = attested.write("y", type="fact", source="zcode") # 短名等价
|
|
230
|
+
assert res["source"] == OWNER # 落库归一化为进程身份
|
|
231
|
+
|
|
232
|
+
def test_feedback_agent_must_match_process(self, attested: MemoryStore):
|
|
233
|
+
mem = attested.write("x", type="fact", source=OWNER)
|
|
234
|
+
with pytest.raises(PermissionError, match="attested agent"):
|
|
235
|
+
attested.feedback(mem["id"], FOREIGN)
|
|
236
|
+
assert attested.feedback(mem["id"], "zcode")["validated_by"] == [OWNER]
|
|
237
|
+
|
|
238
|
+
def test_unattested_keeps_self_declared(self, store: MemoryStore):
|
|
239
|
+
"""回归护栏:未启用 attestation 的 store 行为与旧版完全一致。"""
|
|
240
|
+
assert store.write("x", type="fact", source=FOREIGN)["source"] == FOREIGN
|
|
241
|
+
|
|
242
|
+
def test_cli_env_attestation(self, store: MemoryStore, monkeypatch, capsys):
|
|
243
|
+
priv_id, _ = _seed(store)
|
|
244
|
+
monkeypatch.setenv("COMPOUND_MEMORY_AGENT_ID", OWNER)
|
|
245
|
+
root = ["--root", str(store.root)]
|
|
246
|
+
assert cli_main(root + ["get", priv_id]) == 0 # reader 自动补进程身份
|
|
247
|
+
capsys.readouterr()
|
|
248
|
+
assert cli_main(root + ["get", priv_id, "--reader", FOREIGN]) == 2
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestCliReaderFlag:
|
|
252
|
+
"""CLI 缺 reader 时 fail-closed(exit 2),--reader 全称/短名都放行。"""
|
|
253
|
+
|
|
254
|
+
def test_cli_get_and_search_roundtrip(self, store: MemoryStore, capsys: pytest.CaptureFixture[str]):
|
|
255
|
+
priv_id, _ = _seed(store)
|
|
256
|
+
root = ["--root", str(store.root)]
|
|
257
|
+
assert cli_main(root + ["search", "private draft", "--ns", PRIVATE_NS]) == 2
|
|
258
|
+
assert cli_main(root + ["get", priv_id]) == 2
|
|
259
|
+
assert cli_main(root + ["search", "private draft", "--ns", PRIVATE_NS, "--reader", OWNER]) == 0
|
|
260
|
+
out = json.loads(capsys.readouterr().out)
|
|
261
|
+
# CLI search 输出是裸数组({"hits": ...} 包装只在 MCP 层)
|
|
262
|
+
assert [h["id"] for h in out] == [priv_id]
|
|
263
|
+
assert cli_main(root + ["get", priv_id, "--reader", OWNER_BARE]) == 0
|
|
264
|
+
out = json.loads(capsys.readouterr().out)
|
|
265
|
+
assert out["found"] is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{compound_memory-0.1.0 → compound_memory-0.2.0}/src/compound_memory.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|