memstack-skill-loader 4.18.0__tar.gz → 4.18.1__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 (73) hide show
  1. {memstack_skill_loader-4.18.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.18.1}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/pyproject.toml +51 -51
  3. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/__init__.py +3 -3
  4. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/memory_db.py +97 -52
  5. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/memory_migrations.py +852 -774
  6. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  7. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_memory_migrations.py +178 -22
  8. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/MANIFEST.in +0 -0
  9. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/README.md +0 -0
  10. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/setup.cfg +0 -0
  11. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/__main__.py +0 -0
  12. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/agent_runner.py +0 -0
  13. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/categories.py +0 -0
  14. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/compression.py +0 -0
  15. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/config.py +0 -0
  16. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/dashboard.html +0 -0
  17. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/dashboard.py +0 -0
  18. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/diary_ingest.py +0 -0
  19. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/goal.py +0 -0
  20. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/goal_engine.py +0 -0
  21. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/__init__.py +0 -0
  22. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
  23. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
  24. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
  25. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
  26. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/index_meta.py +0 -0
  27. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/indexer.py +0 -0
  28. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/license.py +0 -0
  29. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/memory_engine.py +0 -0
  30. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  31. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  32. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  33. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  34. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  35. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  36. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/server.py +0 -0
  37. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  38. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/search.py +0 -0
  39. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/server.py +0 -0
  40. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/session_context.py +0 -0
  41. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/skill_config.py +0 -0
  42. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/skill_descriptions.json +0 -0
  43. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/stats.py +0 -0
  44. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/tfidf_search.py +0 -0
  45. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/version_check.py +0 -0
  46. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
  47. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  48. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  49. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  50. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  51. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_advisor_strategy.py +0 -0
  52. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_body_parser_metric.py +0 -0
  53. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_builder_scope.py +0 -0
  54. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_code_read_savings.py +0 -0
  55. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_dashscan.py +0 -0
  56. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_diary_ingest.py +0 -0
  57. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_failed_approaches.py +0 -0
  58. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_git_policy.py +0 -0
  59. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_index_meta_guard.py +0 -0
  60. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_license_environment.py +0 -0
  61. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_license_grace.py +0 -0
  62. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_memory_engine.py +0 -0
  63. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_memory_inspector.py +0 -0
  64. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_memory_scoring.py +0 -0
  65. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_memory_tools.py +0 -0
  66. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_pro_compressor.py +0 -0
  67. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_pro_skills_update.py +0 -0
  68. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_pro_source_resolution.py +0 -0
  69. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_project_dir_canonical.py +0 -0
  70. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_search_memory.py +0 -0
  71. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_session_context.py +0 -0
  72. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_skill_drift.py +0 -0
  73. {memstack_skill_loader-4.18.0 → memstack_skill_loader-4.18.1}/tests/test_wheel_contents.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.18.0
3
+ Version: 4.18.1
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Project-URL: Homepage, https://memstack.pro
6
6
  Project-URL: Documentation, https://memstack.pro/docs
