@luisarg/memory-auto 0.1.2 → 0.1.3
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/package.json +1 -1
- package/server/launcher.mjs +5 -1
- package/server/rebuild_index.py +191 -0
- package/server/store.py +5 -2
package/package.json
CHANGED
package/server/launcher.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { spawn, spawnSync } from 'node:child_process'
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
15
16
|
import { dirname, join } from 'node:path'
|
|
16
17
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
17
18
|
|
|
@@ -57,7 +58,10 @@ function main() {
|
|
|
57
58
|
const withUv = hasUv()
|
|
58
59
|
const { command, args } = resolveRunner(withUv)
|
|
59
60
|
if (!withUv) ensurePipEnv()
|
|
60
|
-
|
|
61
|
+
// Default the uv cache under the OS temp dir (Windows has no /tmp); an
|
|
62
|
+
// explicit UV_CACHE_DIR from the patch layer or env still wins.
|
|
63
|
+
const env = { UV_CACHE_DIR: join(tmpdir(), 'uv-cache'), ...process.env }
|
|
64
|
+
const child = spawn(command, args, { cwd: DIR, env, stdio: 'inherit' })
|
|
61
65
|
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => child.kill(sig))
|
|
62
66
|
child.on('error', (err) => {
|
|
63
67
|
console.error(`[memory-vault-server] spawn failed (${command}): ${err.message}`)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Rebuild the SQLite FTS5 index from the OKF Markdown bundle (index-only).
|
|
2
|
+
|
|
3
|
+
Walks `projects/<project>/<type>/*.md` and `raw/*.md`, parses each file and
|
|
4
|
+
inserts rows directly into SQLite. Markdown stays the source of truth: this
|
|
5
|
+
script never rewrites .md files (upsert_entry's write path would duplicate
|
|
6
|
+
them with today's date on a fresh DB).
|
|
7
|
+
|
|
8
|
+
Idempotent: row ids derive from file paths, dedup keys from content, so
|
|
9
|
+
re-running converges. Run after importing a bundle into a new vault dir
|
|
10
|
+
(memory.db* may be deleted first; the script also works on a populated DB):
|
|
11
|
+
|
|
12
|
+
uv run --directory memory-vault-server python rebuild_index.py \
|
|
13
|
+
--memory-path /path/to/vault
|
|
14
|
+
|
|
15
|
+
DB-only data (profiles) is NOT covered — migrate profiles separately.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_store_mod = None # set in main() once MEMORY_PATH points at the target bundle
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _id_for(relpath: Path) -> str:
|
|
30
|
+
"""Stable row id derived from the file path (idempotent re-runs)."""
|
|
31
|
+
digest = hashlib.sha1(str(relpath).encode()).hexdigest()[:24]
|
|
32
|
+
return f"rebuild-{digest}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _collect(memory_path: Path):
|
|
36
|
+
"""Walk the bundle -> (entries, errors); entries are
|
|
37
|
+
(singular, project, relpath, parsed) tuples, DB-only types skipped."""
|
|
38
|
+
entries: list[tuple[str, str, Path, dict]] = []
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
dir_to_singular = _store_mod._DIR_TO_SINGULAR
|
|
41
|
+
parse = _store_mod._parse_okf_file
|
|
42
|
+
|
|
43
|
+
def handle(path: Path, parsed: dict | None, default_project: str, singular: str):
|
|
44
|
+
if parsed is None:
|
|
45
|
+
errors.append(f"unparseable: {path.relative_to(memory_path)}")
|
|
46
|
+
return
|
|
47
|
+
# Defensive: trust the directory, not the parsed label.
|
|
48
|
+
parsed["entry_type"] = singular
|
|
49
|
+
parsed["project"] = parsed.get("project") or default_project
|
|
50
|
+
entries.append((singular, parsed["project"], path.relative_to(memory_path), parsed))
|
|
51
|
+
|
|
52
|
+
projects_dir = memory_path / "projects"
|
|
53
|
+
if projects_dir.is_dir():
|
|
54
|
+
# Projects may nest (e.g. `@deepseek-ai/dsh-root`): any directory whose
|
|
55
|
+
# name is a type dir is indexed; its project is the dir path below
|
|
56
|
+
# `projects/`, joined with "/".
|
|
57
|
+
def scan(prefix: Path, project: str):
|
|
58
|
+
for d in sorted(child for child in prefix.iterdir() if child.is_dir()):
|
|
59
|
+
singular = dir_to_singular.get(d.name)
|
|
60
|
+
if singular is None:
|
|
61
|
+
if d.name != ".obsidian":
|
|
62
|
+
scan(d, f"{project}/{d.name}" if project else d.name)
|
|
63
|
+
continue
|
|
64
|
+
if singular == "profile":
|
|
65
|
+
continue
|
|
66
|
+
for f in sorted(d.glob("*.md")):
|
|
67
|
+
if f.name == "index.md":
|
|
68
|
+
continue
|
|
69
|
+
handle(f, parse(f), project, singular)
|
|
70
|
+
|
|
71
|
+
for proj_dir in sorted(p for p in projects_dir.iterdir() if p.is_dir()):
|
|
72
|
+
scan(proj_dir, proj_dir.name)
|
|
73
|
+
|
|
74
|
+
raw_dir = memory_path / "raw"
|
|
75
|
+
if raw_dir.is_dir():
|
|
76
|
+
for f in sorted(raw_dir.glob("*.md")):
|
|
77
|
+
handle(f, parse(f), "", "source")
|
|
78
|
+
|
|
79
|
+
return entries, errors
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rebuild(memory_path: Path) -> tuple[int, int, list[str]]:
|
|
83
|
+
"""Insert all bundle entries into the index -> (inserted, updated, errors)."""
|
|
84
|
+
store = _store_mod.MemoryStore(storage_path=memory_path)
|
|
85
|
+
store.initialize()
|
|
86
|
+
|
|
87
|
+
entries, errors = _collect(memory_path)
|
|
88
|
+
if not entries:
|
|
89
|
+
print("No entries found in bundle; nothing to do.", file=sys.stderr)
|
|
90
|
+
return 0, 0, errors
|
|
91
|
+
|
|
92
|
+
n_inserted = n_updated = 0
|
|
93
|
+
for singular, project, relpath, e in entries:
|
|
94
|
+
try:
|
|
95
|
+
dedup_key = (
|
|
96
|
+
store._make_dedup_key(singular, project, e["content"])
|
|
97
|
+
if singular != "source"
|
|
98
|
+
else _id_for(relpath)
|
|
99
|
+
)
|
|
100
|
+
existing = store.db.execute(
|
|
101
|
+
"SELECT id FROM entries WHERE dedup_key = ?", (dedup_key,)
|
|
102
|
+
).fetchone()
|
|
103
|
+
if existing:
|
|
104
|
+
if existing["id"] != _id_for(relpath):
|
|
105
|
+
# Same content already indexed (bundle duplicates collapse):
|
|
106
|
+
# adopt this file's id so re-runs converge, keep row content.
|
|
107
|
+
store.db.execute(
|
|
108
|
+
"UPDATE entries SET id = ?, created_at = ?, updated_at = ? WHERE dedup_key = ?",
|
|
109
|
+
(_id_for(relpath), e["created_at"], e["updated_at"], dedup_key),
|
|
110
|
+
)
|
|
111
|
+
store.db.commit()
|
|
112
|
+
n_updated += 1
|
|
113
|
+
continue
|
|
114
|
+
store._insert_row(
|
|
115
|
+
_id_for(relpath),
|
|
116
|
+
singular,
|
|
117
|
+
project,
|
|
118
|
+
e["content"],
|
|
119
|
+
e["tags"],
|
|
120
|
+
float(e["confidence"] or 1.0),
|
|
121
|
+
e["openspec_change_id"],
|
|
122
|
+
dedup_key,
|
|
123
|
+
e["created_at"],
|
|
124
|
+
e["updated_at"],
|
|
125
|
+
)
|
|
126
|
+
n_inserted += 1
|
|
127
|
+
except Exception as exc: # noqa: BLE001 — one bad file must not stop the rebuild
|
|
128
|
+
errors.append(f"failed: {project}/{relpath}: {exc}")
|
|
129
|
+
return n_inserted, n_updated, errors
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def regen_missing_indexes(memory_path: Path, store) -> int:
|
|
133
|
+
"""Regenerate `<project>/index.md` only where it is missing (nested too)."""
|
|
134
|
+
projects_dir = memory_path / "projects"
|
|
135
|
+
if not projects_dir.is_dir():
|
|
136
|
+
return 0
|
|
137
|
+
n = 0
|
|
138
|
+
|
|
139
|
+
def scan(prefix: Path):
|
|
140
|
+
for d in sorted(child for child in prefix.iterdir() if child.is_dir()):
|
|
141
|
+
if d.name in _store_mod._DIR_TO_SINGULAR or d.name == ".obsidian":
|
|
142
|
+
continue # type dir or editor cache — not a project
|
|
143
|
+
if not (d / "index.md").exists():
|
|
144
|
+
project = str(d.relative_to(projects_dir)).replace(os.sep, "/")
|
|
145
|
+
store._regenerate_project_index(project)
|
|
146
|
+
n += 1
|
|
147
|
+
scan(d)
|
|
148
|
+
|
|
149
|
+
for proj_dir in sorted(p for p in projects_dir.iterdir() if p.is_dir()):
|
|
150
|
+
if not (proj_dir / "index.md").exists():
|
|
151
|
+
store._regenerate_project_index(proj_dir.name)
|
|
152
|
+
n += 1
|
|
153
|
+
scan(proj_dir)
|
|
154
|
+
return n
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
global _store_mod
|
|
159
|
+
parser = argparse.ArgumentParser(description="Rebuild SQLite index from OKF bundle")
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--memory-path",
|
|
162
|
+
default=None,
|
|
163
|
+
help="OKF bundle directory (default: $MEMORY_PATH or repo memory-vault)",
|
|
164
|
+
)
|
|
165
|
+
args = parser.parse_args(argv)
|
|
166
|
+
|
|
167
|
+
# store.py builds its type maps from MEMORY_PATH at import time — point it
|
|
168
|
+
# at the target bundle before importing.
|
|
169
|
+
if args.memory_path:
|
|
170
|
+
os.environ["MEMORY_PATH"] = str(Path(args.memory_path).resolve())
|
|
171
|
+
import store as _store_mod
|
|
172
|
+
|
|
173
|
+
memory_path = Path(os.environ.get("MEMORY_PATH", "")).resolve()
|
|
174
|
+
if not memory_path.is_dir():
|
|
175
|
+
print(f"error: {memory_path} is not a directory", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
|
|
178
|
+
print(f"Rebuilding index from {memory_path} ...")
|
|
179
|
+
n_ins, n_upd, errors = rebuild(memory_path)
|
|
180
|
+
store = _store_mod.MemoryStore(storage_path=memory_path)
|
|
181
|
+
store.initialize()
|
|
182
|
+
n_index = regen_missing_indexes(memory_path, store)
|
|
183
|
+
print(f"Inserted: {n_ins}, already-indexed: {n_upd}, index.md regenerated: {n_index}")
|
|
184
|
+
for err in errors:
|
|
185
|
+
print(f" {err}", file=sys.stderr)
|
|
186
|
+
print(f"Errors: {len(errors)}")
|
|
187
|
+
return 0 if not errors else 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
raise SystemExit(main())
|
package/server/store.py
CHANGED
|
@@ -263,7 +263,10 @@ class MemoryStore:
|
|
|
263
263
|
db_path = self.storage_path / "memory.db"
|
|
264
264
|
if db_path.exists() and db_path.stat().st_size > 0:
|
|
265
265
|
try:
|
|
266
|
-
|
|
266
|
+
# timeout=30: with WAL a live vault may be written by another
|
|
267
|
+
# client (DSH + opencode). Without it the probe waits only 5 s
|
|
268
|
+
# and a busy lock surfaces as "corrupt database".
|
|
269
|
+
test_conn = sqlite3.connect(str(db_path), timeout=30)
|
|
267
270
|
row = test_conn.execute("PRAGMA integrity_check").fetchone()
|
|
268
271
|
test_conn.close()
|
|
269
272
|
if row[0].lower() != "ok":
|
|
@@ -271,7 +274,7 @@ class MemoryStore:
|
|
|
271
274
|
except sqlite3.DatabaseError as e:
|
|
272
275
|
raise RuntimeError(f"corrupt database: {e}") from e
|
|
273
276
|
|
|
274
|
-
self._db = sqlite3.connect(str(db_path))
|
|
277
|
+
self._db = sqlite3.connect(str(db_path), timeout=30)
|
|
275
278
|
self._db.row_factory = sqlite3.Row
|
|
276
279
|
self.db.execute("PRAGMA journal_mode=WAL")
|
|
277
280
|
self.db.execute("PRAGMA foreign_keys=ON")
|