@pi-unipi/memory 2.0.12 → 2.1.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/README.md +51 -7
- package/bridge/mempalace_bridge.py +556 -0
- package/index.ts +7 -12
- package/mempalace.ts +232 -0
- package/package.json +8 -5
- package/storage.ts +237 -22
- package/tools.ts +0 -0
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# @pi-unipi/memory
|
|
2
2
|
|
|
3
|
-
Persistent memory that survives across sessions. Stores facts, preferences, and decisions
|
|
3
|
+
Persistent memory that survives across sessions. Stores facts, preferences, and decisions with semantic vector search, so the agent remembers what you told it last week.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with one-way auto-migration of any existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
|
|
6
|
+
|
|
7
|
+
Two storage tiers: MemPalace (or SQLite) for vector similarity search, markdown files for a durable human-readable copy you can edit by hand. Project-scoped memories stay separate per codebase, global memories are accessible everywhere.
|
|
6
8
|
|
|
7
9
|
## Commands
|
|
8
10
|
|
|
@@ -72,19 +74,61 @@ Examples:
|
|
|
72
74
|
Memory has no configuration file. Storage paths are fixed:
|
|
73
75
|
|
|
74
76
|
```
|
|
75
|
-
~/.unipi/memory/
|
|
77
|
+
~/.unipi/memory/ # UniPi memory root (legacy + markdown tier)
|
|
78
|
+
├── .mempalace-install # Cached MemPalace venv detection
|
|
79
|
+
├── .mempalace-migrated # One-way migration completion flag
|
|
76
80
|
├── global/
|
|
77
|
-
│ ├── memory.db # Global vector DB
|
|
81
|
+
│ ├── memory.db # Global vector DB (SQLite fallback)
|
|
78
82
|
│ └── *.md # Global memory files
|
|
79
83
|
└── <project_name>/
|
|
80
|
-
├── memory.db # Project vector DB
|
|
84
|
+
├── memory.db # Project vector DB (SQLite fallback)
|
|
81
85
|
└── *.md # Project memory files
|
|
86
|
+
|
|
87
|
+
~/.mempalace/palace/ # MemPalace palace (primary backend)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## MemPalace backend
|
|
91
|
+
|
|
92
|
+
On first load, the memory package:
|
|
93
|
+
1. Detects MemPalace; if missing and `uv` is available, runs
|
|
94
|
+
`uv tool install mempalace` once (caches the venv python path in
|
|
95
|
+
`~/.unipi/memory/.mempalace-install`).
|
|
96
|
+
2. Pings the bridge to confirm the palace is usable.
|
|
97
|
+
3. If `~/.unipi/memory/.mempalace-migrated` is absent, performs a one-way
|
|
98
|
+
read-only migration of every legacy memory (SQLite rows + markdown files
|
|
99
|
+
across all projects) into MemPalace drawers, then writes the flag.
|
|
100
|
+
Migration is idempotent (deterministic drawer IDs) and never deletes or
|
|
101
|
+
mutates legacy files.
|
|
102
|
+
|
|
103
|
+
Each memory operation invokes a bundled Python bridge
|
|
104
|
+
(`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). The
|
|
105
|
+
first MemPalace use on a machine also downloads the default ONNX embedding
|
|
106
|
+
model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
|
|
107
|
+
|
|
108
|
+
### Forcing re-detection / re-migration
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
rm ~/.unipi/memory/.mempalace-install # re-detect MemPalace next session
|
|
112
|
+
rm ~/.unipi/memory/.mempalace-migrated # re-run one-way migration next session
|
|
82
113
|
```
|
|
83
114
|
|
|
115
|
+
### Backend override
|
|
116
|
+
|
|
117
|
+
Set `UNIPI_MEMPALACE_BACKEND` to force a MemPalace backend
|
|
118
|
+
(`sqlite_exact`, `qdrant`, `pgvector`, default `chroma`).
|
|
119
|
+
|
|
120
|
+
### Embedder identity
|
|
121
|
+
|
|
122
|
+
MemPalace enforces embedder identity. If a palace was created with a
|
|
123
|
+
different embedding model, writes are rejected — the package then falls
|
|
124
|
+
back to SQLite for that session. Use `mempalace palace set-embedder`
|
|
125
|
+
intentionally to realign, then remove the install cache to re-detect.
|
|
126
|
+
|
|
84
127
|
## Dependencies
|
|
85
128
|
|
|
86
|
-
- `
|
|
87
|
-
- `
|
|
129
|
+
- `mempalace` (Python, auto-installed via `uv`) — primary backend
|
|
130
|
+
- `better-sqlite3` — SQLite fallback database
|
|
131
|
+
- `sqlite-vec` — Vector search extension (fallback)
|
|
88
132
|
- `js-yaml` — YAML frontmatter parsing
|
|
89
133
|
- `@pi-unipi/core` — Shared utilities
|
|
90
134
|
|
|
@@ -0,0 +1,556 @@
|
|
|
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
|
+
mid = safe_id_part(str(fm.get("id") or path.stem))
|
|
249
|
+
return {
|
|
250
|
+
"project": str(fm.get("project") or project),
|
|
251
|
+
"id": mid,
|
|
252
|
+
"title": str(fm.get("title") or path.stem).strip(),
|
|
253
|
+
"content": body.strip(),
|
|
254
|
+
"tags": coerce_tags(fm.get("tags")),
|
|
255
|
+
"type": normalize_type(fm.get("type")),
|
|
256
|
+
"created": str(fm.get("created")) if fm.get("created") else "",
|
|
257
|
+
"updated": str(fm.get("updated")) if fm.get("updated") else "",
|
|
258
|
+
"source_kind": "markdown",
|
|
259
|
+
"source_path": str(path),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def load_sqlite_memories(project: str, db_path: Path) -> list[dict[str, Any]]:
|
|
264
|
+
if not db_path.exists():
|
|
265
|
+
return []
|
|
266
|
+
uri = f"file:{db_path}?mode=ro"
|
|
267
|
+
try:
|
|
268
|
+
conn = sqlite3.connect(uri, uri=True, timeout=10)
|
|
269
|
+
conn.row_factory = sqlite3.Row
|
|
270
|
+
try:
|
|
271
|
+
rows = list(conn.execute("SELECT * FROM memories"))
|
|
272
|
+
finally:
|
|
273
|
+
conn.close()
|
|
274
|
+
except sqlite3.DatabaseError:
|
|
275
|
+
return []
|
|
276
|
+
out: list[dict[str, Any]] = []
|
|
277
|
+
for row in rows:
|
|
278
|
+
out.append({
|
|
279
|
+
"project": str(row["project"] or project),
|
|
280
|
+
"id": safe_id_part(str(row["id"] or row["title"])),
|
|
281
|
+
"title": str(row["title"] or row["id"]),
|
|
282
|
+
"content": str(row["content"] or "").strip(),
|
|
283
|
+
"tags": coerce_tags(row["tags"]),
|
|
284
|
+
"type": normalize_type(row["type"]),
|
|
285
|
+
"created": str(row["created"]) if row["created"] else "",
|
|
286
|
+
"updated": str(row["updated"]) if row["updated"] else "",
|
|
287
|
+
"source_kind": "sqlite",
|
|
288
|
+
"source_path": str(db_path),
|
|
289
|
+
})
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def discover_legacy_memories(source_dir: Path, project_filter: list[str] | None = None) -> list[dict[str, Any]]:
|
|
294
|
+
if not source_dir.exists():
|
|
295
|
+
return []
|
|
296
|
+
filters = set(project_filter or [])
|
|
297
|
+
by_key: dict[tuple[str, str], dict[str, Any]] = {}
|
|
298
|
+
for project_dir in sorted(p for p in source_dir.iterdir() if p.is_dir()):
|
|
299
|
+
project = project_dir.name
|
|
300
|
+
if filters and project not in filters:
|
|
301
|
+
continue
|
|
302
|
+
for md_path in sorted(project_dir.glob("*.md")):
|
|
303
|
+
if md_path.name.startswith("."):
|
|
304
|
+
continue
|
|
305
|
+
rec = parse_markdown_memory(project, md_path)
|
|
306
|
+
if rec:
|
|
307
|
+
by_key[(rec["project"], rec["id"])] = rec
|
|
308
|
+
for rec in load_sqlite_memories(project, project_dir / "memory.db"):
|
|
309
|
+
by_key.setdefault((rec["project"], rec["id"]), rec)
|
|
310
|
+
return sorted(by_key.values(), key=lambda r: (r["project"], r["type"], r["title"], r["id"]))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# Bridge session
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
class Bridge:
|
|
318
|
+
def __init__(self, palace_path: str, backend: str | None = None):
|
|
319
|
+
from mempalace.palace import get_collection # type: ignore
|
|
320
|
+
kwargs: dict[str, Any] = {"create": True}
|
|
321
|
+
if backend:
|
|
322
|
+
kwargs["backend"] = backend
|
|
323
|
+
self.palace_path = palace_path
|
|
324
|
+
self.collection = get_collection(palace_path, **kwargs)
|
|
325
|
+
|
|
326
|
+
def _now(self) -> str:
|
|
327
|
+
return datetime.now(timezone.utc).isoformat()
|
|
328
|
+
|
|
329
|
+
def _wing_room(self, project: str, mtype: str) -> tuple[str, str]:
|
|
330
|
+
return project, f"unipi_{normalize_type(mtype)}"
|
|
331
|
+
|
|
332
|
+
def _source_uri(self, project: str, unipi_id: str) -> str:
|
|
333
|
+
return f"unipi://memory/{quote_uri_part(project)}/{quote_uri_part(unipi_id)}"
|
|
334
|
+
|
|
335
|
+
def _upsert_one(self, rec: dict[str, Any], wing: str | None = None, room: str | None = None) -> str:
|
|
336
|
+
wing = wing or rec["project"]
|
|
337
|
+
room = room or f"unipi_{normalize_type(rec['type'])}"
|
|
338
|
+
source_uri = self._source_uri(rec["project"], rec["id"])
|
|
339
|
+
did = drawer_id_for(wing, room, source_uri, 0)
|
|
340
|
+
doc = build_document(
|
|
341
|
+
rec["title"], rec["content"], rec["tags"], rec["project"],
|
|
342
|
+
rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
|
|
343
|
+
)
|
|
344
|
+
meta = build_metadata(
|
|
345
|
+
wing, room, rec["title"], rec["type"], rec["project"], rec["tags"],
|
|
346
|
+
rec["id"], rec.get("source_kind", "markdown"), self._now(),
|
|
347
|
+
rec.get("updated") or rec.get("created") or "",
|
|
348
|
+
)
|
|
349
|
+
self.collection.upsert(documents=[doc], ids=[did], metadatas=[meta])
|
|
350
|
+
return did
|
|
351
|
+
|
|
352
|
+
# -- commands --
|
|
353
|
+
|
|
354
|
+
def ping(self) -> str:
|
|
355
|
+
return "pong"
|
|
356
|
+
|
|
357
|
+
def count(self, wing: str | None = None) -> int:
|
|
358
|
+
if wing:
|
|
359
|
+
got = self.collection.get(where={"wing": wing})
|
|
360
|
+
else:
|
|
361
|
+
got = self.collection.get()
|
|
362
|
+
return len(got.ids if hasattr(got, "ids") else got.get("ids", []))
|
|
363
|
+
|
|
364
|
+
def store(self, record: dict[str, Any]) -> dict[str, Any]:
|
|
365
|
+
did = self._upsert_one(record)
|
|
366
|
+
return {"id": record["id"], "drawer_id": did}
|
|
367
|
+
|
|
368
|
+
def get(self, id_: str) -> dict[str, Any] | None:
|
|
369
|
+
# IDs in metadata are unipi ids; fetch all and filter (drawer ids differ).
|
|
370
|
+
got = self.collection.get(where={"unipi_id": id_})
|
|
371
|
+
ids = got.ids if hasattr(got, "ids") else got.get("ids", [])
|
|
372
|
+
docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
|
|
373
|
+
metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
|
|
374
|
+
if not ids:
|
|
375
|
+
return None
|
|
376
|
+
return record_from_doc(docs[0], metas[0] if metas else None)
|
|
377
|
+
|
|
378
|
+
def get_by_title(self, wing: str, title: str) -> dict[str, Any] | None:
|
|
379
|
+
got = self.collection.get(where={"wing": wing})
|
|
380
|
+
docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
|
|
381
|
+
metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
|
|
382
|
+
lowered = title.lower()
|
|
383
|
+
for doc, meta in zip(docs, metas):
|
|
384
|
+
rec = record_from_doc(doc, meta)
|
|
385
|
+
if not rec:
|
|
386
|
+
continue
|
|
387
|
+
if rec["title"] == title or rec["title"].lower() == lowered:
|
|
388
|
+
return rec
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
def list(self, wing: str) -> list[dict[str, Any]]:
|
|
392
|
+
got = self.collection.get(where={"wing": wing})
|
|
393
|
+
docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
|
|
394
|
+
metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
|
|
395
|
+
out: list[dict[str, Any]] = []
|
|
396
|
+
for doc, meta in zip(docs, metas):
|
|
397
|
+
rec = record_from_doc(doc, meta)
|
|
398
|
+
if rec:
|
|
399
|
+
out.append({"id": rec["id"], "title": rec["title"], "type": rec["type"]})
|
|
400
|
+
# sort by updated desc (best effort — metadata has no guaranteed order)
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
def list_all(self) -> list[dict[str, Any]]:
|
|
404
|
+
got = self.collection.get()
|
|
405
|
+
docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
|
|
406
|
+
metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
|
|
407
|
+
out: list[dict[str, Any]] = []
|
|
408
|
+
for doc, meta in zip(docs, metas):
|
|
409
|
+
rec = record_from_doc(doc, meta)
|
|
410
|
+
if rec:
|
|
411
|
+
out.append({"id": rec["id"], "title": rec["title"], "type": rec["type"],
|
|
412
|
+
"project": rec["project"]})
|
|
413
|
+
return out
|
|
414
|
+
|
|
415
|
+
def search(self, query: str, wing: str | None = None, limit: int = 10) -> list[dict[str, Any]]:
|
|
416
|
+
where = {"wing": wing} if wing else None
|
|
417
|
+
kwargs: dict[str, Any] = {"query_texts": [query], "n_results": limit}
|
|
418
|
+
if where:
|
|
419
|
+
kwargs["where"] = where
|
|
420
|
+
res = self.collection.query(**kwargs)
|
|
421
|
+
ids = res.ids[0] if res.ids else []
|
|
422
|
+
docs = res.documents[0] if res.documents else []
|
|
423
|
+
metas = res.metadatas[0] if res.metadatas else []
|
|
424
|
+
dists = res.distances[0] if res.distances else []
|
|
425
|
+
out: list[dict[str, Any]] = []
|
|
426
|
+
for doc, meta, dist in zip(docs, metas, dists):
|
|
427
|
+
rec = record_from_doc(doc, meta)
|
|
428
|
+
if not rec:
|
|
429
|
+
continue
|
|
430
|
+
score = max(0.0, 1.0 - float(dist))
|
|
431
|
+
out.append({
|
|
432
|
+
"id": rec["id"], "title": rec["title"], "content": rec["content"],
|
|
433
|
+
"tags": rec["tags"], "project": rec["project"], "type": rec["type"],
|
|
434
|
+
"created": rec["created"], "updated": rec["updated"],
|
|
435
|
+
"score": round(score, 4), "snippet": snippet(rec["content"]),
|
|
436
|
+
})
|
|
437
|
+
return out
|
|
438
|
+
|
|
439
|
+
def delete(self, id_: str) -> bool:
|
|
440
|
+
got = self.collection.get(where={"unipi_id": id_})
|
|
441
|
+
ids = got.ids if hasattr(got, "ids") else got.get("ids", [])
|
|
442
|
+
if not ids:
|
|
443
|
+
return False
|
|
444
|
+
self.collection.delete(ids=ids)
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
def has_title(self, wing: str, title: str) -> bool:
|
|
448
|
+
return self.get_by_title(wing, title) is not None
|
|
449
|
+
|
|
450
|
+
def find_similar(self, wing: str, title: str, threshold: float = 0.6) -> list[dict[str, Any]]:
|
|
451
|
+
got = self.collection.get(where={"wing": wing})
|
|
452
|
+
docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
|
|
453
|
+
metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
|
|
454
|
+
norm = re.sub(r"[^a-z0-9]+", " ", title.lower())
|
|
455
|
+
title_words = set(w for w in norm.split() if len(w) > 2)
|
|
456
|
+
out: list[dict[str, Any]] = []
|
|
457
|
+
for doc, meta in zip(docs, metas):
|
|
458
|
+
rec = record_from_doc(doc, meta)
|
|
459
|
+
if not rec:
|
|
460
|
+
continue
|
|
461
|
+
rnorm = re.sub(r"[^a-z0-9]+", " ", rec["title"].lower())
|
|
462
|
+
rwords = set(w for w in re.split(r"\s+", rnorm) if len(w) > 2)
|
|
463
|
+
union = title_words | rwords
|
|
464
|
+
inter = title_words & rwords
|
|
465
|
+
sim = len(inter) / len(union) if union else 0.0
|
|
466
|
+
if sim >= threshold:
|
|
467
|
+
out.append({"record": rec, "similarity": round(sim, 4)})
|
|
468
|
+
out.sort(key=lambda x: x["similarity"], reverse=True)
|
|
469
|
+
return out
|
|
470
|
+
|
|
471
|
+
def sync_orphaned(self, project_dir: str, wing: str) -> int:
|
|
472
|
+
pdir = Path(project_dir)
|
|
473
|
+
if not pdir.exists():
|
|
474
|
+
return 0
|
|
475
|
+
existing = {r["id"] for r in self.list(wing)}
|
|
476
|
+
synced = 0
|
|
477
|
+
for md_path in sorted(pdir.glob("*.md")):
|
|
478
|
+
if md_path.name.startswith("."):
|
|
479
|
+
continue
|
|
480
|
+
rec = parse_markdown_memory(wing, md_path)
|
|
481
|
+
if not rec:
|
|
482
|
+
continue
|
|
483
|
+
if rec["id"] in existing:
|
|
484
|
+
continue
|
|
485
|
+
self._upsert_one(rec)
|
|
486
|
+
synced += 1
|
|
487
|
+
return synced
|
|
488
|
+
|
|
489
|
+
def migrate(self, source_dir: str, project_filter: list[str] | None = None) -> dict[str, Any]:
|
|
490
|
+
records = discover_legacy_memories(Path(source_dir), project_filter)
|
|
491
|
+
imported = 0
|
|
492
|
+
by_project: dict[str, int] = {}
|
|
493
|
+
for rec in records:
|
|
494
|
+
try:
|
|
495
|
+
self._upsert_one(rec)
|
|
496
|
+
imported += 1
|
|
497
|
+
by_project[rec["project"]] = by_project.get(rec["project"], 0) + 1
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
return {"imported": imported, "projects": by_project}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# Entry point
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def main(argv: list[str]) -> int:
|
|
508
|
+
if len(argv) < 3:
|
|
509
|
+
print(json.dumps({"ok": False, "error": "usage: bridge.py <palace> <command> [args_json]"}))
|
|
510
|
+
return 2
|
|
511
|
+
palace = argv[1]
|
|
512
|
+
cmd = argv[2]
|
|
513
|
+
args_raw = argv[3] if len(argv) > 3 else "{}"
|
|
514
|
+
try:
|
|
515
|
+
args = json.loads(args_raw) if args_raw else {}
|
|
516
|
+
except json.JSONDecodeError as exc:
|
|
517
|
+
print(json.dumps({"ok": False, "error": f"invalid args json: {exc}"}))
|
|
518
|
+
return 2
|
|
519
|
+
|
|
520
|
+
backend = os.environ.get("UNIPI_MEMPALACE_BACKEND") or None
|
|
521
|
+
try:
|
|
522
|
+
bridge = Bridge(palace, backend=backend)
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
print(json.dumps({"ok": False, "error": f"mempalace init failed: {type(exc).__name__}: {exc}"}))
|
|
525
|
+
return 1
|
|
526
|
+
|
|
527
|
+
handlers = {
|
|
528
|
+
"ping": lambda: bridge.ping(),
|
|
529
|
+
"count": lambda: bridge.count(args.get("wing")),
|
|
530
|
+
"store": lambda: bridge.store(args["record"]),
|
|
531
|
+
"get": lambda: bridge.get(args["id"]),
|
|
532
|
+
"get_by_title": lambda: bridge.get_by_title(args["wing"], args["title"]),
|
|
533
|
+
"list": lambda: bridge.list(args["wing"]),
|
|
534
|
+
"list_all": lambda: bridge.list_all(),
|
|
535
|
+
"search": lambda: bridge.search(args["query"], args.get("wing"), int(args.get("limit", 10))),
|
|
536
|
+
"delete": lambda: bridge.delete(args["id"]),
|
|
537
|
+
"has_title": lambda: bridge.has_title(args["wing"], args["title"]),
|
|
538
|
+
"find_similar": lambda: bridge.find_similar(args["wing"], args["title"], float(args.get("threshold", 0.6))),
|
|
539
|
+
"sync_orphaned": lambda: bridge.sync_orphaned(args["project_dir"], args["wing"]),
|
|
540
|
+
"migrate": lambda: bridge.migrate(args["source_dir"], args.get("projects")),
|
|
541
|
+
}
|
|
542
|
+
handler = handlers.get(cmd)
|
|
543
|
+
if handler is None:
|
|
544
|
+
print(json.dumps({"ok": False, "error": f"unknown command: {cmd}"}))
|
|
545
|
+
return 2
|
|
546
|
+
try:
|
|
547
|
+
result = handler()
|
|
548
|
+
print(json.dumps({"ok": True, "result": result}, default=str))
|
|
549
|
+
return 0
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
print(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}))
|
|
552
|
+
return 1
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
if __name__ == "__main__":
|
|
556
|
+
sys.exit(main(sys.argv))
|
package/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Auto-consolidates on compaction.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
10
12
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
13
|
import {
|
|
12
14
|
UNIPI_EVENTS,
|
|
@@ -30,7 +32,7 @@ import { registerMemoryCommands } from "./commands.js";
|
|
|
30
32
|
import { isEmbeddingReady, hasModelChanged } from "./settings.js";
|
|
31
33
|
|
|
32
34
|
/** Package version */
|
|
33
|
-
const VERSION = getPackageVersion(
|
|
35
|
+
const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
|
|
34
36
|
|
|
35
37
|
/** Storage instance for current project */
|
|
36
38
|
let projectStorage: MemoryStorage | null = null;
|
|
@@ -51,14 +53,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
53
|
let recallDone = false;
|
|
52
54
|
let storeDone = false;
|
|
53
55
|
|
|
54
|
-
// Register skills directory
|
|
55
|
-
const skillsDir = new URL("./skills", import.meta.url).pathname;
|
|
56
|
-
pi.on("resources_discover", async (_event, _ctx) => {
|
|
57
|
-
return {
|
|
58
|
-
skillPaths: [skillsDir],
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
|
|
62
56
|
// Register tools and commands
|
|
63
57
|
registerMemoryTools(pi, getStorage, {
|
|
64
58
|
onRecall: () => { recallDone = true; },
|
|
@@ -178,11 +172,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
172
|
} catch (_err) {
|
|
179
173
|
// Count unavailable — status bar shows 0.
|
|
180
174
|
}
|
|
181
|
-
const
|
|
182
|
-
const
|
|
175
|
+
const mempalaceActive = projectStorage?.isMempalace() ?? false;
|
|
176
|
+
const backendIcon = mempalaceActive ? "🧠" : (isEmbeddingReady() ? "⚡" : "📝");
|
|
177
|
+
const warn = hasModelChanged() ? " ⚠" : "";
|
|
183
178
|
ctx.ui.setStatus(
|
|
184
179
|
"unipi-memory",
|
|
185
|
-
`${
|
|
180
|
+
`${backendIcon} mem ${projectCount}p/${projectCountAll}all${warn}`
|
|
186
181
|
);
|
|
187
182
|
}
|
|
188
183
|
});
|
package/mempalace.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unipi/memory — MemPalace backend client
|
|
3
|
+
*
|
|
4
|
+
* Detects and auto-installs MemPalace (via uv), then invokes the bundled
|
|
5
|
+
* Python bridge (bridge/mempalace_bridge.py) once per operation using the
|
|
6
|
+
* MemPalace venv python. Each call is a synchronous spawnSync that prints
|
|
7
|
+
* one JSON line.
|
|
8
|
+
*
|
|
9
|
+
* If MemPalace or uv is unavailable, all operations return null so the
|
|
10
|
+
* storage layer can fall back to the legacy SQLite path. Memory must never
|
|
11
|
+
* hard-fail because the backend is missing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
/** Default MemPalace palace path. */
|
|
21
|
+
export const DEFAULT_PALACE = path.join(os.homedir(), ".mempalace", "palace");
|
|
22
|
+
|
|
23
|
+
const INSTALL_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-install");
|
|
24
|
+
const MIGRATED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-migrated");
|
|
25
|
+
|
|
26
|
+
/** Path to the bundled bridge script. */
|
|
27
|
+
const BRIDGE_PATH = path.join(dirname(fileURLToPath(import.meta.url)), "bridge", "mempalace_bridge.py");
|
|
28
|
+
|
|
29
|
+
function dirname(p: string): string {
|
|
30
|
+
return path.dirname(p);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BridgeResponse<T> {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
result?: T;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MempalaceRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
content: string;
|
|
43
|
+
tags: string[];
|
|
44
|
+
project: string;
|
|
45
|
+
type: "preference" | "decision" | "pattern" | "summary";
|
|
46
|
+
created: string;
|
|
47
|
+
updated: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MempalaceSearchResult extends MempalaceRecord {
|
|
51
|
+
score: number;
|
|
52
|
+
snippet: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MempalaceListItem {
|
|
56
|
+
id: string;
|
|
57
|
+
title: string;
|
|
58
|
+
type: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MempalaceListItemAll extends MempalaceListItem {
|
|
62
|
+
project: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MempalaceInstall {
|
|
66
|
+
python: string;
|
|
67
|
+
version: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Check whether a binary is on PATH. */
|
|
71
|
+
function which(bin: string): string | null {
|
|
72
|
+
try {
|
|
73
|
+
const res = spawnSync(bin, ["--version"], { encoding: "utf-8", timeout: 5000 });
|
|
74
|
+
if (res.status === 0 || res.stdout || res.stderr) return bin;
|
|
75
|
+
} catch { /* ignore */ }
|
|
76
|
+
// Fallback: `which`
|
|
77
|
+
try {
|
|
78
|
+
const res = spawnSync("which", [bin], { encoding: "utf-8" });
|
|
79
|
+
if (res.status === 0) return res.stdout.trim() || null;
|
|
80
|
+
} catch { /* ignore */ }
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Locate the MemPalace venv python after a `uv tool install mempalace`.
|
|
86
|
+
* Uses `uv tool dir` to find the venv root.
|
|
87
|
+
*/
|
|
88
|
+
function findVenvPython(): string | null {
|
|
89
|
+
try {
|
|
90
|
+
const res = spawnSync("uv", ["tool", "dir"], { encoding: "utf-8", timeout: 5000 });
|
|
91
|
+
if (res.status !== 0 || !res.stdout.trim()) return null;
|
|
92
|
+
const candidate = path.join(res.stdout.trim(), "mempalace", "bin", "python");
|
|
93
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
94
|
+
// Some platforms use Scripts/ on Windows — not relevant here but be safe.
|
|
95
|
+
const win = path.join(res.stdout.trim(), "mempalace", "Scripts", "python.exe");
|
|
96
|
+
if (fs.existsSync(win)) return win;
|
|
97
|
+
} catch { /* ignore */ }
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read a cached install record. */
|
|
102
|
+
function readCachedInstall(): MempalaceInstall | null {
|
|
103
|
+
try {
|
|
104
|
+
if (fs.existsSync(INSTALL_FLAG)) {
|
|
105
|
+
const parsed = JSON.parse(fs.readFileSync(INSTALL_FLAG, "utf-8"));
|
|
106
|
+
if (parsed && parsed.python && fs.existsSync(parsed.python)) {
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch { /* ignore */ }
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Persist an install record so we don't re-detect every session. */
|
|
115
|
+
function writeCachedInstall(install: MempalaceInstall): void {
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(INSTALL_FLAG), { recursive: true });
|
|
118
|
+
fs.writeFileSync(INSTALL_FLAG, JSON.stringify(install, null, 2), "utf-8");
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Detect mempalace version via the venv python. */
|
|
123
|
+
function detectVersion(python: string): string {
|
|
124
|
+
try {
|
|
125
|
+
const res = spawnSync(python, ["-c", "import mempalace; print(getattr(mempalace,'__version__','unknown'))"], { encoding: "utf-8", timeout: 5000 });
|
|
126
|
+
return (res.stdout || "").trim() || "unknown";
|
|
127
|
+
} catch {
|
|
128
|
+
return "unknown";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Ensure MemPalace is installed and return the venv python path.
|
|
134
|
+
* Auto-installs via `uv tool install mempalace` if missing and uv is
|
|
135
|
+
* available. Returns null if MemPalace cannot be made available (caller
|
|
136
|
+
* should fall back to legacy SQLite storage).
|
|
137
|
+
*/
|
|
138
|
+
export function ensureMempalace(): MempalaceInstall | null {
|
|
139
|
+
const cached = readCachedInstall();
|
|
140
|
+
if (cached) return cached;
|
|
141
|
+
|
|
142
|
+
// 1. Already installed via uv tool? Locate venv python.
|
|
143
|
+
let python = findVenvPython();
|
|
144
|
+
|
|
145
|
+
// 2. If not, and uv is available, install it.
|
|
146
|
+
if (!python && which("uv")) {
|
|
147
|
+
try {
|
|
148
|
+
const res = spawnSync("uv", ["tool", "install", "mempalace"], {
|
|
149
|
+
encoding: "utf-8",
|
|
150
|
+
timeout: 180_000, // first install downloads deps + embedding model
|
|
151
|
+
});
|
|
152
|
+
if (res.status === 0) {
|
|
153
|
+
python = findVenvPython();
|
|
154
|
+
}
|
|
155
|
+
} catch { /* ignore — fall back */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!python) return null;
|
|
159
|
+
|
|
160
|
+
const version = detectVersion(python);
|
|
161
|
+
const install = { python, version };
|
|
162
|
+
writeCachedInstall(install);
|
|
163
|
+
return install;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Drop the cached install record (forces re-detection next session). */
|
|
167
|
+
export function invalidateInstallCache(): void {
|
|
168
|
+
try { if (fs.existsSync(INSTALL_FLAG)) fs.unlinkSync(INSTALL_FLAG); } catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Has the one-way legacy migration been completed? */
|
|
172
|
+
export function isMigrated(): boolean {
|
|
173
|
+
return fs.existsSync(MIGRATED_FLAG);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mark the one-way legacy migration complete. */
|
|
177
|
+
export function markMigrated(): void {
|
|
178
|
+
try {
|
|
179
|
+
fs.mkdirSync(path.dirname(MIGRATED_FLAG), { recursive: true });
|
|
180
|
+
fs.writeFileSync(MIGRATED_FLAG, new Date().toISOString(), "utf-8");
|
|
181
|
+
} catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Force re-migration by clearing the flag. */
|
|
185
|
+
export function clearMigratedFlag(): void {
|
|
186
|
+
try { if (fs.existsSync(MIGRATED_FLAG)) fs.unlinkSync(MIGRATED_FLAG); } catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run one bridge command synchronously. Returns the parsed result, or null
|
|
191
|
+
* on any failure (timeout, non-zero exit, bad JSON, ok=false).
|
|
192
|
+
*/
|
|
193
|
+
export function runBridge<T = unknown>(
|
|
194
|
+
install: MempalaceInstall,
|
|
195
|
+
palace: string,
|
|
196
|
+
cmd: string,
|
|
197
|
+
args: Record<string, unknown> = {},
|
|
198
|
+
): T | null {
|
|
199
|
+
let argsJson: string;
|
|
200
|
+
try {
|
|
201
|
+
argsJson = JSON.stringify(args);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
let res;
|
|
206
|
+
try {
|
|
207
|
+
res = spawnSync(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
|
|
208
|
+
encoding: "utf-8",
|
|
209
|
+
timeout: 60_000,
|
|
210
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
211
|
+
});
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (res.error || res.status !== 0) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const out = (res.stdout || "").trim();
|
|
219
|
+
if (!out) return null;
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(out) as BridgeResponse<T>;
|
|
222
|
+
if (!parsed.ok) return null;
|
|
223
|
+
return (parsed.result ?? null) as T | null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Ping the bridge — returns true if the backend is alive. */
|
|
230
|
+
export function ping(install: MempalaceInstall, palace: string): boolean {
|
|
231
|
+
return runBridge<string>(install, palace, "ping") === "pong";
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.0
|
|
4
|
-
"description": "Persistent cross-session memory with
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"pi-coding-agent",
|
|
22
22
|
"unipi",
|
|
23
23
|
"memory",
|
|
24
|
+
"mempalace",
|
|
24
25
|
"vector-search",
|
|
25
26
|
"sqlite-vec"
|
|
26
27
|
],
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
"settings.ts",
|
|
33
34
|
"tools.ts",
|
|
34
35
|
"commands.ts",
|
|
36
|
+
"mempalace.ts",
|
|
37
|
+
"bridge/mempalace_bridge.py",
|
|
35
38
|
"tui/**/*",
|
|
36
39
|
"skills/**/*",
|
|
37
40
|
"README.md"
|
|
@@ -40,11 +43,11 @@
|
|
|
40
43
|
"better-sqlite3": "^12.9.0",
|
|
41
44
|
"sqlite-vec": "^0.1.9",
|
|
42
45
|
"js-yaml": "^4.1.0",
|
|
43
|
-
"@pi-unipi/core": "2.0
|
|
44
|
-
"@pi-unipi/info-screen": "2.0
|
|
46
|
+
"@pi-unipi/core": "2.1.0",
|
|
47
|
+
"@pi-unipi/info-screen": "2.1.0"
|
|
45
48
|
},
|
|
46
49
|
"peerDependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
48
51
|
"typebox": "^1.1.38"
|
|
49
52
|
},
|
|
50
53
|
"devDependencies": {
|
package/storage.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @unipi/memory — Storage layer
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Primary backend: MemPalace (auto-installed via uv, auto-migrated from
|
|
5
|
+
* legacy data). Falls back to SQLite + sqlite-vec when MemPalace/uv is
|
|
6
|
+
* unavailable, so memory never hard-fails. Markdown files remain the
|
|
7
|
+
* durable human-readable tier and the migration source.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import Database from "better-sqlite3";
|
|
@@ -12,6 +14,44 @@ import * as fs from "node:fs";
|
|
|
12
14
|
import * as path from "node:path";
|
|
13
15
|
import * as os from "node:os";
|
|
14
16
|
import { randomUUID } from "node:crypto";
|
|
17
|
+
import {
|
|
18
|
+
ensureMempalace,
|
|
19
|
+
runBridge,
|
|
20
|
+
isMigrated,
|
|
21
|
+
markMigrated,
|
|
22
|
+
DEFAULT_PALACE,
|
|
23
|
+
type MempalaceInstall,
|
|
24
|
+
type MempalaceRecord,
|
|
25
|
+
type MempalaceSearchResult,
|
|
26
|
+
type MempalaceListItem,
|
|
27
|
+
type MempalaceListItemAll,
|
|
28
|
+
} from "./mempalace.js";
|
|
29
|
+
|
|
30
|
+
export type MemoryBackend = "mempalace" | "sqlite";
|
|
31
|
+
|
|
32
|
+
/** Convert a MemPalace record (plain JSON) into a MemoryRecord. */
|
|
33
|
+
function toMemoryRecord(r: MempalaceRecord): MemoryRecord {
|
|
34
|
+
return {
|
|
35
|
+
id: r.id,
|
|
36
|
+
title: r.title,
|
|
37
|
+
content: r.content,
|
|
38
|
+
tags: Array.isArray(r.tags) ? r.tags : [],
|
|
39
|
+
project: r.project,
|
|
40
|
+
type: (r.type as MemoryRecord["type"]) || "summary",
|
|
41
|
+
created: r.created || "",
|
|
42
|
+
updated: r.updated || "",
|
|
43
|
+
embedding: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether MemPalace is detectable on this machine (for status display). */
|
|
48
|
+
export function isMempalaceAvailable(): boolean {
|
|
49
|
+
try {
|
|
50
|
+
return ensureMempalace() !== null;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
15
55
|
|
|
16
56
|
/** Memory row from SQLite queries */
|
|
17
57
|
interface MemoryRow {
|
|
@@ -203,27 +243,43 @@ export class MemoryStorage {
|
|
|
203
243
|
private db: Database.Database | null = null;
|
|
204
244
|
private projectName: string;
|
|
205
245
|
private scopeDir: string;
|
|
246
|
+
private backend: MemoryBackend = "sqlite";
|
|
247
|
+
private mempalaceInstall: MempalaceInstall | null = null;
|
|
248
|
+
private palacePath: string = DEFAULT_PALACE;
|
|
206
249
|
|
|
207
250
|
constructor(projectName: string) {
|
|
208
251
|
this.projectName = projectName;
|
|
209
252
|
this.scopeDir = getProjectDir(projectName);
|
|
210
253
|
}
|
|
211
254
|
|
|
255
|
+
/** Active backend ("mempalace" when available, else "sqlite"). */
|
|
256
|
+
getBackend(): MemoryBackend {
|
|
257
|
+
return this.backend;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True when the MemPalace backend is active for this instance. */
|
|
261
|
+
isMempalace(): boolean {
|
|
262
|
+
return this.backend === "mempalace" && this.mempalaceInstall !== null;
|
|
263
|
+
}
|
|
264
|
+
|
|
212
265
|
/**
|
|
213
|
-
* Initialize
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* IMPORTANT: We never delete the DB here — another session may have it open.
|
|
219
|
-
* If all retries fail, we throw and let this session run without memory.
|
|
266
|
+
* Initialize storage. Tries MemPalace first (auto-install + one-way
|
|
267
|
+
* auto-migration of legacy memories); falls back to SQLite if MemPalace
|
|
268
|
+
* is unavailable. Never throws for backend unavailability — only throws
|
|
269
|
+
* if the SQLite fallback itself fails to open.
|
|
220
270
|
*/
|
|
221
271
|
init(): void {
|
|
222
|
-
// Ensure directory exists
|
|
272
|
+
// Ensure directory exists (used by both backends for markdown tier).
|
|
223
273
|
if (!fs.existsSync(this.scopeDir)) {
|
|
224
274
|
fs.mkdirSync(this.scopeDir, { recursive: true });
|
|
225
275
|
}
|
|
226
276
|
|
|
277
|
+
if (this.tryInitMempalace()) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Fallback: SQLite + sqlite-vec.
|
|
282
|
+
this.backend = "sqlite";
|
|
227
283
|
const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
|
|
228
284
|
const maxRetries = 5;
|
|
229
285
|
|
|
@@ -243,27 +299,54 @@ export class MemoryStorage {
|
|
|
243
299
|
this.close();
|
|
244
300
|
|
|
245
301
|
if (isTransient && attempt < maxRetries) {
|
|
246
|
-
// Likely concurrent access — back off and retry.
|
|
247
|
-
// Do NOT delete the DB: another session may have it open
|
|
248
|
-
// and deleting open files on WSL/Windows is unsafe.
|
|
249
302
|
const delayMs = 50 * Math.pow(2, attempt - 1); // 50, 100, 200, 400
|
|
250
|
-
// Removed console.warn — transient retries are normal during concurrent access.
|
|
251
|
-
// Memory availability visible via info-screen memory group.
|
|
252
303
|
const end = Date.now() + delayMs;
|
|
253
304
|
while (Date.now() < end) { /* busy wait */ }
|
|
254
305
|
continue;
|
|
255
306
|
}
|
|
256
307
|
|
|
257
|
-
// Either non-transient error, or retries exhausted.
|
|
258
|
-
// Log and throw — this session will run without memory.
|
|
259
|
-
if (isTransient) {
|
|
260
|
-
// Removed console.warn — memory unavailable status visible via info-screen.
|
|
261
|
-
}
|
|
262
308
|
throw err;
|
|
263
309
|
}
|
|
264
310
|
}
|
|
265
311
|
}
|
|
266
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Attempt to initialize the MemPalace backend. Returns true on success.
|
|
315
|
+
* Handles auto-install and one-way auto-migration of legacy memories.
|
|
316
|
+
* Never throws — any failure returns false so the SQLite fallback runs.
|
|
317
|
+
*/
|
|
318
|
+
private tryInitMempalace(): boolean {
|
|
319
|
+
let install: MempalaceInstall | null;
|
|
320
|
+
try {
|
|
321
|
+
install = ensureMempalace();
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
if (!install) return false;
|
|
326
|
+
|
|
327
|
+
// Sanity ping — if the palace/bridge is broken, fall back.
|
|
328
|
+
const ok = runBridge<string>(install, this.palacePath, "ping");
|
|
329
|
+
if (ok !== "pong") return false;
|
|
330
|
+
|
|
331
|
+
this.mempalaceInstall = install;
|
|
332
|
+
this.backend = "mempalace";
|
|
333
|
+
|
|
334
|
+
// One-way auto-migration of legacy memories (idempotent).
|
|
335
|
+
if (!isMigrated()) {
|
|
336
|
+
try {
|
|
337
|
+
runBridge(install, this.palacePath, "migrate", {
|
|
338
|
+
source_dir: getMemoryBaseDir(),
|
|
339
|
+
});
|
|
340
|
+
markMigrated();
|
|
341
|
+
} catch {
|
|
342
|
+
// Migration failed — palace still usable for new stores; legacy
|
|
343
|
+
// memories can be re-migrated later. Do not block.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
267
350
|
/**
|
|
268
351
|
* Open database and set up schema. Called by init() with retry logic.
|
|
269
352
|
*/
|
|
@@ -341,6 +424,7 @@ export class MemoryStorage {
|
|
|
341
424
|
* Check if database is healthy.
|
|
342
425
|
*/
|
|
343
426
|
isHealthy(): boolean {
|
|
427
|
+
if (this.isMempalace()) return true;
|
|
344
428
|
if (!this.db) return false;
|
|
345
429
|
try {
|
|
346
430
|
this.db.prepare("SELECT 1").get();
|
|
@@ -355,8 +439,6 @@ export class MemoryStorage {
|
|
|
355
439
|
* Uses transaction to ensure atomicity — either all writes succeed or none do.
|
|
356
440
|
*/
|
|
357
441
|
store(record: MemoryRecord): void {
|
|
358
|
-
if (!this.db) throw new Error("Storage not initialized");
|
|
359
|
-
|
|
360
442
|
// Generate ID from title if not provided
|
|
361
443
|
if (!record.id) {
|
|
362
444
|
record.id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
@@ -370,6 +452,13 @@ export class MemoryStorage {
|
|
|
370
452
|
// Set project if not provided
|
|
371
453
|
if (!record.project) record.project = this.projectName;
|
|
372
454
|
|
|
455
|
+
if (this.isMempalace()) {
|
|
456
|
+
this.storeMempalace(record);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!this.db) throw new Error("Storage not initialized");
|
|
461
|
+
|
|
373
462
|
// Prepare markdown content BEFORE transaction (fail fast)
|
|
374
463
|
const mdPath = path.join(this.scopeDir, `${record.id}.md`);
|
|
375
464
|
const frontmatter: MemoryFrontmatter = {
|
|
@@ -444,6 +533,37 @@ export class MemoryStorage {
|
|
|
444
533
|
}
|
|
445
534
|
}
|
|
446
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Store a record via the MemPalace backend. Also writes the markdown
|
|
538
|
+
* tier so the human-readable file and legacy migration source stay
|
|
539
|
+
* consistent and durable as a fallback source.
|
|
540
|
+
*/
|
|
541
|
+
private storeMempalace(record: MemoryRecord): void {
|
|
542
|
+
const install = this.mempalaceInstall!;
|
|
543
|
+
runBridge(install, this.palacePath, "store", {
|
|
544
|
+
record: {
|
|
545
|
+
id: record.id,
|
|
546
|
+
title: record.title,
|
|
547
|
+
content: record.content,
|
|
548
|
+
tags: record.tags,
|
|
549
|
+
project: record.project,
|
|
550
|
+
type: record.type,
|
|
551
|
+
created: record.created,
|
|
552
|
+
updated: record.updated,
|
|
553
|
+
source_kind: "markdown",
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
// Markdown tier (durable human copy + fallback source).
|
|
557
|
+
try {
|
|
558
|
+
const mdPath = path.join(this.scopeDir, `${record.id}.md`);
|
|
559
|
+
const dir = path.dirname(mdPath);
|
|
560
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
561
|
+
writeMemoryFile(mdPath, record);
|
|
562
|
+
} catch {
|
|
563
|
+
// Palace write succeeded; markdown is best-effort.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
447
567
|
/**
|
|
448
568
|
* Sync orphaned markdown files into the database.
|
|
449
569
|
* Reads all .md files in the project dir, parses frontmatter,
|
|
@@ -451,6 +571,15 @@ export class MemoryStorage {
|
|
|
451
571
|
* Returns count of synced files.
|
|
452
572
|
*/
|
|
453
573
|
syncOrphanedFiles(): number {
|
|
574
|
+
if (this.isMempalace()) {
|
|
575
|
+
const install = this.mempalaceInstall!;
|
|
576
|
+
const synced = runBridge<number>(install, this.palacePath, "sync_orphaned", {
|
|
577
|
+
project_dir: this.scopeDir,
|
|
578
|
+
wing: this.projectName,
|
|
579
|
+
});
|
|
580
|
+
return synced ?? 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
454
583
|
if (!this.db) throw new Error("Storage not initialized");
|
|
455
584
|
|
|
456
585
|
const files = fs.readdirSync(this.scopeDir)
|
|
@@ -506,6 +635,13 @@ export class MemoryStorage {
|
|
|
506
635
|
* Check if a memory with the given title already exists.
|
|
507
636
|
*/
|
|
508
637
|
hasByTitle(title: string): boolean {
|
|
638
|
+
if (this.isMempalace()) {
|
|
639
|
+
const install = this.mempalaceInstall!;
|
|
640
|
+
return runBridge<boolean>(install, this.palacePath, "has_title", {
|
|
641
|
+
wing: this.projectName,
|
|
642
|
+
title,
|
|
643
|
+
}) ?? false;
|
|
644
|
+
}
|
|
509
645
|
if (!this.db) throw new Error("Storage not initialized");
|
|
510
646
|
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
511
647
|
const row = this.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(id);
|
|
@@ -517,6 +653,14 @@ export class MemoryStorage {
|
|
|
517
653
|
* Returns array of { record, similarity } sorted by similarity desc.
|
|
518
654
|
*/
|
|
519
655
|
findSimilarByTitle(title: string, threshold = 0.6): Array<{ record: MemoryRecord; similarity: number }> {
|
|
656
|
+
if (this.isMempalace()) {
|
|
657
|
+
const install = this.mempalaceInstall!;
|
|
658
|
+
const rows = runBridge<Array<{ record: MempalaceRecord; similarity: number }>>(
|
|
659
|
+
install, this.palacePath, "find_similar",
|
|
660
|
+
{ wing: this.projectName, title, threshold },
|
|
661
|
+
) ?? [];
|
|
662
|
+
return rows.map((r) => ({ record: toMemoryRecord(r.record), similarity: r.similarity }));
|
|
663
|
+
}
|
|
520
664
|
if (!this.db) throw new Error("Storage not initialized");
|
|
521
665
|
|
|
522
666
|
const allRows = this.db.prepare("SELECT id, title FROM memories").all() as MemoryRow[];
|
|
@@ -549,6 +693,11 @@ export class MemoryStorage {
|
|
|
549
693
|
* Get a memory record by ID.
|
|
550
694
|
*/
|
|
551
695
|
getById(id: string): MemoryRecord | null {
|
|
696
|
+
if (this.isMempalace()) {
|
|
697
|
+
const install = this.mempalaceInstall!;
|
|
698
|
+
const rec = runBridge<MempalaceRecord | null>(install, this.palacePath, "get", { id });
|
|
699
|
+
return rec ? toMemoryRecord(rec) : null;
|
|
700
|
+
}
|
|
552
701
|
if (!this.db) throw new Error("Storage not initialized");
|
|
553
702
|
|
|
554
703
|
const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
|
|
@@ -571,6 +720,14 @@ export class MemoryStorage {
|
|
|
571
720
|
* Get a memory record by title (fuzzy match).
|
|
572
721
|
*/
|
|
573
722
|
getByTitle(title: string): MemoryRecord | null {
|
|
723
|
+
if (this.isMempalace()) {
|
|
724
|
+
const install = this.mempalaceInstall!;
|
|
725
|
+
const rec = runBridge<MempalaceRecord | null>(install, this.palacePath, "get_by_title", {
|
|
726
|
+
wing: this.projectName,
|
|
727
|
+
title,
|
|
728
|
+
});
|
|
729
|
+
return rec ? toMemoryRecord(rec) : null;
|
|
730
|
+
}
|
|
574
731
|
if (!this.db) throw new Error("Storage not initialized");
|
|
575
732
|
|
|
576
733
|
// Try exact match first
|
|
@@ -610,6 +767,13 @@ export class MemoryStorage {
|
|
|
610
767
|
* List all memories (titles only).
|
|
611
768
|
*/
|
|
612
769
|
listAll(): Array<{ id: string; title: string; type: string }> {
|
|
770
|
+
if (this.isMempalace()) {
|
|
771
|
+
const install = this.mempalaceInstall!;
|
|
772
|
+
const items = runBridge<MempalaceListItem[]>(install, this.palacePath, "list", {
|
|
773
|
+
wing: this.projectName,
|
|
774
|
+
}) ?? [];
|
|
775
|
+
return items;
|
|
776
|
+
}
|
|
613
777
|
if (!this.db) throw new Error("Storage not initialized");
|
|
614
778
|
|
|
615
779
|
const rows = this.db.prepare("SELECT id, title, type FROM memories ORDER BY updated DESC").all() as MemoryRow[];
|
|
@@ -620,6 +784,16 @@ export class MemoryStorage {
|
|
|
620
784
|
* Delete a memory by ID.
|
|
621
785
|
*/
|
|
622
786
|
delete(id: string): boolean {
|
|
787
|
+
if (this.isMempalace()) {
|
|
788
|
+
const install = this.mempalaceInstall!;
|
|
789
|
+
const ok = runBridge<boolean>(install, this.palacePath, "delete", { id }) ?? false;
|
|
790
|
+
// Also remove the markdown tier if present.
|
|
791
|
+
try {
|
|
792
|
+
const mdPath = path.join(this.scopeDir, `${id}.md`);
|
|
793
|
+
if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
|
|
794
|
+
} catch { /* ignore */ }
|
|
795
|
+
return ok;
|
|
796
|
+
}
|
|
623
797
|
if (!this.db) throw new Error("Storage not initialized");
|
|
624
798
|
|
|
625
799
|
// Delete from vector table
|
|
@@ -658,6 +832,19 @@ export class MemoryStorage {
|
|
|
658
832
|
* Search memories using hybrid approach.
|
|
659
833
|
*/
|
|
660
834
|
search(query: string, limit = 10, embedding?: Float32Array | null): SearchResult[] {
|
|
835
|
+
if (this.isMempalace()) {
|
|
836
|
+
const install = this.mempalaceInstall!;
|
|
837
|
+
const rows = runBridge<MempalaceSearchResult[]>(install, this.palacePath, "search", {
|
|
838
|
+
query,
|
|
839
|
+
wing: this.projectName,
|
|
840
|
+
limit,
|
|
841
|
+
}) ?? [];
|
|
842
|
+
return rows.map((r) => ({
|
|
843
|
+
record: toMemoryRecord(r),
|
|
844
|
+
score: r.score,
|
|
845
|
+
snippet: r.snippet,
|
|
846
|
+
}));
|
|
847
|
+
}
|
|
661
848
|
if (!this.db) throw new Error("Storage not initialized");
|
|
662
849
|
|
|
663
850
|
const results: Map<string, SearchResult> = new Map();
|
|
@@ -801,6 +988,21 @@ export function searchAllProjects(
|
|
|
801
988
|
query: string,
|
|
802
989
|
limit = 10
|
|
803
990
|
): SearchResult[] {
|
|
991
|
+
// MemPalace global path: query across all wings in one call.
|
|
992
|
+
const install = ensureMempalace();
|
|
993
|
+
if (install) {
|
|
994
|
+
const rows = runBridge<MempalaceSearchResult[]>(install, DEFAULT_PALACE, "search", {
|
|
995
|
+
query,
|
|
996
|
+
limit,
|
|
997
|
+
}) ?? [];
|
|
998
|
+
return rows.map((r) => ({
|
|
999
|
+
record: toMemoryRecord(r),
|
|
1000
|
+
score: r.score,
|
|
1001
|
+
snippet: r.snippet,
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// SQLite fallback: iterate project directories.
|
|
804
1006
|
const projectDirs = getAllProjectDirs();
|
|
805
1007
|
const allResults: SearchResult[] = [];
|
|
806
1008
|
|
|
@@ -835,6 +1037,19 @@ export function listAllProjects(): Array<{
|
|
|
835
1037
|
title: string;
|
|
836
1038
|
type: string;
|
|
837
1039
|
}> {
|
|
1040
|
+
// MemPalace global path: list all drawers across wings.
|
|
1041
|
+
const install = ensureMempalace();
|
|
1042
|
+
if (install) {
|
|
1043
|
+
const items = runBridge<MempalaceListItemAll[]>(install, DEFAULT_PALACE, "list_all", {}) ?? [];
|
|
1044
|
+
return items.map((m) => ({
|
|
1045
|
+
project: m.project,
|
|
1046
|
+
id: m.id,
|
|
1047
|
+
title: m.title,
|
|
1048
|
+
type: m.type,
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// SQLite fallback: iterate project directories.
|
|
838
1053
|
const projectDirs = getAllProjectDirs();
|
|
839
1054
|
const allMemories: Array<{
|
|
840
1055
|
project: string;
|
package/tools.ts
CHANGED
|
File without changes
|