@@ -1,51 +1,51 @@
1
- [build-system]
2
- requires = ["setuptools>=68.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "memstack-skill-loader"
7
- version = "4.18.0"
8
- description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
- readme = "README.md"
10
- requires-python = ">=3.10"
11
- dependencies = [
12
- # Upper bound is load-bearing: mcp 2.0.0 (2026-07-28) removed the
13
- # Server.list_tools()/call_tool() decorator API that server.py is built on.
14
- # Do not relax without porting server.py to the 2.x API first.
15
- "mcp>=1.0.0,<2.0.0",
16
- "lancedb>=0.6.0",
17
- "sentence-transformers>=2.2.0",
18
- "pyarrow>=14.0.0",
19
- "httpx>=0.24.0",
20
- "aiohttp>=3.9",
21
- ]
22
-
23
- [project.urls]
24
- Homepage = "https://memstack.pro"
25
- Documentation = "https://memstack.pro/docs"
26
-
27
- [project.optional-dependencies]
28
- # setuptools is a TEST dependency here, not only a build dependency. The
29
- # packaging guard in tests/test_wheel_contents.py builds a wheel and inspects
30
- # it, and it fails rather than skips when the backend is absent, because a skip
31
- # is a silent pass. Python 3.12+ venvs no longer ship setuptools, so a bare venv
32
- # needs: pip install -e ".[dev]"
33
- dev = ["setuptools>=68"]
34
-
35
- [project.scripts]
36
- memstack-index = "memstack_skill_loader.indexer:main"
37
-
38
- [tool.setuptools.packages.find]
39
- where = ["src"]
40
-
41
- [tool.setuptools.package-data]
42
- memstack_skill_loader = ["dashboard.html", "skill_descriptions.json"]
43
-
44
- [tool.pytest.ini_options]
45
- # Test the WORKING TREE, not whatever is installed. This package is installed
46
- # non-editable, so without this the suite imported memstack_skill_loader from
47
- # site-packages and silently graded a stale copy: a source change could be
48
- # green here and still be broken, and a new test could fail against code that
49
- # was already fixed. pythonpath puts src/ ahead of site-packages for the whole
50
- # suite, so `python -m pytest` needs no PYTHONPATH prefix to be correct.
51
- pythonpath = ["src"]
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "memstack-skill-loader"
7
+ version = "4.18.1"
8
+ description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ # Upper bound is load-bearing: mcp 2.0.0 (2026-07-28) removed the
13
+ # Server.list_tools()/call_tool() decorator API that server.py is built on.
14
+ # Do not relax without porting server.py to the 2.x API first.
15
+ "mcp>=1.0.0,<2.0.0",
16
+ "lancedb>=0.6.0",
17
+ "sentence-transformers>=2.2.0",
18
+ "pyarrow>=14.0.0",
19
+ "httpx>=0.24.0",
20
+ "aiohttp>=3.9",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://memstack.pro"
25
+ Documentation = "https://memstack.pro/docs"
26
+
27
+ [project.optional-dependencies]
28
+ # setuptools is a TEST dependency here, not only a build dependency. The
29
+ # packaging guard in tests/test_wheel_contents.py builds a wheel and inspects
30
+ # it, and it fails rather than skips when the backend is absent, because a skip
31
+ # is a silent pass. Python 3.12+ venvs no longer ship setuptools, so a bare venv
32
+ # needs: pip install -e ".[dev]"
33
+ dev = ["setuptools>=68"]
34
+
35
+ [project.scripts]
36
+ memstack-index = "memstack_skill_loader.indexer:main"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-data]
42
+ memstack_skill_loader = ["dashboard.html", "skill_descriptions.json"]
43
+
44
+ [tool.pytest.ini_options]
45
+ # Test the WORKING TREE, not whatever is installed. This package is installed
46
+ # non-editable, so without this the suite imported memstack_skill_loader from
47
+ # site-packages and silently graded a stale copy: a source change could be
48
+ # green here and still be broken, and a new test could fail against code that
49
+ # was already fixed. pythonpath puts src/ ahead of site-packages for the whole
50
+ # suite, so `python -m pytest` needs no PYTHONPATH prefix to be correct.
51
+ pythonpath = ["src"]
@@ -1,3 +1,3 @@
1
- """MemStack Skill Loader — MCP server for semantic skill search."""
2
-
3
- __version__ = "4.18.0"
1
+ """MemStack Skill Loader — MCP server for semantic skill search."""
2
+
3
+ __version__ = "4.18.1"
@@ -11,6 +11,18 @@ from pathlib import Path, PureWindowsPath
11
11
  DB_PATH = Path.home() / ".memstack" / "memory.db"
12
12
  SCHEMA_VERSION = 1
13
13
 
14
+ #: Seconds a connection waits for a lock before raising "database is locked".
15
+ #: Deliberately the same number as ``memory_engine._BUSY_TIMEOUT_S``: the two
16
+ #: modules open connections to the SAME file, and a five-second waiter losing to
17
+ #: a thirty-second one is a difference with no reason behind it.
18
+ #:
19
+ #: This is a cushion for TRANSIENT contention, not a fix for a held lock. The
20
+ #: rehome migration used to compute its map inside its own BEGIN IMMEDIATE, so
21
+ #: every connection opened here waited the full timeout and then failed no
22
+ #: matter how large the number was — the holder was the caller. That map now
23
+ #: runs before the write lock is taken, so this timeout should never be reached.
24
+ _BUSY_TIMEOUT_S = 30
25
+
14
26
  # ── project_dir canonicalization ─────────────────────────────────────────────
