@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luisarg/memory-auto",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Auto-captura memoria DSH (idle, commit, compaction, dispose)",
6
6
  "repository": {
@@ -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
- const child = spawn(command, args, { cwd: DIR, env: process.env, stdio: 'inherit' })
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
- test_conn = sqlite3.connect(str(db_path))
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")