memory-arbiter-mcp 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.
- memory_arbiter/__init__.py +3 -0
- memory_arbiter/arbitration.py +100 -0
- memory_arbiter/config.py +71 -0
- memory_arbiter/db.py +242 -0
- memory_arbiter/degrade.py +35 -0
- memory_arbiter/models.py +87 -0
- memory_arbiter/search.py +53 -0
- memory_arbiter/server.py +97 -0
- memory_arbiter/tools.py +106 -0
- memory_arbiter_mcp-0.1.0.dist-info/METADATA +242 -0
- memory_arbiter_mcp-0.1.0.dist-info/RECORD +15 -0
- memory_arbiter_mcp-0.1.0.dist-info/WHEEL +5 -0
- memory_arbiter_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- memory_arbiter_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- memory_arbiter_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from .models import ProtectionLevel, SourceType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SOURCE_RANK = {
|
|
10
|
+
SourceType.USER_CONFIRMED.value: 100,
|
|
11
|
+
SourceType.DOCUMENT_EXTRACTED.value: 70,
|
|
12
|
+
SourceType.AGENT_GENERATED.value: 45,
|
|
13
|
+
SourceType.PENDING.value: 20,
|
|
14
|
+
SourceType.UNKNOWN.value: 10,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compare_memories(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
reasons: list[str] = []
|
|
20
|
+
left_protected = is_user_protected(left)
|
|
21
|
+
right_protected = is_user_protected(right)
|
|
22
|
+
if left_protected and not right_protected:
|
|
23
|
+
return decision(left, right, left, "left", ["left is user_confirmed or locked; automatic overwrite is forbidden"])
|
|
24
|
+
if right_protected and not left_protected:
|
|
25
|
+
return decision(left, right, right, "right", ["right is user_confirmed or locked; automatic overwrite is forbidden"])
|
|
26
|
+
if left_protected and right_protected:
|
|
27
|
+
return decision(left, right, None, "manual_review", ["both records are user protected; manual review required"])
|
|
28
|
+
|
|
29
|
+
left_event = parse_time(left.get("event_time"))
|
|
30
|
+
right_event = parse_time(right.get("event_time"))
|
|
31
|
+
if left_event != right_event:
|
|
32
|
+
winner = left if left_event > right_event else right
|
|
33
|
+
side = "left" if winner is left else "right"
|
|
34
|
+
reasons.append(f"{side} has newer event_time; fact occurrence time has priority")
|
|
35
|
+
return decision(left, right, winner, side, reasons)
|
|
36
|
+
|
|
37
|
+
left_source = SOURCE_RANK.get(left.get("source_type"), 0)
|
|
38
|
+
right_source = SOURCE_RANK.get(right.get("source_type"), 0)
|
|
39
|
+
if left_source != right_source:
|
|
40
|
+
winner = left if left_source > right_source else right
|
|
41
|
+
side = "left" if winner is left else "right"
|
|
42
|
+
reasons.append(f"{side} has stronger source_type")
|
|
43
|
+
return decision(left, right, winner, side, reasons)
|
|
44
|
+
|
|
45
|
+
left_conf = float(left.get("confidence") or 0)
|
|
46
|
+
right_conf = float(right.get("confidence") or 0)
|
|
47
|
+
if left_conf != right_conf:
|
|
48
|
+
winner = left if left_conf > right_conf else right
|
|
49
|
+
side = "left" if winner is left else "right"
|
|
50
|
+
reasons.append(f"{side} has higher confidence")
|
|
51
|
+
return decision(left, right, winner, side, reasons)
|
|
52
|
+
|
|
53
|
+
left_ingest = parse_time(left.get("ingest_time"))
|
|
54
|
+
right_ingest = parse_time(right.get("ingest_time"))
|
|
55
|
+
if left_ingest != right_ingest:
|
|
56
|
+
winner = left if left_ingest > right_ingest else right
|
|
57
|
+
side = "left" if winner is left else "right"
|
|
58
|
+
reasons.append(f"{side} has newer ingest_time after equal event_time/source/confidence")
|
|
59
|
+
return decision(left, right, winner, side, reasons)
|
|
60
|
+
|
|
61
|
+
return decision(left, right, None, "tie", ["records are equivalent under configured arbitration rules"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_user_protected(record: dict[str, Any]) -> bool:
|
|
65
|
+
return record.get("source_type") == SourceType.USER_CONFIRMED.value or record.get("protection_level") in {
|
|
66
|
+
ProtectionLevel.LOCKED.value,
|
|
67
|
+
ProtectionLevel.PROTECTED.value,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_time(value: Any) -> datetime:
|
|
72
|
+
if not value:
|
|
73
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
74
|
+
try:
|
|
75
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
76
|
+
if parsed.tzinfo is None:
|
|
77
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
78
|
+
return parsed.astimezone(timezone.utc)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def decision(left: dict[str, Any], right: dict[str, Any], winner: Optional[dict[str, Any]], side: str, reasons: list[str]) -> dict[str, Any]:
|
|
84
|
+
loser = None
|
|
85
|
+
if winner:
|
|
86
|
+
loser = right if winner.get("id") == left.get("id") else left
|
|
87
|
+
return {
|
|
88
|
+
"winner_side": side,
|
|
89
|
+
"winner_id": winner.get("id") if winner else None,
|
|
90
|
+
"loser_id": loser.get("id") if loser else None,
|
|
91
|
+
"manual_review": side in {"manual_review", "tie"},
|
|
92
|
+
"reasons": reasons,
|
|
93
|
+
"rule_order": [
|
|
94
|
+
"user_confirmed/locked protection",
|
|
95
|
+
"event_time",
|
|
96
|
+
"source_type",
|
|
97
|
+
"confidence",
|
|
98
|
+
"ingest_time",
|
|
99
|
+
],
|
|
100
|
+
}
|
memory_arbiter/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AgentPolicy:
|
|
12
|
+
# Per-client overrides. Any client *not* listed here defaults to enabled.
|
|
13
|
+
client_defaults: dict[str, bool] = field(default_factory=dict)
|
|
14
|
+
default_enabled: bool = True
|
|
15
|
+
allow_agents: list[str] = field(default_factory=list)
|
|
16
|
+
deny_agents: list[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
def enabled_for(self, client: str, agent_id: str) -> bool:
|
|
19
|
+
if agent_id in self.deny_agents:
|
|
20
|
+
return False
|
|
21
|
+
if agent_id in self.allow_agents:
|
|
22
|
+
return True
|
|
23
|
+
normalized = (client or "").lower()
|
|
24
|
+
if normalized in self.client_defaults:
|
|
25
|
+
return self.client_defaults[normalized]
|
|
26
|
+
# Default-allow: any unrecognised client is enabled.
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Settings:
|
|
32
|
+
db_path: Path
|
|
33
|
+
backup_jsonl: Path
|
|
34
|
+
policy_path: Optional[Path] = None
|
|
35
|
+
client: str = "codex"
|
|
36
|
+
agent_id: str = "default"
|
|
37
|
+
workspace: str = "default"
|
|
38
|
+
enable_sqlite_vec: bool = True
|
|
39
|
+
policy: AgentPolicy = field(default_factory=AgentPolicy)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_env(cls) -> "Settings":
|
|
43
|
+
cwd = Path.cwd()
|
|
44
|
+
policy_raw = os.getenv("MEMORY_ARBITER_POLICY")
|
|
45
|
+
settings = cls(
|
|
46
|
+
db_path=Path(os.getenv("MEMORY_ARBITER_DB_PATH", cwd / "memory_arbiter.sqlite3")).expanduser(),
|
|
47
|
+
backup_jsonl=Path(os.getenv("MEMORY_ARBITER_BACKUP_JSONL", cwd / "memory_arbiter.backup.jsonl")).expanduser(),
|
|
48
|
+
policy_path=Path(policy_raw).expanduser() if policy_raw else None,
|
|
49
|
+
client=os.getenv("MEMORY_ARBITER_CLIENT", "codex"),
|
|
50
|
+
agent_id=os.getenv("MEMORY_ARBITER_AGENT_ID", "default"),
|
|
51
|
+
workspace=os.getenv("MEMORY_ARBITER_WORKSPACE", "default"),
|
|
52
|
+
enable_sqlite_vec=os.getenv("MEMORY_ARBITER_ENABLE_SQLITE_VEC", "true").lower() not in {"0", "false", "no"},
|
|
53
|
+
)
|
|
54
|
+
settings.policy = load_policy(settings.policy_path)
|
|
55
|
+
return settings
|
|
56
|
+
|
|
57
|
+
def defaults(self) -> dict[str, str]:
|
|
58
|
+
return {"agent_id": self.agent_id, "workspace": self.workspace}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_policy(path: Optional[Path]) -> AgentPolicy:
|
|
62
|
+
if not path or not path.exists():
|
|
63
|
+
return AgentPolicy()
|
|
64
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
65
|
+
raw: dict[str, Any] = json.load(fh)
|
|
66
|
+
return AgentPolicy(
|
|
67
|
+
client_defaults=dict(raw.get("client_defaults") or AgentPolicy().client_defaults),
|
|
68
|
+
default_enabled=bool(raw.get("default_enabled", True)),
|
|
69
|
+
allow_agents=list(raw.get("allow_agents") or []),
|
|
70
|
+
deny_agents=list(raw.get("deny_agents") or []),
|
|
71
|
+
)
|
memory_arbiter/db.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from .config import Settings
|
|
9
|
+
from .degrade import DegradeState
|
|
10
|
+
from .models import MemoryRecord, utc_now_iso
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemoryDB:
|
|
14
|
+
def __init__(self, settings: Settings):
|
|
15
|
+
self.settings = settings
|
|
16
|
+
self.state = DegradeState()
|
|
17
|
+
self.conn: Optional[sqlite3.Connection] = None
|
|
18
|
+
self._connect()
|
|
19
|
+
|
|
20
|
+
def _connect(self) -> None:
|
|
21
|
+
self.settings.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
try:
|
|
23
|
+
self.conn = sqlite3.connect(str(self.settings.db_path))
|
|
24
|
+
self.conn.row_factory = sqlite3.Row
|
|
25
|
+
self.conn.execute("PRAGMA journal_mode=WAL")
|
|
26
|
+
self.conn.execute("PRAGMA foreign_keys=ON")
|
|
27
|
+
self._init_schema()
|
|
28
|
+
self._probe_features()
|
|
29
|
+
except sqlite3.Error as exc:
|
|
30
|
+
self.conn = None
|
|
31
|
+
self.state.sqlite_writable = False
|
|
32
|
+
self.state.mode = "jsonl_backup"
|
|
33
|
+
self.state.jsonl_backup_active = True
|
|
34
|
+
self.state.warn(f"SQLite unavailable or not writable: {exc}. Using JSONL append-only backup when possible.")
|
|
35
|
+
|
|
36
|
+
def _init_schema(self) -> None:
|
|
37
|
+
assert self.conn is not None
|
|
38
|
+
self.conn.executescript(
|
|
39
|
+
"""
|
|
40
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
content TEXT NOT NULL,
|
|
43
|
+
agent_id TEXT NOT NULL,
|
|
44
|
+
workspace TEXT NOT NULL,
|
|
45
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
46
|
+
source_type TEXT NOT NULL,
|
|
47
|
+
source_ref TEXT,
|
|
48
|
+
event_time TEXT NOT NULL,
|
|
49
|
+
ingest_time TEXT NOT NULL,
|
|
50
|
+
confidence REAL NOT NULL DEFAULT 0.5,
|
|
51
|
+
protection_level TEXT NOT NULL DEFAULT 'normal',
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
53
|
+
subject TEXT,
|
|
54
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
55
|
+
created_at TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS conflicts (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
left_id INTEGER NOT NULL,
|
|
60
|
+
right_id INTEGER NOT NULL,
|
|
61
|
+
subject TEXT,
|
|
62
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
63
|
+
reason TEXT NOT NULL,
|
|
64
|
+
winner_id INTEGER,
|
|
65
|
+
created_at TEXT NOT NULL,
|
|
66
|
+
resolved_at TEXT,
|
|
67
|
+
FOREIGN KEY(left_id) REFERENCES memories(id),
|
|
68
|
+
FOREIGN KEY(right_id) REFERENCES memories(id)
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(workspace, agent_id, status);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_memories_subject ON memories(workspace, subject);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_memories_event ON memories(event_time, ingest_time);
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
self.conn.commit()
|
|
76
|
+
|
|
77
|
+
def _probe_features(self) -> None:
|
|
78
|
+
assert self.conn is not None
|
|
79
|
+
if self.settings.enable_sqlite_vec:
|
|
80
|
+
try:
|
|
81
|
+
import sqlite_vec # type: ignore
|
|
82
|
+
|
|
83
|
+
self.conn.enable_load_extension(True)
|
|
84
|
+
sqlite_vec.load(self.conn)
|
|
85
|
+
self.state.sqlite_vec_available = True
|
|
86
|
+
self.state.mode = "sqlite_vec"
|
|
87
|
+
except Exception as exc: # pragma: no cover - depends on local optional package
|
|
88
|
+
self.state.warn(f"sqlite-vec unavailable: {exc}. Semantic recall disabled; falling back to FTS5 or keyword search.")
|
|
89
|
+
else:
|
|
90
|
+
self.state.warn("sqlite-vec disabled by configuration. Semantic recall disabled.")
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
self.conn.execute(
|
|
94
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(content, tags, subject, content='memories', content_rowid='id')"
|
|
95
|
+
)
|
|
96
|
+
self._rebuild_fts()
|
|
97
|
+
self.state.fts5_available = True
|
|
98
|
+
if not self.state.sqlite_vec_available:
|
|
99
|
+
self.state.mode = "fts5"
|
|
100
|
+
except sqlite3.Error as exc:
|
|
101
|
+
self.state.warn(f"SQLite FTS5 unavailable: {exc}. Falling back to LIKE/keyword search.")
|
|
102
|
+
if not self.state.sqlite_vec_available:
|
|
103
|
+
self.state.mode = "like"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
self.conn.execute("CREATE TABLE IF NOT EXISTS write_probe (id INTEGER)")
|
|
107
|
+
self.conn.execute("INSERT INTO write_probe(id) VALUES (1)")
|
|
108
|
+
self.conn.execute("DELETE FROM write_probe")
|
|
109
|
+
self.conn.commit()
|
|
110
|
+
except sqlite3.Error as exc:
|
|
111
|
+
self.state.sqlite_writable = False
|
|
112
|
+
self.state.mode = "jsonl_backup"
|
|
113
|
+
self.state.jsonl_backup_active = True
|
|
114
|
+
self.state.warn(f"SQLite opened read-only or write probe failed: {exc}. Writes will use JSONL backup when possible.")
|
|
115
|
+
|
|
116
|
+
def _rebuild_fts(self) -> None:
|
|
117
|
+
assert self.conn is not None
|
|
118
|
+
try:
|
|
119
|
+
self.conn.execute("INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')")
|
|
120
|
+
self.conn.commit()
|
|
121
|
+
except sqlite3.Error:
|
|
122
|
+
self.conn.rollback()
|
|
123
|
+
|
|
124
|
+
def insert_memory(self, record: MemoryRecord) -> Tuple[Optional[int], list[str]]:
|
|
125
|
+
warnings: list[str] = []
|
|
126
|
+
if not record.content:
|
|
127
|
+
raise ValueError("content is required")
|
|
128
|
+
if self.conn is None or not self.state.sqlite_writable:
|
|
129
|
+
self._append_backup(record)
|
|
130
|
+
warnings.append("SQLite write unavailable; wrote append-only JSONL backup.")
|
|
131
|
+
return None, warnings
|
|
132
|
+
cur = self.conn.execute(
|
|
133
|
+
"""
|
|
134
|
+
INSERT INTO memories
|
|
135
|
+
(content, agent_id, workspace, tags, source_type, source_ref, event_time, ingest_time,
|
|
136
|
+
confidence, protection_level, status, subject, metadata, created_at)
|
|
137
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
138
|
+
""",
|
|
139
|
+
(
|
|
140
|
+
record.content,
|
|
141
|
+
record.agent_id,
|
|
142
|
+
record.workspace,
|
|
143
|
+
json.dumps(record.tags, ensure_ascii=False),
|
|
144
|
+
record.source_type,
|
|
145
|
+
record.source_ref,
|
|
146
|
+
record.event_time,
|
|
147
|
+
record.ingest_time,
|
|
148
|
+
record.confidence,
|
|
149
|
+
record.protection_level,
|
|
150
|
+
record.status,
|
|
151
|
+
record.subject,
|
|
152
|
+
json.dumps(record.metadata, ensure_ascii=False),
|
|
153
|
+
utc_now_iso(),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
memory_id = int(cur.lastrowid)
|
|
157
|
+
if self.state.fts5_available:
|
|
158
|
+
self.conn.execute(
|
|
159
|
+
"INSERT INTO memories_fts(rowid, content, tags, subject) VALUES (?, ?, ?, ?)",
|
|
160
|
+
(memory_id, record.content, " ".join(record.tags), record.subject or ""),
|
|
161
|
+
)
|
|
162
|
+
self.conn.commit()
|
|
163
|
+
return memory_id, warnings
|
|
164
|
+
|
|
165
|
+
def _append_backup(self, record: MemoryRecord) -> None:
|
|
166
|
+
self.settings.backup_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
payload = record.__dict__.copy()
|
|
168
|
+
payload["backup_written_at"] = utc_now_iso()
|
|
169
|
+
with self.settings.backup_jsonl.open("a", encoding="utf-8") as fh:
|
|
170
|
+
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
171
|
+
self.state.jsonl_backup_active = True
|
|
172
|
+
|
|
173
|
+
def get_memory(self, memory_id: int) -> Optional[dict[str, Any]]:
|
|
174
|
+
if self.conn is None:
|
|
175
|
+
return None
|
|
176
|
+
row = self.conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
|
|
177
|
+
return row_to_dict(row) if row else None
|
|
178
|
+
|
|
179
|
+
def update_memory(self, memory_id: int, updates: dict[str, Any]) -> bool:
|
|
180
|
+
if self.conn is None or not self.state.sqlite_writable:
|
|
181
|
+
return False
|
|
182
|
+
allowed = {"source_type", "confidence", "protection_level", "status", "metadata"}
|
|
183
|
+
pairs = [(key, value) for key, value in updates.items() if key in allowed]
|
|
184
|
+
if not pairs:
|
|
185
|
+
return True
|
|
186
|
+
sql = ", ".join(f"{key} = ?" for key, _ in pairs)
|
|
187
|
+
values = [json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v for _, v in pairs]
|
|
188
|
+
values.append(memory_id)
|
|
189
|
+
self.conn.execute(f"UPDATE memories SET {sql} WHERE id = ?", values)
|
|
190
|
+
self.conn.commit()
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
def list_memories(self, workspace: Optional[str] = None, subject: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
194
|
+
if self.conn is None:
|
|
195
|
+
return []
|
|
196
|
+
clauses = ["status != 'deleted'"]
|
|
197
|
+
params: list[Any] = []
|
|
198
|
+
if workspace:
|
|
199
|
+
clauses.append("workspace = ?")
|
|
200
|
+
params.append(workspace)
|
|
201
|
+
if subject:
|
|
202
|
+
clauses.append("subject = ?")
|
|
203
|
+
params.append(subject)
|
|
204
|
+
params.append(limit)
|
|
205
|
+
rows = self.conn.execute(
|
|
206
|
+
f"SELECT * FROM memories WHERE {' AND '.join(clauses)} ORDER BY event_time DESC, ingest_time DESC LIMIT ?",
|
|
207
|
+
params,
|
|
208
|
+
).fetchall()
|
|
209
|
+
return [row_to_dict(row) for row in rows]
|
|
210
|
+
|
|
211
|
+
def record_conflict(self, left_id: int, right_id: int, subject: Optional[str], reason: str, winner_id: Optional[int]) -> Optional[int]:
|
|
212
|
+
if self.conn is None or not self.state.sqlite_writable:
|
|
213
|
+
return None
|
|
214
|
+
cur = self.conn.execute(
|
|
215
|
+
"""
|
|
216
|
+
INSERT INTO conflicts(left_id, right_id, subject, reason, winner_id, created_at)
|
|
217
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
218
|
+
""",
|
|
219
|
+
(left_id, right_id, subject, reason, winner_id, utc_now_iso()),
|
|
220
|
+
)
|
|
221
|
+
self.conn.commit()
|
|
222
|
+
return int(cur.lastrowid)
|
|
223
|
+
|
|
224
|
+
def list_conflicts(self, status: str = "open", limit: int = 50) -> list[dict[str, Any]]:
|
|
225
|
+
if self.conn is None:
|
|
226
|
+
return []
|
|
227
|
+
rows = self.conn.execute(
|
|
228
|
+
"SELECT * FROM conflicts WHERE status = ? ORDER BY created_at DESC LIMIT ?",
|
|
229
|
+
(status, limit),
|
|
230
|
+
).fetchall()
|
|
231
|
+
return [row_to_dict(row) for row in rows]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
|
235
|
+
data = dict(row)
|
|
236
|
+
for key in ("tags", "metadata"):
|
|
237
|
+
if key in data and isinstance(data[key], str):
|
|
238
|
+
try:
|
|
239
|
+
data[key] = json.loads(data[key])
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
pass
|
|
242
|
+
return data
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DegradeState:
|
|
9
|
+
mode: str = "sqlite"
|
|
10
|
+
warnings: list[str] = field(default_factory=list)
|
|
11
|
+
sqlite_vec_available: bool = False
|
|
12
|
+
fts5_available: bool = False
|
|
13
|
+
sqlite_writable: bool = True
|
|
14
|
+
jsonl_backup_active: bool = False
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def degraded(self) -> bool:
|
|
18
|
+
return bool(self.warnings) or self.mode != "sqlite_vec"
|
|
19
|
+
|
|
20
|
+
def warn(self, message: str) -> None:
|
|
21
|
+
if message not in self.warnings:
|
|
22
|
+
self.warnings.append(message)
|
|
23
|
+
|
|
24
|
+
def response(self, data: Any, ok: bool = True, extra_warnings: Optional[list[str]] = None) -> dict[str, Any]:
|
|
25
|
+
warnings = list(self.warnings)
|
|
26
|
+
for warning in extra_warnings or []:
|
|
27
|
+
if warning not in warnings:
|
|
28
|
+
warnings.append(warning)
|
|
29
|
+
return {
|
|
30
|
+
"ok": ok,
|
|
31
|
+
"mode": self.mode,
|
|
32
|
+
"warnings": warnings,
|
|
33
|
+
"degraded": bool(warnings) or self.mode != "sqlite_vec",
|
|
34
|
+
"data": data,
|
|
35
|
+
}
|
memory_arbiter/models.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SourceType(str, Enum):
|
|
10
|
+
USER_CONFIRMED = "user_confirmed"
|
|
11
|
+
DOCUMENT_EXTRACTED = "document_extracted"
|
|
12
|
+
AGENT_GENERATED = "agent_generated"
|
|
13
|
+
UNKNOWN = "unknown"
|
|
14
|
+
PENDING = "pending"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProtectionLevel(str, Enum):
|
|
18
|
+
NORMAL = "normal"
|
|
19
|
+
PROTECTED = "protected"
|
|
20
|
+
LOCKED = "locked"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MemoryStatus(str, Enum):
|
|
24
|
+
ACTIVE = "active"
|
|
25
|
+
SUPERSEDED = "superseded"
|
|
26
|
+
CONFLICTED = "conflicted"
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
DELETED = "deleted"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def utc_now_iso() -> str:
|
|
32
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_iso(value: Optional[str]) -> str:
|
|
36
|
+
if not value:
|
|
37
|
+
return utc_now_iso()
|
|
38
|
+
try:
|
|
39
|
+
normalized = value.replace("Z", "+00:00")
|
|
40
|
+
parsed = datetime.fromisoformat(normalized)
|
|
41
|
+
if parsed.tzinfo is None:
|
|
42
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
43
|
+
return parsed.astimezone(timezone.utc).replace(microsecond=0).isoformat()
|
|
44
|
+
except ValueError:
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class MemoryRecord:
|
|
50
|
+
content: str
|
|
51
|
+
agent_id: str
|
|
52
|
+
workspace: str
|
|
53
|
+
tags: list[str] = field(default_factory=list)
|
|
54
|
+
source_type: str = SourceType.UNKNOWN.value
|
|
55
|
+
source_ref: Optional[str] = None
|
|
56
|
+
event_time: str = field(default_factory=utc_now_iso)
|
|
57
|
+
ingest_time: str = field(default_factory=utc_now_iso)
|
|
58
|
+
confidence: float = 0.5
|
|
59
|
+
protection_level: str = ProtectionLevel.NORMAL.value
|
|
60
|
+
status: str = MemoryStatus.ACTIVE.value
|
|
61
|
+
subject: Optional[str] = None
|
|
62
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
id: Optional[int] = None
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_input(cls, payload: dict[str, Any], defaults: dict[str, str]) -> "MemoryRecord":
|
|
67
|
+
source_type = payload.get("source_type") or SourceType.UNKNOWN.value
|
|
68
|
+
protection = payload.get("protection_level") or ProtectionLevel.NORMAL.value
|
|
69
|
+
status = payload.get("status") or MemoryStatus.ACTIVE.value
|
|
70
|
+
if source_type == SourceType.USER_CONFIRMED.value:
|
|
71
|
+
protection = ProtectionLevel.LOCKED.value
|
|
72
|
+
status = MemoryStatus.ACTIVE.value
|
|
73
|
+
return cls(
|
|
74
|
+
content=str(payload["content"]).strip(),
|
|
75
|
+
agent_id=str(payload.get("agent_id") or defaults.get("agent_id") or "default"),
|
|
76
|
+
workspace=str(payload.get("workspace") or defaults.get("workspace") or "default"),
|
|
77
|
+
tags=list(payload.get("tags") or []),
|
|
78
|
+
source_type=str(source_type),
|
|
79
|
+
source_ref=payload.get("source_ref"),
|
|
80
|
+
event_time=normalize_iso(payload.get("event_time")),
|
|
81
|
+
ingest_time=normalize_iso(payload.get("ingest_time")),
|
|
82
|
+
confidence=float(payload.get("confidence", 0.5)),
|
|
83
|
+
protection_level=str(protection),
|
|
84
|
+
status=str(status),
|
|
85
|
+
subject=payload.get("subject"),
|
|
86
|
+
metadata=dict(payload.get("metadata") or {}),
|
|
87
|
+
)
|
memory_arbiter/search.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .db import MemoryDB, row_to_dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def search_memories(db: MemoryDB, query: str, workspace: Optional[str] = None, tags: Optional[list[str]] = None, limit: int = 10) -> Tuple[list[dict[str, Any]], list[str]]:
|
|
9
|
+
warnings: list[str] = []
|
|
10
|
+
if db.conn is None:
|
|
11
|
+
return [], ["SQLite unavailable; search cannot read JSONL backup in MVP."]
|
|
12
|
+
limit = max(1, min(int(limit), 100))
|
|
13
|
+
query = (query or "").strip()
|
|
14
|
+
rows = []
|
|
15
|
+
if db.state.fts5_available and query:
|
|
16
|
+
sql = """
|
|
17
|
+
SELECT m.*, bm25(memories_fts) AS score
|
|
18
|
+
FROM memories_fts
|
|
19
|
+
JOIN memories m ON memories_fts.rowid = m.id
|
|
20
|
+
WHERE memories_fts MATCH ? AND m.status != 'deleted'
|
|
21
|
+
"""
|
|
22
|
+
params: list[Any] = [query]
|
|
23
|
+
if workspace:
|
|
24
|
+
sql += " AND m.workspace = ?"
|
|
25
|
+
params.append(workspace)
|
|
26
|
+
sql += " ORDER BY score LIMIT ?"
|
|
27
|
+
params.append(limit)
|
|
28
|
+
try:
|
|
29
|
+
rows = db.conn.execute(sql, params).fetchall()
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
warnings.append(f"FTS5 query failed: {exc}. Falling back to LIKE search.")
|
|
32
|
+
rows = []
|
|
33
|
+
if not rows:
|
|
34
|
+
like = f"%{query}%"
|
|
35
|
+
clauses = ["status != 'deleted'"]
|
|
36
|
+
params = []
|
|
37
|
+
if query:
|
|
38
|
+
clauses.append("(content LIKE ? OR subject LIKE ? OR tags LIKE ?)")
|
|
39
|
+
params.extend([like, like, like])
|
|
40
|
+
if workspace:
|
|
41
|
+
clauses.append("workspace = ?")
|
|
42
|
+
params.append(workspace)
|
|
43
|
+
for tag in tags or []:
|
|
44
|
+
clauses.append("tags LIKE ?")
|
|
45
|
+
params.append(f"%{tag}%")
|
|
46
|
+
params.append(limit)
|
|
47
|
+
rows = db.conn.execute(
|
|
48
|
+
f"SELECT *, 0 AS score FROM memories WHERE {' AND '.join(clauses)} ORDER BY event_time DESC, ingest_time DESC LIMIT ?",
|
|
49
|
+
params,
|
|
50
|
+
).fetchall()
|
|
51
|
+
if query and not db.state.fts5_available:
|
|
52
|
+
warnings.append("Using LIKE/keyword search because sqlite-vec and FTS5 are unavailable.")
|
|
53
|
+
return [row_to_dict(row) for row in rows], warnings
|
memory_arbiter/server.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from .config import Settings
|
|
7
|
+
from .tools import MemoryTools
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_server() -> Any:
|
|
11
|
+
try:
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
except Exception as exc:
|
|
14
|
+
raise RuntimeError(
|
|
15
|
+
"MCP Python SDK is not installed. Install with `pip install -r requirements.txt` "
|
|
16
|
+
"or `pip install mcp`, then run `memory-arbiter-mcp` again."
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
app = FastMCP("memory-arbiter-mcp")
|
|
20
|
+
tools = MemoryTools(Settings.from_env())
|
|
21
|
+
|
|
22
|
+
@app.tool()
|
|
23
|
+
def memory_write(
|
|
24
|
+
content: str,
|
|
25
|
+
agent_id: Optional[str] = None,
|
|
26
|
+
workspace: Optional[str] = None,
|
|
27
|
+
tags: Optional[list[str]] = None,
|
|
28
|
+
source_type: str = "unknown",
|
|
29
|
+
source_ref: Optional[str] = None,
|
|
30
|
+
event_time: Optional[str] = None,
|
|
31
|
+
ingest_time: Optional[str] = None,
|
|
32
|
+
confidence: float = 0.5,
|
|
33
|
+
protection_level: str = "normal",
|
|
34
|
+
status: str = "active",
|
|
35
|
+
subject: Optional[str] = None,
|
|
36
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""写入一条结构化记忆到跨工具共享记忆库。必填 content(正文),建议填 subject(标题)、tags(标签)、source_type(来源类型:agent_generated/user_confirmed/document_extracted)。"""
|
|
39
|
+
return tools.memory_write(
|
|
40
|
+
content=content,
|
|
41
|
+
agent_id=agent_id,
|
|
42
|
+
workspace=workspace,
|
|
43
|
+
tags=tags or [],
|
|
44
|
+
source_type=source_type,
|
|
45
|
+
source_ref=source_ref,
|
|
46
|
+
event_time=event_time,
|
|
47
|
+
ingest_time=ingest_time,
|
|
48
|
+
confidence=confidence,
|
|
49
|
+
protection_level=protection_level,
|
|
50
|
+
status=status,
|
|
51
|
+
subject=subject,
|
|
52
|
+
metadata=metadata or {},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@app.tool()
|
|
56
|
+
def memory_search(query: str = "", workspace: Optional[str] = None, tags: Optional[list[str]] = None, limit: int = 10) -> dict[str, Any]:
|
|
57
|
+
"""按关键词搜索跨工具共享记忆库中的已有记忆。开始新任务前先搜一下,避免重复记录。"""
|
|
58
|
+
return tools.memory_search(query=query, workspace=workspace, tags=tags or [], limit=limit)
|
|
59
|
+
|
|
60
|
+
@app.tool()
|
|
61
|
+
def memory_compare(left_id: int, right_id: int) -> dict[str, Any]:
|
|
62
|
+
"""比较两条记忆是否冲突,返回可解释的比较理由,不落冲突记录。"""
|
|
63
|
+
return tools.memory_compare(left_id=left_id, right_id=right_id)
|
|
64
|
+
|
|
65
|
+
@app.tool()
|
|
66
|
+
def memory_arbitrate(left_id: int, right_id: int, mark_conflict: bool = True, apply: bool = False) -> dict[str, Any]:
|
|
67
|
+
"""仲裁两条冲突记忆的胜者与败者。mark_conflict=true 记录冲突,apply=true 自动将非保护败方标记为 superseded。"""
|
|
68
|
+
return tools.memory_arbitrate(left_id=left_id, right_id=right_id, mark_conflict=mark_conflict, apply=apply)
|
|
69
|
+
|
|
70
|
+
@app.tool()
|
|
71
|
+
def memory_list_conflicts(status: str = "open", limit: int = 50) -> dict[str, Any]:
|
|
72
|
+
"""列出记忆冲突记录,默认只看 open 状态。"""
|
|
73
|
+
return tools.memory_list_conflicts(status=status, limit=limit)
|
|
74
|
+
|
|
75
|
+
@app.tool()
|
|
76
|
+
def memory_confirm(memory_id: int, source_ref: Optional[str] = None, confidence: float = 1.0) -> dict[str, Any]:
|
|
77
|
+
"""将一条记忆标记为用户确认,提升为 user_confirmed + locked 保护级别,禁止自动覆盖。"""
|
|
78
|
+
return tools.memory_confirm(memory_id=memory_id, source_ref=source_ref, confidence=confidence)
|
|
79
|
+
|
|
80
|
+
@app.tool()
|
|
81
|
+
def memory_status() -> dict[str, Any]:
|
|
82
|
+
"""查看 memory-arbiter 运行状态:数据库路径、降级模式、客户端标识、策略配置。"""
|
|
83
|
+
return tools.memory_status()
|
|
84
|
+
|
|
85
|
+
return app
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main() -> None:
|
|
89
|
+
try:
|
|
90
|
+
build_server().run()
|
|
91
|
+
except RuntimeError as exc:
|
|
92
|
+
print(str(exc), file=sys.stderr)
|
|
93
|
+
raise SystemExit(2)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
memory_arbiter/tools.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .arbitration import compare_memories
|
|
6
|
+
from .config import Settings
|
|
7
|
+
from .db import MemoryDB
|
|
8
|
+
from .models import MemoryRecord, ProtectionLevel, SourceType
|
|
9
|
+
from .search import search_memories
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MemoryTools:
|
|
13
|
+
def __init__(self, settings: Optional[Settings] = None, db: Optional[MemoryDB] = None):
|
|
14
|
+
self.settings = settings or Settings.from_env()
|
|
15
|
+
self.db = db or MemoryDB(self.settings)
|
|
16
|
+
|
|
17
|
+
def _allowed(self, agent_id: Optional[str] = None, client: Optional[str] = None) -> Tuple[bool, list[str]]:
|
|
18
|
+
actual_agent = agent_id or self.settings.agent_id
|
|
19
|
+
actual_client = client or self.settings.client
|
|
20
|
+
if self.settings.policy.enabled_for(actual_client, actual_agent):
|
|
21
|
+
return True, []
|
|
22
|
+
return False, [f"Memory arbiter disabled by policy for client={actual_client}, agent_id={actual_agent}."]
|
|
23
|
+
|
|
24
|
+
def memory_write(self, **payload: Any) -> dict[str, Any]:
|
|
25
|
+
allowed, warnings = self._allowed(payload.get("agent_id"), payload.get("client"))
|
|
26
|
+
if not allowed:
|
|
27
|
+
return self.db.state.response({"written": False}, ok=False, extra_warnings=warnings)
|
|
28
|
+
try:
|
|
29
|
+
record = MemoryRecord.from_input(payload, self.settings.defaults())
|
|
30
|
+
memory_id, write_warnings = self.db.insert_memory(record)
|
|
31
|
+
data = {"id": memory_id, "backup_only": memory_id is None, "record": {**record.__dict__, "id": memory_id}}
|
|
32
|
+
return self.db.state.response(data, extra_warnings=warnings + write_warnings)
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
return self.db.state.response({"error": str(exc)}, ok=False, extra_warnings=warnings)
|
|
35
|
+
|
|
36
|
+
def memory_search(self, query: str = "", workspace: Optional[str] = None, tags: Optional[list[str]] = None, limit: int = 10, **_: Any) -> dict[str, Any]:
|
|
37
|
+
results, warnings = search_memories(self.db, query, workspace or self.settings.workspace, tags, limit)
|
|
38
|
+
return self.db.state.response({"results": results, "count": len(results)}, extra_warnings=warnings)
|
|
39
|
+
|
|
40
|
+
def memory_compare(self, left_id: Optional[int] = None, right_id: Optional[int] = None, left: Optional[dict[str, Any]] = None, right: Optional[dict[str, Any]] = None, **_: Any) -> dict[str, Any]:
|
|
41
|
+
left_record = left or (self.db.get_memory(int(left_id)) if left_id is not None else None)
|
|
42
|
+
right_record = right or (self.db.get_memory(int(right_id)) if right_id is not None else None)
|
|
43
|
+
if not left_record or not right_record:
|
|
44
|
+
return self.db.state.response({"error": "left and right records are required"}, ok=False)
|
|
45
|
+
return self.db.state.response({"comparison": compare_memories(left_record, right_record), "left": left_record, "right": right_record})
|
|
46
|
+
|
|
47
|
+
def memory_arbitrate(self, left_id: int, right_id: int, mark_conflict: bool = True, apply: bool = False, **_: Any) -> dict[str, Any]:
|
|
48
|
+
left = self.db.get_memory(int(left_id))
|
|
49
|
+
right = self.db.get_memory(int(right_id))
|
|
50
|
+
if not left or not right:
|
|
51
|
+
return self.db.state.response({"error": "memory id not found"}, ok=False)
|
|
52
|
+
comparison = compare_memories(left, right)
|
|
53
|
+
conflict_id = None
|
|
54
|
+
if mark_conflict:
|
|
55
|
+
reason = "; ".join(comparison["reasons"])
|
|
56
|
+
conflict_id = self.db.record_conflict(int(left_id), int(right_id), left.get("subject") or right.get("subject"), reason, comparison["winner_id"])
|
|
57
|
+
applied = False
|
|
58
|
+
if apply and comparison["winner_id"] and comparison["loser_id"] and not comparison["manual_review"]:
|
|
59
|
+
loser = self.db.get_memory(int(comparison["loser_id"]))
|
|
60
|
+
if loser and loser.get("protection_level") != ProtectionLevel.LOCKED.value and loser.get("source_type") != SourceType.USER_CONFIRMED.value:
|
|
61
|
+
applied = self.db.update_memory(int(comparison["loser_id"]), {"status": "superseded"})
|
|
62
|
+
return self.db.state.response({"comparison": comparison, "conflict_id": conflict_id, "applied": applied})
|
|
63
|
+
|
|
64
|
+
def memory_list_conflicts(self, status: str = "open", limit: int = 50, **_: Any) -> dict[str, Any]:
|
|
65
|
+
conflicts = self.db.list_conflicts(status=status, limit=int(limit))
|
|
66
|
+
return self.db.state.response({"conflicts": conflicts, "count": len(conflicts)})
|
|
67
|
+
|
|
68
|
+
def memory_confirm(self, memory_id: int, source_ref: Optional[str] = None, confidence: float = 1.0, **_: Any) -> dict[str, Any]:
|
|
69
|
+
memory = self.db.get_memory(int(memory_id))
|
|
70
|
+
if not memory:
|
|
71
|
+
return self.db.state.response({"error": "memory id not found"}, ok=False)
|
|
72
|
+
metadata = dict(memory.get("metadata") or {})
|
|
73
|
+
metadata["confirmed_from"] = source_ref or "manual"
|
|
74
|
+
ok = self.db.update_memory(
|
|
75
|
+
int(memory_id),
|
|
76
|
+
{
|
|
77
|
+
"source_type": SourceType.USER_CONFIRMED.value,
|
|
78
|
+
"confidence": float(confidence),
|
|
79
|
+
"protection_level": ProtectionLevel.LOCKED.value,
|
|
80
|
+
"status": "active",
|
|
81
|
+
"metadata": metadata,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
updated = self.db.get_memory(int(memory_id)) if ok else memory
|
|
85
|
+
return self.db.state.response({"confirmed": ok, "record": updated})
|
|
86
|
+
|
|
87
|
+
def memory_status(self, **_: Any) -> dict[str, Any]:
|
|
88
|
+
return self.db.state.response(
|
|
89
|
+
{
|
|
90
|
+
"db_path": str(self.settings.db_path),
|
|
91
|
+
"backup_jsonl": str(self.settings.backup_jsonl),
|
|
92
|
+
"sqlite_vec_available": self.db.state.sqlite_vec_available,
|
|
93
|
+
"fts5_available": self.db.state.fts5_available,
|
|
94
|
+
"sqlite_writable": self.db.state.sqlite_writable,
|
|
95
|
+
"jsonl_backup_active": self.db.state.jsonl_backup_active,
|
|
96
|
+
"client": self.settings.client,
|
|
97
|
+
"agent_id": self.settings.agent_id,
|
|
98
|
+
"workspace": self.settings.workspace,
|
|
99
|
+
"policy": {
|
|
100
|
+
"client_defaults": self.settings.policy.client_defaults,
|
|
101
|
+
"default_enabled": self.settings.policy.default_enabled,
|
|
102
|
+
"allow_agents": self.settings.policy.allow_agents,
|
|
103
|
+
"deny_agents": self.settings.policy.deny_agents,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memory-arbiter-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local MCP memory arbiter with dual timeline conflict handling.
|
|
5
|
+
Author: OpenClaw Project
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: mcp>=1.2.0
|
|
11
|
+
Requires-Dist: pydantic>=2.6.0
|
|
12
|
+
Provides-Extra: vec
|
|
13
|
+
Requires-Dist: sqlite-vec>=0.1.1; extra == "vec"
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8.0.0; extra == "test"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# memory-arbiter-mcp
|
|
19
|
+
|
|
20
|
+
**[中文](#中文) | [English](#english)**
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<a id="english"></a>
|
|
25
|
+
|
|
26
|
+
## English
|
|
27
|
+
|
|
28
|
+
A lightweight, fully local MCP Server that gives your AI coding tools a **shared memory store** with built-in conflict arbitration.
|
|
29
|
+
|
|
30
|
+
Every tool — ZCode, Codex, Cursor, Claude Code — has its own memory. They don't talk to each other. Memory Arbiter fixes this: one SQLite database, all tools read and write through the same MCP protocol, conflicts are resolved by structured rules (not LLM guesswork).
|
|
31
|
+
|
|
32
|
+
### Features
|
|
33
|
+
|
|
34
|
+
- **Structured memory write**: `content`, `agent_id`, `workspace`, `tags`, `source_type`, `event_time`, `ingest_time`, `confidence`, `protection_level`, and more.
|
|
35
|
+
- **Source trust levels**: `user_confirmed` > `document_extracted` > `agent_generated` > `unknown`.
|
|
36
|
+
- **Dual timeline arbitration**: resolves conflicts by user confirmation → event time → source trust → ingest time. Every decision comes with an explainable rationale.
|
|
37
|
+
- **Locked protection**: `user_confirmed` memories are automatically locked — no agent can overwrite them.
|
|
38
|
+
- **Client policy system**: per-client enable/disable, agent allow/deny lists for multi-agent governance.
|
|
39
|
+
- **Graceful degradation**: `sqlite-vec` → FTS5 → `LIKE` → JSONL backup. Never crashes.
|
|
40
|
+
- **Zero cloud, zero LLM calls**: pure local SQLite. No Postgres, Redis, or external services.
|
|
41
|
+
|
|
42
|
+
### Quick Start
|
|
43
|
+
|
|
44
|
+
**Requirements**: Python 3.11+
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Clone
|
|
48
|
+
git clone https://github.com/billy12151/memory-arbiter-mcp.git
|
|
49
|
+
cd memory-arbiter-mcp
|
|
50
|
+
|
|
51
|
+
# Setup
|
|
52
|
+
python3.11 -m venv .venv
|
|
53
|
+
source .venv/bin/activate
|
|
54
|
+
pip install -r requirements.txt
|
|
55
|
+
pip install -e .
|
|
56
|
+
|
|
57
|
+
# Optional: semantic recall via sqlite-vec
|
|
58
|
+
pip install '.[vec]'
|
|
59
|
+
|
|
60
|
+
# Run
|
|
61
|
+
memory-arbiter-mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Connect Your Tool
|
|
65
|
+
|
|
66
|
+
Add to your tool's MCP config (see `examples/` for ready-made templates):
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"mcpServers": {
|
|
71
|
+
"memory-arbiter": {
|
|
72
|
+
"command": "/path/to/memory-arbiter-mcp/.venv/bin/memory-arbiter-mcp",
|
|
73
|
+
"env": {
|
|
74
|
+
"MEMORY_ARBITER_CLIENT": "zcode",
|
|
75
|
+
"MEMORY_ARBITER_AGENT_ID": "zcode-default",
|
|
76
|
+
"MEMORY_ARBITER_DB_PATH": "~/.local/share/memory-arbiter/memory.sqlite3"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
> Change `MEMORY_ARBITER_CLIENT` for each tool (`zcode`, `codex`, `cursor`, `claude-code`). All tools share the same `DB_PATH` — that's the whole point.
|
|
84
|
+
|
|
85
|
+
> ⚠️ **New session required**: MCP servers are loaded at session startup. Already-open sessions won't see the new tools. Start a fresh session after configuring.
|
|
86
|
+
|
|
87
|
+
### Client Config Locations
|
|
88
|
+
|
|
89
|
+
| Client | Config Location |
|
|
90
|
+
|---|---|
|
|
91
|
+
| ZCode | `~/.zcode/v2/` MCP config |
|
|
92
|
+
| Codex CLI | `~/.codex/` MCP config |
|
|
93
|
+
| Claude Code | `.mcp.json` in project root |
|
|
94
|
+
| Cursor | `~/.cursor/mcp.json` |
|
|
95
|
+
|
|
96
|
+
### MCP Tools
|
|
97
|
+
|
|
98
|
+
| Tool | Description |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `memory_write` | Write a memory (`source_type=user_confirmed` auto-locks) |
|
|
101
|
+
| `memory_search` | Search memories (FTS5 → LIKE fallback) |
|
|
102
|
+
| `memory_compare` | Compare two memories, returns explanation only |
|
|
103
|
+
| `memory_arbitrate` | Arbitrate conflict, can record result (`apply=true`) |
|
|
104
|
+
| `memory_confirm` | Promote a memory to user-confirmed and locked |
|
|
105
|
+
| `memory_list_conflicts` | List unresolved conflicts |
|
|
106
|
+
| `memory_status` | Show current mode, degradation status, storage paths |
|
|
107
|
+
|
|
108
|
+
### Data Migration
|
|
109
|
+
|
|
110
|
+
Moving to a new machine? Just copy the SQLite file:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# Copy the database
|
|
114
|
+
cp ~/.local/share/memory-arbiter/memory.sqlite3 /new/machine/~/.local/share/memory-arbiter/
|
|
115
|
+
|
|
116
|
+
# Reinstall the project (don't copy .venv — rebuild it)
|
|
117
|
+
python3.11 -m venv .venv
|
|
118
|
+
source .venv/bin/activate
|
|
119
|
+
pip install -e .
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Testing
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3.11 -m pip install -r requirements.txt
|
|
126
|
+
python3.11 -m pytest
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
<a id="中文"></a>
|
|
136
|
+
|
|
137
|
+
## 中文
|
|
138
|
+
|
|
139
|
+
一个轻量、完全本地运行的 MCP Server,给你的 AI 编程工具提供**共享记忆库**,内置冲突仲裁机制。
|
|
140
|
+
|
|
141
|
+
你同时用 ZCode、Codex、Cursor、Claude Code——每个工具都有各自的记忆,互不相通。Memory Arbiter 解决这个问题:一个 SQLite 数据库,所有工具通过同一个 MCP 协议读写,冲突由结构化规则仲裁,不依赖大模型。
|
|
142
|
+
|
|
143
|
+
### 核心能力
|
|
144
|
+
|
|
145
|
+
- **结构化写入**:`content`、`agent_id`、`workspace`、`tags`、`source_type`、`event_time`、`ingest_time`、`confidence`、`protection_level` 等。
|
|
146
|
+
- **来源可信度**:`user_confirmed` > `document_extracted` > `agent_generated` > `unknown`。
|
|
147
|
+
- **双时间轴仲裁**:按 用户确认 → 事件发生时间 → 来源可信度 → 录入时间 的优先级判定,输出可解释的裁决理由。
|
|
148
|
+
- **锁定保护**:`user_confirmed` 的记忆自动锁定,任何 Agent 都不能自动覆盖。
|
|
149
|
+
- **客户端策略**:按客户端启用/禁用,Agent 级别的 allow/deny 白名单控制。
|
|
150
|
+
- **逐级降级**:`sqlite-vec` → FTS5 → `LIKE` → JSONL 备份,不会崩。
|
|
151
|
+
- **零云依赖、零大模型调用**:纯本地 SQLite,不需要 Postgres、Redis 或外部服务。
|
|
152
|
+
|
|
153
|
+
### 快速开始
|
|
154
|
+
|
|
155
|
+
**要求**:Python 3.11+
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# 克隆
|
|
159
|
+
git clone https://github.com/billy12151/memory-arbiter-mcp.git
|
|
160
|
+
cd memory-arbiter-mcp
|
|
161
|
+
|
|
162
|
+
# 安装
|
|
163
|
+
python3.11 -m venv .venv
|
|
164
|
+
source .venv/bin/activate
|
|
165
|
+
pip install -r requirements.txt
|
|
166
|
+
pip install -e .
|
|
167
|
+
|
|
168
|
+
# 可选:启用语义召回增强(sqlite-vec)
|
|
169
|
+
pip install '.[vec]'
|
|
170
|
+
|
|
171
|
+
# 启动
|
|
172
|
+
memory-arbiter-mcp
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 接入工具
|
|
176
|
+
|
|
177
|
+
在你的工具的 MCP 配置中加入(完整示例见 `examples/` 目录):
|
|
178
|
+
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"mcpServers": {
|
|
182
|
+
"memory-arbiter": {
|
|
183
|
+
"command": "/path/to/memory-arbiter-mcp/.venv/bin/memory-arbiter-mcp",
|
|
184
|
+
"env": {
|
|
185
|
+
"MEMORY_ARBITER_CLIENT": "zcode",
|
|
186
|
+
"MEMORY_ARBITER_AGENT_ID": "zcode-default",
|
|
187
|
+
"MEMORY_ARBITER_DB_PATH": "~/.local/share/memory-arbiter/memory.sqlite3"
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
> 每个工具改一下 `MEMORY_ARBITER_CLIENT` 标识(`zcode`、`codex`、`cursor`、`claude-code`),共享同一个 `DB_PATH`——这就是跨工具记忆共享的关键。
|
|
195
|
+
|
|
196
|
+
> ⚠️ **需要新建会话**:MCP Server 在客户端启动时加载,已经打开的会话不会识别新添加的 Server。配置好后请新建一个会话。
|
|
197
|
+
|
|
198
|
+
### 客户端配置位置
|
|
199
|
+
|
|
200
|
+
| 客户端 | 配置文件位置 |
|
|
201
|
+
|---|---|
|
|
202
|
+
| ZCode | `~/.zcode/v2/` 下 MCP 配置 |
|
|
203
|
+
| Codex CLI | `~/.codex/` 下 MCP 配置 |
|
|
204
|
+
| Claude Code | 项目根目录 `.mcp.json` |
|
|
205
|
+
| Cursor | `~/.cursor/mcp.json` |
|
|
206
|
+
|
|
207
|
+
### MCP 工具
|
|
208
|
+
|
|
209
|
+
| 工具 | 说明 |
|
|
210
|
+
|---|---|
|
|
211
|
+
| `memory_write` | 写入记忆(`source_type=user_confirmed` 自动锁定) |
|
|
212
|
+
| `memory_search` | 搜索记忆(FTS5 → LIKE 自动降级) |
|
|
213
|
+
| `memory_compare` | 比较两条记忆,只返回解释 |
|
|
214
|
+
| `memory_arbitrate` | 仲裁冲突,自动判定胜者(`apply=true` 时落记录) |
|
|
215
|
+
| `memory_confirm` | 用户确认某条记忆,锁定保护 |
|
|
216
|
+
| `memory_list_conflicts` | 列出未解决的冲突 |
|
|
217
|
+
| `memory_status` | 查看运行状态、模式、降级原因 |
|
|
218
|
+
|
|
219
|
+
### 数据迁移
|
|
220
|
+
|
|
221
|
+
换电脑只需拷贝一个文件:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# 拷贝数据库
|
|
225
|
+
cp ~/.local/share/memory-arbiter/memory.sqlite3 新电脑:~/.local/share/memory-arbiter/
|
|
226
|
+
|
|
227
|
+
# 重新安装项目(.venv 不要拷贝,新机器上重建)
|
|
228
|
+
python3.11 -m venv .venv
|
|
229
|
+
source .venv/bin/activate
|
|
230
|
+
pip install -e .
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 测试
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
python3.11 -m pip install -r requirements.txt
|
|
237
|
+
python3.11 -m pytest
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### License
|
|
241
|
+
|
|
242
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
memory_arbiter/__init__.py,sha256=MK_3xJdjTbJvb7M4a2awe38mp6Q5pLaO9OxCWjbJSCc,57
|
|
2
|
+
memory_arbiter/arbitration.py,sha256=q5kSm5TPenazamzV-rFnZXTTdQbeGhg-lo4HeUF9aNw,4144
|
|
3
|
+
memory_arbiter/config.py,sha256=EitfTJijPPWP5NjVvk9vFk_iH2EEl3iTE9y3-K6Fs0U,2715
|
|
4
|
+
memory_arbiter/db.py,sha256=ilsWjiGVoq39V4VdHAVsTds8Q7gN_9Mw6Wmsou6y5bo,10185
|
|
5
|
+
memory_arbiter/degrade.py,sha256=vLk_gmSMh6wOb56e35kY4pW3SDoJJ2iqtrFwD4x-hhU,1091
|
|
6
|
+
memory_arbiter/models.py,sha256=EIeMRTDRb7qe63GqKeETyuXe0AJp8giv0UXjj-ZVwoo,3048
|
|
7
|
+
memory_arbiter/search.py,sha256=DWFr2a_UmcmobbNRlxq5BXGkCJiTZPhB8usFD5wdHLo,2119
|
|
8
|
+
memory_arbiter/server.py,sha256=VRKfaJ0e39iWgxaLx7ctybeLRq9KQZFYahk7PYSMCGM,3946
|
|
9
|
+
memory_arbiter/tools.py,sha256=xsGGgIAcqcAVYosR05zyiYHrxFgvaTwavnrOYCmAAMs,6233
|
|
10
|
+
memory_arbiter_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=26R1Y1S9Zz9xhwCqKr1BOiXRjk-Z8bSK9lC8wSVaPDk,1067
|
|
11
|
+
memory_arbiter_mcp-0.1.0.dist-info/METADATA,sha256=oA6f70bn16Nlt1ur8_wsvhXOlbJRiVQe0F8aTpSt4ts,7776
|
|
12
|
+
memory_arbiter_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
memory_arbiter_mcp-0.1.0.dist-info/entry_points.txt,sha256=Wkl1Xqd0vLB4Rb3c8sc6Gsgk14TfVmBx4x7pBonPaoI,66
|
|
14
|
+
memory_arbiter_mcp-0.1.0.dist-info/top_level.txt,sha256=FTEDicSQQ34Y267bTtOdFbxwBOSU4jL_ihE4mF5hMBI,15
|
|
15
|
+
memory_arbiter_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 billy12151
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
memory_arbiter
|