15
27
  # project_dir was stored raw from os.getcwd(), which on Windows returns whichever
16
28
  # drive-letter case the process happened to be launched with. Two spellings of
@@ -63,11 +75,20 @@ def _resolve_db_path() -> Path:
63
75
 
64
76
 
65
77
  def _get_conn() -> sqlite3.Connection:
78
+ # NOTE: opening a connection WRITES. _init_tables ends with an
79
+ # "INSERT OR IGNORE INTO schema_version", and SQLite takes the write lock
80
+ # at statement start whether or not the row already exists. So this is not
81
+ # a read-only operation and must never be called from inside another
82
+ # transaction on the same file.
66
83
  db_path = _resolve_db_path()
67
84
  db_path.parent.mkdir(parents=True, exist_ok=True)
68
- conn = sqlite3.connect(str(db_path), timeout=5)
85
+ conn = sqlite3.connect(str(db_path), timeout=_BUSY_TIMEOUT_S)
69
86
  conn.row_factory = sqlite3.Row
70
87
  conn.execute("PRAGMA journal_mode=WAL")
88
+ # Stated as a PRAGMA as well as the connect() kwarg. They set the same
89
+ # thing, but the PRAGMA is the one that survives a future refactor of the
90
+ # connect call, and it is the form the engine uses.
91
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_S * 1000}")
71
92
  _init_tables(conn)
72
93
  return conn
73
94
 
@@ -771,59 +792,67 @@ def find_project_dirs_by_name(name: str) -> list[str]:
771
792
  stored paths are Windows backslash paths, and ``os.path.basename`` does not
772
793
  split on ``\\`` when this DB is read on a POSIX host. ``PureWindowsPath``
773
794
  treats both ``\\`` and ``/`` as separators regardless of the running OS.
774
- """
775
- try:
776
- if name is None:
777
- return []
778
- if not isinstance(name, str):
779
- name = str(name)
780
- key = name.strip().lower()
781
- if not key:
782
- return []
783
795
 
784
- conn = _get_conn()
785
- try:
786
- rows = conn.execute(
787
- "SELECT DISTINCT project_dir FROM ("
788
- "SELECT project_dir FROM context_facts "
789
- "UNION SELECT project_dir FROM procedural_memory "
790
- "UNION SELECT project_dir FROM session_summaries "
791
- "UNION SELECT project_dir FROM temporal_events "
792
- "UNION SELECT project_dir FROM failed_approaches"
793
- ") WHERE project_dir IS NOT NULL"
794
- ).fetchall()
795
- finally:
796
- conn.close()
796
+ RAISES on an unreachable store. This used to swallow every exception and
797
+ return ``[]``, which made "the store has never seen this project" and "the
798
+ store could not be read" the same answer. That cost a data migration: the
799
+ rehome migration's fail-open tripwire lives in ``diary_ingest._resolves``,
800
+ one level ABOVE this function, and could never fire because the exception
801
+ died here. A caller that wants to continue without an answer catches it and
802
+ says so; a caller that must not guess lets it propagate. Both exist.
797
803
 
798
- matches = set()
799
- for row in rows:
800
- pdir = row[0]
801
- if not pdir:
802
- continue
803
- if PureWindowsPath(pdir).name.lower() == key:
804
- matches.add(pdir)
805
-
806
- # Collapse spellings that denote the SAME directory (drive-letter case,
807
- # separator style) before counting — otherwise one stray "c:\..." row
808
- # turns a perfectly well-known project into an ambiguous one. Distinct
809
- # directories that merely share a basename remain separate entries.
810
- groups: dict = {}
811
- for match in matches:
812
- groups.setdefault(_project_dir_key(match), []).append(match)
813
-
814
- # Deterministic representative per group: sorted() puts the upper-case
815
- # drive first, and canonicalizing makes the result independent of which
816
- # spellings happen to be in the store.
817
- return sorted(
818
- canonical_project_dir(sorted(spellings)[0])
819
- for spellings in groups.values()
820
- )
821
- except Exception as exc:
822
- print(
823
- f"[memstack-memory] find_project_dirs_by_name failed: {exc}",
824
- file=sys.stderr,
825
- )
804
+ Removing the swallow also removed a ``print`` of the raw exception text.
805
+ That string is not allowlisted and could carry a path or a connection
806
+ string, so the callers now report the exception CLASS and nothing else.
807
+
808
+ Bad INPUT is still not an error: None, a non-string and an empty name all
809
+ return ``[]``, because those are answerable questions whose answer is "no".
810
+ """
811
+ if name is None:
826
812
  return []
