sqlite-graph-memory 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/CHANGELOG.md +22 -0
  2. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/PKG-INFO +13 -3
  3. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/README.md +12 -2
  4. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/bench/README.md +1 -1
  5. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/eval/build_gold.py +6 -3
  6. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/examples/README.md +1 -1
  7. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/pyproject.toml +1 -1
  8. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/__init__.py +1 -1
  9. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/brain_ask.py +3 -0
  10. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/index_notes.py +6 -6
  11. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/mcp_server.py +0 -1
  12. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_eval.py +19 -0
  13. sqlite_graph_memory-0.3.3/tests/test_feedback_link.py +29 -0
  14. sqlite_graph_memory-0.3.3/tests/test_index_notes_dotfiles.py +81 -0
  15. sqlite_graph_memory-0.3.3/tests/test_mcp_script_mode.py +59 -0
  16. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/.gitignore +0 -0
  17. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/CITATION.cff +0 -0
  18. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/LICENSE +0 -0
  19. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/bench/requirements.txt +0 -0
  20. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/docs/RELEASING.md +0 -0
  21. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/docs/bitemporal.md +0 -0
  22. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/eval/run_eval.py +0 -0
  23. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/examples/claude-code-stop-hook.json +0 -0
  24. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/examples/mcp-config.json +0 -0
  25. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/requirements.txt +0 -0
  26. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/ppr.py +0 -0
  27. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/schema.sql +0 -0
  28. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/turnstate_hook.py +0 -0
  29. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/turnstate_show.py +0 -0
  30. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/src/sqlite_graph_memory/vault_stats.py +0 -0
  31. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_bench.py +0 -0
  32. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_brain_ask.py +0 -0
  33. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_index_notes.py +0 -0
  34. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_llms_txt.py +0 -0
  35. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_mcp_server.py +0 -0
  36. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_optional_extra.py +0 -0
  37. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_ppr.py +0 -0
  38. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_star_prompt.py +0 -0
  39. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_turnstate_hook.py +0 -0
  40. {sqlite_graph_memory-0.3.2 → sqlite_graph_memory-0.3.3}/tests/test_vault_stats.py +0 -0
@@ -4,6 +4,28 @@ What shipped, in plain words. Entries below v0.1.3 were written on 2026-09-05 fr
4
4
  release notes that already existed — the file itself did not exist until then, so those three are
5
5
  recorded after the fact rather than backdated to look contemporaneous.
6
6
 
