dsh-plugin-xujie-memory 0.5.0 → 0.7.0
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.
- package/ACCEPTANCE.md +37 -0
- package/BUILD-PROVENANCE.json +53 -0
- package/CHANGELOG.md +40 -1
- package/LICENSE +21 -21
- package/README.md +67 -17
- package/README_EN.md +54 -8
- package/cordis.patch.yml +49 -49
- package/lib/client.js +17219 -0
- package/lib/controller.js +307 -0
- package/lib/index.js +200 -14
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +15104 -0
- package/lib/typert.remote-client.d.ts +40 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +15093 -0
- package/lib/types.d.ts +13 -0
- package/lib/types.js +1 -0
- package/package.json +49 -5
- package/python/specs/characters/aloof.json +14 -14
- package/python/specs/characters/gentle.json +14 -14
- package/python/specs/characters/tsundere.json +14 -14
- package/python/specs/debt.v1.json +31 -31
- package/python/specs/nickname.v1.json +16 -16
- package/python/specs/preference.v1.json +16 -16
- package/python/xujie_memory/__init__.py +123 -123
- package/python/xujie_memory/admission.py +211 -211
- package/python/xujie_memory/audit.py +377 -377
- package/python/xujie_memory/benchmark.py +281 -281
- package/python/xujie_memory/character.py +199 -199
- package/python/xujie_memory/console.py +41 -41
- package/python/xujie_memory/consolidate.py +356 -356
- package/python/xujie_memory/due.py +121 -121
- package/python/xujie_memory/emotion.py +573 -573
- package/python/xujie_memory/engine.py +1333 -1312
- package/python/xujie_memory/errors.py +98 -98
- package/python/xujie_memory/importance.py +104 -104
- package/python/xujie_memory/judge.py +350 -350
- package/python/xujie_memory/management.py +203 -0
- package/python/xujie_memory/memory.py +71 -10
- package/python/xujie_memory/orchestrator.py +690 -666
- package/python/xujie_memory/phrasing.py +292 -292
- package/python/xujie_memory/privacy.py +105 -0
- package/python/xujie_memory/schema.sql +492 -480
- package/python/xujie_memory/server.py +76 -0
- package/python/xujie_memory/specs.py +170 -170
- package/python/xujie_memory/summary.py +238 -238
- package/scripts/build.mjs +19 -0
- package/scripts/prepack.mjs +9 -0
- package/scripts/test-all.mjs +9 -0
- package/scripts/test-node.mjs +10 -0
- package/src/client/MemoryPanel.tsx +536 -0
- package/src/client/NeuralField.tsx +88 -0
- package/src/client/css.d.ts +4 -0
- package/src/client/icons.tsx +28 -0
- package/src/client/index.tsx +59 -0
- package/src/client/memory-adapter.ts +67 -0
- package/src/client/model.ts +79 -0
- package/src/client/neural/engine.ts +999 -0
- package/src/client/neural/morphology.ts +219 -0
- package/src/client/panel.css +337 -0
- package/src/client/types.ts +6 -0
- package/src/controller.ts +88 -0
- package/src/generated/typert.host.js +449 -0
- package/src/generated/typert.remote-client.js +438 -0
- package/src/index.ts +2 -0
- package/src/types.ts +13 -0
- package/test/cordis-fixture.mjs +5 -0
- package/test/management_regression.py +187 -0
- package/test/node-regression.entry.mjs +52 -0
- package/vendor/DSH-LICENSE.txt +21 -0
- package/vendor/THIRD-PARTY.md +6 -0
- package/vendor/dsh-protocol/index.ts +484 -0
- package/vendor/dsh-protocol/json-value.ts +53 -0
- package/vendor/dsh-protocol/owned-value.ts +39 -0
- package/vendor/dsh-protocol/principal.ts +17 -0
- package/vendor/dsh-protocol/remote-access.ts +38 -0
- package/vendor/dsh-protocol/remote-error.ts +49 -0
- package/vendor/dsh-protocol/remote-info.ts +161 -0
- package/vendor/dsh-protocol/types.ts +939 -0
- package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
- package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
- package/vendor/licenses/zod-MIT.txt +21 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Token-protected data plane for the DSH owner-only, in-app memory panel.
|
|
2
|
+
|
|
3
|
+
The Host authenticates its owner using the DSH Gateway. The browser never gets
|
|
4
|
+
the kernel token. Explicit scope ids come from catalog(), never guessed user ids.
|
|
5
|
+
Source traces stay immutable: edits atomically replace evidence with a new trace.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from .memory import Memory
|
|
13
|
+
from .profile import ProfileStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ManagementError(ValueError):
|
|
17
|
+
def __init__(self, code: str):
|
|
18
|
+
self.code = code
|
|
19
|
+
super().__init__(code)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Management:
|
|
23
|
+
def __init__(self, service):
|
|
24
|
+
self.service = service
|
|
25
|
+
self.engine = service.engine
|
|
26
|
+
self.memory = Memory(self.engine)
|
|
27
|
+
|
|
28
|
+
def scope(self, scope_id):
|
|
29
|
+
row = self.engine._conn().execute(
|
|
30
|
+
"SELECT * FROM scopes WHERE scope_id=?", (scope_id,)
|
|
31
|
+
).fetchone()
|
|
32
|
+
if row is None:
|
|
33
|
+
raise ManagementError("scope_not_found")
|
|
34
|
+
return dict(row)
|
|
35
|
+
|
|
36
|
+
def catalog(self):
|
|
37
|
+
rows = self.engine._conn().execute(
|
|
38
|
+
"SELECT s.scope_id,s.agent_id,COUNT(t.trace_id) AS traces FROM scopes s"
|
|
39
|
+
" LEFT JOIN traces t ON s.scope_id=t.scope_id AND t.erased_at IS NULL"
|
|
40
|
+
" GROUP BY s.scope_id ORDER BY s.agent_id,s.scope_id"
|
|
41
|
+
).fetchall()
|
|
42
|
+
return {"ok": True, "scope_mode": self.service.scope_mode,
|
|
43
|
+
"scopes": [dict(r) for r in rows]}
|
|
44
|
+
|
|
45
|
+
def detail(self, scope_id, trace_id):
|
|
46
|
+
self.scope(scope_id)
|
|
47
|
+
row = self.engine.get_trace(trace_id)
|
|
48
|
+
if row is None or row["scope_id"] != scope_id or row["erased_at"]:
|
|
49
|
+
raise ManagementError("trace_not_found")
|
|
50
|
+
weights = self.memory.lifecycle.batch_priority([trace_id]).get(trace_id, {})
|
|
51
|
+
return {"ok": True, "trace": {
|
|
52
|
+
"id": trace_id, "trace_id": trace_id, "scope_id": scope_id,
|
|
53
|
+
"content": row["content"], "content_hash": row["content_hash"],
|
|
54
|
+
"recorded_at": row["recorded_at"], "source_kind": row["source_kind"],
|
|
55
|
+
"importance": weights.get("base", 1), "anchor": bool(weights.get("anchor")),
|
|
56
|
+
"priority": weights.get("priority", 0),
|
|
57
|
+
}}
|
|
58
|
+
|
|
59
|
+
def snapshot(self, scope_id, limit=250):
|
|
60
|
+
self.scope(scope_id)
|
|
61
|
+
conn = self.engine._conn()
|
|
62
|
+
limit = max(1, min(500, int(limit)))
|
|
63
|
+
# A short read transaction prevents mixed snapshots during concurrent edits.
|
|
64
|
+
with self.engine._tx():
|
|
65
|
+
rows = conn.execute(
|
|
66
|
+
"SELECT trace_id,content,content_hash,recorded_at FROM traces"
|
|
67
|
+
" WHERE scope_id=? AND erased_at IS NULL"
|
|
68
|
+
" ORDER BY recorded_at DESC,trace_id LIMIT ?", (scope_id, limit)
|
|
69
|
+
).fetchall()
|
|
70
|
+
weights = self.memory.lifecycle.batch_priority([r["trace_id"] for r in rows])
|
|
71
|
+
nodes, edges = [], []
|
|
72
|
+
visible = {r["trace_id"] for r in rows}
|
|
73
|
+
for i, r in enumerate(rows):
|
|
74
|
+
w = weights.get(r["trace_id"], {})
|
|
75
|
+
node = {"id": r["trace_id"], "trace_id": r["trace_id"],
|
|
76
|
+
"layer": "episodic", "kind": "trace", "content": r["content"],
|
|
77
|
+
"content_hash": r["content_hash"], "recorded_at": r["recorded_at"],
|
|
78
|
+
"importance": w.get("base", 1), "anchor": bool(w.get("anchor")),
|
|
79
|
+
"priority": w.get("priority", 0), "editable": True}
|
|
80
|
+
nodes.append(node)
|
|
81
|
+
if node["anchor"]:
|
|
82
|
+
aid = "anchor:" + r["trace_id"]
|
|
83
|
+
nodes.append({**node, "id": aid, "layer": "semantic", "kind": "anchor"})
|
|
84
|
+
edges.append({"source": r["trace_id"], "target": aid,
|
|
85
|
+
"kind": "anchor_projection"})
|
|
86
|
+
if i < 8:
|
|
87
|
+
wid = "working:" + r["trace_id"]
|
|
88
|
+
nodes.append({**node, "id": wid, "layer": "working", "kind": "recent"})
|
|
89
|
+
edges.append({"source": r["trace_id"], "target": wid,
|
|
90
|
+
"kind": "working_projection"})
|
|
91
|
+
states = conn.execute(
|
|
92
|
+
"SELECT state_key,value_json,status,evaluated_as_of FROM states"
|
|
93
|
+
" WHERE scope_id=? AND status IN ('known','stale','disputed')"
|
|
94
|
+
" ORDER BY evaluated_as_of DESC,state_key LIMIT ?", (scope_id, limit)
|
|
95
|
+
).fetchall()
|
|
96
|
+
for r in states:
|
|
97
|
+
evidence = conn.execute(
|
|
98
|
+
"SELECT DISTINCT v.trace_id FROM event_evidence v"
|
|
99
|
+
" JOIN events e ON e.event_id=v.event_id"
|
|
100
|
+
" JOIN traces t ON t.trace_id=v.trace_id"
|
|
101
|
+
" WHERE e.state_key=? AND e.void=0 AND t.erased_at IS NULL"
|
|
102
|
+
" ORDER BY v.trace_id", (r["state_key"],),
|
|
103
|
+
).fetchall()
|
|
104
|
+
parts = r["state_key"].split("|")
|
|
105
|
+
label = " · ".join(parts[1:4])
|
|
106
|
+
nodes.append({"id": r["state_key"], "layer": "semantic", "kind": "state",
|
|
107
|
+
"content": label + ": " + (r["value_json"] or "未知"),
|
|
108
|
+
"status": r["status"], "editable": False,
|
|
109
|
+
"evidence": [e["trace_id"] for e in evidence]})
|
|
110
|
+
for e in evidence:
|
|
111
|
+
if e["trace_id"] in visible:
|
|
112
|
+
edges.append({"source": e["trace_id"], "target": r["state_key"],
|
|
113
|
+
"kind": "evidence"})
|
|
114
|
+
summaries = conn.execute(
|
|
115
|
+
"SELECT summary_id,text,input_refs_json,created_at FROM summaries"
|
|
116
|
+
" WHERE scope_id=? AND lifecycle_state='active' AND text<>''"
|
|
117
|
+
" ORDER BY created_at DESC,summary_id LIMIT 60", (scope_id,),
|
|
118
|
+
).fetchall()
|
|
119
|
+
for r in summaries:
|
|
120
|
+
try:
|
|
121
|
+
refs = json.loads(r["input_refs_json"])
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
continue
|
|
124
|
+
nodes.append({"id": r["summary_id"], "layer": "episodic", "kind": "summary",
|
|
125
|
+
"content": r["text"], "editable": False,
|
|
126
|
+
"recorded_at": r["created_at"], "evidence": refs})
|
|
127
|
+
for ref in refs:
|
|
128
|
+
if ref in visible:
|
|
129
|
+
edges.append({"source": ref, "target": r["summary_id"],
|
|
130
|
+
"kind": "summary_input"})
|
|
131
|
+
pending = conn.execute(
|
|
132
|
+
"SELECT request_id,kind,detail_json FROM clarifications"
|
|
133
|
+
" WHERE scope_id=? AND status='open' ORDER BY created_at DESC LIMIT 50",
|
|
134
|
+
(scope_id,),
|
|
135
|
+
).fetchall()
|
|
136
|
+
for r in pending:
|
|
137
|
+
nodes.append({"id": r["request_id"], "layer": "working",
|
|
138
|
+
"kind": "clarification", "editable": False,
|
|
139
|
+
"content": r["kind"] + ":" + (r["detail_json"] or "")})
|
|
140
|
+
cards = []
|
|
141
|
+
store = ProfileStore(self.engine)
|
|
142
|
+
for row in store.profiles(scope_id):
|
|
143
|
+
cards.append(store.card(scope_id, row["account_id"], limit=50))
|
|
144
|
+
stats = self.memory.stats(scope_id)
|
|
145
|
+
return {"ok": True, "scope_id": scope_id, "scope_mode": self.service.scope_mode,
|
|
146
|
+
"nodes": nodes, "edges": edges, "cards": cards, "stats": stats,
|
|
147
|
+
"truncated": stats["traces"] > len(rows),
|
|
148
|
+
"limit": limit, "render_links_are_not_semantic_inference": True}
|
|
149
|
+
|
|
150
|
+
def search(self, scope_id, query, limit=30):
|
|
151
|
+
self.scope(scope_id)
|
|
152
|
+
return {"ok": True, "hits": self.memory.search(scope_id, query, limit=limit, candidates=None)}
|
|
153
|
+
|
|
154
|
+
def edit(self, scope_id, trace_id, content, expected_hash):
|
|
155
|
+
self.scope(scope_id)
|
|
156
|
+
content = content.strip()
|
|
157
|
+
if not content or len(content) > 2000:
|
|
158
|
+
raise ManagementError("invalid_content_length")
|
|
159
|
+
with self.engine._tx():
|
|
160
|
+
trace = self.detail(scope_id, trace_id)["trace"]
|
|
161
|
+
if not expected_hash or trace["content_hash"] != expected_hash:
|
|
162
|
+
raise ManagementError("version_conflict")
|
|
163
|
+
if trace["content"] == content:
|
|
164
|
+
return {"ok": True, "changed": False, "trace": trace}
|
|
165
|
+
original = self.engine.get_trace(trace_id)
|
|
166
|
+
actor = original["actor_id"]
|
|
167
|
+
occurred_at = original.get("source_time") or original["recorded_at"]
|
|
168
|
+
self.memory.forget(scope_id, trace_id)
|
|
169
|
+
# A saved edit is an explicit user correction, not a fabricated
|
|
170
|
+
# conversational turn. Do not run profile-watching/auto-replies.
|
|
171
|
+
result = self.service.orchestrator.ingest.ingest(
|
|
172
|
+
scope_id, "edit:" + trace_id + ":" + uuid.uuid4().hex,
|
|
173
|
+
actor, "user_statement", content,
|
|
174
|
+
context={"peer_id": "assistant", "effective_at": occurred_at}, confirmed=True,
|
|
175
|
+
pattern_escalation=False,
|
|
176
|
+
)
|
|
177
|
+
self.engine._conn().execute(
|
|
178
|
+
"UPDATE traces SET source_time=?,source_time_precision=? WHERE trace_id=?",
|
|
179
|
+
(occurred_at, original.get("source_time_precision"), result.trace_id),
|
|
180
|
+
)
|
|
181
|
+
self.memory.mark(scope_id, result.trace_id, trace["importance"])
|
|
182
|
+
if trace["anchor"]:
|
|
183
|
+
self.memory.lifecycle.set_anchor(result.trace_id, True)
|
|
184
|
+
store = ProfileStore(self.engine)
|
|
185
|
+
for row in store.profiles(scope_id):
|
|
186
|
+
store.derive_from_memory(scope_id, row["account_id"])
|
|
187
|
+
self.engine._conn().execute(
|
|
188
|
+
"INSERT INTO memory_edits(edit_id,scope_id,old_trace_id,new_trace_id,edited_at)"
|
|
189
|
+
" VALUES (?,?,?,?,?)",
|
|
190
|
+
(uuid.uuid4().hex, scope_id, trace_id, result.trace_id, self.engine.now()),
|
|
191
|
+
)
|
|
192
|
+
saved = self.detail(scope_id, result.trace_id)["trace"]
|
|
193
|
+
return {"ok": True, "changed": True, "replaced_trace_id": trace_id, "trace": saved,
|
|
194
|
+
"pending": len(result.clarifications)}
|
|
195
|
+
|
|
196
|
+
def forget(self, scope_id, trace_id, expected_hash):
|
|
197
|
+
self.scope(scope_id)
|
|
198
|
+
with self.engine._tx():
|
|
199
|
+
trace = self.detail(scope_id, trace_id)["trace"]
|
|
200
|
+
if not expected_hash or trace["content_hash"] != expected_hash:
|
|
201
|
+
raise ManagementError("version_conflict")
|
|
202
|
+
result = self.memory.forget(scope_id, trace_id)
|
|
203
|
+
return {"ok": True, **result}
|
|
@@ -80,18 +80,21 @@ class FuzzySearch:
|
|
|
80
80
|
self.engine = engine
|
|
81
81
|
|
|
82
82
|
def search(self, scope_id: str, query: str, *, limit: int = 5,
|
|
83
|
-
candidates: int = 800) -> list[dict[str, Any]]:
|
|
83
|
+
candidates: int | None = 800) -> list[dict[str, Any]]:
|
|
84
84
|
query = (query or "").strip()
|
|
85
85
|
if not query:
|
|
86
86
|
return []
|
|
87
|
-
|
|
88
|
-
"SELECT trace_id, content, recorded_at FROM traces"
|
|
87
|
+
sql = (
|
|
88
|
+
"SELECT trace_id, content, content_hash, recorded_at FROM traces"
|
|
89
89
|
" WHERE scope_id=? AND erased_at IS NULL"
|
|
90
|
-
" ORDER BY recorded_at DESC
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
" ORDER BY recorded_at DESC"
|
|
91
|
+
)
|
|
92
|
+
params = (scope_id,)
|
|
93
|
+
if candidates is not None:
|
|
94
|
+
sql += " LIMIT ?"
|
|
95
|
+
params += (max(1, int(candidates)),)
|
|
96
|
+
rows = self.engine._conn().execute(sql, params)
|
|
97
|
+
# 自动召回保持候选上限,管理界面可显式搜索全部有效原话。
|
|
95
98
|
q = _normalize(query)
|
|
96
99
|
if not q:
|
|
97
100
|
return []
|
|
@@ -124,6 +127,7 @@ class FuzzySearch:
|
|
|
124
127
|
out.append({
|
|
125
128
|
"trace_id": row["trace_id"],
|
|
126
129
|
"content": row["content"],
|
|
130
|
+
"content_hash": row["content_hash"],
|
|
127
131
|
"score": round(float(score), 4),
|
|
128
132
|
"priority": w.get("priority"),
|
|
129
133
|
"anchor": bool(w.get("anchor")),
|
|
@@ -217,6 +221,63 @@ class Memory:
|
|
|
217
221
|
raise ValueError("trace_not_found")
|
|
218
222
|
return self.lifecycle.curve(trace_id, points=max(2, min(60, int(points))))
|
|
219
223
|
|
|
224
|
+
# ------------------------------------------------------------ 删除 / 统计
|
|
225
|
+
def forget(self, scope_id: str, trace_id: str) -> dict[str, Any]:
|
|
226
|
+
"""删一条记忆:内容清空、FTS 移除、依赖它的事件一起失效。
|
|
227
|
+
|
|
228
|
+
不是物理删行——行还在,`erased_at` 有值,审计能看见"删过什么"。
|
|
229
|
+
依赖传播(欠账、偏好等由它推出的事实)由 `Engine.erase_trace` 负责,
|
|
230
|
+
它会在同一事务里把受影响的状态重算干净。
|
|
231
|
+
"""
|
|
232
|
+
trace = self.engine.get_trace(trace_id)
|
|
233
|
+
if trace is None or trace.get("scope_id") != scope_id:
|
|
234
|
+
raise ValueError("trace_not_found")
|
|
235
|
+
# One outer transaction covers evidence, summaries AND the person card.
|
|
236
|
+
# Nested Engine transactions use savepoints.
|
|
237
|
+
with self.engine._tx():
|
|
238
|
+
already_erased = bool(trace.get("erased_at"))
|
|
239
|
+
result = {"state_keys": []} if already_erased else self.engine.erase_trace(trace_id)
|
|
240
|
+
from .profile import ProfileStore
|
|
241
|
+
profiles = ProfileStore(self.engine)
|
|
242
|
+
accounts = self.engine._conn().execute(
|
|
243
|
+
"SELECT account_id FROM person_profiles WHERE scope_id=?", (scope_id,)
|
|
244
|
+
).fetchall()
|
|
245
|
+
for account in accounts:
|
|
246
|
+
profiles.derive_from_memory(scope_id, account["account_id"])
|
|
247
|
+
return {"trace_id": trace_id, "erased": True, "already_erased": already_erased,
|
|
248
|
+
"state_keys": result["state_keys"]}
|
|
249
|
+
|
|
250
|
+
def stats(self, scope_id: str) -> dict[str, Any]:
|
|
251
|
+
"""库里的账:有多少条、多少条被删过、多少条锚住。
|
|
252
|
+
|
|
253
|
+
透明度用:用户问"你到底记了我多少东西"时,这里给得出数。
|
|
254
|
+
"""
|
|
255
|
+
conn = self.engine._conn()
|
|
256
|
+
|
|
257
|
+
def count(sql: str, *args) -> int:
|
|
258
|
+
return int(conn.execute(sql, args).fetchone()[0])
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
"scope_id": scope_id,
|
|
262
|
+
"traces": count(
|
|
263
|
+
"SELECT COUNT(*) FROM traces WHERE scope_id=? AND erased_at IS NULL",
|
|
264
|
+
scope_id),
|
|
265
|
+
"traces_erased": count(
|
|
266
|
+
"SELECT COUNT(*) FROM traces WHERE scope_id=? AND erased_at IS NOT NULL",
|
|
267
|
+
scope_id),
|
|
268
|
+
"events": count(
|
|
269
|
+
"SELECT COUNT(*) FROM events WHERE scope_id=?", scope_id),
|
|
270
|
+
"states": count(
|
|
271
|
+
"SELECT COUNT(*) FROM states WHERE scope_id=?", scope_id),
|
|
272
|
+
"anchors": len(self.lifecycle.anchored(scope_id)),
|
|
273
|
+
"clarifications_open": count(
|
|
274
|
+
"SELECT COUNT(*) FROM clarifications"
|
|
275
|
+
" WHERE scope_id=? AND status='open'", scope_id),
|
|
276
|
+
"summaries": count(
|
|
277
|
+
"SELECT COUNT(*) FROM summaries WHERE scope_id=?", scope_id),
|
|
278
|
+
}
|
|
279
|
+
|
|
220
280
|
# ------------------------------------------------------------ 搜索
|
|
221
|
-
def search(self, scope_id: str, query: str, *, limit: int = 5
|
|
222
|
-
|
|
281
|
+
def search(self, scope_id: str, query: str, *, limit: int = 5,
|
|
282
|
+
candidates: int | None = 800) -> list[dict]:
|
|
283
|
+
return self.fuzzy.search(scope_id, query, limit=limit, candidates=candidates)
|