compound-memory 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """compound-memory: local multi-agent shared memory with compounding."""
2
+
3
+ __version__ = "0.1.0"
compound_memory/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """CLI:不经 MCP 直接访问同一 store(脚本、定时蒸馏、衰减扫描等运维面)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import datetime as dt
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import __version__
13
+ from .embedding import auto_encoder
14
+ from .storage import DISTILL_DUP_SIM_THRESHOLD, MEMORY_TYPES, MemoryStore, PROMOTION_USES_THRESHOLD, default_root
15
+
16
+
17
+ def _emit(payload: Any) -> None:
18
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
19
+
20
+
21
+ def _open_store(args: argparse.Namespace) -> MemoryStore:
22
+ # CLI 与 server 同权:vec extra + 模型就绪即启用向量路,否则自动降级纯词面
23
+ return MemoryStore(Path(args.root), embedder=auto_encoder())
24
+
25
+
26
+ def cmd_init(args: argparse.Namespace) -> None:
27
+ store = _open_store(args)
28
+ _emit({"ok": True, "root": str(store.root)})
29
+
30
+
31
+ def cmd_write(args: argparse.Namespace) -> None:
32
+ _emit(_open_store(args).write(content=args.content, type=args.type, source=args.source, ns=args.ns, key=args.key))
33
+
34
+
35
+ def cmd_search(args: argparse.Namespace) -> None:
36
+ _emit(
37
+ _open_store(args).search(
38
+ query=args.query, ns=args.ns, top_k=args.top_k, include_neighbors=args.include_neighbors
39
+ )
40
+ )
41
+
42
+
43
+ def cmd_get(args: argparse.Namespace) -> None:
44
+ _emit(_open_store(args).get(args.id))
45
+
46
+
47
+ def cmd_link(args: argparse.Namespace) -> None:
48
+ _emit(_open_store(args).link(args.a, args.b))
49
+
50
+
51
+ def cmd_feedback(args: argparse.Namespace) -> None:
52
+ _emit(_open_store(args).feedback(args.id, args.agent))
53
+
54
+
55
+ def cmd_decay(args: argparse.Namespace) -> None:
56
+ # --now 经固定 clock 的 store 注入——时间接缝只有 clock 一条(store 不另设 now= 参数)
57
+ if args.now:
58
+ store = MemoryStore(Path(args.root), clock=lambda: dt.date.fromisoformat(args.now))
59
+ else:
60
+ store = _open_store(args)
61
+ _emit({"archived": store.decay_sweep()})
62
+
63
+
64
+ def cmd_revive(args: argparse.Namespace) -> None:
65
+ _emit(_open_store(args).revive(args.id))
66
+
67
+
68
+ def cmd_stats(args: argparse.Namespace) -> None:
69
+ _emit(_open_store(args).stats())
70
+
71
+
72
+ def cmd_rebuild_index(args: argparse.Namespace) -> None:
73
+ _emit(_open_store(args).rebuild_index())
74
+
75
+
76
+ def cmd_review_queue(args: argparse.Namespace) -> None:
77
+ _emit(_open_store(args).review_queue())
78
+
79
+
80
+ def cmd_review_resolve(args: argparse.Namespace) -> None:
81
+ _emit(_open_store(args).review_resolve(ids=args.ids, all=args.all))
82
+
83
+
84
+ def cmd_distill_plan(args: argparse.Namespace) -> None:
85
+ _emit(
86
+ _open_store(args).distill_plan(
87
+ window_days=args.window,
88
+ min_uses=args.min_uses,
89
+ min_confidence=args.min_confidence,
90
+ ns=args.ns,
91
+ )
92
+ )
93
+
94
+
95
+ def cmd_distill_apply(args: argparse.Namespace) -> None:
96
+ _emit(
97
+ _open_store(args).distill_apply(
98
+ content=args.content,
99
+ type=args.type,
100
+ source=args.source,
101
+ source_ids=[s.strip() for s in args.sources.split(",") if s.strip()],
102
+ ns=args.ns,
103
+ key=args.key,
104
+ confidence=args.confidence,
105
+ )
106
+ )
107
+
108
+
109
+ def cmd_git_log(args: argparse.Namespace) -> None:
110
+ _emit(_open_store(args).git_log(limit=args.limit))
111
+
112
+
113
+ def build_parser() -> argparse.ArgumentParser:
114
+ parser = argparse.ArgumentParser(prog="compound-memory", description="local multi-agent shared memory")
115
+ parser.add_argument("--version", action="version", version=__version__)
116
+ parser.add_argument("--root", default=None, help="store root (default: $COMPOUND_MEMORY_ROOT or ~/.agents/memory)")
117
+ sub = parser.add_subparsers(dest="command", required=True)
118
+
119
+ p = sub.add_parser("init"); p.set_defaults(func=cmd_init)
120
+
121
+ p = sub.add_parser("write")
122
+ p.add_argument("content"); p.add_argument("type", choices=MEMORY_TYPES)
123
+ p.add_argument("source"); p.add_argument("--ns", default="_shared"); p.add_argument("--key", default=None)
124
+ p.set_defaults(func=cmd_write)
125
+
126
+ p = sub.add_parser("search")
127
+ p.add_argument("query"); p.add_argument("--ns", default="_shared"); p.add_argument("--top-k", type=int, default=5)
128
+ p.add_argument("--no-neighbors", dest="include_neighbors", action="store_false",
129
+ help="omit embedded one-hop neighbors from hits")
130
+ p.set_defaults(func=cmd_search)
131
+
132
+ p = sub.add_parser("get"); p.add_argument("id"); p.set_defaults(func=cmd_get)
133
+ p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b"); p.set_defaults(func=cmd_link)
134
+ p = sub.add_parser("feedback"); p.add_argument("id"); p.add_argument("agent"); p.set_defaults(func=cmd_feedback)
135
+
136
+ p = sub.add_parser("decay"); p.add_argument("--now", default=None, help="ISO date override (testing)")
137
+ p.set_defaults(func=cmd_decay)
138
+
139
+ p = sub.add_parser("revive"); p.add_argument("id"); p.set_defaults(func=cmd_revive)
140
+ p = sub.add_parser(
141
+ "distill-plan",
142
+ help="scan distillation candidates and print a signal-annotated list",
143
+ description="Scan distillation candidates (deterministic half of distillation). Signals per candidate: "
144
+ f"merge_with (same ns/type/key, strong), possible_dup_of (BM25 normalized_similarity >= {DISTILL_DUP_SIM_THRESHOLD}, weak), "
145
+ f"promotion_candidate (episode uses >= {PROMOTION_USES_THRESHOLD}). Judgment (merge/summarize) stays with the calling agent.",
146
+ )
147
+ p.add_argument("--window", type=int, default=30, help="recency window in days (last_used first, created fallback)")
148
+ p.add_argument("--min-uses", type=int, default=1, help="activity gate: uses >= this")
149
+ p.add_argument("--min-confidence", type=float, default=0.5, help="activity gate: confidence >= this")
150
+ p.add_argument("--ns", default="_shared")
151
+ p.set_defaults(func=cmd_distill_plan)
152
+ p = sub.add_parser("distill-apply")
153
+ p.add_argument("content"); p.add_argument("type", choices=MEMORY_TYPES); p.add_argument("source")
154
+ p.add_argument("--sources", required=True, help="comma-separated source memory ids")
155
+ p.add_argument("--ns", default="_shared"); p.add_argument("--key", default=None)
156
+ p.add_argument("--confidence", type=float, default=None)
157
+ p.set_defaults(func=cmd_distill_apply)
158
+ p = sub.add_parser("stats"); p.set_defaults(func=cmd_stats)
159
+ p = sub.add_parser("rebuild-index"); p.set_defaults(func=cmd_rebuild_index)
160
+ p = sub.add_parser("review-queue"); p.set_defaults(func=cmd_review_queue)
161
+ p = sub.add_parser(
162
+ "review-resolve",
163
+ help="mark review-queue conflicts as resolved",
164
+ description="Clear review-queue entries after the caller has judged the conflict "
165
+ "(old/new trade-off stays with the calling agent or human). Pass memory ids to clear "
166
+ "matching rows (either the old or new id of a row counts), or --all to clear the queue. "
167
+ "Unknown ids are rejected atomically; the cleanup is auto-committed.",
168
+ )
169
+ p.add_argument("ids", nargs="*", metavar="ID")
170
+ p.add_argument("--all", action="store_true", help="clear the whole queue")
171
+ p.set_defaults(func=cmd_review_resolve)
172
+ p = sub.add_parser("git-log"); p.add_argument("--limit", type=int, default=5); p.set_defaults(func=cmd_git_log)
173
+ return parser
174
+
175
+
176
+ def main(argv: list[str] | None = None) -> int:
177
+ args = build_parser().parse_args(argv)
178
+ if args.root is None:
179
+ args.root = default_root()
180
+ try:
181
+ args.func(args)
182
+ return 0
183
+ except (ValueError, PermissionError) as exc:
184
+ # store 接口的调用方错误统一在这里翻译成 JSON(MCP 侧由框架转 is_error)
185
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
186
+ return 2
187
+
188
+
189
+ if __name__ == "__main__":
190
+ sys.exit(main())
@@ -0,0 +1,96 @@
1
+ """BGE-small-zh 本地 embedding 编码器(向量召回路的模型缝)。
2
+
3
+ - 模型:默认 Xenova/bge-small-zh-v1.5 ONNX int8(24MB,经 hf-mirror 下载到 HF 缓存,
4
+ 本地推理全程无外发,spec story 17)。缓存缺失时构造即失败——不自动联网下载。
5
+ repo id 与输出维度可经环境变量覆盖(换模型属运维动作,改后需显式 rebuild-index):
6
+ - COMPOUND_MEMORY_EMBEDDING_MODEL:HF repo id(默认 Xenova/bge-small-zh-v1.5);
7
+ - COMPOUND_MEMORY_EMBEDDING_DIM:模型输出维度(默认 512,须与模型一致)。
8
+ - 依赖:属可选 extra `vec`(onnxruntime/tokenizers/numpy)。本模块 import 任何
9
+ 失败都置 VEC_AVAILABLE=False,由调用方降级为纯词面检索(检索降级不报错)。
10
+ - 编码语义(与 vec-spike 验证一致):[CLS] 表示 + L2 归一化,truncation 512。
11
+ 单条长文档实测 ~400ms,查询短文本 ~15ms。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import glob
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Callable
20
+
21
+ # 模型与维度的单一定义点:vector_index 建表维度也从这里 import,勿另设常量
22
+ MODEL_REPO_ID = os.environ.get("COMPOUND_MEMORY_EMBEDDING_MODEL", "Xenova/bge-small-zh-v1.5")
23
+ EMBED_DIM = int(os.environ.get("COMPOUND_MEMORY_EMBEDDING_DIM", "512"))
24
+
25
+
26
+ def _cache_glob(repo_id: str) -> str:
27
+ """HF 缓存目录 glob:repo id 的 "/" 替换为 "--"(如 a/b → models--a--b)。"""
28
+ return f".cache/huggingface/hub/models--{repo_id.replace('/', '--')}/snapshots/*"
29
+
30
+
31
+ _MODEL_GLOB = _cache_glob(MODEL_REPO_ID)
32
+
33
+ try:
34
+ import numpy as np
35
+ import onnxruntime as ort
36
+ from tokenizers import Tokenizer
37
+
38
+ VEC_AVAILABLE = True
39
+ except ImportError: # pragma: no cover - 取决于安装环境是否带 vec extra
40
+ VEC_AVAILABLE = False
41
+
42
+
43
+ class ModelMissingError(RuntimeError):
44
+ """HF 缓存中找不到 BGE ONNX 模型(本地优先:不做自动下载,交调用方降级)。"""
45
+
46
+
47
+ def auto_encoder() -> "Callable[[list[str]], list[list[float]]] | None":
48
+ """入口默认缝:依赖与模型都就绪才返回编码器(encode 绑定方法),否则 None(降级纯词面)。"""
49
+ if not VEC_AVAILABLE:
50
+ return None
51
+ try:
52
+ return BgeEncoder().encode
53
+ except (ModelMissingError, OSError):
54
+ return None
55
+
56
+
57
+ class BgeEncoder:
58
+ """惰性加载的 ONNX 编码器;构造只解析模型路径,首次 encode 才建 session。"""
59
+
60
+ def __init__(self, home: Path | None = None) -> None:
61
+ if not VEC_AVAILABLE:
62
+ raise ModelMissingError("vec dependencies not installed (pip install 'compound-memory[vec]')")
63
+ base = home or Path.home()
64
+ snaps = sorted(glob.glob(str(base / _MODEL_GLOB)))
65
+ if not snaps:
66
+ raise ModelMissingError(
67
+ f"{MODEL_REPO_ID} ONNX model not found in HF cache; "
68
+ "download via hf-mirror.com (see docs/specs/0001-compound-memory-spec.md)"
69
+ )
70
+ snap = Path(snaps[-1])
71
+ self._onnx_path = snap / "onnx" / "model_quantized.onnx"
72
+ self._tokenizer_path = snap / "tokenizer.json"
73
+ if not self._onnx_path.exists() or not self._tokenizer_path.exists():
74
+ raise ModelMissingError(f"incomplete model snapshot: {snap}")
75
+ self._session: "ort.InferenceSession | None" = None
76
+ self._tokenizer: "Tokenizer | None" = None
77
+
78
+ def encode(self, texts: list[str]) -> list[list[float]]:
79
+ """批量编码;L2 归一化后的 [CLS] 表示(余弦可直接用作相似度)。"""
80
+ assert VEC_AVAILABLE # 构造已保证;reassure 类型检查
81
+ if self._session is None or self._tokenizer is None:
82
+ self._tokenizer = Tokenizer.from_file(str(self._tokenizer_path))
83
+ self._tokenizer.enable_truncation(max_length=512)
84
+ self._tokenizer.enable_padding()
85
+ self._session = ort.InferenceSession(str(self._onnx_path), providers=["CPUExecutionProvider"])
86
+ encs = self._tokenizer.encode_batch(texts)
87
+ feed = {
88
+ "input_ids": np.array([e.ids for e in encs], dtype=np.int64),
89
+ "attention_mask": np.array([e.attention_mask for e in encs], dtype=np.int64),
90
+ "token_type_ids": np.array([e.type_ids for e in encs], dtype=np.int64),
91
+ }
92
+ names = {i.name for i in self._session.get_inputs()}
93
+ out = self._session.run(None, {k: v for k, v in feed.items() if k in names})[0]
94
+ cls = out[:, 0, :]
95
+ normed = cls / np.linalg.norm(cls, axis=1, keepdims=True)
96
+ return normed.tolist()
@@ -0,0 +1,203 @@
1
+ """Index: token→path 的可重建缓存(纯路径集合视图)。
2
+
3
+ 不变量在此唯一归属:活动记忆必被索引,归档记忆必不在索引。
4
+ 缓存文件缺失或损坏 ⇒ 经注入的 scan_pairs 全量重建——降级到慢,绝不报错。
5
+ 活性是 store 级而非进程级:读路径检测跨进程缓存更新(重载)与带外目录
6
+ 变更(增量对账,只应用 diff);手编已有文件的内容不改目录 mtime,那条路走显式 rebuild。
7
+ 检索文本知识来自 scoring.doc_text(单一定义点)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from .model import Memory
17
+ from .scoring import tokenize, doc_text
18
+
19
+
20
+ class Index:
21
+ """Deep module: 三个动词 sync / candidates / rebuild,缓存机制全部在实现内。
22
+
23
+ 调用方无需感知缓存何时加载、何时重建、archived 走哪条路,
24
+ 也无需感知缓存是否被其他进程更新过——活性检测在读路径内部完成。
25
+ """
26
+
27
+ def __init__(self, root: Path, scan_pairs: Callable[[], list[tuple[Memory, str]]]) -> None:
28
+ self._root = root
29
+ self._scan_pairs = scan_pairs
30
+ self._dir = root / "index"
31
+ self._dir.mkdir(parents=True, exist_ok=True)
32
+ self._path = self._dir / "tokens.json"
33
+ self._data: dict[str, list[str]] | None = None # None = 未加载
34
+ self._dead = False # 落盘缓存缺失或损坏,待重建
35
+ self._loaded_stamp: int | None = None # 缓存文件上次加载时的 mtime_ns
36
+
37
+ # ---------- 缓存活性(重建/重载协议在这里,调用方不可见) ----------
38
+
39
+ def _load(self) -> dict[str, list[str]]:
40
+ if self._data is None:
41
+ try:
42
+ self._data = json.loads(self._path.read_text(encoding="utf-8"))
43
+ self._loaded_stamp = self._cache_stamp()
44
+ except (OSError, json.JSONDecodeError):
45
+ self._data = {}
46
+ self._dead = True
47
+ return self._data
48
+
49
+ def _cache_stamp(self) -> int | None:
50
+ try:
51
+ return self._path.stat().st_mtime_ns
52
+ except OSError:
53
+ return None
54
+
55
+ def _ensure_live(self) -> None:
56
+ """写路径保活:缓存缺失/损坏 ⇒ 重建(随后的增量 upsert 基于活缓存)。"""
57
+ self._load()
58
+ if self._dead or not self._path.exists():
59
+ self.rebuild(self._scan_pairs())
60
+
61
+ def _ensure_fresh(self) -> None:
62
+ """读路径三级自愈(原 Q1-A 决策的跨进程扩展):
63
+
64
+ 1. 缺失/损坏 ⇒ 全量重建;
65
+ 2. 缓存文件 mtime 变了(其他进程写过)⇒ 丢弃内存态重载;
66
+ 3. 任一 ns/type 目录 mtime 晚于缓存文件(带外新增/删除 .md)
67
+ ⇒ 增量对账:scan 后只更新 diff 的条目(与 VectorIndex._reconcile
68
+ 同构,perf-bench:千条库带外写后首查的大头)。
69
+ """
70
+ self._ensure_live()
71
+ stamp = self._cache_stamp()
72
+ if self._loaded_stamp is not None and stamp != self._loaded_stamp:
73
+ self._data = None
74
+ self._load()
75
+ if self._dead:
76
+ self.rebuild(self._scan_pairs())
77
+ return
78
+ stamp = self._loaded_stamp if self._loaded_stamp is not None else stamp
79
+ if stamp is not None and self._dirs_newer_than(stamp):
80
+ self._reconcile()
81
+
82
+ def _dirs_newer_than(self, cache_stamp: int) -> bool:
83
+ """活动区目录在缓存落盘后发生过增删(新增/删除文件会更新父目录 mtime)。"""
84
+ ns_root = self._root / "namespaces"
85
+ if not ns_root.is_dir():
86
+ return False
87
+ try:
88
+ ns_dirs = [d for d in ns_root.iterdir() if d.is_dir()]
89
+ except OSError:
90
+ return False
91
+ for ns_dir in ns_dirs:
92
+ try:
93
+ if ns_dir.stat().st_mtime_ns > cache_stamp:
94
+ return True
95
+ type_dirs = [d for d in ns_dir.iterdir() if d.is_dir()]
96
+ except OSError:
97
+ continue
98
+ for t_dir in type_dirs:
99
+ try:
100
+ if t_dir.stat().st_mtime_ns > cache_stamp:
101
+ return True
102
+ except OSError:
103
+ continue
104
+ return False
105
+
106
+ def _reconcile(self) -> None:
107
+ """带外增删的增量对账:反转缓存出 rel→tokens 基线,scan 后只应用 diff。
108
+
109
+ 与 VectorIndex._reconcile 同构——带外写一条不再放大成全库重建
110
+ (scan + tokenize + tokens.json 全量重写,千条库秒级)。diff 应用在
111
+ 内存态完成后一次落盘;缓存与全量重建是集合等价的(rels 列表顺序
112
+ 不保证一致,candidates 的 sorted 输出不受影响)。
113
+ """
114
+ index = self._load()
115
+ known: dict[str, set[str]] = {}
116
+ for tok, rels in index.items():
117
+ for rel in rels:
118
+ known.setdefault(rel, set()).add(tok)
119
+ active: dict[str, set[str]] = {}
120
+ for mem, rel in self._scan_pairs():
121
+ if not mem.archived:
122
+ active[rel] = set(tokenize(doc_text(mem)))
123
+ changed = False
124
+ for rel, tokens in active.items():
125
+ if known.get(rel) == tokens:
126
+ continue
127
+ self._purge(rel)
128
+ for tok in tokens:
129
+ index.setdefault(tok, []).append(rel)
130
+ changed = True
131
+ for rel in known:
132
+ if rel not in active:
133
+ self._purge(rel)
134
+ changed = True
135
+ if changed:
136
+ self._save()
137
+
138
+ def _save(self) -> None:
139
+ # 目录可能被外部整体移走(测试模拟缓存丢失、或人为 rm -rf index/),写前确保存在
140
+ self._dir.mkdir(parents=True, exist_ok=True)
141
+ tmp = self._path.with_suffix(".tmp")
142
+ tmp.write_text(json.dumps(self._data or {}, ensure_ascii=False, sort_keys=True), encoding="utf-8")
143
+ tmp.replace(self._path)
144
+ self._loaded_stamp = self._cache_stamp() # 自己写盘后刷新基线,避免自触发重载
145
+
146
+ # ---------- interface ----------
147
+
148
+ def sync(self, mem: Memory, rel_path: str) -> None:
149
+ """使索引与 mem 一致:active ⇒ rel_path 已索引;archived ⇒ rel_path 已移除。
150
+
151
+ rel_path 指该记忆在活动区的路径;归档场景由调用方传入原活动路径。
152
+ """
153
+ self._ensure_live()
154
+ if mem.archived:
155
+ self._remove(rel_path)
156
+ else:
157
+ self._upsert(mem, rel_path)
158
+
159
+ def candidates(self, tokens: list[str]) -> list[str]:
160
+ """文档含任一 query token 的相对路径;[] 表示无匹配。
161
+
162
+ 读路径三级自愈(跨进程重载 / 带外重建)在内部完成,调用方无感。
163
+ """
164
+ self._ensure_fresh()
165
+ index = self._load()
166
+ rels: set[str] = set()
167
+ for tok in tokens:
168
+ rels.update(index.get(tok, []))
169
+ return sorted(rels)
170
+
171
+ def rebuild(self, memories: list[tuple[Memory, str]]) -> dict[str, int]:
172
+ """以 (memory, rel_path) 序对全量重建;返回计数。"""
173
+ index: dict[str, list[str]] = {}
174
+ for mem, rel in memories:
175
+ for tok in set(tokenize(doc_text(mem))):
176
+ index.setdefault(tok, []).append(rel)
177
+ self._data = index
178
+ self._dead = False
179
+ self._save()
180
+ return {"memories": len(memories), "tokens": len(index)}
181
+
182
+ # ---------- 内部:变更原语 ----------
183
+
184
+ def _purge(self, rel_path: str) -> None:
185
+ """清除某条路径的全部词条残留(不落盘,由调用方决定后续写)。"""
186
+ index = self._load()
187
+ for tok in list(index):
188
+ if rel_path in index[tok]:
189
+ index[tok].remove(rel_path)
190
+ if not index[tok]:
191
+ del index[tok]
192
+
193
+ def _upsert(self, mem: Memory, rel_path: str) -> None:
194
+ """加入/刷新一条记忆的词条;先移除其残留旧路径。"""
195
+ self._purge(rel_path)
196
+ index = self._load()
197
+ for tok in set(tokenize(doc_text(mem))):
198
+ index.setdefault(tok, []).append(rel_path)
199
+ self._save()
200
+
201
+ def _remove(self, rel_path: str) -> None:
202
+ self._purge(rel_path)
203
+ self._save()
@@ -0,0 +1,44 @@
1
+ """Domain model shared across modules (moved out of storage to break the cycle)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TypeSpec:
10
+ """一种记忆类型的全套规格——全系统唯一的类型知识源(CONTEXT.md: memory type)。"""
11
+
12
+ ttl_days: int | None # 归档 TTL;None = 永不衰减
13
+ weight: float # 检索类型权重
14
+ tau_days: float # 新近半衰期(天)
15
+
16
+
17
+ TYPE_SPEC: dict[str, TypeSpec] = {
18
+ "episode": TypeSpec(ttl_days=90, weight=0.5, tau_days=30.0),
19
+ "fact": TypeSpec(ttl_days=None, weight=0.9, tau_days=365.0),
20
+ "insight": TypeSpec(ttl_days=180, weight=0.7, tau_days=90.0),
21
+ "skill": TypeSpec(ttl_days=None, weight=1.0, tau_days=365.0),
22
+ }
23
+ MEMORY_TYPES = tuple(TYPE_SPEC)
24
+ TTL_DAYS: dict[str, int | None] = {t: s.ttl_days for t, s in TYPE_SPEC.items()}
25
+
26
+
27
+ @dataclass
28
+ class Memory:
29
+ id: str
30
+ ns: str
31
+ type: str
32
+ source: str
33
+ created: str
34
+ content: str
35
+ confidence: float = 0.5
36
+ uses: int = 0
37
+ last_used: str | None = None
38
+ links: list[str] = field(default_factory=list)
39
+ ttl: int | None = None
40
+ key: str | None = None
41
+ validated_by: list[str] = field(default_factory=list)
42
+ archived: bool = False
43
+ # 写入来源通道(frontmatter 可选字段):普通写入不落盘,仅蒸馏产物为 "distillation"
44
+ origin: str | None = None
@@ -0,0 +1,82 @@
1
+ """Review queue(冲突队列):review-queue.md 的生成、解析与清除——行格式单一定义点。
2
+
3
+ 每行一条冲突记录,由 MemoryStore._write_new 在同 key fact/insight 内容冲突时 append;
4
+ 裁决(新旧取舍)归调用方,这里只登记、展示与清除,不做判断、不碰 git。
5
+
6
+ 行结构(append 生成,机器写入):old/new 记忆 id 各在 "(` 与 " (" 边界;
7
+ 非贪婪到首个 " vs "——content 截断 40 字符,正则回溯保证内容含 " vs " 时仍取对 id。
8
+ 解析失败的行 fail-safe 保留:宁可不登记,不误删记录。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Callable
17
+
18
+ from .model import Memory
19
+
20
+ _REVIEW_ROW_RE = re.compile(r"^- \S+ conflict `[^`]+`: (?P<old>\S+) \(.*?\) vs (?P<new>\S+) \(")
21
+
22
+
23
+ class ReviewQueue:
24
+ """冲突队列 artifact 的所有者:append 写入、lines 展示、resolve 清除。"""
25
+
26
+ def __init__(self, path: Path, clock: Callable[[], dt.date]) -> None:
27
+ self.path = path
28
+ self._clock = clock
29
+
30
+ def append(self, old: Memory, new: Memory) -> None:
31
+ line = (
32
+ f"- {self._clock().isoformat()} conflict `{new.ns}/{new.type}/{new.key}`: "
33
+ f"{old.id} ({old.source}: {old.content[:40]}) vs {new.id} ({new.source}: {new.content[:40]})\n"
34
+ )
35
+ with self.path.open("a", encoding="utf-8") as fh:
36
+ fh.write(line)
37
+
38
+ def lines(self) -> list[str]:
39
+ """展示行(去 "- " 前缀,即 CLI review-queue 的输出)。"""
40
+ if not self.path.exists():
41
+ return []
42
+ return [
43
+ line[2:].rstrip("\n")
44
+ for line in self.path.read_text(encoding="utf-8").splitlines()
45
+ if line.startswith("- ")
46
+ ]
47
+
48
+ def resolve(self, ids: list[str] | None = None, all: bool = False) -> dict[str, int]:
49
+ """清除命中行,返回 {"resolved", "remaining"};git commit 归调用方(MemoryStore)。
50
+
51
+ - all=True:清空整个队列(幂等,空队列返回 resolved=0)。
52
+ - 按 id:行内 old/new 任一命中即整行清除;任一 id 未命中任何行 ⇒
53
+ ValueError 原子拒绝(队列原样保留),避免半清状态让调用方误判。
54
+ """
55
+ if all and ids:
56
+ raise ValueError("pass either ids or --all, not both")
57
+ if not all and not ids:
58
+ raise ValueError("review-resolve needs memory ids or --all")
59
+ if self.path.exists():
60
+ lines = self.path.read_text(encoding="utf-8").splitlines(keepends=True)
61
+ else:
62
+ lines = []
63
+ if all:
64
+ keep = []
65
+ removed = sum(1 for line in lines if line.startswith("- "))
66
+ else:
67
+ wanted = set(ids or [])
68
+ covered: set[str] = set()
69
+ matched: list[bool] = []
70
+ for line in lines:
71
+ m = _REVIEW_ROW_RE.match(line)
72
+ matched.append(m is not None and bool(wanted & {m.group("old"), m.group("new")}))
73
+ if m is not None:
74
+ covered |= {m.group("old"), m.group("new")}
75
+ missing = wanted - covered
76
+ if missing:
77
+ raise ValueError(f"ids not found in review queue: {', '.join(sorted(missing))}")
78
+ keep = [line for line, hit in zip(lines, matched) if not hit]
79
+ removed = sum(matched)
80
+ if removed:
81
+ self.path.write_text("".join(keep), encoding="utf-8")
82
+ return {"resolved": removed, "remaining": sum(1 for line in keep if line.startswith("- "))}