7
+ ## v0.3.3 — 2026-10-26
8
+
9
+ **Hidden note files are skipped like hidden folders.** A note whose filename starts with a
10
+ dot, such as `.ideas.md` or `.draft.md`, used to be indexed when it sat in the vault root or a
11
+ visible folder, while the same file inside `.drafts/` was skipped. Now both are skipped, so
12
+ after re-indexing those notes stop showing up in answers. `BRAIN_INDEX_HIDDEN=1` brings back
13
+ the old behaviour and indexes hidden files and folders alike. `eval/build_gold.py` now walks the
14
+ vault with the indexer's own filter, so it follows the same rule and the same variable instead
15
+ of keeping its own copy (issue #25).
16
+
17
+ **The MCP server works from a source checkout and finds your index where you launch it**
18
+ (issue #24, reported by @Kaap10). `sgm-mcp` used to run the recall child with its working
19
+ directory set to the package folder, so a relative `BRAIN_INDEX_DIR=index` resolved inside
20
+ site-packages and your own index was never found; from a plain clone without `pip install -e`,
21
+ `python src/sqlite_graph_memory/mcp_server.py --test` failed with ModuleNotFoundError. Recall now
22
+ runs in the server's own working directory, and CI reproduces the user's launch: installed wheel,
23
+ relative index, started from another directory.
24
+
25
+ **A three-question feedback form.** "What were you trying to do, what broke, which machine": an
26
+ issue template, a README section, and a link printed next to the missing-index error, because
27
+ this project collects no telemetry and that form is how we learn what is missing.
28
+
7
29
  ## v0.3.2 — 2026-10-24
8
30
 
9
31
  **The PageRank lane the README credits is now in the package** (#51). `ppr.py` runs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sqlite-graph-memory
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Associative memory for LLM agents: Graph RAG over a folder of markdown notes, in one SQLite file, with no server to run.
5
5
  Project-URL: Homepage, https://github.com/tonydzi/sqlite-graph-memory
6
6
  Project-URL: Repository, https://github.com/tonydzi/sqlite-graph-memory
@@ -126,7 +126,7 @@ points, the `[[wikilinks]]` you already wrote by hand are the graph, and a cross
126
126
  reranks what the hop dragged in.
127
127
  SQLite is the only database ([`schema.sql`](src/sqlite_graph_memory/schema.sql)).
128
128
 
129
- Status: **pilot**, and the word is load-bearing: it runs daily in one real setup (a ~100k-note
129
+ Status: **pilot**, and the word is load-bearing: it runs daily in one real setup (an index of ~28k curated notes, ~93k chunks, rebuilt nightly from a ~270k-file
130
130
  Obsidian vault driven by Claude Code), but it is deliberately minimal and makes no attempt
131
131
  to be general — see [What's intentionally missing](#whats-intentionally-missing), which is
132
132
  where the uneven test coverage is described.
@@ -252,7 +252,7 @@ present, and CPU works fine for small corpora.
252
252
 
253
253
  ### What `--ab` looks like
254
254
 
255
- Output of `brain_ask.py` with `--ab`, on a toy 4-note corpus (real runs use a ~100k-note vault):
255
+ Output of `brain_ask.py` with `--ab`, on a toy 4-note corpus (real runs use an index of ~28k notes / ~93k chunks):
256
256
 
257
257
  ```
258
258
  A/B RECALL: how should agent memory work
@@ -491,6 +491,16 @@ Questions, war stories, or you want to run this on your own fleet:
491
491
  - 🌐 [palo-alto.ai](https://palo-alto.ai) · [Palo Alto AI Research Lab](https://github.com/tonydzi)
492
492
  - 🧪 **Engineers: want to test-drive this setup?** Message me — I hand out free starter seeds to engineers who test and report back.
493
493
 
494
+ ## Tell us what is missing
495
+
496
+ You tried this on your own notes and something was missing or broke. That is the most useful
497
+ thing anyone can tell us.
498
+
499
+ - **You started and it did not work:** [open the 3-question form](https://github.com/tonydzi/sqlite-graph-memory/issues/new?template=feature_request.yml) (what you tried, what broke, which machine).
500
+ - **Not sure it is a bug, or just an idea:** [Discussions → Ideas](https://github.com/tonydzi/sqlite-graph-memory/discussions/categories/ideas).
501
+
502
+ Nothing in this package phones home. There is no telemetry, so we only know what you write to us.
503
+
494
504
  ## Contributors welcome — and there is a queue
495
505
 
496
506
  Issues labelled [`accepted`](https://github.com/tonydzi/sqlite-graph-memory/issues?q=is%3Aissue+is%3Aopen+label%3Aaccepted)
@@ -75,7 +75,7 @@ points, the `[[wikilinks]]` you already wrote by hand are the graph, and a cross
75
75
  reranks what the hop dragged in.
76
76
  SQLite is the only database ([`schema.sql`](src/sqlite_graph_memory/schema.sql)).
77
77
 
78
- Status: **pilot**, and the word is load-bearing: it runs daily in one real setup (a ~100k-note
78
+ Status: **pilot**, and the word is load-bearing: it runs daily in one real setup (an index of ~28k curated notes, ~93k chunks, rebuilt nightly from a ~270k-file
79
79
  Obsidian vault driven by Claude Code), but it is deliberately minimal and makes no attempt
80
80
  to be general — see [What's intentionally missing](#whats-intentionally-missing), which is
81
81
  where the uneven test coverage is described.
@@ -201,7 +201,7 @@ present, and CPU works fine for small corpora.
201
201
 
202
202
  ### What `--ab` looks like
203
203
 
204
- Output of `brain_ask.py` with `--ab`, on a toy 4-note corpus (real runs use a ~100k-note vault):
204
+ Output of `brain_ask.py` with `--ab`, on a toy 4-note corpus (real runs use an index of ~28k notes / ~93k chunks):
205
205
 
206
206
  ```
207
207
  A/B RECALL: how should agent memory work
@@ -440,6 +440,16 @@ Questions, war stories, or you want to run this on your own fleet:
440
440
  - 🌐 [palo-alto.ai](https://palo-alto.ai) · [Palo Alto AI Research Lab](https://github.com/tonydzi)
441
441
  - 🧪 **Engineers: want to test-drive this setup?** Message me — I hand out free starter seeds to engineers who test and report back.
442
442
 
443
+ ## Tell us what is missing
444
+
445
+ You tried this on your own notes and something was missing or broke. That is the most useful
446
+ thing anyone can tell us.
447
+
448
+ - **You started and it did not work:** [open the 3-question form](https://github.com/tonydzi/sqlite-graph-memory/issues/new?template=feature_request.yml) (what you tried, what broke, which machine).
449
+ - **Not sure it is a bug, or just an idea:** [Discussions → Ideas](https://github.com/tonydzi/sqlite-graph-memory/discussions/categories/ideas).
450
+
451
+ Nothing in this package phones home. There is no telemetry, so we only know what you write to us.
452
+
443
453
  ## Contributors welcome — and there is a queue
444
454
 
445
455
  Issues labelled [`accepted`](https://github.com/tonydzi/sqlite-graph-memory/issues?q=is%3Aissue+is%3Aopen+label%3Aaccepted)
@@ -140,7 +140,7 @@ has seen.
140
140
 
141
141
  ## Limits, stated up front
142
142
 
143
- - 202 notes is small. Dense retrieval at this size is easier than in a 100k-note vault,
143
+ - 202 notes is small. Dense retrieval at this size is easier than in the ~28k-note production index,
144
144
  so the bench measures failure modes, and it does not show how a system scales.
145
145
  - The text is English only and comes from templates. The sentences are cleaner than real
146
146
  notes, and that flatters extractive systems.
@@ -40,6 +40,9 @@ import sys
40
40
  from collections import Counter
41
41
  from pathlib import Path
42
42
 
43
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
44
+ from sqlite_graph_memory.index_notes import iter_notes # noqa: E402
45
+
43
46
  WIKILINK_RX = re.compile(r'\[\[([^\]\|#]+)(?:[|#][^\]]*)?\]\]')
44
47
  CYRILLIC_RX = re.compile(r'[а-яё]', re.I)
45
48
 
@@ -121,10 +124,10 @@ def read_notes(notes_dir, stems, folders=None):
121
124
  """Every indexed note, parsed into the fields the four classes need."""
122
125
  root = Path(notes_dir)
123
126
  paths = []
124
- for p in sorted(root.rglob('*.md')):
127
+ # The indexer's own walk: hidden files and dirs, conflict copies and BRAIN_INDEX_HIDDEN
128
+ # are decided in one place, so a gold answer is never a note the indexer would skip.
129
+ for p in iter_notes(root):
125
130
  rel = p.relative_to(root)
126
- if any(part.startswith('.') for part in rel.parts[:-1]): continue
127
- if 'sync-conflict' in p.name: continue
128
131
  if folders and (not rel.parts[:-1] or rel.parts[0] not in folders): continue
129
132
  if p.stem.lower() not in stems: continue
130
133
  paths.append(p)
@@ -109,7 +109,7 @@ All components share standard configuration variables:
109
109
  | `BRAIN_INDEX_DIR` | `./index` | Directory where `index_notes.py` writes and `brain_ask.py` reads `_brain_e5.npy` and metadata. |
110
110
  | `TURNSTATE_DB` | `./turnstate.db` | SQLite database holding per-turn ledger (`turns`) and A/B telemetry (`ab_recall`). |
111
111
  | `BRAIN_ANSWER_OUT` | `./_brain_answer.txt` | File where the formatted context bundle or answer is mirrored. |
112
- | `BRAIN_INDEX_HIDDEN` | `0` | If `1`, indexes hidden dot-directories too (by default skipped). |
112
+ | `BRAIN_INDEX_HIDDEN` | `0` | If `1`, indexes hidden dot-files (`.draft.md`) and dot-directories too (by default both are skipped). |
113
113
  | `BRAIN_AB_JSON` | `None` | If set, `--ab` mode writes a structured JSON diff to this file path. |
114
114
  | `AB_SOURCE` | `work` | Telemetry label written to `ab_recall.source` (`work`, `eval`, etc.). |
115
115
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlite-graph-memory"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Associative memory for LLM agents: Graph RAG over a folder of markdown notes, in one SQLite file, with no server to run."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -21,6 +21,6 @@ the indexer and the reranker will tell you what is missing instead of failing
21
21
  on an ``ImportError`` at startup.
22
22
  """
23
23
 
24
- __version__ = "0.3.2"
24
+ __version__ = "0.3.3"
25
25
 
26
26
  __all__ = ["__version__"]
@@ -31,6 +31,7 @@ for _s in (sys.stdout, sys.stderr):
31
31
  try: _s.reconfigure(encoding='utf-8')
32
32
  except Exception: pass
33
33
 
34
+ FEEDBACK_URL = 'https://github.com/tonydzi/sqlite-graph-memory/discussions/categories/ideas'
34
35
  INDEX_DIR = Path(os.getenv('BRAIN_INDEX_DIR', 'index'))
35
36
  AB_DB = Path(os.getenv('TURNSTATE_DB', 'turnstate.db'))
36
37
  EMB = INDEX_DIR / '_brain_e5.npy'
@@ -299,6 +300,8 @@ def main():
299
300
  query = ' '.join(a for a in args if a not in flags)
300
301
  if not query or not EMB.exists():
301
302
  sys.stderr.write('need an embedding index (%s) and a query — run index_notes.py first\n' % EMB)
303
+ if query and not EMB.exists():
304
+ print('Stuck? Tell us what you were trying to do: %s' % FEEDBACK_URL, file=sys.stderr)
302
305
  raise SystemExit(1)
303
306
 
304
307
  dev = pick_device()
@@ -12,9 +12,9 @@ USAGE:
12
12
  python index_notes.py <notes_dir>
13
13
  Config (env, optional):
14
14
  BRAIN_INDEX_DIR output dir (default: ./index)
15
- BRAIN_INDEX_HIDDEN 1 = also index dot-directories (default: skip them,
16
- so .stversions/.obsidian/.git and Syncthing conflict
17
- copies never enter the index)
15
+ BRAIN_INDEX_HIDDEN 1 = also index dot-files and dot-directories (default:
16
+ skip them, so .draft.md, .stversions/.obsidian/.git and
17
+ Syncthing conflict copies never enter the index)
18
18
  """
19
19
  import os, re, sys, pickle
20
20
  from pathlib import Path
@@ -77,15 +77,15 @@ CONFLICT_MARKER = 'sync-conflict'
77
77
  def iter_notes(notes_dir):
78
78
  """Files that count as a note under ``notes_dir``.
79
79
 
80
- Skips dot-directories and Syncthing conflict copies. Set ``BRAIN_INDEX_HIDDEN=1``
81
- to index hidden folders too — someone will legitimately keep notes in one.
80
+ Skips dot-directories, dot-files (``.draft.md``) and Syncthing conflict copies.
81
+ Set ``BRAIN_INDEX_HIDDEN=1`` to index hidden paths too — someone will legitimately keep notes in one.
82
82
  """
83
83
  root = Path(notes_dir)
84
84
  include_hidden = os.getenv('BRAIN_INDEX_HIDDEN', '') not in ('', '0', 'false', 'no')
85
85
  out = []
86
86
  for p in root.rglob('*.md'):
87
87
  rel = p.relative_to(root)
88
- if not include_hidden and any(part.startswith('.') for part in rel.parts[:-1]):
88
+ if not include_hidden and any(part.startswith('.') for part in rel.parts):
89
89
  continue
90
90
  if CONFLICT_MARKER in p.name:
91
91
  continue
@@ -131,7 +131,6 @@ def run_recall(query: str, mode: str = "associative") -> tuple[str, bool]:
131
131
  call_env["PYTHONPATH"] = f"{src_dir}{os.pathsep}{existing_pp}" if existing_pp else src_dir
132
132
  res = subprocess.run(
133
133
  cmd,
134
- cwd=str(ROOT_DIR),
135
134
  env=call_env,
136
135
  capture_output=True,
137
136
  text=True,
@@ -158,6 +158,25 @@ def test_a_bridge_question_records_how_many_links_were_cut(tmp_path):
158
158
  assert bridge['gold_truncated'] == 4
159
159
 
160
160
 
161
+ def test_gold_skips_hidden_notes_exactly_like_the_indexer(tmp_path, monkeypatch):
162
+ """build_gold walks the vault with the indexer's own filter (issue #25).
163
+
164
+ A root-level `.draft.md` is never indexed, so it must never become a question either,
165
+ even when a stale index still lists its stem. `BRAIN_INDEX_HIDDEN=1` lets it into the
166
+ index, so it must let it into the gold set too: one rule, one place.
167
+ """
168
+ vault = _vault(tmp_path)
169
+ (vault / '.draft.md').write_text('---\ntitle: "Hidden draft"\n---\n\n' + PARA + '\n',
170
+ encoding='utf-8')
171
+ stems = INDEXED | {'.draft'}
172
+
173
+ monkeypatch.delenv('BRAIN_INDEX_HIDDEN', raising=False)
174
+ assert '.draft' not in {n['stem'] for n in read_notes(vault, stems)}
175
+
176
+ monkeypatch.setenv('BRAIN_INDEX_HIDDEN', '1')
177
+ assert '.draft' in {n['stem'] for n in read_notes(vault, stems)}
178
+
179
+
161
180
  def test_a_filename_living_in_two_folders_is_reported_as_ambiguous(tmp_path):
162
181
  """Found by the review panel: `[[foo]]` names both files, so neither can be gold."""
163
182
  vault = _vault(tmp_path)
@@ -0,0 +1,29 @@
1
+ """A missing index must say where to tell us about it (stderr), without needing a model."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
8
+
9
+ from sqlite_graph_memory import brain_ask # noqa: E402
10
+
11
+
12
+ def test_missing_index_prints_feedback_link(tmp_path, monkeypatch, capsys):
13
+ monkeypatch.setattr(brain_ask, "EMB", tmp_path / "nope.npy")
14
+ monkeypatch.setattr(sys, "argv", ["sgm-ask", "anything"])
15
+ with pytest.raises(SystemExit) as exc:
16
+ brain_ask.main()
17
+ assert exc.value.code == 1
18
+ out = capsys.readouterr()
19
+ assert "need an embedding index" in out.err
20
+ assert brain_ask.FEEDBACK_URL in out.err
21
+
22
+
23
+ def test_empty_query_does_not_nag(tmp_path, monkeypatch, capsys):
24
+ monkeypatch.setattr(brain_ask, "EMB", tmp_path / "nope.npy")
25
+ monkeypatch.setattr(sys, "argv", ["sgm-ask"])
26
+ with pytest.raises(SystemExit) as exc:
27
+ brain_ask.main()
28
+ assert exc.value.code == 1
29
+ assert brain_ask.FEEDBACK_URL not in capsys.readouterr().err
@@ -0,0 +1,81 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Dot-FILES are skipped the same way dot-DIRECTORIES are (issue #25).
3
+
4
+ `iter_notes` filtered hidden *directories* by checking `rel.parts[:-1]` — every
5
+ path component except the last. The last component is the filename, so a note
6
+ called `.draft.md` or `.trash.md` sitting in the vault root (or in any visible
7
+ folder) was indexed as if it were a real note, while the same file one level
8
+ down inside `.drafts/` was correctly skipped. Reproduced 2026-10-13 with
9
+ `.secret.md` next to `ok.md`: `iter_notes()` returned both.
10
+
11
+ RED-FIRST: restore `rel.parts[:-1]` on the filter line and
12
+ `test_dot_file_in_root_is_skipped` fails with `.draft.md` in the result.
13
+
14
+ Both halves are pinned on purpose — the directory case already worked and must
15
+ keep working, the file case is the fix — and `BRAIN_INDEX_HIDDEN=1` must still
16
+ let both through, because someone legitimately keeps notes in a hidden folder.
17
+
18
+ No model download, no network: pure file work.
19
+ """
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
24
+ from sqlite_graph_memory.index_notes import iter_notes # noqa: E402
25
+
26
+
27
+ def _vault(tmp_path: Path) -> Path:
28
+ (tmp_path / "ok.md").write_text("visible note", encoding="utf-8")
29
+ (tmp_path / ".draft.md").write_text("hidden file in root", encoding="utf-8")
30
+ (tmp_path / "sub").mkdir()
31
+ (tmp_path / "sub" / ".trash.md").write_text("hidden file in visible dir", encoding="utf-8")
32
+ (tmp_path / ".drafts").mkdir()
33
+ (tmp_path / ".drafts" / "inside.md").write_text("visible name, hidden dir", encoding="utf-8")
34
+ return tmp_path
35
+
36
+
37
+ def _names(root: Path) -> set[str]:
38
+ # as_posix(): the assertions spell paths with "/". str() gives a backslash on
39
+ # Windows, so "sub/.trash.md" would never match and the skip test would pass
40
+ # for the wrong reason.
41
+ return {p.relative_to(root).as_posix() for p in iter_notes(root)}
42
+
43
+
44
+ def test_dot_file_in_root_is_skipped(tmp_path, monkeypatch):
45
+ monkeypatch.delenv("BRAIN_INDEX_HIDDEN", raising=False)
46
+ assert ".draft.md" not in _names(_vault(tmp_path))
47
+
48
+
49
+ def test_dot_file_in_visible_dir_is_skipped(tmp_path, monkeypatch):
50
+ monkeypatch.delenv("BRAIN_INDEX_HIDDEN", raising=False)
51
+ assert "sub/.trash.md" not in _names(_vault(tmp_path))
52
+
53
+
54
+ def test_dot_dir_still_skipped_and_visible_note_still_kept(tmp_path, monkeypatch):
55
+ """The pre-existing behaviour must survive the fix."""
56
+ monkeypatch.delenv("BRAIN_INDEX_HIDDEN", raising=False)
57
+ names = _names(_vault(tmp_path))
58
+ assert ".drafts/inside.md" not in names
59
+ assert "ok.md" in names
60
+
61
+
62
+ def test_hidden_flag_admits_dot_files_and_dot_dirs(tmp_path, monkeypatch):
63
+ """Opt-in must cover both kinds of hidden path, not just directories."""
64
+ monkeypatch.setenv("BRAIN_INDEX_HIDDEN", "1")
65
+ names = _names(_vault(tmp_path))
66
+ assert {"ok.md", ".draft.md", "sub/.trash.md", ".drafts/inside.md"} <= names
67
+
68
+
69
+ def test_vault_root_that_is_itself_a_dot_directory_is_indexed(tmp_path, monkeypatch):
70
+ """Only path components BELOW the vault root decide what is hidden.
71
+
72
+ A vault kept at ``~/.vault`` or ``~/.notes`` is ordinary. A filter that checks
73
+ the absolute path (``p.parts``) instead of the relative one (``rel.parts``)
74
+ sees ``.vault`` in every path and silently returns nothing.
75
+ """
76
+ monkeypatch.delenv("BRAIN_INDEX_HIDDEN", raising=False)
77
+ root = tmp_path / ".vault"
78
+ root.mkdir()
79
+ names = _names(_vault(root))
80
+ assert "ok.md" in names
81
+ assert ".draft.md" not in names and ".drafts/inside.md" not in names
@@ -0,0 +1,59 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Script-mode and working-directory behaviour of the MCP server.
3
+
4
+ Kept in its own file so it does not collide with edits to test_mcp_server.py.
5
+ Runs offline: no model, no index, no network.
6
+ """
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ SRC = Path(__file__).resolve().parents[1] / "src"
14
+ sys.path.insert(0, str(SRC))
15
+ from sqlite_graph_memory import __version__ # noqa: E402
16
+ from sqlite_graph_memory import mcp_server # noqa: E402
17
+
18
+ SCRIPT = SRC / "sqlite_graph_memory" / "mcp_server.py"
19
+
20
+
21
+ def test_script_mode_starts_without_installed_package(tmp_path):
22
+ """`python src/sqlite_graph_memory/mcp_server.py` must not need the package on sys.path.
23
+
24
+ Python puts the script's own directory (not src/) on sys.path, so an
25
+ absolute `from sqlite_graph_memory import ...` at import time crashed with
26
+ ModuleNotFoundError before the server answered anything (issue #24).
27
+ `-S` drops site-packages so an installed or editable copy cannot mask it.
28
+ """
29
+ env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
30
+ req = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
31
+ res = subprocess.run(
32
+ [sys.executable, "-S", str(SCRIPT)],
33
+ input=json.dumps(req) + "\n",
34
+ capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=30,
35
+ )
36
+ assert res.returncode == 0, res.stderr
37
+ resp = json.loads(res.stdout.strip().splitlines()[-1])
38
+ assert resp["result"]["serverInfo"]["version"] == __version__
39
+
40
+
41
+ def test_recall_subprocess_inherits_server_cwd(tmp_path, monkeypatch):
42
+ """Relative BRAIN_INDEX_DIR / TURNSTATE_DB must resolve against the user's cwd.
43
+
44
+ On an installed wheel the package dir is site-packages/sqlite_graph_memory;
45
+ running brain_ask there made `BRAIN_INDEX_DIR=index` miss the user's ./index.
46
+ """
47
+ work = tmp_path / "work"
48
+ work.mkdir()
49
+ stub = tmp_path / "stub_brain_ask.py"
50
+ stub.write_text(
51
+ "import os, pathlib\n"
52
+ "pathlib.Path(os.environ['BRAIN_ANSWER_OUT']).write_text(os.getcwd(), encoding='utf-8')\n",
53
+ encoding="utf-8",
54
+ )
55
+ monkeypatch.setattr(mcp_server, "_brain_ask_cmd", lambda: [sys.executable, str(stub)])
56
+ monkeypatch.chdir(work)
57
+ text, is_error = mcp_server.run_recall("anything")
58
+ assert is_error is False, text
59
+ assert Path(text).resolve() == work.resolve()