@pi-unipi/unipi 2.4.2 → 2.5.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 (40) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +23 -22
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/btw/package.json +2 -2
  6. package/packages/cocoindex/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/core/package.json +1 -1
  9. package/packages/core/sandbox.ts +7 -6
  10. package/packages/footer/package.json +2 -2
  11. package/packages/image/package.json +2 -2
  12. package/packages/info-screen/package.json +2 -2
  13. package/packages/input-shortcuts/package.json +2 -2
  14. package/packages/kanboard/package.json +2 -2
  15. package/packages/mcp/README.md +9 -0
  16. package/packages/mcp/package.json +5 -2
  17. package/packages/mcp/src/bridge/registry.ts +244 -137
  18. package/packages/mcp/src/bridge/translator.ts +77 -9
  19. package/packages/mcp/src/index.ts +43 -67
  20. package/packages/mcp/src/tui/settings-overlay.ts +3 -9
  21. package/packages/memory/README.md +15 -11
  22. package/packages/memory/bridge/mempalace_bridge.py +636 -0
  23. package/packages/memory/mempalace.ts +169 -18
  24. package/packages/memory/package.json +3 -3
  25. package/packages/memory/storage.ts +27 -10
  26. package/packages/milestone/README.md +11 -3
  27. package/packages/milestone/hooks.ts +103 -29
  28. package/packages/milestone/index.ts +1 -1
  29. package/packages/milestone/package.json +5 -2
  30. package/packages/notify/package.json +2 -2
  31. package/packages/ralph/package.json +3 -3
  32. package/packages/subagents/package.json +1 -1
  33. package/packages/unipi/bundled.js +693 -408
  34. package/packages/updater/package.json +2 -2
  35. package/packages/utility/package.json +2 -2
  36. package/packages/web-api/package.json +2 -2
  37. package/packages/workflow/README.md +8 -0
  38. package/packages/workflow/commands.ts +16 -26
  39. package/packages/workflow/index.ts +165 -85
  40. package/packages/workflow/package.json +5 -2
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env python3
2
+ """UniPi memory <-> MemPalace bridge.
3
+
4
+ Single-command JSON bridge invoked once per operation by the TypeScript
5
+ memory package. Loads MemPalace, opens the palace collection, performs one
6
+ command, prints one JSON line to stdout, exits.
7
+
8
+ Usage:
9
+ python mempalace_bridge.py <palace_path> <command> [args_json]
10
+
11
+ stdout (always exactly one JSON line):
12
+ {"ok": true, "result": <value>}
13
+ {"ok": false, "error": "<message>"}
14
+
15
+ Design notes:
16
+ - MemPalace embeds drawer text internally (default ONNX MiniLM model). UniPi
17
+ never passes embeddings through this bridge; search uses query_texts.
18
+ - Drawer documents are markdown with YAML frontmatter so the human-readable
19
+ tier is preserved and the bridge can recover title/type without metadata.
20
+ - Drawer IDs are deterministic via make_drawer_id_from_chunk(wing, room,
21
+ source_uri, 0), so upserts are idempotent and re-migration does not
22
+ duplicate records.
23
+ - Metadata is kept scalar/string-only for backend portability (ChromaDB,
24
+ sqlite_exact, qdrant, pgvector).
25
+ - This script never deletes or mutates UniPi legacy files; migration is a
26
+ read-only copy into MemPalace.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import re
35
+ import sqlite3
36
+ import sys
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Any, Iterable
40
+
41
+ try:
42
+ import yaml # type: ignore
43
+ except Exception: # pragma: no cover
44
+ yaml = None
45
+
46
+ MEMORY_TYPES = {"preference", "decision", "pattern", "summary"}
47
+ MIGRATION_AGENT = "unipi-memory-bridge"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # ID + URI helpers (mirror mempalace.ids when available, else deterministic fallback)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def quote_uri_part(value: str) -> str:
55
+ return re.sub(r"[^A-Za-z0-9_.~-]", lambda m: f"%{ord(m.group(0)):02X}", value)
56
+
57
+
58
+ def safe_id_part(value: str) -> str:
59
+ cleaned = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()
60
+ return cleaned or "unknown"
61
+
62
+
63
+ def stable_hash(parts: Iterable[Any], length: int = 24) -> str:
64
+ payload = "".join(f"{len(str(part))}:{part}" for part in parts).encode("utf-8")
65
+ return hashlib.sha256(payload).hexdigest()[:length]
66
+
67
+
68
+ def fallback_drawer_id(wing: str, room: str, source_file: str, chunk_index: int = 0) -> str:
69
+ return (
70
+ f"drawer_{safe_id_part(wing)}_{safe_id_part(room)}_"
71
+ f"{stable_hash((source_file, str(chunk_index)))}"
72
+ )
73
+
74
+
75
+ def drawer_id_for(wing: str, room: str, source_uri: str, chunk_index: int = 0) -> str:
76
+ try:
77
+ from mempalace.ids import make_drawer_id_from_chunk # type: ignore
78
+ return make_drawer_id_from_chunk(wing, room, source_uri, chunk_index)
79
+ except Exception:
80
+ return fallback_drawer_id(wing, room, source_uri, chunk_index)
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Frontmatter (YAML when available, minimal fallback otherwise)
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def dump_frontmatter(data: dict[str, Any]) -> str:
88
+ if yaml is not None:
89
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=10_000)
90
+ lines: list[str] = []
91
+ for key, value in data.items():
92
+ if isinstance(value, list):
93
+ lines.append(f"{key}:")
94
+ lines.extend(f" - {item}" for item in value)
95
+ else:
96
+ lines.append(f"{key}: {value}")
97
+ return "\n".join(lines) + "\n"
98
+
99
+
100
+ def parse_frontmatter(text: str) -> tuple[dict[str, Any], str] | None:
101
+ if not text.startswith("---\n"):
102
+ return None
103
+ end = text.find("\n---", 4)
104
+ if end == -1:
105
+ return None
106
+ raw_fm = text[4:end]
107
+ body_start = end + len("\n---")
108
+ if text[body_start : body_start + 1] == "\n":
109
+ body_start += 1
110
+ body = text[body_start:]
111
+ if yaml is not None:
112
+ loaded = yaml.safe_load(raw_fm) or {}
113
+ if not isinstance(loaded, dict):
114
+ return None
115
+ return loaded, body
116
+ # minimal fallback parser
117
+ data: dict[str, Any] = {}
118
+ current_list_key: str | None = None
119
+ for raw_line in raw_fm.splitlines():
120
+ line = raw_line.rstrip()
121
+ if not line:
122
+ continue
123
+ if current_list_key and line.startswith(" - "):
124
+ data.setdefault(current_list_key, []).append(line[4:].strip().strip("'\""))
125
+ continue
126
+ current_list_key = None
127
+ if ":" not in line:
128
+ continue
129
+ key, value = line.split(":", 1)
130
+ key = key.strip()
131
+ value = value.strip()
132
+ if value == "":
133
+ data[key] = []
134
+ current_list_key = key
135
+ elif value.startswith("[") and value.endswith("]"):
136
+ data[key] = [v.strip().strip("'\"") for v in value[1:-1].split(",") if v.strip()]
137
+ else:
138
+ data[key] = value.strip("'\"")
139
+ return data, body
140
+
141
+
142
+ def coerce_tags(value: Any) -> list[str]:
143
+ if value is None:
144
+ return []
145
+ if isinstance(value, list):
146
+ return [str(v) for v in value if str(v).strip()]
147
+ if isinstance(value, str):
148
+ stripped = value.strip()
149
+ if not stripped:
150
+ return []
151
+ try:
152
+ parsed = json.loads(stripped)
153
+ if isinstance(parsed, list):
154
+ return [str(v) for v in parsed if str(v).strip()]
155
+ except Exception:
156
+ pass
157
+ return [part.strip() for part in stripped.split(",") if part.strip()]
158
+ return [str(value)]
159
+
160
+
161
+ def normalize_type(value: Any) -> str:
162
+ lowered = str(value or "summary").strip().lower()
163
+ return lowered if lowered in MEMORY_TYPES else "summary"
164
+
165
+
166
+ def build_document(title: str, content: str, tags: list[str], project: str,
167
+ created: str, updated: str, mtype: str, unipi_id: str) -> str:
168
+ fm = {
169
+ "title": title,
170
+ "tags": tags,
171
+ "project": project,
172
+ "created": created,
173
+ "updated": updated,
174
+ "type": mtype,
175
+ "unipi_id": unipi_id,
176
+ }
177
+ fm = {k: v for k, v in fm.items() if v not in (None, [], "")}
178
+ return f"---\n{dump_frontmatter(fm)}---\n\n{content.strip()}\n"
179
+
180
+
181
+ def build_metadata(wing: str, room: str, title: str, mtype: str, project: str,
182
+ tags: list[str], unipi_id: str, source_kind: str, now: str,
183
+ content_date: str = "") -> dict[str, Any]:
184
+ return {
185
+ "wing": wing,
186
+ "room": room,
187
+ "source_file": f"unipi://memory/{quote_uri_part(project)}/{quote_uri_part(unipi_id)}",
188
+ "chunk_index": 0,
189
+ "added_by": MIGRATION_AGENT,
190
+ "filed_at": now,
191
+ "content_date": content_date,
192
+ "unipi_project": project,
193
+ "unipi_id": unipi_id,
194
+ "unipi_title": title,
195
+ "unipi_type": mtype,
196
+ "unipi_tags": ",".join(tags),
197
+ "unipi_source_kind": source_kind,
198
+ "normalize_version": 2,
199
+ "id_recipe": "unipi-bridge-v1",
200
+ }
201
+
202
+
203
+ def record_from_doc(doc: str, meta: dict[str, Any] | None) -> dict[str, Any] | None:
204
+ """Recover a UniPi-style record from a drawer document + metadata."""
205
+ parsed = parse_frontmatter(doc) if doc else None
206
+ fm: dict[str, Any] = {}
207
+ body = doc or ""
208
+ if parsed:
209
+ fm, body = parsed
210
+ meta = meta or {}
211
+ unipi_id = fm.get("unipi_id") or meta.get("unipi_id") or ""
212
+ if not unipi_id:
213
+ # fall back to drawer id if metadata lost
214
+ return None
215
+ title = str(fm.get("title") or meta.get("unipi_title") or unipi_id)
216
+ return {
217
+ "id": unipi_id,
218
+ "title": title,
219
+ "content": body.strip(),
220
+ "tags": coerce_tags(fm.get("tags") if "tags" in fm else meta.get("unipi_tags")),
221
+ "project": str(fm.get("project") or meta.get("unipi_project") or meta.get("wing") or ""),
222
+ "type": normalize_type(fm.get("type") or meta.get("unipi_type")),
223
+ "created": str(fm.get("created") or ""),
224
+ "updated": str(fm.get("updated") or ""),
225
+ }
226
+
227
+
228
+ def snippet(text: str, length: int = 200) -> str:
229
+ text = (text or "").strip().replace("\n", " ")
230
+ return text[:length] + ("..." if len(text) > length else "")
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Legacy UniPi source reading (read-only)
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def parse_markdown_memory(project: str, path: Path) -> dict[str, Any] | None:
238
+ try:
239
+ text = path.read_text(encoding="utf-8")
240
+ except UnicodeDecodeError:
241
+ text = path.read_text(encoding="utf-8", errors="replace")
242
+ except OSError:
243
+ return None
244
+ parsed = parse_frontmatter(text)
245
+ if parsed is None:
246
+ return None
247
+ fm, body = parsed
248
+ # Explicit IDs are authoritative and must not be normalized: UniPi's TS
249
+ # store permits leading/trailing underscores. Legacy files lack `id`, so
250
+ # retain their established filename normalization.
251
+ explicit_id = fm.get("id")
252
+ mid = str(explicit_id) if explicit_id is not None else safe_id_part(path.stem)
253
+ if not mid:
254
+ mid = "unknown"
255
+ return {
256
+ "project": str(fm.get("project") or project),
257
+ "id": mid,
258
+ "title": str(fm.get("title") or path.stem).strip(),
259
+ "content": body.strip(),
260
+ "tags": coerce_tags(fm.get("tags")),
261
+ "type": normalize_type(fm.get("type")),
262
+ "created": str(fm.get("created")) if fm.get("created") else "",
263
+ "updated": str(fm.get("updated")) if fm.get("updated") else "",
264
+ "source_kind": "markdown",
265
+ "source_path": str(path),
266
+ }
267
+
268
+
269
+ def load_sqlite_memories(project: str, db_path: Path) -> list[dict[str, Any]]:
270
+ if not db_path.exists():
271
+ return []
272
+ uri = f"file:{db_path}?mode=ro"
273
+ try:
274
+ conn = sqlite3.connect(uri, uri=True, timeout=10)
275
+ conn.row_factory = sqlite3.Row
276
+ try:
277
+ rows = list(conn.execute("SELECT * FROM memories"))
278
+ finally:
279
+ conn.close()
280
+ except sqlite3.DatabaseError:
281
+ return []
282
+ out: list[dict[str, Any]] = []
283
+ for row in rows:
284
+ out.append({
285
+ "project": str(row["project"] or project),
286
+ "id": safe_id_part(str(row["id"] or row["title"])),
287
+ "title": str(row["title"] or row["id"]),
288
+ "content": str(row["content"] or "").strip(),
289
+ "tags": coerce_tags(row["tags"]),
290
+ "type": normalize_type(row["type"]),
291
+ "created": str(row["created"]) if row["created"] else "",
292
+ "updated": str(row["updated"]) if row["updated"] else "",
293
+ "source_kind": "sqlite",
294
+ "source_path": str(db_path),
295
+ })
296
+ return out
297
+
298
+
299
+ def discover_legacy_memories(source_dir: Path, project_filter: list[str] | None = None) -> list[dict[str, Any]]:
300
+ if not source_dir.exists():
301
+ return []
302
+ filters = set(project_filter or [])
303
+ by_key: dict[tuple[str, str], dict[str, Any]] = {}
304
+ for project_dir in sorted(p for p in source_dir.iterdir() if p.is_dir()):
305
+ project = project_dir.name
306
+ if filters and project not in filters:
307
+ continue
308
+ for md_path in sorted(project_dir.glob("*.md")):
309
+ if md_path.name.startswith("."):
310
+ continue
311
+ rec = parse_markdown_memory(project, md_path)
312
+ if rec:
313
+ by_key[(rec["project"], rec["id"])] = rec
314
+ for rec in load_sqlite_memories(project, project_dir / "memory.db"):
315
+ by_key.setdefault((rec["project"], rec["id"]), rec)
316
+ return sorted(by_key.values(), key=lambda r: (r["project"], r["type"], r["title"], r["id"]))
317
+
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # Bridge session
321
+ # ---------------------------------------------------------------------------
322
+
323
+ class Bridge:
324
+ def __init__(self, palace_path: str, backend: str | None = None):
325
+ from mempalace.palace import get_collection # type: ignore
326
+ kwargs: dict[str, Any] = {"create": True}
327
+ if backend:
328
+ kwargs["backend"] = backend
329
+ self.palace_path = palace_path
330
+ self.collection = get_collection(palace_path, **kwargs)
331
+
332
+ def _now(self) -> str:
333
+ return datetime.now(timezone.utc).isoformat()
334
+
335
+ def _wing_room(self, project: str, mtype: str) -> tuple[str, str]:
336
+ return project, f"unipi_{normalize_type(mtype)}"
337
+
338
+ def _source_uri(self, project: str, unipi_id: str) -> str:
339
+ return f"unipi://memory/{quote_uri_part(project)}/{quote_uri_part(unipi_id)}"
340
+
341
+ def _upsert_one(self, rec: dict[str, Any], wing: str | None = None, room: str | None = None) -> str:
342
+ wing = wing or rec["project"]
343
+ room = room or f"unipi_{normalize_type(rec['type'])}"
344
+ source_uri = self._source_uri(rec["project"], rec["id"])
345
+ did = drawer_id_for(wing, room, source_uri, 0)
346
+ doc = build_document(
347
+ rec["title"], rec["content"], rec["tags"], rec["project"],
348
+ rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
349
+ )
350
+ meta = build_metadata(
351
+ wing, room, rec["title"], rec["type"], rec["project"], rec["tags"],
352
+ rec["id"], rec.get("source_kind", "markdown"), self._now(),
353
+ rec.get("updated") or rec.get("created") or "",
354
+ )
355
+ self.collection.upsert(documents=[doc], ids=[did], metadatas=[meta])
356
+ return did
357
+
358
+ # -- commands --
359
+
360
+ def ping(self) -> str:
361
+ return "pong"
362
+
363
+ def count(self, wing: str | None = None) -> int:
364
+ if wing:
365
+ got = self.collection.get(where={"wing": wing})
366
+ else:
367
+ got = self.collection.get()
368
+ return len(got.ids if hasattr(got, "ids") else got.get("ids", []))
369
+
370
+ def store(self, record: dict[str, Any]) -> dict[str, Any]:
371
+ did = self._upsert_one(record)
372
+ return {"id": record["id"], "drawer_id": did}
373
+
374
+ def get(self, id_: str) -> dict[str, Any] | None:
375
+ # IDs in metadata are unipi ids; fetch all and filter (drawer ids differ).
376
+ got = self.collection.get(where={"unipi_id": id_})
377
+ ids = got.ids if hasattr(got, "ids") else got.get("ids", [])
378
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
379
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
380
+ if not ids:
381
+ return None
382
+ return record_from_doc(docs[0], metas[0] if metas else None)
383
+
384
+ def get_by_title(self, wing: str, title: str) -> dict[str, Any] | None:
385
+ got = self.collection.get(where={"wing": wing})
386
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
387
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
388
+ lowered = title.lower()
389
+ for doc, meta in zip(docs, metas):
390
+ rec = record_from_doc(doc, meta)
391
+ if not rec:
392
+ continue
393
+ if rec["title"] == title or rec["title"].lower() == lowered:
394
+ return rec
395
+ return None
396
+
397
+ def list(self, wing: str) -> list[dict[str, Any]]:
398
+ got = self.collection.get(where={"wing": wing})
399
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
400
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
401
+ out: list[dict[str, Any]] = []
402
+ for doc, meta in zip(docs, metas):
403
+ rec = record_from_doc(doc, meta)
404
+ if rec:
405
+ out.append({"id": rec["id"], "title": rec["title"], "type": rec["type"]})
406
+ # sort by updated desc (best effort — metadata has no guaranteed order)
407
+ return out
408
+
409
+ def list_all(self) -> list[dict[str, Any]]:
410
+ got = self.collection.get()
411
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
412
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
413
+ out: list[dict[str, Any]] = []
414
+ for doc, meta in zip(docs, metas):
415
+ rec = record_from_doc(doc, meta)
416
+ if rec:
417
+ out.append({"id": rec["id"], "title": rec["title"], "type": rec["type"],
418
+ "project": rec["project"]})
419
+ return out
420
+
421
+ def search(self, query: str, wing: str | None = None, limit: int = 10) -> list[dict[str, Any]]:
422
+ where = {"wing": wing} if wing else None
423
+ kwargs: dict[str, Any] = {"query_texts": [query], "n_results": limit}
424
+ if where:
425
+ kwargs["where"] = where
426
+ res = self.collection.query(**kwargs)
427
+ ids = res.ids[0] if res.ids else []
428
+ docs = res.documents[0] if res.documents else []
429
+ metas = res.metadatas[0] if res.metadatas else []
430
+ dists = res.distances[0] if res.distances else []
431
+ out: list[dict[str, Any]] = []
432
+ for doc, meta, dist in zip(docs, metas, dists):
433
+ rec = record_from_doc(doc, meta)
434
+ if not rec:
435
+ continue
436
+ score = max(0.0, 1.0 - float(dist))
437
+ out.append({
438
+ "id": rec["id"], "title": rec["title"], "content": rec["content"],
439
+ "tags": rec["tags"], "project": rec["project"], "type": rec["type"],
440
+ "created": rec["created"], "updated": rec["updated"],
441
+ "score": round(score, 4), "snippet": snippet(rec["content"]),
442
+ })
443
+ return out
444
+
445
+ def delete(self, id_: str) -> bool:
446
+ got = self.collection.get(where={"unipi_id": id_})
447
+ ids = got.ids if hasattr(got, "ids") else got.get("ids", [])
448
+ if not ids:
449
+ return False
450
+ self.collection.delete(ids=ids)
451
+ return True
452
+
453
+ def has_title(self, wing: str, title: str) -> bool:
454
+ return self.get_by_title(wing, title) is not None
455
+
456
+ def find_similar(self, wing: str, title: str, threshold: float = 0.6) -> list[dict[str, Any]]:
457
+ got = self.collection.get(where={"wing": wing})
458
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
459
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
460
+ norm = re.sub(r"[^a-z0-9]+", " ", title.lower())
461
+ title_words = set(w for w in norm.split() if len(w) > 2)
462
+ out: list[dict[str, Any]] = []
463
+ for doc, meta in zip(docs, metas):
464
+ rec = record_from_doc(doc, meta)
465
+ if not rec:
466
+ continue
467
+ rnorm = re.sub(r"[^a-z0-9]+", " ", rec["title"].lower())
468
+ rwords = set(w for w in re.split(r"\s+", rnorm) if len(w) > 2)
469
+ union = title_words | rwords
470
+ inter = title_words & rwords
471
+ sim = len(inter) / len(union) if union else 0.0
472
+ if sim >= threshold:
473
+ out.append({"record": rec, "similarity": round(sim, 4)})
474
+ out.sort(key=lambda x: x["similarity"], reverse=True)
475
+ return out
476
+
477
+ def sync_orphaned(self, project_dir: str, wing: str) -> int:
478
+ pdir = Path(project_dir)
479
+ if not pdir.exists():
480
+ return 0
481
+ existing = {r["id"] for r in self.list(wing)}
482
+ synced = 0
483
+ for md_path in sorted(pdir.glob("*.md")):
484
+ if md_path.name.startswith("."):
485
+ continue
486
+ rec = parse_markdown_memory(wing, md_path)
487
+ if not rec:
488
+ continue
489
+ if rec["id"] in existing:
490
+ continue
491
+ self._upsert_one(rec)
492
+ synced += 1
493
+ return synced
494
+
495
+ def migrate(self, source_dir: str, project_filter: list[str] | None = None) -> dict[str, Any]:
496
+ """Idempotently import and then verify every discovered UniPi record.
497
+
498
+ Migration callers must not infer success merely from a bridge process
499
+ exiting cleanly. Return explicit discovery/failure/verification counts
500
+ so UniPi only writes its completion marker after full verification.
501
+ """
502
+ records = discover_legacy_memories(Path(source_dir), project_filter)
503
+ imported = 0
504
+ skipped = 0
505
+ failed = 0
506
+ errors: list[str] = []
507
+ by_project: dict[str, int] = {}
508
+ expected = {(rec["project"], rec["id"]) for rec in records}
509
+
510
+ # Read once and skip unchanged records. Without this, adding one new
511
+ # markdown memory would re-embed every historical drawer on catch-up.
512
+ got = self.collection.get()
513
+ docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
514
+ metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
515
+ existing_docs: dict[tuple[str, str], str] = {}
516
+ for doc, meta in zip(docs, metas):
517
+ existing = record_from_doc(doc, meta)
518
+ if not existing:
519
+ continue
520
+ existing_docs[(existing["project"], existing["id"])] = doc
521
+
522
+ for rec in records:
523
+ key = (rec["project"], rec["id"])
524
+ expected_doc = build_document(
525
+ rec["title"], rec["content"], rec["tags"], rec["project"],
526
+ rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
527
+ )
528
+ if existing_docs.get(key) == expected_doc:
529
+ skipped += 1
530
+ continue
531
+ try:
532
+ self._upsert_one(rec)
533
+ imported += 1
534
+ existing_docs[key] = expected_doc
535
+ by_project[rec["project"]] = by_project.get(rec["project"], 0) + 1
536
+ except Exception as exc:
537
+ failed += 1
538
+ if len(errors) < 20:
539
+ errors.append(
540
+ f"{rec['project']}/{rec['id']}: {type(exc).__name__}: {exc}"
541
+ )
542
+
543
+ # Re-read after writes and verify exact durable documents, not just
544
+ # optimistic in-memory bookkeeping or collection counts. A palace may
545
+ # also contain drawers created by other harnesses/import recipes.
546
+ verified_get = self.collection.get()
547
+ verified_docs = (
548
+ verified_get.documents
549
+ if hasattr(verified_get, "documents")
550
+ else verified_get.get("documents", [])
551
+ )
552
+ verified_metas = (
553
+ verified_get.metadatas
554
+ if hasattr(verified_get, "metadatas")
555
+ else verified_get.get("metadatas", [])
556
+ )
557
+ persisted: dict[tuple[str, str], str] = {}
558
+ for doc, meta in zip(verified_docs, verified_metas):
559
+ persisted_rec = record_from_doc(doc, meta)
560
+ if persisted_rec:
561
+ persisted[(persisted_rec["project"], persisted_rec["id"])] = doc
562
+ verified = 0
563
+ for rec in records:
564
+ expected_doc = build_document(
565
+ rec["title"], rec["content"], rec["tags"], rec["project"],
566
+ rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
567
+ )
568
+ if persisted.get((rec["project"], rec["id"])) == expected_doc:
569
+ verified += 1
570
+
571
+ return {
572
+ "discovered": len(records),
573
+ "imported": imported,
574
+ "updated": imported,
575
+ "skipped": skipped,
576
+ "failed": failed,
577
+ "verified": verified,
578
+ "projects": by_project,
579
+ "errors": errors,
580
+ }
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Entry point
585
+ # ---------------------------------------------------------------------------
586
+
587
+ def main(argv: list[str]) -> int:
588
+ if len(argv) < 3:
589
+ print(json.dumps({"ok": False, "error": "usage: bridge.py <palace> <command> [args_json]"}))
590
+ return 2
591
+ palace = argv[1]
592
+ cmd = argv[2]
593
+ args_raw = argv[3] if len(argv) > 3 else "{}"
594
+ try:
595
+ args = json.loads(args_raw) if args_raw else {}
596
+ except json.JSONDecodeError as exc:
597
+ print(json.dumps({"ok": False, "error": f"invalid args json: {exc}"}))
598
+ return 2
599
+
600
+ backend = os.environ.get("UNIPI_MEMPALACE_BACKEND") or None
601
+ try:
602
+ bridge = Bridge(palace, backend=backend)
603
+ except Exception as exc:
604
+ print(json.dumps({"ok": False, "error": f"mempalace init failed: {type(exc).__name__}: {exc}"}))
605
+ return 1
606
+
607
+ handlers = {
608
+ "ping": lambda: bridge.ping(),
609
+ "count": lambda: bridge.count(args.get("wing")),
610
+ "store": lambda: bridge.store(args["record"]),
611
+ "get": lambda: bridge.get(args["id"]),
612
+ "get_by_title": lambda: bridge.get_by_title(args["wing"], args["title"]),
613
+ "list": lambda: bridge.list(args["wing"]),
614
+ "list_all": lambda: bridge.list_all(),
615
+ "search": lambda: bridge.search(args["query"], args.get("wing"), int(args.get("limit", 10))),
616
+ "delete": lambda: bridge.delete(args["id"]),
617
+ "has_title": lambda: bridge.has_title(args["wing"], args["title"]),
618
+ "find_similar": lambda: bridge.find_similar(args["wing"], args["title"], float(args.get("threshold", 0.6))),
619
+ "sync_orphaned": lambda: bridge.sync_orphaned(args["project_dir"], args["wing"]),
620
+ "migrate": lambda: bridge.migrate(args["source_dir"], args.get("projects")),
621
+ }
622
+ handler = handlers.get(cmd)
623
+ if handler is None:
624
+ print(json.dumps({"ok": False, "error": f"unknown command: {cmd}"}))
625
+ return 2
626
+ try:
627
+ result = handler()
628
+ print(json.dumps({"ok": True, "result": result}, default=str))
629
+ return 0
630
+ except Exception as exc:
631
+ print(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}))
632
+ return 1
633
+
634
+
635
+ if __name__ == "__main__":
636
+ sys.exit(main(sys.argv))