@luisarg/memory-mcp 0.1.2 → 0.1.4

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/dist/index.d.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
-
4
3
  //#region src/index.d.ts
5
- declare const name = "memory-mcp";
6
- interface Config {
4
+ export declare const name = "memory-mcp";
5
+ export interface Config {
7
6
  memoryPath: string;
8
7
  serverDir: string;
9
8
  }
10
- declare const Config: Schema<Config>;
11
- declare function apply(ctx: Context, config: Config): void;
9
+ export declare const Config: Schema<Config>;
10
+ export declare function apply(ctx: Context, config: Config): void;
12
11
  //#endregion
13
- export { Config, apply, name };
14
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;cAOa,IAAA;UAEI,MAAA;EAFJ,UAAI,EAAA,MAAA;EAEA,SAAM,EAAA,MAAA;AAKvB;AAwCgB,cAxCH,MAwCc,EAxCN,MAwCuB,CAxChB,MAwCsB,CAAA;iBAAlC,KAAA,MAAW,iBAAiB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;qBAOa;iBAEI;EACf;EACA;;qBAGW,QAAQ,OAAO;wBAwCZ,MAAM,KAAK,SAAS,QAAQ"}
package/dist/index.js CHANGED
@@ -3,7 +3,6 @@ import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import Schema from "@deepseek-ai/schemastery";
6
-
7
6
  //#region src/index.ts
8
7
  const name = "memory-mcp";
9
8
  const Config = Schema.object({
@@ -49,7 +48,7 @@ function apply(ctx, config) {
49
48
  for (const file of ["launcher.mjs", "requirements.txt"]) if (ensureFile(serverDir, bundled, file)) console.log(`[memory-mcp] installed ${file} -> ${serverDir}`);
50
49
  if (!existsSync(join(serverDir, "server.py"))) console.warn(`[memory-mcp] memory-vault-server not found at ${serverDir} and not bundled — set DSH_MEMORY_SERVER_DIR (or run \`node scripts/bundle-assets.mjs\` in a checkout)`);
51
50
  }
52
-
53
51
  //#endregion
54
52
  export { Config, apply, name };
53
+
55
54
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["Config: Schema<Config>"],"sources":["../src/index.ts"],"sourcesContent":["import { cpSync, existsSync, mkdirSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { dirname, isAbsolute, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { Context } from '@deepseek-ai/cordis'\nimport Schema from '@deepseek-ai/schemastery'\n\nexport const name = 'memory-mcp'\n\nexport interface Config {\n memoryPath: string\n serverDir: string\n}\n\nexport const Config: Schema<Config> = Schema.object({\n memoryPath: Schema.string().default(process.env.DSH_MEMORY_PATH ?? ''),\n serverDir: Schema.string().default(process.env.DSH_MEMORY_SERVER_DIR ?? ''),\n})\n\nconst packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))\n\n/** Harness home, resolved like the harness itself ($DSH_HOME, or ~/.dsh). */\nfunction dshHome(): string {\n const env = process.env.DSH_HOME?.trim()\n return env && env.length > 0 ? env : join(homedir(), '.dsh')\n}\n\n/** Absolute paths stay; empty/relative values resolve under the harness home. */\nfunction resolveUnderHome(value: string, segment: string): string {\n const v = value.trim()\n if (v.length === 0) return join(dshHome(), segment)\n return isAbsolute(v) ? v : join(dshHome(), v)\n}\n\n/** Copy the bundled dir into `target` when `key` is missing there. */\nfunction ensure(target: string, bundled: string, key: string): boolean {\n if (existsSync(join(target, key))) return false\n if (!existsSync(bundled)) return false\n mkdirSync(target, { recursive: true })\n cpSync(bundled, target, { recursive: true })\n return true\n}\n\n/** Copy one bundled file into `target` when missing (upgrades add files 0.1.1 → 0.1.2). */\nfunction ensureFile(target: string, bundled: string, file: string): boolean {\n const dest = join(target, file)\n if (existsSync(dest)) return false\n const src = join(bundled, file)\n if (!existsSync(src)) return false\n mkdirSync(target, { recursive: true })\n cpSync(src, dest)\n return true\n}\n\nexport function apply(ctx: Context, config: Config) {\n const serverDir = resolveUnderHome(config.serverDir, 'memory-vault-server')\n const memoryPath = resolveUnderHome(config.memoryPath, 'memory-vault')\n\n // Self-contained install: first boot copies the bundled server and vault\n // starter under the harness home when they are missing. Env-overridden\n // paths are respected (never overwritten, never copied over).\n if (ensure(serverDir, join(packageRoot, 'server'), 'server.py')) {\n console.log(`[memory-mcp] installed memory-vault-server -> ${serverDir}`)\n }\n if (ensure(memoryPath, join(packageRoot, 'vault'), 'type-registry.yaml')) {\n console.log(`[memory-mcp] installed vault starter -> ${memoryPath}`)\n }\n // launcher.mjs runs the server via uv or the pip-venv fallback; upgrades of\n // existing installs (server.py already present) still need the new files.\n const bundled = join(packageRoot, 'server')\n for (const file of ['launcher.mjs', 'requirements.txt']) {\n if (ensureFile(serverDir, bundled, file)) {\n console.log(`[memory-mcp] installed ${file} -> ${serverDir}`)\n }\n }\n if (!existsSync(join(serverDir, 'server.py'))) {\n console.warn(\n `[memory-mcp] memory-vault-server not found at ${serverDir} and not bundled — ` +\n 'set DSH_MEMORY_SERVER_DIR (or run `node scripts/bundle-assets.mjs` in a checkout)',\n )\n }\n}\n"],"mappings":";;;;;;;AAOA,MAAa,OAAO;AAOpB,MAAaA,SAAyB,OAAO,OAAO;CAClD,YAAY,OAAO,QAAQ,CAAC,QAAQ,QAAQ,IAAI,mBAAmB,GAAG;CACtE,WAAW,OAAO,QAAQ,CAAC,QAAQ,QAAQ,IAAI,yBAAyB,GAAG;CAC5E,CAAC;AAEF,MAAM,cAAc,QAAQ,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,CAAC;;AAGpE,SAAS,UAAkB;CACzB,MAAM,MAAM,QAAQ,IAAI,UAAU,MAAM;AACxC,QAAO,OAAO,IAAI,SAAS,IAAI,MAAM,KAAK,SAAS,EAAE,OAAO;;;AAI9D,SAAS,iBAAiB,OAAe,SAAyB;CAChE,MAAM,IAAI,MAAM,MAAM;AACtB,KAAI,EAAE,WAAW,EAAG,QAAO,KAAK,SAAS,EAAE,QAAQ;AACnD,QAAO,WAAW,EAAE,GAAG,IAAI,KAAK,SAAS,EAAE,EAAE;;;AAI/C,SAAS,OAAO,QAAgB,SAAiB,KAAsB;AACrE,KAAI,WAAW,KAAK,QAAQ,IAAI,CAAC,CAAE,QAAO;AAC1C,KAAI,CAAC,WAAW,QAAQ,CAAE,QAAO;AACjC,WAAU,QAAQ,EAAE,WAAW,MAAM,CAAC;AACtC,QAAO,SAAS,QAAQ,EAAE,WAAW,MAAM,CAAC;AAC5C,QAAO;;;AAIT,SAAS,WAAW,QAAgB,SAAiB,MAAuB;CAC1E,MAAM,OAAO,KAAK,QAAQ,KAAK;AAC/B,KAAI,WAAW,KAAK,CAAE,QAAO;CAC7B,MAAM,MAAM,KAAK,SAAS,KAAK;AAC/B,KAAI,CAAC,WAAW,IAAI,CAAE,QAAO;AAC7B,WAAU,QAAQ,EAAE,WAAW,MAAM,CAAC;AACtC,QAAO,KAAK,KAAK;AACjB,QAAO;;AAGT,SAAgB,MAAM,KAAc,QAAgB;CAClD,MAAM,YAAY,iBAAiB,OAAO,WAAW,sBAAsB;CAC3E,MAAM,aAAa,iBAAiB,OAAO,YAAY,eAAe;AAKtE,KAAI,OAAO,WAAW,KAAK,aAAa,SAAS,EAAE,YAAY,CAC7D,SAAQ,IAAI,iDAAiD,YAAY;AAE3E,KAAI,OAAO,YAAY,KAAK,aAAa,QAAQ,EAAE,qBAAqB,CACtE,SAAQ,IAAI,2CAA2C,aAAa;CAItE,MAAM,UAAU,KAAK,aAAa,SAAS;AAC3C,MAAK,MAAM,QAAQ,CAAC,gBAAgB,mBAAmB,CACrD,KAAI,WAAW,WAAW,SAAS,KAAK,CACtC,SAAQ,IAAI,0BAA0B,KAAK,MAAM,YAAY;AAGjE,KAAI,CAAC,WAAW,KAAK,WAAW,YAAY,CAAC,CAC3C,SAAQ,KACN,iDAAiD,UAAU,wGAE5D"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { cpSync, existsSync, mkdirSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { dirname, isAbsolute, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { Context } from '@deepseek-ai/cordis'\nimport Schema from '@deepseek-ai/schemastery'\n\nexport const name = 'memory-mcp'\n\nexport interface Config {\n memoryPath: string\n serverDir: string\n}\n\nexport const Config: Schema<Config> = Schema.object({\n memoryPath: Schema.string().default(process.env.DSH_MEMORY_PATH ?? ''),\n serverDir: Schema.string().default(process.env.DSH_MEMORY_SERVER_DIR ?? ''),\n})\n\nconst packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))\n\n/** Harness home, resolved like the harness itself ($DSH_HOME, or ~/.dsh). */\nfunction dshHome(): string {\n const env = process.env.DSH_HOME?.trim()\n return env && env.length > 0 ? env : join(homedir(), '.dsh')\n}\n\n/** Absolute paths stay; empty/relative values resolve under the harness home. */\nfunction resolveUnderHome(value: string, segment: string): string {\n const v = value.trim()\n if (v.length === 0) return join(dshHome(), segment)\n return isAbsolute(v) ? v : join(dshHome(), v)\n}\n\n/** Copy the bundled dir into `target` when `key` is missing there. */\nfunction ensure(target: string, bundled: string, key: string): boolean {\n if (existsSync(join(target, key))) return false\n if (!existsSync(bundled)) return false\n mkdirSync(target, { recursive: true })\n cpSync(bundled, target, { recursive: true })\n return true\n}\n\n/** Copy one bundled file into `target` when missing (upgrades add files 0.1.1 → 0.1.2). */\nfunction ensureFile(target: string, bundled: string, file: string): boolean {\n const dest = join(target, file)\n if (existsSync(dest)) return false\n const src = join(bundled, file)\n if (!existsSync(src)) return false\n mkdirSync(target, { recursive: true })\n cpSync(src, dest)\n return true\n}\n\nexport function apply(ctx: Context, config: Config) {\n const serverDir = resolveUnderHome(config.serverDir, 'memory-vault-server')\n const memoryPath = resolveUnderHome(config.memoryPath, 'memory-vault')\n\n // Self-contained install: first boot copies the bundled server and vault\n // starter under the harness home when they are missing. Env-overridden\n // paths are respected (never overwritten, never copied over).\n if (ensure(serverDir, join(packageRoot, 'server'), 'server.py')) {\n console.log(`[memory-mcp] installed memory-vault-server -> ${serverDir}`)\n }\n if (ensure(memoryPath, join(packageRoot, 'vault'), 'type-registry.yaml')) {\n console.log(`[memory-mcp] installed vault starter -> ${memoryPath}`)\n }\n // launcher.mjs runs the server via uv or the pip-venv fallback; upgrades of\n // existing installs (server.py already present) still need the new files.\n const bundled = join(packageRoot, 'server')\n for (const file of ['launcher.mjs', 'requirements.txt']) {\n if (ensureFile(serverDir, bundled, file)) {\n console.log(`[memory-mcp] installed ${file} -> ${serverDir}`)\n }\n }\n if (!existsSync(join(serverDir, 'server.py'))) {\n console.warn(\n `[memory-mcp] memory-vault-server not found at ${serverDir} and not bundled — ` +\n 'set DSH_MEMORY_SERVER_DIR (or run `node scripts/bundle-assets.mjs` in a checkout)',\n )\n }\n}\n"],"mappings":";;;;;;AAOA,MAAa,OAAO;AAOpB,MAAa,SAAyB,OAAO,OAAO;CAClD,YAAY,OAAO,OAAO,CAAC,CAAC,QAAQ,QAAQ,IAAI,mBAAmB,EAAE;CACrE,WAAW,OAAO,OAAO,CAAC,CAAC,QAAQ,QAAQ,IAAI,yBAAyB,EAAE;AAC5E,CAAC;AAED,MAAM,cAAc,QAAQ,QAAQ,cAAc,YAAY,GAAG,CAAC,CAAC;;AAGnE,SAAS,UAAkB;CACzB,MAAM,MAAM,QAAQ,IAAI,UAAU,KAAK;CACvC,OAAO,OAAO,IAAI,SAAS,IAAI,MAAM,KAAK,QAAQ,GAAG,MAAM;AAC7D;;AAGA,SAAS,iBAAiB,OAAe,SAAyB;CAChE,MAAM,IAAI,MAAM,KAAK;CACrB,IAAI,EAAE,WAAW,GAAG,OAAO,KAAK,QAAQ,GAAG,OAAO;CAClD,OAAO,WAAW,CAAC,IAAI,IAAI,KAAK,QAAQ,GAAG,CAAC;AAC9C;;AAGA,SAAS,OAAO,QAAgB,SAAiB,KAAsB;CACrE,IAAI,WAAW,KAAK,QAAQ,GAAG,CAAC,GAAG,OAAO;CAC1C,IAAI,CAAC,WAAW,OAAO,GAAG,OAAO;CACjC,UAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;CACrC,OAAO,SAAS,QAAQ,EAAE,WAAW,KAAK,CAAC;CAC3C,OAAO;AACT;;AAGA,SAAS,WAAW,QAAgB,SAAiB,MAAuB;CAC1E,MAAM,OAAO,KAAK,QAAQ,IAAI;CAC9B,IAAI,WAAW,IAAI,GAAG,OAAO;CAC7B,MAAM,MAAM,KAAK,SAAS,IAAI;CAC9B,IAAI,CAAC,WAAW,GAAG,GAAG,OAAO;CAC7B,UAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;CACrC,OAAO,KAAK,IAAI;CAChB,OAAO;AACT;AAEA,SAAgB,MAAM,KAAc,QAAgB;CAClD,MAAM,YAAY,iBAAiB,OAAO,WAAW,qBAAqB;CAC1E,MAAM,aAAa,iBAAiB,OAAO,YAAY,cAAc;CAKrE,IAAI,OAAO,WAAW,KAAK,aAAa,QAAQ,GAAG,WAAW,GAC5D,QAAQ,IAAI,iDAAiD,WAAW;CAE1E,IAAI,OAAO,YAAY,KAAK,aAAa,OAAO,GAAG,oBAAoB,GACrE,QAAQ,IAAI,2CAA2C,YAAY;CAIrE,MAAM,UAAU,KAAK,aAAa,QAAQ;CAC1C,KAAK,MAAM,QAAQ,CAAC,gBAAgB,kBAAkB,GACpD,IAAI,WAAW,WAAW,SAAS,IAAI,GACrC,QAAQ,IAAI,0BAA0B,KAAK,MAAM,WAAW;CAGhE,IAAI,CAAC,WAAW,KAAK,WAAW,WAAW,CAAC,GAC1C,QAAQ,KACN,iDAAiD,UAAU,uGAE7D;AAEJ"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luisarg/memory-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "DSH MCP client for the memory vault",
6
6
  "repository": {
@@ -30,14 +30,14 @@
30
30
  "build": "tsdown",
31
31
  "dev": "tsdown --watch",
32
32
  "test": "vitest run --passWithNoTests",
33
- "prepare": "tsdown && node ../../scripts/bundle-assets.mjs"
33
+ "prepare": "tsdown"
34
34
  },
35
35
  "dependencies": {
36
- "@deepseek-ai/cordis": "4.0.1",
37
- "@deepseek-ai/schemastery": "3.18.1"
36
+ "@deepseek-ai/cordis": "4.0.2",
37
+ "@deepseek-ai/schemastery": "3.18.2"
38
38
  },
39
39
  "devDependencies": {
40
- "typescript": "^5.9.2",
41
- "tsdown": "^0.15.6"
40
+ "typescript": "^7.0.2",
41
+ "tsdown": "^0.23.0"
42
42
  }
43
43
  }
@@ -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/server.py CHANGED
@@ -1,8 +1,13 @@
1
1
  """MCP server exposing memory store tools via the Model Context Protocol.
2
2
 
3
- Eight tools (per openspec/specs/memory-mcp-server/spec.md):
3
+ Ten tools (per openspec/specs/memory-mcp-server/spec.md):
4
4
  search_memory, store_decision, store_fact, store_learning,
5
- store_convention, store_profile, export_memories, get_profile, ping.
5
+ store_convention, store_profile, store_source, export_memories,
6
+ get_profile, ping.
7
+
8
+ Note: `type-registry.yaml` also declares `context` and `idea`. They are valid
9
+ `entries.entry_type` values and legal filters here, but no `store_*` tool
10
+ creates them — nothing writes them today.
6
11
 
7
12
  All reads are explicit (no background polling). Server validates storage
8
13
  accessibility at startup.
@@ -190,7 +195,13 @@ def _tool_definitions() -> list[Tool]:
190
195
  return [
191
196
  Tool(
192
197
  name="search_memory",
193
- description="Search memory entries across projects. Omit project to search all projects.",
198
+ description=(
199
+ "Search memory entries across projects. Omit project to search all projects. "
200
+ "The query is tokenized and OR-matched (any term hits), ranked by relevance. "
201
+ "Tags are OR-matched too: passing ['ci','release'] returns entries with either tag. "
202
+ "Filters narrow the result set; at most 50 entries are returned. "
203
+ "Source entries are excluded unless entry_type='source' with no other filter."
204
+ ),
194
205
  inputSchema={
195
206
  "type": "object",
196
207
  "properties": {
@@ -203,78 +214,143 @@ def _tool_definitions() -> list[Tool]:
203
214
  ),
204
215
  Tool(
205
216
  name="store_decision",
206
- description="Store a decision entry",
217
+ description=(
218
+ "Store a decision: an architectural or design choice that was made and why. "
219
+ "Deduplicated by content hash, so re-storing the same text updates instead of "
220
+ "duplicating."
221
+ ),
207
222
  inputSchema={
208
223
  "type": "object",
209
224
  "required": ["project", "content"],
210
225
  "properties": {
211
226
  "project": {"type": "string"},
212
- "content": {"type": "string"},
213
- "description": {"type": "string"},
214
- "tags": {"type": "array", "items": {"type": "string"}},
227
+ "content": {
228
+ "type": "string",
229
+ "description": "One paragraph, no headings or bullet lists.",
230
+ },
231
+ "description": {
232
+ "type": "string",
233
+ "description": "One-sentence queryable summary; derived from content if omitted.",
234
+ },
235
+ "tags": {
236
+ "type": "array",
237
+ "items": {"type": "string"},
238
+ "description": "Lowercase-kebab tags, e.g. architecture/python/testing.",
239
+ },
215
240
  "openspec_change_id": {"type": "string"},
216
241
  },
217
242
  },
218
243
  ),
219
244
  Tool(
220
245
  name="store_fact",
221
- description="Store a fact entry",
246
+ description=(
247
+ "Store a fact: a stable, verifiable statement about the project (version, "
248
+ "constraint, path, endpoint). Deduplicated by content hash. Prefer one atomic "
249
+ "fact per call over a bundle of several."
250
+ ),
222
251
  inputSchema={
223
252
  "type": "object",
224
253
  "required": ["project", "content"],
225
254
  "properties": {
226
255
  "project": {"type": "string"},
227
- "content": {"type": "string"},
228
- "description": {"type": "string"},
229
- "tags": {"type": "array", "items": {"type": "string"}},
256
+ "content": {
257
+ "type": "string",
258
+ "description": "One paragraph, no headings or bullet lists.",
259
+ },
260
+ "description": {
261
+ "type": "string",
262
+ "description": "One-sentence queryable summary; derived from content if omitted.",
263
+ },
264
+ "tags": {
265
+ "type": "array",
266
+ "items": {"type": "string"},
267
+ "description": "Lowercase-kebab tags, e.g. architecture/python/testing.",
268
+ },
230
269
  "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
231
270
  },
232
271
  },
233
272
  ),
234
273
  Tool(
235
274
  name="store_learning",
236
- description="Store a learning entry",
275
+ description=(
276
+ "Store a learning: a non-obvious lesson, debugging insight, or solution found — "
277
+ "something that cost effort and would otherwise be rediscovered. Deduplicated by "
278
+ "content hash."
279
+ ),
237
280
  inputSchema={
238
281
  "type": "object",
239
282
  "required": ["project", "content"],
240
283
  "properties": {
241
284
  "project": {"type": "string"},
242
- "content": {"type": "string"},
243
- "description": {"type": "string"},
244
- "tags": {"type": "array", "items": {"type": "string"}},
285
+ "content": {
286
+ "type": "string",
287
+ "description": "One paragraph, no headings or bullet lists.",
288
+ },
289
+ "description": {
290
+ "type": "string",
291
+ "description": "One-sentence queryable summary; derived from content if omitted.",
292
+ },
293
+ "tags": {
294
+ "type": "array",
295
+ "items": {"type": "string"},
296
+ "description": "Lowercase-kebab tags, e.g. architecture/python/testing.",
297
+ },
245
298
  },
246
299
  },
247
300
  ),
248
301
  Tool(
249
302
  name="store_convention",
250
- description="Store a convention entry",
303
+ description=(
304
+ "Store a convention: an agreed style rule, naming pattern, or coding standard. "
305
+ "Deduplicated by content hash."
306
+ ),
251
307
  inputSchema={
252
308
  "type": "object",
253
309
  "required": ["project", "content"],
254
310
  "properties": {
255
311
  "project": {"type": "string"},
256
- "content": {"type": "string"},
257
- "description": {"type": "string"},
258
- "tags": {"type": "array", "items": {"type": "string"}},
312
+ "content": {
313
+ "type": "string",
314
+ "description": "One paragraph, no headings or bullet lists.",
315
+ },
316
+ "description": {
317
+ "type": "string",
318
+ "description": "One-sentence queryable summary; derived from content if omitted.",
319
+ },
320
+ "tags": {
321
+ "type": "array",
322
+ "items": {"type": "string"},
323
+ "description": "Lowercase-kebab tags, e.g. architecture/python/testing.",
324
+ },
259
325
  },
260
326
  },
261
327
  ),
262
328
  Tool(
263
329
  name="store_profile",
264
- description="Store or update a user profile entry for a project",
330
+ description=(
331
+ "Replace the tech profile for a project. One profile per project: the content "
332
+ "overwrites the previous one, it does not append. Pass the complete profile text."
333
+ ),
265
334
  inputSchema={
266
335
  "type": "object",
267
336
  "required": ["project", "content"],
268
337
  "properties": {
269
338
  "project": {"type": "string"},
270
- "content": {"type": "string"},
339
+ "content": {
340
+ "type": "string",
341
+ "description": "The full profile — replaces the stored one.",
342
+ },
271
343
  "tags": {"type": "array", "items": {"type": "string"}},
272
344
  },
273
345
  },
274
346
  ),
275
347
  Tool(
276
348
  name="store_source",
277
- description="Store a source reference (article, transcript, PDF, video, link)",
349
+ description=(
350
+ "Store an external source reference (article, transcript, PDF, video, link) under "
351
+ "raw/. Immutable: a later store with the same URL returns the existing entry, and "
352
+ "reusing a title slug with different content is rejected."
353
+ ),
278
354
  inputSchema={
279
355
  "type": "object",
280
356
  "required": ["url", "title", "description", "source_kind"],
@@ -294,7 +370,10 @@ def _tool_definitions() -> list[Tool]:
294
370
  ),
295
371
  Tool(
296
372
  name="export_memories",
297
- description="Export all memory entries for a project (no limit)",
373
+ description=(
374
+ "Export every stored entry for one project, newest first, with no result limit. "
375
+ "Use for a full project dump, not for lookups — search_memory is cheaper."
376
+ ),
298
377
  inputSchema={
299
378
  "type": "object",
300
379
  "required": ["project"],
@@ -306,7 +385,11 @@ def _tool_definitions() -> list[Tool]:
306
385
  ),
307
386
  Tool(
308
387
  name="get_profile",
309
- description="Retrieve the global tech profile for a project",
388
+ description=(
389
+ "Retrieve the stored tech profile for a project. Returns profile entries by "
390
+ "default; pass entry_type to query another type instead (then it is a "
391
+ "most-recent-first lookup capped at 10)."
392
+ ),
310
393
  inputSchema={
311
394
  "type": "object",
312
395
  "required": ["project"],
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")
@@ -767,12 +770,15 @@ class MemoryStore:
767
770
  project: str,
768
771
  entry_type: str | None = None,
769
772
  ) -> list[dict]:
770
- """Retrieve profile entries for a project."""
771
- sql = "SELECT * FROM entries WHERE project = ?"
772
- params: list = [project]
773
- if entry_type:
774
- sql += " AND entry_type = ?"
775
- params.append(entry_type)
773
+ """Retrieve the profile for a project.
774
+
775
+ Defaults to ``profile`` entries: the tool is named get_profile, so an
776
+ unfiltered call returning the 10 most recent rows of any type was a
777
+ silent wrong answer. Pass ``entry_type`` to use it as a recency query.
778
+ """
779
+ entry_type = entry_type or "profile"
780
+ sql = "SELECT * FROM entries WHERE project = ? AND entry_type = ?"
781
+ params: list = [project, entry_type]
776
782
  sql += " ORDER BY updated_at DESC LIMIT 10"
777
783
  rows = self.db.execute(sql, params).fetchall()
778
784
  return [dict(r) for r in rows]
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """Self-check: get_profile answers with the profile, not with recent noise.
3
+
4
+ Regression guard for the bug where an unfiltered get_profile returned the 10
5
+ most recently updated rows of ANY type, so a caller asking for the profile of a
6
+ busy project got unrelated decisions and facts.
7
+
8
+ Run: python3 memory-vault-server/test_get_profile.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ SERVER_DIR = Path(__file__).resolve().parent
21
+ REPO_VAULT = SERVER_DIR.parent / "memory-vault"
22
+ sys.path.insert(0, str(SERVER_DIR))
23
+
24
+
25
+ def main() -> int:
26
+ with tempfile.TemporaryDirectory() as tmp:
27
+ # store.py resolves the type registry from MEMORY_PATH at import time,
28
+ # so seed the throwaway vault before importing it.
29
+ vault = Path(tmp)
30
+ (vault / "projects").mkdir()
31
+ shutil.copy(REPO_VAULT / "type-registry.yaml", vault / "type-registry.yaml")
32
+ os.environ["MEMORY_PATH"] = str(vault)
33
+ import store as store_mod
34
+
35
+ s = store_mod.MemoryStore(storage_path=vault)
36
+ s.initialize()
37
+
38
+ s.upsert_profile(project="proj", content="PROFILE: python + sqlite")
39
+ # Written afterwards, so these outrank the profile in updated_at:
40
+ # the old code returned them and called it a profile.
41
+ s.upsert_entry("decision", "proj", "DECISION: use sqlite for storage")
42
+ s.upsert_entry("fact", "proj", "FACT: python 3.11 is required")
43
+
44
+ got = s.get_profile(project="proj")
45
+ assert len(got) == 1, f"expected only the profile row, got {len(got)}: {got}"
46
+ assert got[0]["entry_type"] == "profile", got[0]["entry_type"]
47
+ assert "PROFILE" in got[0]["content"], got[0]["content"]
48
+
49
+ # entry_type=None must behave exactly like the default.
50
+ assert s.get_profile(project="proj", entry_type=None) == got
51
+
52
+ # Explicit entry_type still works (and is now a plain recency lookup).
53
+ facts = s.get_profile(project="proj", entry_type="fact")
54
+ assert len(facts) == 1 and facts[0]["entry_type"] == "fact", facts
55
+
56
+ # A project with no profile yields nothing rather than unrelated rows.
57
+ s.upsert_entry("fact", "other", "FACT: unrelated project")
58
+ assert s.get_profile(project="other") == [], "profile leaked across projects"
59
+
60
+ # The MCP tool handler agrees (binds the tool default to the store fix).
61
+ # Needs the pinned `mcp` version from requirements.txt; skip when the
62
+ # ambient one is older instead of failing the whole check.
63
+ try:
64
+ import server
65
+
66
+ found = json.loads(server.handle_get_profile(s, {"project": "proj"}))
67
+ assert len(found) == 1 and found[0]["entry_type"] == "profile", found
68
+ except ImportError as exc:
69
+ print(f"skip: MCP handler assertion ({exc})")
70
+
71
+ s._db.close()
72
+
73
+ print("ok: get_profile returns the profile, not the most recent rows")
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())