813
+ if not isinstance(name, str):
814
+ name = str(name)
815
+ key = name.strip().lower()
816
+ if not key:
817
+ return []
818
+
819
+ conn = _get_conn()
820
+ try:
821
+ rows = conn.execute(
822
+ "SELECT DISTINCT project_dir FROM ("
823
+ "SELECT project_dir FROM context_facts "
824
+ "UNION SELECT project_dir FROM procedural_memory "
825
+ "UNION SELECT project_dir FROM session_summaries "
826
+ "UNION SELECT project_dir FROM temporal_events "
827
+ "UNION SELECT project_dir FROM failed_approaches"
828
+ ") WHERE project_dir IS NOT NULL"
829
+ ).fetchall()
830
+ finally:
831
+ conn.close()
832
+
833
+ matches = set()
834
+ for row in rows:
835
+ pdir = row[0]
836
+ if not pdir:
837
+ continue
838
+ if PureWindowsPath(pdir).name.lower() == key:
839
+ matches.add(pdir)
840
+
841
+ # Collapse spellings that denote the SAME directory (drive-letter case,
842
+ # separator style) before counting — otherwise one stray "c:\..." row
843
+ # turns a perfectly well-known project into an ambiguous one. Distinct
844
+ # directories that merely share a basename remain separate entries.
845
+ groups: dict = {}
846
+ for match in matches:
847
+ groups.setdefault(_project_dir_key(match), []).append(match)
848
+
849
+ # Deterministic representative per group: sorted() puts the upper-case
850
+ # drive first, and canonicalizing makes the result independent of which
851
+ # spellings happen to be in the store.
852
+ return sorted(
853
+ canonical_project_dir(sorted(spellings)[0])
854
+ for spellings in groups.values()
855
+ )
827
856
 
828
857
 
829
858
  def resolve_project_dir_by_name(name: str) -> str | None:
@@ -833,8 +862,24 @@ def resolve_project_dir_by_name(name: str) -> str | None:
833
862
  no sensible behavior for a collision. Callers that must distinguish "never
834
863
  seen" from "shares a basename with another project" — and act differently —
835
864
  should use :func:`find_project_dirs_by_name` and inspect the length.
865
+
866
+ FAIL-OPEN, and this is where that decision now lives rather than inside the
867
+ lookup. Every caller of this function already treats ``None`` as "no usable
868
+ path", so an unreachable store degrading to ``None`` changes nothing for
869
+ them, and the alternative — raising into paths that only wanted a directory
870
+ name — would break working features to report a condition they cannot act
871
+ on. Only the exception CLASS is printed; the message is attacker-influenced
872
+ free text and is never echoed.
836
873
  """
837
- matches = find_project_dirs_by_name(name)
874
+ try:
875
+ matches = find_project_dirs_by_name(name)
876
+ except Exception as exc: # noqa: BLE001 -- see FAIL-OPEN above
877
+ print(
878
+ f"[memstack-memory] project lookup failed ({type(exc).__name__}); "
879
+ f"treating the project as unknown",
880
+ file=sys.stderr,
881
+ )
882
+ return None
838
883
  return matches[0] if len(matches) == 1 else None
839
884
 
840
885