memstack-skill-loader 4.11.0__tar.gz → 4.12.0__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.
- {memstack_skill_loader-4.11.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.12.0}/PKG-INFO +1 -1
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/pyproject.toml +1 -1
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/__init__.py +1 -1
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/memory_db.py +86 -3
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
- memstack_skill_loader-4.12.0/tests/test_search_memory.py +291 -0
- memstack_skill_loader-4.11.0/tests/test_search_memory.py +0 -161
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/MANIFEST.in +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/README.md +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/setup.cfg +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/__main__.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/agent_runner.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/categories.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/compression.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/config.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/dashboard.html +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/dashboard.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/goal.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/goal_engine.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/indexer.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/license.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/server.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/search.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/server.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/skill_config.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/skill_descriptions.json +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/stats.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/version_check.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_advisor_strategy.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_body_parser_metric.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_code_read_savings.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_failed_approaches.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_license_environment.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_license_grace.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_pro_compressor.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_pro_skills_update.py +0 -0
- {memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_skill_drift.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "memstack-skill-loader"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.12.0"
|
|
8
8
|
description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/memory_db.py
RENAMED
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import sqlite3
|
|
7
7
|
import sys
|
|
8
|
-
from pathlib import Path
|
|
8
|
+
from pathlib import Path, PureWindowsPath
|
|
9
9
|
|
|
10
10
|
DB_PATH = Path.home() / ".memstack" / "memory.db"
|
|
11
11
|
SCHEMA_VERSION = 1
|
|
@@ -107,6 +107,15 @@ def _init_tables(conn: sqlite3.Connection) -> None:
|
|
|
107
107
|
cur.execute(
|
|
108
108
|
"CREATE INDEX IF NOT EXISTS idx_procedural_category ON procedural_memory(category)"
|
|
109
109
|
)
|
|
110
|
+
# Structural idempotency for insert_lesson (INSERT OR IGNORE): the same
|
|
111
|
+
# (project_dir, title, content) IS the same lesson. NOTE: SQLite treats NULLs
|
|
112
|
+
# as DISTINCT in a UNIQUE index, so two GLOBAL lessons (project_dir IS NULL)
|
|
113
|
+
# with identical title+content both insert and IGNORE will not suppress the
|
|
114
|
+
# second. Named-project lessons dedupe; globals do not. Intentional, not fixed.
|
|
115
|
+
cur.execute(
|
|
116
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_procedural_unique "
|
|
117
|
+
"ON procedural_memory(project_dir, title, content)"
|
|
118
|
+
)
|
|
110
119
|
|
|
111
120
|
cur.execute(
|
|
112
121
|
"""
|
|
@@ -282,7 +291,7 @@ def insert_lesson(
|
|
|
282
291
|
if created_at:
|
|
283
292
|
# Caller-supplied timestamp: bind it (never string-format it in).
|
|
284
293
|
cur = conn.execute(
|
|
285
|
-
"INSERT INTO procedural_memory "
|
|
294
|
+
"INSERT OR IGNORE INTO procedural_memory "
|
|
286
295
|
"(project_dir, category, title, content, stack_tags, created_at) "
|
|
287
296
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
288
297
|
(pdir, category, title, content, tags_json, created_at),
|
|
@@ -291,11 +300,24 @@ def insert_lesson(
|
|
|
291
300
|
# Default path: omit created_at so the column DEFAULT
|
|
292
301
|
# (datetime('now')) applies. Byte-identical to prior behavior.
|
|
293
302
|
cur = conn.execute(
|
|
294
|
-
"INSERT INTO procedural_memory "
|
|
303
|
+
"INSERT OR IGNORE INTO procedural_memory "
|
|
295
304
|
"(project_dir, category, title, content, stack_tags) "
|
|
296
305
|
"VALUES (?, ?, ?, ?, ?)",
|
|
297
306
|
(pdir, category, title, content, tags_json),
|
|
298
307
|
)
|
|
308
|
+
if cur.rowcount == 0:
|
|
309
|
+
# idx_procedural_unique(project_dir, title, content) suppressed a
|
|
310
|
+
# duplicate. lastrowid is meaningless now, so do NOT index a phantom
|
|
311
|
+
# FTS row and do NOT commit. Return the EXISTING row's id: storing a
|
|
312
|
+
# lesson already stored is success, not failure (None would read as a
|
|
313
|
+
# failed insert). `IS` matches NULL-safely, though a NULL project_dir
|
|
314
|
+
# never reaches here (NULLs are distinct in the UNIQUE index).
|
|
315
|
+
existing = conn.execute(
|
|
316
|
+
"SELECT id FROM procedural_memory "
|
|
317
|
+
"WHERE project_dir IS ? AND title=? AND content=?",
|
|
318
|
+
(pdir, title, content),
|
|
319
|
+
).fetchone()
|
|
320
|
+
return existing[0] if existing else None
|
|
299
321
|
row_id = cur.lastrowid
|
|
300
322
|
_index_fts(conn, "procedural_memory", row_id,
|
|
301
323
|
f"{title} {content}", pdir or "")
|
|
@@ -673,6 +695,67 @@ def get_recent_project_dirs(limit: int = 10) -> list[str]:
|
|
|
673
695
|
return []
|
|
674
696
|
|
|
675
697
|
|
|
698
|
+
def resolve_project_dir_by_name(name: str) -> str | None:
|
|
699
|
+
"""Resolve a project NAME to a stored ``project_dir`` path, or None.
|
|
700
|
+
|
|
701
|
+
The store keeps ``project_dir`` as absolute paths (e.g.
|
|
702
|
+
``C:\\Projects\\adminstack``); callers such as the Diary only know the
|
|
703
|
+
project *name* (``adminstack``). This matches ``name`` case-insensitively
|
|
704
|
+
against the basename of every distinct ``project_dir`` the store already
|
|
705
|
+
knows, unioned across the five tables that carry the column. It assumes no
|
|
706
|
+
on-disk layout and never fabricates a path: a name the store has never seen
|
|
707
|
+
returns None, so the function works on any machine.
|
|
708
|
+
|
|
709
|
+
Basenames are split with ``PureWindowsPath``, not ``os.path.basename``: the
|
|
710
|
+
stored paths are Windows backslash paths, and ``os.path.basename`` does not
|
|
711
|
+
split on ``\\`` when this DB is read on a POSIX host. ``PureWindowsPath``
|
|
712
|
+
treats both ``\\`` and ``/`` as separators regardless of the running OS.
|
|
713
|
+
|
|
714
|
+
If a basename matches more than one distinct path (a collision), returns
|
|
715
|
+
None rather than guessing which project was meant.
|
|
716
|
+
"""
|
|
717
|
+
try:
|
|
718
|
+
if name is None:
|
|
719
|
+
return None
|
|
720
|
+
if not isinstance(name, str):
|
|
721
|
+
name = str(name)
|
|
722
|
+
key = name.strip().lower()
|
|
723
|
+
if not key:
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
conn = _get_conn()
|
|
727
|
+
try:
|
|
728
|
+
rows = conn.execute(
|
|
729
|
+
"SELECT DISTINCT project_dir FROM ("
|
|
730
|
+
"SELECT project_dir FROM context_facts "
|
|
731
|
+
"UNION SELECT project_dir FROM procedural_memory "
|
|
732
|
+
"UNION SELECT project_dir FROM session_summaries "
|
|
733
|
+
"UNION SELECT project_dir FROM temporal_events "
|
|
734
|
+
"UNION SELECT project_dir FROM failed_approaches"
|
|
735
|
+
") WHERE project_dir IS NOT NULL"
|
|
736
|
+
).fetchall()
|
|
737
|
+
finally:
|
|
738
|
+
conn.close()
|
|
739
|
+
|
|
740
|
+
matches = set()
|
|
741
|
+
for row in rows:
|
|
742
|
+
pdir = row[0]
|
|
743
|
+
if not pdir:
|
|
744
|
+
continue
|
|
745
|
+
if PureWindowsPath(pdir).name.lower() == key:
|
|
746
|
+
matches.add(pdir)
|
|
747
|
+
|
|
748
|
+
if len(matches) == 1:
|
|
749
|
+
return next(iter(matches))
|
|
750
|
+
return None # 0 matches => miss; >1 => ambiguous collision, do not guess
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
print(
|
|
753
|
+
f"[memstack-memory] resolve_project_dir_by_name failed: {exc}",
|
|
754
|
+
file=sys.stderr,
|
|
755
|
+
)
|
|
756
|
+
return None
|
|
757
|
+
|
|
758
|
+
|
|
676
759
|
def _human_size(nbytes: int) -> str:
|
|
677
760
|
for unit in ("B", "KB", "MB", "GB"):
|
|
678
761
|
if nbytes < 1024:
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Unit tests for memory_db.search_memory (the user-facing memory_search MCP tool).
|
|
2
|
+
|
|
3
|
+
Hermetic: the autouse ``isolated_memory_db`` fixture in tests/conftest.py
|
|
4
|
+
redirects the memory DB to a per-test tmp file, so no test touches the real
|
|
5
|
+
~/.memstack/memory.db. No network, no CLI.
|
|
6
|
+
|
|
7
|
+
Regression coverage for the phrase-match bug: search_memory used to wrap the
|
|
8
|
+
query as an FTS5 phrase (f'"{query}"'), so a multi-word query only matched an
|
|
9
|
+
exact consecutive run of tokens. It now routes through _fts_or_query (the same
|
|
10
|
+
sanitizer query_failed_approaches uses): OR of 3+ char tokens, punctuation
|
|
11
|
+
stripped, empty -> [] before any MATCH.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from memstack_skill_loader import memory_db
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _seed(project_dir, summary, session_id="s1", event_type="note"):
|
|
18
|
+
"""Insert one temporal_event; its summary is what lands in memory_fts."""
|
|
19
|
+
return memory_db.insert_event(project_dir, session_id, event_type, summary)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# 1. Regression: multi-word query matches non-consecutive terms ---------------
|
|
23
|
+
|
|
24
|
+
def test_multiword_matches_non_consecutive_terms():
|
|
25
|
+
pd = "C:/proj/alpha"
|
|
26
|
+
target = "authentication for the login endpoint using JWT"
|
|
27
|
+
_seed(pd, target)
|
|
28
|
+
|
|
29
|
+
results = memory_db.search_memory("jwt auth login", project_dir=pd)
|
|
30
|
+
|
|
31
|
+
# Phrase-match wrapping would have returned 0 here (terms aren't
|
|
32
|
+
# consecutive); OR-tokenization matches on jwt/login.
|
|
33
|
+
assert len(results) >= 1
|
|
34
|
+
assert any(r["content"] == target for r in results)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# 2. Punctuation safety: raw special chars must not reach MATCH raw -----------
|
|
38
|
+
|
|
39
|
+
def test_punctuation_queries_do_not_raise():
|
|
40
|
+
pd = "C:/proj/alpha"
|
|
41
|
+
_seed(pd, "jwt token for the login endpoint")
|
|
42
|
+
|
|
43
|
+
for q in ('auth: "login"', "jwt-token (login)", "C:/path/thing"):
|
|
44
|
+
result = memory_db.search_memory(q, project_dir=pd)
|
|
45
|
+
assert isinstance(result, list) # no exception, always a list
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# 3. Empty / too-short query returns [] WITHOUT opening a connection ----------
|
|
49
|
+
|
|
50
|
+
def test_empty_and_short_query_short_circuit(monkeypatch):
|
|
51
|
+
# If the guard fails to short-circuit, _get_conn would be called; the
|
|
52
|
+
# counter proves no MATCH was attempted (the counter is inspected outside
|
|
53
|
+
# the function, so search_memory's broad except cannot hide a miss).
|
|
54
|
+
calls = {"n": 0}
|
|
55
|
+
|
|
56
|
+
def tracking_conn():
|
|
57
|
+
calls["n"] += 1
|
|
58
|
+
raise AssertionError("search_memory should not open a connection here")
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(memory_db, "_get_conn", tracking_conn)
|
|
61
|
+
|
|
62
|
+
assert memory_db.search_memory("") == [] # no tokens
|
|
63
|
+
assert memory_db.search_memory("a to") == [] # all tokens < 3 chars
|
|
64
|
+
assert calls["n"] == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# 4. project_dir filter still scopes results ----------------------------------
|
|
68
|
+
|
|
69
|
+
def test_project_dir_filter_scopes_results():
|
|
70
|
+
pd_a = "C:/proj/alpha"
|
|
71
|
+
pd_b = "C:/proj/beta"
|
|
72
|
+
_seed(pd_a, "deployment pipeline for alpha service")
|
|
73
|
+
_seed(pd_b, "deployment pipeline for beta service")
|
|
74
|
+
|
|
75
|
+
results = memory_db.search_memory("deployment pipeline", project_dir=pd_a)
|
|
76
|
+
|
|
77
|
+
assert len(results) >= 1
|
|
78
|
+
assert all(r["project_dir"] == pd_a for r in results)
|
|
79
|
+
assert not any("beta" in r["content"] for r in results)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_no_project_dir_returns_all_projects():
|
|
83
|
+
pd_a = "C:/proj/alpha"
|
|
84
|
+
pd_b = "C:/proj/beta"
|
|
85
|
+
_seed(pd_a, "deployment pipeline for alpha service")
|
|
86
|
+
_seed(pd_b, "deployment pipeline for beta service")
|
|
87
|
+
|
|
88
|
+
results = memory_db.search_memory("deployment pipeline") # no filter
|
|
89
|
+
|
|
90
|
+
dirs = {r["project_dir"] for r in results}
|
|
91
|
+
assert pd_a in dirs and pd_b in dirs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# 5. Limit is capped at 500 ---------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def test_limit_capped_at_500(monkeypatch):
|
|
97
|
+
# Capture the params bound to the SELECT; LIMIT is the last one. A fake
|
|
98
|
+
# connection lets us read the capped value without seeding 500+ rows.
|
|
99
|
+
captured = {}
|
|
100
|
+
|
|
101
|
+
class _FakeCursor:
|
|
102
|
+
def fetchall(self):
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
class _FakeConn:
|
|
106
|
+
def execute(self, sql, params=()):
|
|
107
|
+
captured["params"] = list(params)
|
|
108
|
+
return _FakeCursor()
|
|
109
|
+
|
|
110
|
+
def close(self):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(memory_db, "_get_conn", lambda: _FakeConn())
|
|
114
|
+
|
|
115
|
+
memory_db.search_memory("alpha", limit=100_000)
|
|
116
|
+
|
|
117
|
+
assert captured["params"][-1] == 500 # capped down from 100000
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# 6. insert_lesson created_at parameter (additive, keyword-only) --------------
|
|
121
|
+
|
|
122
|
+
def _lesson_created_at(project_dir, title):
|
|
123
|
+
"""Read back the created_at for a specific lesson via get_lessons."""
|
|
124
|
+
for r in memory_db.get_lessons(project_dir=project_dir):
|
|
125
|
+
if r["title"] == title:
|
|
126
|
+
return r["created_at"]
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_insert_lesson_without_created_at_applies_default():
|
|
131
|
+
pd = "C:/proj/lessons"
|
|
132
|
+
rid = memory_db.insert_lesson(
|
|
133
|
+
"default ts lesson", "body of the default-timestamp lesson", "lesson",
|
|
134
|
+
project_dir=pd,
|
|
135
|
+
)
|
|
136
|
+
assert isinstance(rid, int)
|
|
137
|
+
ts = _lesson_created_at(pd, "default ts lesson")
|
|
138
|
+
assert ts is not None and ts.strip() != "" # column DEFAULT datetime('now') applied
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_insert_lesson_stores_explicit_created_at():
|
|
142
|
+
pd = "C:/proj/lessons"
|
|
143
|
+
rid = memory_db.insert_lesson(
|
|
144
|
+
"explicit ts lesson", "body of the explicit-timestamp lesson", "lesson",
|
|
145
|
+
project_dir=pd, created_at="2026-02-24 16:55:18",
|
|
146
|
+
)
|
|
147
|
+
assert isinstance(rid, int)
|
|
148
|
+
assert _lesson_created_at(pd, "explicit ts lesson") == "2026-02-24 16:55:18"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_insert_lesson_explicit_created_at_is_searchable():
|
|
152
|
+
# Proves _index_fts fired on the explicit-timestamp INSERT path.
|
|
153
|
+
pd = "C:/proj/lessons"
|
|
154
|
+
memory_db.insert_lesson(
|
|
155
|
+
"searchable timestamped lesson",
|
|
156
|
+
"deployment pipeline runbook for the beta service",
|
|
157
|
+
"lesson",
|
|
158
|
+
project_dir=pd, created_at="2026-02-24 16:55:18",
|
|
159
|
+
)
|
|
160
|
+
results = memory_db.search_memory("deployment pipeline runbook", project_dir=pd)
|
|
161
|
+
assert any("deployment pipeline runbook" in r["content"] for r in results)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# 7. resolve_project_dir_by_name -----------------------------------------------
|
|
165
|
+
# Name (from the Diary) -> stored project_dir path, matched on basename across
|
|
166
|
+
# all five project_dir-bearing tables. Each test seeds its own rows into the
|
|
167
|
+
# clean per-test DB via the real writers.
|
|
168
|
+
|
|
169
|
+
def test_resolve_exact_name_match():
|
|
170
|
+
pd = "C:\\Projects\\adminstack"
|
|
171
|
+
memory_db.insert_fact(pd, "status", "status", "ok")
|
|
172
|
+
assert memory_db.resolve_project_dir_by_name("adminstack") == pd
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_resolve_case_insensitive_match():
|
|
176
|
+
pd = "C:\\Projects\\adminstack"
|
|
177
|
+
memory_db.insert_fact(pd, "status", "status", "ok")
|
|
178
|
+
# Upper-cased query resolves to the path in its ORIGINAL stored casing.
|
|
179
|
+
assert memory_db.resolve_project_dir_by_name("ADMINSTACK") == pd
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_resolve_no_match_returns_none():
|
|
183
|
+
memory_db.insert_fact("C:\\Projects\\adminstack", "status", "status", "ok")
|
|
184
|
+
assert memory_db.resolve_project_dir_by_name("nonexistent-project") is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_resolve_none_empty_whitespace_return_none():
|
|
188
|
+
memory_db.insert_fact("C:\\Projects\\adminstack", "status", "status", "ok")
|
|
189
|
+
assert memory_db.resolve_project_dir_by_name(None) is None
|
|
190
|
+
assert memory_db.resolve_project_dir_by_name("") is None
|
|
191
|
+
assert memory_db.resolve_project_dir_by_name(" ") is None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_resolve_non_string_returns_none_without_raising():
|
|
195
|
+
memory_db.insert_fact("C:\\Projects\\adminstack", "status", "status", "ok")
|
|
196
|
+
# Coerced with str(); no basename equals "123" or "12.5", so -> None, no raise.
|
|
197
|
+
assert memory_db.resolve_project_dir_by_name(123) is None
|
|
198
|
+
assert memory_db.resolve_project_dir_by_name(12.5) is None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_resolve_basename_collision_returns_none():
|
|
202
|
+
# Two distinct paths sharing a basename: the lookup is ambiguous -> None,
|
|
203
|
+
# and must NOT silently pick either one.
|
|
204
|
+
memory_db.insert_fact("C:\\Projects\\adminstack", "status", "status", "a")
|
|
205
|
+
memory_db.insert_fact("D:\\Backup\\adminstack", "status", "status", "b")
|
|
206
|
+
assert memory_db.resolve_project_dir_by_name("adminstack") is None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_resolve_null_project_dir_does_not_break_query():
|
|
210
|
+
# A global lesson stores project_dir=NULL in procedural_memory. The UNION
|
|
211
|
+
# must tolerate it and still resolve a real name.
|
|
212
|
+
memory_db.insert_lesson("global rule", "applies everywhere", "lesson") # NULL pd
|
|
213
|
+
pd = "C:\\Projects\\memstack"
|
|
214
|
+
memory_db.insert_fact(pd, "status", "status", "ok")
|
|
215
|
+
assert memory_db.resolve_project_dir_by_name("memstack") == pd
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_resolve_matches_basename_only_not_path_substring():
|
|
219
|
+
# "Projects" is a parent path component, never a basename -> must not resolve.
|
|
220
|
+
memory_db.insert_fact("C:\\Projects\\adminstack", "status", "status", "ok")
|
|
221
|
+
assert memory_db.resolve_project_dir_by_name("Projects") is None
|
|
222
|
+
assert memory_db.resolve_project_dir_by_name("C:") is None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# 8. insert_lesson idempotency (UNIQUE index + INSERT OR IGNORE) ---------------
|
|
226
|
+
# A retried/repeated diary save must not duplicate a lesson. Dedup is keyed on
|
|
227
|
+
# (project_dir, title, content); NULL project_dir is intentionally NOT deduped.
|
|
228
|
+
|
|
229
|
+
def _procedural_count():
|
|
230
|
+
conn = memory_db._get_conn()
|
|
231
|
+
try:
|
|
232
|
+
return conn.execute("SELECT COUNT(*) FROM procedural_memory").fetchone()[0]
|
|
233
|
+
finally:
|
|
234
|
+
conn.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fts_procedural_count():
|
|
238
|
+
conn = memory_db._get_conn()
|
|
239
|
+
try:
|
|
240
|
+
return conn.execute(
|
|
241
|
+
"SELECT COUNT(*) FROM memory_fts WHERE source_table='procedural_memory'"
|
|
242
|
+
).fetchone()[0]
|
|
243
|
+
finally:
|
|
244
|
+
conn.close()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_insert_lesson_same_triple_is_idempotent():
|
|
248
|
+
pd = "C:\\Projects\\adminstack"
|
|
249
|
+
id1 = memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
250
|
+
id2 = memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
251
|
+
assert id1 is not None
|
|
252
|
+
assert id2 == id1 # same id both times, not None
|
|
253
|
+
assert _procedural_count() == 1 # exactly one row
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_insert_lesson_dup_does_not_duplicate_fts():
|
|
257
|
+
pd = "C:\\Projects\\adminstack"
|
|
258
|
+
memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
259
|
+
memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
260
|
+
assert _fts_procedural_count() == 1 # phantom FTS row NOT written
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_insert_lesson_same_title_different_content_two_rows():
|
|
264
|
+
pd = "C:\\Projects\\adminstack"
|
|
265
|
+
memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
266
|
+
memory_db.insert_lesson("t1", "body two", "lesson", project_dir=pd)
|
|
267
|
+
assert _procedural_count() == 2 # not over-deduping on title alone
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_insert_lesson_same_triple_different_project_two_rows():
|
|
271
|
+
memory_db.insert_lesson("t1", "body one", "lesson", project_dir="C:\\Projects\\a")
|
|
272
|
+
memory_db.insert_lesson("t1", "body one", "lesson", project_dir="C:\\Projects\\b")
|
|
273
|
+
assert _procedural_count() == 2 # project_dir is part of the key
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_insert_lesson_two_identical_globals_both_insert():
|
|
277
|
+
# Documented NULL behavior: NULLs are distinct in a UNIQUE index, so IGNORE
|
|
278
|
+
# does not suppress the second global lesson. This test encodes the caveat.
|
|
279
|
+
memory_db.insert_lesson("g", "same body", "lesson", global_=True)
|
|
280
|
+
memory_db.insert_lesson("g", "same body", "lesson", global_=True)
|
|
281
|
+
assert _procedural_count() == 2
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_insert_lesson_preexisting_row_still_dedupes():
|
|
285
|
+
# Insert once, then insert the identical triple: the second resolves to the
|
|
286
|
+
# existing row rather than adding a duplicate.
|
|
287
|
+
pd = "C:\\Projects\\adminstack"
|
|
288
|
+
first = memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
289
|
+
again = memory_db.insert_lesson("t1", "body one", "lesson", project_dir=pd)
|
|
290
|
+
assert again == first
|
|
291
|
+
assert _procedural_count() == 1
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
"""Unit tests for memory_db.search_memory (the user-facing memory_search MCP tool).
|
|
2
|
-
|
|
3
|
-
Hermetic: the autouse ``isolated_memory_db`` fixture in tests/conftest.py
|
|
4
|
-
redirects the memory DB to a per-test tmp file, so no test touches the real
|
|
5
|
-
~/.memstack/memory.db. No network, no CLI.
|
|
6
|
-
|
|
7
|
-
Regression coverage for the phrase-match bug: search_memory used to wrap the
|
|
8
|
-
query as an FTS5 phrase (f'"{query}"'), so a multi-word query only matched an
|
|
9
|
-
exact consecutive run of tokens. It now routes through _fts_or_query (the same
|
|
10
|
-
sanitizer query_failed_approaches uses): OR of 3+ char tokens, punctuation
|
|
11
|
-
stripped, empty -> [] before any MATCH.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from memstack_skill_loader import memory_db
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _seed(project_dir, summary, session_id="s1", event_type="note"):
|
|
18
|
-
"""Insert one temporal_event; its summary is what lands in memory_fts."""
|
|
19
|
-
return memory_db.insert_event(project_dir, session_id, event_type, summary)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# 1. Regression: multi-word query matches non-consecutive terms ---------------
|
|
23
|
-
|
|
24
|
-
def test_multiword_matches_non_consecutive_terms():
|
|
25
|
-
pd = "C:/proj/alpha"
|
|
26
|
-
target = "authentication for the login endpoint using JWT"
|
|
27
|
-
_seed(pd, target)
|
|
28
|
-
|
|
29
|
-
results = memory_db.search_memory("jwt auth login", project_dir=pd)
|
|
30
|
-
|
|
31
|
-
# Phrase-match wrapping would have returned 0 here (terms aren't
|
|
32
|
-
# consecutive); OR-tokenization matches on jwt/login.
|
|
33
|
-
assert len(results) >= 1
|
|
34
|
-
assert any(r["content"] == target for r in results)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# 2. Punctuation safety: raw special chars must not reach MATCH raw -----------
|
|
38
|
-
|
|
39
|
-
def test_punctuation_queries_do_not_raise():
|
|
40
|
-
pd = "C:/proj/alpha"
|
|
41
|
-
_seed(pd, "jwt token for the login endpoint")
|
|
42
|
-
|
|
43
|
-
for q in ('auth: "login"', "jwt-token (login)", "C:/path/thing"):
|
|
44
|
-
result = memory_db.search_memory(q, project_dir=pd)
|
|
45
|
-
assert isinstance(result, list) # no exception, always a list
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# 3. Empty / too-short query returns [] WITHOUT opening a connection ----------
|
|
49
|
-
|
|
50
|
-
def test_empty_and_short_query_short_circuit(monkeypatch):
|
|
51
|
-
# If the guard fails to short-circuit, _get_conn would be called; the
|
|
52
|
-
# counter proves no MATCH was attempted (the counter is inspected outside
|
|
53
|
-
# the function, so search_memory's broad except cannot hide a miss).
|
|
54
|
-
calls = {"n": 0}
|
|
55
|
-
|
|
56
|
-
def tracking_conn():
|
|
57
|
-
calls["n"] += 1
|
|
58
|
-
raise AssertionError("search_memory should not open a connection here")
|
|
59
|
-
|
|
60
|
-
monkeypatch.setattr(memory_db, "_get_conn", tracking_conn)
|
|
61
|
-
|
|
62
|
-
assert memory_db.search_memory("") == [] # no tokens
|
|
63
|
-
assert memory_db.search_memory("a to") == [] # all tokens < 3 chars
|
|
64
|
-
assert calls["n"] == 0
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
# 4. project_dir filter still scopes results ----------------------------------
|
|
68
|
-
|
|
69
|
-
def test_project_dir_filter_scopes_results():
|
|
70
|
-
pd_a = "C:/proj/alpha"
|
|
71
|
-
pd_b = "C:/proj/beta"
|
|
72
|
-
_seed(pd_a, "deployment pipeline for alpha service")
|
|
73
|
-
_seed(pd_b, "deployment pipeline for beta service")
|
|
74
|
-
|
|
75
|
-
results = memory_db.search_memory("deployment pipeline", project_dir=pd_a)
|
|
76
|
-
|
|
77
|
-
assert len(results) >= 1
|
|
78
|
-
assert all(r["project_dir"] == pd_a for r in results)
|
|
79
|
-
assert not any("beta" in r["content"] for r in results)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_no_project_dir_returns_all_projects():
|
|
83
|
-
pd_a = "C:/proj/alpha"
|
|
84
|
-
pd_b = "C:/proj/beta"
|
|
85
|
-
_seed(pd_a, "deployment pipeline for alpha service")
|
|
86
|
-
_seed(pd_b, "deployment pipeline for beta service")
|
|
87
|
-
|
|
88
|
-
results = memory_db.search_memory("deployment pipeline") # no filter
|
|
89
|
-
|
|
90
|
-
dirs = {r["project_dir"] for r in results}
|
|
91
|
-
assert pd_a in dirs and pd_b in dirs
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# 5. Limit is capped at 500 ---------------------------------------------------
|
|
95
|
-
|
|
96
|
-
def test_limit_capped_at_500(monkeypatch):
|
|
97
|
-
# Capture the params bound to the SELECT; LIMIT is the last one. A fake
|
|
98
|
-
# connection lets us read the capped value without seeding 500+ rows.
|
|
99
|
-
captured = {}
|
|
100
|
-
|
|
101
|
-
class _FakeCursor:
|
|
102
|
-
def fetchall(self):
|
|
103
|
-
return []
|
|
104
|
-
|
|
105
|
-
class _FakeConn:
|
|
106
|
-
def execute(self, sql, params=()):
|
|
107
|
-
captured["params"] = list(params)
|
|
108
|
-
return _FakeCursor()
|
|
109
|
-
|
|
110
|
-
def close(self):
|
|
111
|
-
pass
|
|
112
|
-
|
|
113
|
-
monkeypatch.setattr(memory_db, "_get_conn", lambda: _FakeConn())
|
|
114
|
-
|
|
115
|
-
memory_db.search_memory("alpha", limit=100_000)
|
|
116
|
-
|
|
117
|
-
assert captured["params"][-1] == 500 # capped down from 100000
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# 6. insert_lesson created_at parameter (additive, keyword-only) --------------
|
|
121
|
-
|
|
122
|
-
def _lesson_created_at(project_dir, title):
|
|
123
|
-
"""Read back the created_at for a specific lesson via get_lessons."""
|
|
124
|
-
for r in memory_db.get_lessons(project_dir=project_dir):
|
|
125
|
-
if r["title"] == title:
|
|
126
|
-
return r["created_at"]
|
|
127
|
-
return None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def test_insert_lesson_without_created_at_applies_default():
|
|
131
|
-
pd = "C:/proj/lessons"
|
|
132
|
-
rid = memory_db.insert_lesson(
|
|
133
|
-
"default ts lesson", "body of the default-timestamp lesson", "lesson",
|
|
134
|
-
project_dir=pd,
|
|
135
|
-
)
|
|
136
|
-
assert isinstance(rid, int)
|
|
137
|
-
ts = _lesson_created_at(pd, "default ts lesson")
|
|
138
|
-
assert ts is not None and ts.strip() != "" # column DEFAULT datetime('now') applied
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def test_insert_lesson_stores_explicit_created_at():
|
|
142
|
-
pd = "C:/proj/lessons"
|
|
143
|
-
rid = memory_db.insert_lesson(
|
|
144
|
-
"explicit ts lesson", "body of the explicit-timestamp lesson", "lesson",
|
|
145
|
-
project_dir=pd, created_at="2026-02-24 16:55:18",
|
|
146
|
-
)
|
|
147
|
-
assert isinstance(rid, int)
|
|
148
|
-
assert _lesson_created_at(pd, "explicit ts lesson") == "2026-02-24 16:55:18"
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def test_insert_lesson_explicit_created_at_is_searchable():
|
|
152
|
-
# Proves _index_fts fired on the explicit-timestamp INSERT path.
|
|
153
|
-
pd = "C:/proj/lessons"
|
|
154
|
-
memory_db.insert_lesson(
|
|
155
|
-
"searchable timestamped lesson",
|
|
156
|
-
"deployment pipeline runbook for the beta service",
|
|
157
|
-
"lesson",
|
|
158
|
-
project_dir=pd, created_at="2026-02-24 16:55:18",
|
|
159
|
-
)
|
|
160
|
-
results = memory_db.search_memory("deployment pipeline runbook", project_dir=pd)
|
|
161
|
-
assert any("deployment pipeline runbook" in r["content"] for r in results)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/config.py
RENAMED
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/dashboard.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/goal.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/indexer.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/license.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/search.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/server.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/src/memstack_skill_loader/stats.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_advisor_strategy.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_body_parser_metric.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_code_read_savings.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_failed_approaches.py
RENAMED
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_license_environment.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memstack_skill_loader-4.11.0 → memstack_skill_loader-4.12.0}/tests/test_pro_skills_update.py
RENAMED
|
File without changes
|
|
File without changes
|