memory-lab-mcp 0.1.1__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
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from claude_memory_lab.memory import (
12
+ add_memory,
13
+ classify_risk,
14
+ get_memory,
15
+ normalize_project_key,
16
+ supersede_memory,
17
+ update_memory,
18
+ )
19
+
20
+ SOURCE_METADATA_KEYS = (
21
+ "source_kind",
22
+ "source_path",
23
+ "source_type",
24
+ "source_name",
25
+ "origin_session_id",
26
+ "content_sha256",
27
+ )
28
+
29
+ TYPE_MAPPING = {
30
+ "feedback": "preference",
31
+ "project": "knowledge",
32
+ "reference": "knowledge",
33
+ "user": "preference",
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class AutoMemory:
39
+ path: Path
40
+ name: str
41
+ content: str
42
+ source_type: str
43
+ memory_type: str
44
+ origin_session_id: str | None
45
+ content_sha256: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ImportAction:
50
+ kind: str
51
+ memory: AutoMemory
52
+ memory_id: int | None = None
53
+ duplicate_id: int | None = None
54
+
55
+
56
+ def _parse_scalar(value: str, path: Path, key: str) -> str:
57
+ value = value.strip()
58
+ if not value or value in {">", "|", ">-", "|-"}:
59
+ raise ValueError(f"unsupported {key} in {path}")
60
+ if value[0] in {'"', "'"}:
61
+ try:
62
+ parsed = ast.literal_eval(value)
63
+ except (SyntaxError, ValueError) as exc:
64
+ raise ValueError(f"invalid quoted {key} in {path}") from exc
65
+ if not isinstance(parsed, str):
66
+ raise ValueError(f"invalid {key} in {path}")
67
+ return parsed.strip()
68
+ return value
69
+
70
+
71
+ def parse_auto_memory(path: Path) -> AutoMemory:
72
+ text = path.read_text(encoding="utf-8")
73
+ lines = text.splitlines()
74
+ if not lines or lines[0].strip() != "---":
75
+ raise ValueError(f"missing frontmatter in {path}")
76
+ try:
77
+ closing = next(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---")
78
+ except StopIteration as exc:
79
+ raise ValueError(f"unterminated frontmatter in {path}") from exc
80
+
81
+ fields: dict[str, str] = {}
82
+ metadata: dict[str, str] = {}
83
+ section: str | None = None
84
+ for line in lines[1:closing]:
85
+ if not line.strip() or line.lstrip().startswith("#"):
86
+ continue
87
+ indentation = len(line) - len(line.lstrip())
88
+ if ":" not in line:
89
+ raise ValueError(f"malformed frontmatter in {path}")
90
+ key, value = line.strip().split(":", 1)
91
+ if indentation == 0:
92
+ section = key if not value.strip() else None
93
+ if value.strip():
94
+ fields[key] = _parse_scalar(value, path, key)
95
+ elif section == "metadata":
96
+ metadata[key] = _parse_scalar(value, path, f"metadata.{key}")
97
+ else:
98
+ raise ValueError(f"unsupported nested frontmatter in {path}")
99
+
100
+ name = fields.get("name", "").strip()
101
+ content = fields.get("description", "").strip()
102
+ source_type = (fields.get("type") or metadata.get("type") or "").strip().lower()
103
+ if not name:
104
+ raise ValueError(f"missing name in {path}")
105
+ if not content:
106
+ raise ValueError(f"missing description in {path}")
107
+ if source_type not in TYPE_MAPPING:
108
+ raise ValueError(f"unknown auto-memory type {source_type!r} in {path}")
109
+
110
+ return AutoMemory(
111
+ path=path,
112
+ name=name,
113
+ content=content,
114
+ source_type=source_type,
115
+ memory_type=TYPE_MAPPING[source_type],
116
+ origin_session_id=fields.get("originSessionId") or metadata.get("originSessionId"),
117
+ content_sha256=hashlib.sha256(content.encode("utf-8")).hexdigest(),
118
+ )
119
+
120
+
121
+ def load_selected_memories(source_dir: Path, includes: list[str]) -> list[AutoMemory]:
122
+ root = source_dir.expanduser().resolve()
123
+ if not root.is_dir():
124
+ raise ValueError(f"auto-memory directory not found: {root}")
125
+
126
+ memories = []
127
+ seen_paths = set()
128
+ for include in includes:
129
+ path = (root / include).resolve()
130
+ if path.parent != root:
131
+ raise ValueError(f"auto-memory path must be directly inside {root}: {include}")
132
+ if path.name == "MEMORY.md" or path in seen_paths:
133
+ continue
134
+ seen_paths.add(path)
135
+ if path.suffix.lower() != ".md" or not path.is_file():
136
+ raise ValueError(f"auto-memory file not found: {path}")
137
+ memories.append(parse_auto_memory(path))
138
+ return memories
139
+
140
+
141
+ def _metadata(memory: AutoMemory) -> dict[str, object]:
142
+ metadata: dict[str, object] = {
143
+ "source_kind": "claude_auto_memory",
144
+ "source_path": str(memory.path),
145
+ "source_type": memory.source_type,
146
+ "source_name": memory.name,
147
+ "content_sha256": memory.content_sha256,
148
+ }
149
+ if memory.origin_session_id:
150
+ metadata["origin_session_id"] = memory.origin_session_id
151
+ return metadata
152
+
153
+
154
+ def _source_metadata_view(metadata: dict[str, object]) -> dict[str, object]:
155
+ return {key: metadata.get(key) for key in SOURCE_METADATA_KEYS}
156
+
157
+
158
+ def plan_auto_memory_import(
159
+ conn: sqlite3.Connection | None,
160
+ source_dir: Path,
161
+ includes: list[str],
162
+ project: str,
163
+ ) -> list[ImportAction]:
164
+ memories = load_selected_memories(source_dir, includes)
165
+ project_key = normalize_project_key(project)
166
+ if project_key is None:
167
+ raise ValueError("project must not be blank")
168
+ rows = (
169
+ conn.execute(
170
+ """
171
+ select id, content, memory_type, status, risk_tier, metadata_json
172
+ from memories
173
+ where status in ('active', 'pending')
174
+ and (lower(project_key) = ? or project_key is null)
175
+ order by case status when 'active' then 0 else 1 end, id
176
+ """,
177
+ (project_key,),
178
+ ).fetchall()
179
+ if conn is not None
180
+ else []
181
+ )
182
+ by_source: dict[str, sqlite3.Row] = {}
183
+ by_hash: dict[str, list[sqlite3.Row]] = {}
184
+ row_hashes: dict[int, str] = {}
185
+ for row in rows:
186
+ metadata = json.loads(row["metadata_json"])
187
+ content_hash = metadata.get("content_sha256")
188
+ if not isinstance(content_hash, str):
189
+ content_hash = hashlib.sha256(
190
+ row["content"].strip().encode("utf-8")
191
+ ).hexdigest()
192
+ row_hashes[int(row["id"])] = content_hash
193
+ by_hash.setdefault(content_hash, []).append(row)
194
+ if metadata.get("source_kind") == "claude_auto_memory":
195
+ source_path = metadata.get("source_path")
196
+ if isinstance(source_path, str):
197
+ by_source[source_path] = row
198
+
199
+ vacated_ids = {
200
+ int(source_row["id"])
201
+ for memory in memories
202
+ if (source_row := by_source.get(str(memory.path))) is not None
203
+ and row_hashes[int(source_row["id"])] != memory.content_sha256
204
+ }
205
+
206
+ def available_duplicate(content_hash: str) -> sqlite3.Row | None:
207
+ return next(
208
+ (
209
+ row
210
+ for row in by_hash.get(content_hash, [])
211
+ if int(row["id"]) not in vacated_ids
212
+ ),
213
+ None,
214
+ )
215
+
216
+ if os.getenv("MEMORY_LAB_REQUIRE_REVIEW", "") == "1":
217
+ pending_changes = []
218
+ for memory in memories:
219
+ source_row = by_source.get(str(memory.path))
220
+ if source_row is None or source_row["status"] != "active":
221
+ continue
222
+ source_id = int(source_row["id"])
223
+ if row_hashes[source_id] == memory.content_sha256:
224
+ continue
225
+ tier = (
226
+ "high"
227
+ if source_row["risk_tier"] == "high"
228
+ else classify_risk(memory.content, memory.memory_type)
229
+ )
230
+ if tier == "high" and available_duplicate(memory.content_sha256) is None:
231
+ pending_changes.append(
232
+ (row_hashes[source_id], memory.content_sha256)
233
+ )
234
+ pending_old_hashes = {old_hash for old_hash, _ in pending_changes}
235
+ if any(
236
+ new_hash in pending_old_hashes
237
+ for _, new_hash in pending_changes
238
+ ):
239
+ raise ValueError(
240
+ "review-gated content dependency must be resolved before import"
241
+ )
242
+
243
+ actions_by_index: dict[int, ImportAction] = {}
244
+ planned_hashes: set[str] = set()
245
+ indexed_memories = list(enumerate(memories))
246
+ for index, memory in indexed_memories:
247
+ source_row = by_source.get(str(memory.path))
248
+ if source_row is None:
249
+ continue
250
+ source_metadata = json.loads(source_row["metadata_json"])
251
+ if source_row["memory_type"] != memory.memory_type:
252
+ raise ValueError(f"auto-memory type changed for {memory.path}")
253
+ if source_metadata.get("content_sha256") == memory.content_sha256:
254
+ if _source_metadata_view(source_metadata) == _source_metadata_view(
255
+ _metadata(memory)
256
+ ):
257
+ actions_by_index[index] = ImportAction(
258
+ "unchanged", memory, int(source_row["id"])
259
+ )
260
+ else:
261
+ actions_by_index[index] = ImportAction(
262
+ "update", memory, int(source_row["id"])
263
+ )
264
+ planned_hashes.add(memory.content_sha256)
265
+ continue
266
+ duplicate_row = available_duplicate(memory.content_sha256)
267
+ if duplicate_row is not None:
268
+ actions_by_index[index] = ImportAction(
269
+ "duplicate",
270
+ memory,
271
+ int(source_row["id"]),
272
+ int(duplicate_row["id"]),
273
+ )
274
+ continue
275
+ if memory.content_sha256 in planned_hashes:
276
+ actions_by_index[index] = ImportAction(
277
+ "duplicate", memory, int(source_row["id"])
278
+ )
279
+ continue
280
+ actions_by_index[index] = ImportAction(
281
+ "update", memory, int(source_row["id"])
282
+ )
283
+ planned_hashes.add(memory.content_sha256)
284
+
285
+ for index, memory in indexed_memories:
286
+ if index in actions_by_index:
287
+ continue
288
+ duplicate_row = available_duplicate(memory.content_sha256)
289
+ if duplicate_row is not None or memory.content_sha256 in planned_hashes:
290
+ actions_by_index[index] = ImportAction("duplicate", memory)
291
+ continue
292
+ actions_by_index[index] = ImportAction("add", memory)
293
+ planned_hashes.add(memory.content_sha256)
294
+
295
+ return [actions_by_index[index] for index in range(len(memories))]
296
+
297
+
298
+ def _transfer_pending_predecessors(
299
+ conn: sqlite3.Connection,
300
+ pending_ids: set[int],
301
+ created_by_hash: dict[str, int],
302
+ ) -> None:
303
+ for pending_id in pending_ids:
304
+ pending = get_memory(conn, pending_id)
305
+ predecessor_value = pending.metadata.get("pending_supersedes")
306
+ predecessor_ids = (
307
+ predecessor_value
308
+ if isinstance(predecessor_value, list)
309
+ else [predecessor_value]
310
+ if predecessor_value is not None
311
+ else []
312
+ )
313
+ remaining = []
314
+ for predecessor_id in predecessor_ids:
315
+ predecessor = get_memory(conn, int(predecessor_id))
316
+ if predecessor is None:
317
+ raise ValueError(f"memory {predecessor_id} not found")
318
+ predecessor_hash = predecessor.metadata.get("content_sha256")
319
+ if not isinstance(predecessor_hash, str):
320
+ predecessor_hash = hashlib.sha256(
321
+ predecessor.content.strip().encode("utf-8")
322
+ ).hexdigest()
323
+ successor_id = created_by_hash.get(predecessor_hash)
324
+ successor = (
325
+ get_memory(conn, successor_id)
326
+ if successor_id is not None
327
+ else None
328
+ )
329
+ if (
330
+ successor is not None
331
+ and successor.status == "active"
332
+ and successor.id != predecessor.id
333
+ ):
334
+ supersede_memory(
335
+ conn,
336
+ predecessor.id,
337
+ successor.id,
338
+ "human",
339
+ commit=False,
340
+ )
341
+ else:
342
+ remaining.append(predecessor.id)
343
+ metadata = dict(pending.metadata)
344
+ metadata.pop("pending_supersedes", None)
345
+ if remaining:
346
+ metadata["pending_supersedes"] = (
347
+ remaining[0] if len(remaining) == 1 else remaining
348
+ )
349
+ conn.execute(
350
+ "update memories set metadata_json = ? where id = ?",
351
+ (json.dumps(metadata, sort_keys=True), pending.id),
352
+ )
353
+
354
+
355
+ def execute_auto_memory_import(
356
+ conn: sqlite3.Connection,
357
+ actions: list[ImportAction],
358
+ project: str,
359
+ ) -> None:
360
+ created_by_hash: dict[str, int] = {}
361
+ pending_ids: set[int] = set()
362
+ conn.execute("savepoint import_auto_memory")
363
+ try:
364
+ for action in actions:
365
+ if action.kind == "add":
366
+ written = add_memory(
367
+ conn,
368
+ action.memory.content,
369
+ action.memory.memory_type,
370
+ "human",
371
+ project_key=project,
372
+ metadata=_metadata(action.memory),
373
+ commit=False,
374
+ )
375
+ if written.status == "active" or action.memory.content_sha256 not in created_by_hash:
376
+ created_by_hash[action.memory.content_sha256] = written.id
377
+ if written.status == "pending":
378
+ pending_ids.add(written.id)
379
+ elif action.kind == "update":
380
+ written = update_memory(
381
+ conn,
382
+ action.memory_id,
383
+ action.memory.content,
384
+ "human",
385
+ metadata=_metadata(action.memory),
386
+ commit=False,
387
+ )
388
+ if written.status == "active" or action.memory.content_sha256 not in created_by_hash:
389
+ created_by_hash[action.memory.content_sha256] = written.id
390
+ if written.status == "pending":
391
+ pending_ids.add(written.id)
392
+
393
+ for action in actions:
394
+ if action.kind != "duplicate" or action.memory_id is None:
395
+ continue
396
+ old = get_memory(conn, action.memory_id)
397
+ if old is not None and old.status == "superseded":
398
+ continue
399
+ successor_id = created_by_hash.get(
400
+ action.memory.content_sha256
401
+ ) or action.duplicate_id
402
+ if successor_id is None:
403
+ raise ValueError(
404
+ f"duplicate successor not found for {action.memory.path}"
405
+ )
406
+ supersede_memory(
407
+ conn,
408
+ action.memory_id,
409
+ successor_id,
410
+ "human",
411
+ commit=False,
412
+ )
413
+ successor = get_memory(conn, successor_id)
414
+ if successor is not None and successor.status == "pending":
415
+ pending_ids.add(successor.id)
416
+
417
+ _transfer_pending_predecessors(
418
+ conn,
419
+ pending_ids,
420
+ created_by_hash,
421
+ )
422
+ conn.execute("release savepoint import_auto_memory")
423
+ conn.commit()
424
+ except Exception:
425
+ conn.execute("rollback to savepoint import_auto_memory")
426
+ conn.execute("release savepoint import_auto_memory")
427
+ raise
@@ -0,0 +1,197 @@
1
+ """Chinese-to-English query bridge for a lexical, English-language store.
2
+
3
+ Memories are written in English while the questions are asked in Chinese, so a
4
+ pure-CJK query has nothing to match: FTS5 yields no terms for it, and the
5
+ `content LIKE '%term%'` fallback finds no Chinese inside an English memory.
6
+ Measured on the live store, that made Chinese questions fail outright.
7
+
8
+ This maps the recurring Chinese vocabulary onto the English words the memories
9
+ actually use, and appends the expansions to the query before it is tokenized.
10
+ It is deliberately a hand-maintained lookup rather than embeddings or a
11
+ translation call: it costs nothing at query time, is deterministic, and its
12
+ effect is measurable by `eval/memory-v2.json`.
13
+
14
+ Keys are matched as substrings, so Traditional and Simplified forms are listed
15
+ separately, and every key is at least two characters: single characters fire
16
+ inside unrelated words (打掃 -> scan, 封鎖 -> lock) and were measured to add no
17
+ recall. Terms were taken from the 120 most recent real Chinese queries in
18
+ query_log plus the phrasings in the retrieval eval; general question vocabulary
19
+ is preferred over proper nouns, which would only fit the eval.
20
+
21
+ ponytail: expansions are appended unbounded. Measured ceiling — a pathological
22
+ query containing every key expands to ~260 FTS terms and still returns in under
23
+ 30 ms; a realistic long Chinese prompt takes a few ms. Flat OR chains of 10000
24
+ terms were also measured to run fine, so there is no depth limit to respect —
25
+ add a cap only if query latency actually regresses.
26
+
27
+ Entries that are generic enough to appear in almost any sentence (自動, 完整,
28
+ 實際, 安全, 版本 ...) are deliberately absent: they expanded into terms that
29
+ matched unrelated memories and doubled the hard-negative false-positive rate
30
+ without adding any recall.
31
+ """
32
+ GLOSSARY = {
33
+ # retrieval / memory domain
34
+ "記憶": "memory memories",
35
+ "记忆": "memory memories",
36
+ "搜尋": "search retrieval",
37
+ "搜索": "search retrieval",
38
+ "檢索": "retrieval search",
39
+ "注入": "inject injection",
40
+ "相關性": "relevance",
41
+ "門檻": "threshold cutoff",
42
+ "阈值": "threshold cutoff",
43
+ "分數": "score scores scoring",
44
+ "分数": "score scores scoring",
45
+ "排序": "rank ranking sort",
46
+ "過濾": "filter",
47
+ "筆記": "note notes",
48
+ "笔记": "note notes",
49
+ "索引": "index",
50
+ "快取": "cache",
51
+ "缓存": "cache",
52
+ # engineering verbs / nouns
53
+ "測試": "test tests testing",
54
+ "测试": "test tests testing",
55
+ "問題": "issue issues problem problems bug bugs",
56
+ "问题": "issue issues problem problems bug bugs",
57
+ "代碼": "code",
58
+ "代码": "code",
59
+ "程式碼": "code",
60
+ "執行": "run execute",
61
+ "执行": "run execute",
62
+ "驗收": "acceptance verify",
63
+ "验收": "acceptance verify",
64
+ "結果": "result",
65
+ "结果": "result",
66
+ "專案": "project projects",
67
+ "项目": "project projects",
68
+ "設定": "config setting",
69
+ "设定": "config setting",
70
+ "配置": "config configuration",
71
+ "資料": "data",
72
+ "数据": "data",
73
+ "資料庫": "database",
74
+ "数据库": "database",
75
+ "確認": "confirm verify",
76
+ "确认": "confirm verify",
77
+ "檢查": "check",
78
+ "检查": "check",
79
+ "實作": "implement implementation",
80
+ "实现": "implement implementation",
81
+ "合併": "merge",
82
+ "流程": "workflow process",
83
+ "規格": "spec specification",
84
+ "规格": "spec specification",
85
+ "契約": "contract",
86
+ "契约": "contract",
87
+ "約束": "constraint",
88
+ "约束": "constraint",
89
+ "風險": "risk",
90
+ "风险": "risk",
91
+ "行為": "behavior",
92
+ "行为": "behavior",
93
+ "狀態": "status state",
94
+ "状态": "status state",
95
+ "架構": "architecture",
96
+ "效能": "performance",
97
+ "性能": "performance",
98
+ "權限": "permission",
99
+ "備份": "backup",
100
+ "遷移": "migration migrate",
101
+ "迁移": "migration migrate",
102
+ "部署": "deploy deploys deployment",
103
+ "回滾": "rollback",
104
+ "發布": "release publish",
105
+ "提交": "commit",
106
+ "分支": "branch",
107
+ "標籤": "label tag",
108
+ "欄位": "column columns field fields",
109
+ "監控": "monitor",
110
+ "指標": "metric",
111
+ "警報": "alert",
112
+ "逾時": "timeout",
113
+ "重試": "retry",
114
+ "併發": "concurrency concurrent",
115
+ "憑證": "credential",
116
+ "登入": "login authenticate",
117
+ "端點": "endpoint",
118
+ "請求": "request",
119
+ "回應": "response",
120
+ "迴圈": "loop",
121
+ "循环": "loop",
122
+ "清單": "list",
123
+ "決定": "decision decisions decide decided",
124
+ "决定": "decision decisions decide decided",
125
+ "錯誤": "error errors bug bugs",
126
+ "错误": "error errors bug bugs",
127
+ "修法": "fix option approach",
128
+ "修復": "fix fixes fixed repair",
129
+ "修复": "fix fixes fixed repair",
130
+ "實測": "measured verified",
131
+ "編碼": "encoding",
132
+ "编码": "encoding",
133
+ # general vocabulary that recurs in questions rather than in answers
134
+ "原因": "cause reason root cause",
135
+ "模組": "module modules",
136
+ "模块": "module",
137
+ "檔案": "file files",
138
+ "文件": "file document",
139
+ "內容": "content",
140
+ "内容": "content",
141
+ "證明": "proof prove evidence",
142
+ "证明": "proof prove evidence",
143
+ "候選": "candidate",
144
+ "回傳": "return returns",
145
+ "返回": "return returns",
146
+ "結論": "conclusion conclude",
147
+ "分析": "analysis analyze",
148
+ "可靠": "reliable trust sound",
149
+ "相信": "trust",
150
+ "內建": "bundled built-in",
151
+ "内置": "bundled built-in",
152
+ "標示": "flag label",
153
+ "解決": "resolve fix",
154
+ "解决": "resolve fix",
155
+ "限制": "limit limits constraint constraints ceiling",
156
+ "重複": "duplicate repeated",
157
+ "重复": "duplicate repeated",
158
+ "順序": "order ordering",
159
+ "範圍": "scope range",
160
+ "范围": "scope range",
161
+ # project-specific vocabulary
162
+ "卡片": "card cards",
163
+ "掃描": "scan scanner",
164
+ "扫描": "scan scanner",
165
+ "辨識": "recognition identify OCR",
166
+ "识别": "recognition identify OCR",
167
+ "圖像": "image",
168
+ "图像": "image",
169
+ "裁切": "crop",
170
+ "權益": "equity",
171
+ "帳戶": "account",
172
+ "账户": "account",
173
+ "槓桿": "leverage",
174
+ "杠杆": "leverage",
175
+ "部位": "position sizing",
176
+ "策略": "strategy",
177
+ "交易": "trade trading",
178
+ }
179
+
180
+
181
+ def expand_cjk_query(query: str) -> str:
182
+ """Append English equivalents for the Chinese terms present in the query.
183
+
184
+ Returns the query unchanged when it contains no known Chinese term, so
185
+ English queries keep their exact current behaviour.
186
+ """
187
+ words = [
188
+ word
189
+ for chinese, english in GLOSSARY.items()
190
+ if chinese in query
191
+ for word in english.split()
192
+ ]
193
+ if not words:
194
+ return query
195
+ # Deduplicate: FTS5 really does double-count a repeated term, and overlapping
196
+ # entries (資料/資料庫, 問題/錯誤 both yielding "bug") would silently weight it.
197
+ return f"{query} {' '.join(dict.fromkeys(words))}"