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.
Files changed (84) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +40 -1
  4. package/LICENSE +21 -21
  5. package/README.md +67 -17
  6. package/README_EN.md +54 -8
  7. package/cordis.patch.yml +49 -49
  8. package/lib/client.js +17219 -0
  9. package/lib/controller.js +307 -0
  10. package/lib/index.js +200 -14
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +15104 -0
  13. package/lib/typert.remote-client.d.ts +40 -0
  14. package/lib/typert.remote-client.d.ts.map +1 -0
  15. package/lib/typert.remote-client.js +15093 -0
  16. package/lib/types.d.ts +13 -0
  17. package/lib/types.js +1 -0
  18. package/package.json +49 -5
  19. package/python/specs/characters/aloof.json +14 -14
  20. package/python/specs/characters/gentle.json +14 -14
  21. package/python/specs/characters/tsundere.json +14 -14
  22. package/python/specs/debt.v1.json +31 -31
  23. package/python/specs/nickname.v1.json +16 -16
  24. package/python/specs/preference.v1.json +16 -16
  25. package/python/xujie_memory/__init__.py +123 -123
  26. package/python/xujie_memory/admission.py +211 -211
  27. package/python/xujie_memory/audit.py +377 -377
  28. package/python/xujie_memory/benchmark.py +281 -281
  29. package/python/xujie_memory/character.py +199 -199
  30. package/python/xujie_memory/console.py +41 -41
  31. package/python/xujie_memory/consolidate.py +356 -356
  32. package/python/xujie_memory/due.py +121 -121
  33. package/python/xujie_memory/emotion.py +573 -573
  34. package/python/xujie_memory/engine.py +1333 -1312
  35. package/python/xujie_memory/errors.py +98 -98
  36. package/python/xujie_memory/importance.py +104 -104
  37. package/python/xujie_memory/judge.py +350 -350
  38. package/python/xujie_memory/management.py +203 -0
  39. package/python/xujie_memory/memory.py +71 -10
  40. package/python/xujie_memory/orchestrator.py +690 -666
  41. package/python/xujie_memory/phrasing.py +292 -292
  42. package/python/xujie_memory/privacy.py +105 -0
  43. package/python/xujie_memory/schema.sql +492 -480
  44. package/python/xujie_memory/server.py +76 -0
  45. package/python/xujie_memory/specs.py +170 -170
  46. package/python/xujie_memory/summary.py +238 -238
  47. package/scripts/build.mjs +19 -0
  48. package/scripts/prepack.mjs +9 -0
  49. package/scripts/test-all.mjs +9 -0
  50. package/scripts/test-node.mjs +10 -0
  51. package/src/client/MemoryPanel.tsx +536 -0
  52. package/src/client/NeuralField.tsx +88 -0
  53. package/src/client/css.d.ts +4 -0
  54. package/src/client/icons.tsx +28 -0
  55. package/src/client/index.tsx +59 -0
  56. package/src/client/memory-adapter.ts +67 -0
  57. package/src/client/model.ts +79 -0
  58. package/src/client/neural/engine.ts +999 -0
  59. package/src/client/neural/morphology.ts +219 -0
  60. package/src/client/panel.css +337 -0
  61. package/src/client/types.ts +6 -0
  62. package/src/controller.ts +88 -0
  63. package/src/generated/typert.host.js +449 -0
  64. package/src/generated/typert.remote-client.js +438 -0
  65. package/src/index.ts +2 -0
  66. package/src/types.ts +13 -0
  67. package/test/cordis-fixture.mjs +5 -0
  68. package/test/management_regression.py +187 -0
  69. package/test/node-regression.entry.mjs +52 -0
  70. package/vendor/DSH-LICENSE.txt +21 -0
  71. package/vendor/THIRD-PARTY.md +6 -0
  72. package/vendor/dsh-protocol/index.ts +484 -0
  73. package/vendor/dsh-protocol/json-value.ts +53 -0
  74. package/vendor/dsh-protocol/owned-value.ts +39 -0
  75. package/vendor/dsh-protocol/principal.ts +17 -0
  76. package/vendor/dsh-protocol/remote-access.ts +38 -0
  77. package/vendor/dsh-protocol/remote-error.ts +49 -0
  78. package/vendor/dsh-protocol/remote-info.ts +161 -0
  79. package/vendor/dsh-protocol/types.ts +939 -0
  80. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  81. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  82. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  83. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  84. 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
- rows = self.engine._conn().execute(
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 LIMIT ?",
91
- (scope_id, max(1, int(candidates))),
92
- ).fetchall()
93
- # v0.52:查询只预处理一次;内容的二元组按文本缓存(进程内 LRU)。
94
- # 每轮 800 条候选,旧版每条都重新规范化查询,单轮约 20ms。
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) -> list[dict]:
222
- return self.fuzzy.search(scope_id, query, limit=limit)
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)