memstack-skill-loader 4.17.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.
- {memstack_skill_loader-4.17.0/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.18.1}/PKG-INFO +1 -1
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/pyproject.toml +51 -51
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/__init__.py +3 -3
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/diary_ingest.py +193 -35
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/memory_db.py +97 -52
- memstack_skill_loader-4.18.1/src/memstack_skill_loader/memory_migrations.py +852 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_diary_ingest.py +242 -0
- memstack_skill_loader-4.18.1/tests/test_memory_migrations.py +877 -0
- memstack_skill_loader-4.17.0/src/memstack_skill_loader/memory_migrations.py +0 -370
- memstack_skill_loader-4.17.0/tests/test_memory_migrations.py +0 -286
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/MANIFEST.in +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/README.md +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/setup.cfg +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/__main__.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/agent_runner.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/categories.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/compression.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/config.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/dashboard.html +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/dashboard.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/goal.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/goal_engine.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/__init__.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/index_meta.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/indexer.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/license.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/memory_engine.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/__init__.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/_diag.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/compressor.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/server.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/search.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/server.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/session_context.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/skill_config.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/skill_descriptions.json +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/stats.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/tfidf_search.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/version_check.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_advisor_strategy.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_body_parser_metric.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_builder_scope.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_code_read_savings.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_dashscan.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_failed_approaches.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_git_policy.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_index_meta_guard.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_license_environment.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_license_grace.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_memory_engine.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_memory_inspector.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_memory_scoring.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_memory_tools.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_pro_compressor.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_pro_skills_update.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_pro_source_resolution.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_project_dir_canonical.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_search_memory.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_session_context.py +0 -0
- {memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/tests/test_skill_drift.py +0 -0
- {memstack_skill_loader-4.17.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.
|
|
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.
|
|
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"]
|
{memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/__init__.py
RENAMED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
"""MemStack Skill Loader — MCP server for semantic skill search."""
|
|
2
|
-
|
|
3
|
-
__version__ = "4.
|
|
1
|
+
"""MemStack Skill Loader — MCP server for semantic skill search."""
|
|
2
|
+
|
|
3
|
+
__version__ = "4.18.1"
|
|
@@ -6,6 +6,10 @@ module by path, exactly like the devlog-webhook:
|
|
|
6
6
|
|
|
7
7
|
python -m memstack_skill_loader.diary_ingest "<markdown-backup-path>"
|
|
8
8
|
|
|
9
|
+
Pass ``--project NAME`` to override the derived project entirely. That is the
|
|
10
|
+
escape hatch for a project whose real name looks like another project's name
|
|
11
|
+
plus a suffix, which the derivation's third rung would otherwise shorten.
|
|
12
|
+
|
|
9
13
|
Each fact line is stored through :class:`MemoryStore` with ``source_type='diary'``
|
|
10
14
|
and ``source_ref`` set to the diary file path, canonicalized to an absolute
|
|
11
15
|
forward-slash path so the same file re-ingested from any cwd/arg form dedupes.
|
|
@@ -18,10 +22,16 @@ already safe on disk before this starts.
|
|
|
18
22
|
|
|
19
23
|
What changed from "always exits 0": the exit code now classifies the outcome,
|
|
20
24
|
and every run over a diary that HAS a FACTS block prints one stdout summary
|
|
21
|
-
(N ingested, M duplicate, K skipped) plus one indented reason
|
|
22
|
-
Silence used to be the only output on success, which made a
|
|
23
|
-
indistinguishable from a hook that never ran, a wrong path, or a
|
|
24
|
-
parsed to nothing.
|
|
25
|
+
(N ingested, M duplicate, K skipped into project 'X') plus one indented reason
|
|
26
|
+
per skipped line. Silence used to be the only output on success, which made a
|
|
27
|
+
clean ingest indistinguishable from a hook that never ran, a wrong path, or a
|
|
28
|
+
block that parsed to nothing.
|
|
29
|
+
|
|
30
|
+
The summary NAMES THE PROJECT it wrote to, and marks a namespace nothing has
|
|
31
|
+
ever been stored under as NEW. Counting alone was not enough: a diary misfiled
|
|
32
|
+
under a phantom namespace still reported a perfectly clean "17 ingested" and
|
|
33
|
+
exited 0, so the number a reader would check was right while the facts were
|
|
34
|
+
unreachable from any recall for the real project.
|
|
25
35
|
|
|
26
36
|
exit 0 nothing was lost: no FACTS block at all (silent, nothing on stdout),
|
|
27
37
|
or at least one fact ingested, or an all-duplicates re-run of an
|
|
@@ -230,54 +240,122 @@ def parse_facts_block(text: str) -> FactsParse:
|
|
|
230
240
|
return FactsParse(present=True, facts=facts, errors=errors, linenos=linenos)
|
|
231
241
|
|
|
232
242
|
|
|
233
|
-
def _resolves(name: str) -> bool:
|
|
243
|
+
def _resolves(name: str, failures: list[str] | None = None) -> bool:
|
|
234
244
|
"""True if the store already knows a project directory with this basename.
|
|
235
245
|
|
|
236
246
|
Any non-empty result counts. Two hits mean an ambiguous basename collision,
|
|
237
247
|
not "unknown" -- we only need to know the name exists, never which directory
|
|
238
248
|
it maps to. Fail-open: an unreachable store answers "unknown", which leaves
|
|
239
249
|
the raw slug in place rather than breaking the diary save.
|
|
250
|
+
|
|
251
|
+
Failing open is still right, but it used to be SILENT, and a silent
|
|
252
|
+
"unknown" is indistinguishable from a real one: an unreachable store minted
|
|
253
|
+
a phantom namespace for every project on the machine and said nothing. So
|
|
254
|
+
the first failure of a derivation warns, naming only the exception CLASS.
|
|
255
|
+
The exception's message is never echoed -- it is attacker-influenced free
|
|
256
|
+
text in the same sense a claim is, and the secrets policy forbids printing
|
|
257
|
+
what has not cleared an allowlist.
|
|
258
|
+
|
|
259
|
+
``failures`` collects the classes seen so one derivation warns once rather
|
|
260
|
+
than once per rung; passing None keeps the single-shot behavior for direct
|
|
261
|
+
callers.
|
|
240
262
|
"""
|
|
241
263
|
try:
|
|
242
264
|
return bool(find_project_dirs_by_name(name))
|
|
243
|
-
except Exception: # noqa: BLE001 -- never break the diary-save path
|
|
265
|
+
except Exception as exc: # noqa: BLE001 -- never break the diary-save path
|
|
266
|
+
if not failures:
|
|
267
|
+
_warn(
|
|
268
|
+
f"project lookup failed ({type(exc).__name__}); cannot tell a "
|
|
269
|
+
f"known project from a new one, so the derived slug stands as-is"
|
|
270
|
+
)
|
|
271
|
+
if failures is not None:
|
|
272
|
+
failures.append(type(exc).__name__)
|
|
244
273
|
return False
|
|
245
274
|
|
|
246
275
|
|
|
247
|
-
def
|
|
248
|
-
"""Derive a
|
|
276
|
+
def _derive_project(path: str) -> tuple[str, bool]:
|
|
277
|
+
"""Derive a project slug from a diary filename, and say whether it resolved.
|
|
278
|
+
|
|
279
|
+
Returns ``(slug, resolved)``. ``resolved`` is True when some rung matched a
|
|
280
|
+
project the store already knows, and False when every rung fell through and
|
|
281
|
+
the raw slug is standing on its own. The caller needs that second value to
|
|
282
|
+
tell "this diary belongs to a known project" from "this diary is about to
|
|
283
|
+
mint a namespace nobody has ever seen", which is the distinction the summary
|
|
284
|
+
line now reports.
|
|
249
285
|
|
|
250
286
|
``2026-07-20-memstack-skill-loader.md`` -> ``memstack-skill-loader``.
|
|
251
287
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
288
|
+
Three rungs, tried in order, first hit wins:
|
|
289
|
+
|
|
290
|
+
1. The raw slug, exactly. Checking raw FIRST is what keeps a project
|
|
291
|
+
genuinely named with a trailing number (``proj-2``) or a name that merely
|
|
292
|
+
looks like a suffix resolving to itself: a suffix is only ever stripped
|
|
293
|
+
when doing so turns an unknown name into a known one.
|
|
294
|
+
2. A trailing ``-<digits>`` stripped. The diary skill writes
|
|
295
|
+
``{date}-{project}-N.md`` when a project already has a diary for that
|
|
296
|
+
date, and that ``-3`` used to become a separate project.
|
|
297
|
+
3. Trailing ``-segments`` stripped progressively, longest first. A diary
|
|
298
|
+
named ``{date}-{project}-{topic}.md`` -- the documented format is
|
|
299
|
+
``{date}-{project}.md``, but descriptive names are written constantly --
|
|
300
|
+
filed every fact under ``{project}-{topic}``, invisible to any recall for
|
|
301
|
+
``{project}``. ``foreman-turn-record`` tries ``foreman-turn``, then
|
|
302
|
+
``foreman``. 495 facts across 37 phantom namespaces accumulated this way
|
|
303
|
+
before rung 3 existed.
|
|
304
|
+
|
|
305
|
+
Rung 3 is bounded by the segment count and never strips to empty, so the
|
|
306
|
+
last candidate it can offer is the first segment alone.
|
|
307
|
+
|
|
308
|
+
If nothing resolves the raw slug stands, exactly as before, and downstream
|
|
309
|
+
auto-registration handles the genuinely-new project -- but the caller now
|
|
310
|
+
knows to say so out loud.
|
|
311
|
+
|
|
312
|
+
Rung 3 is deliberately greedy, and that trades one silent failure for a
|
|
313
|
+
loud, correctable one: a genuinely distinct project whose name begins with
|
|
314
|
+
a known project's name (``deedstack-mobile`` alongside ``deedstack``) will
|
|
315
|
+
resolve to the shorter one and SAY it did. The escape hatch is the
|
|
316
|
+
``--project`` flag, which skips derivation entirely.
|
|
263
317
|
"""
|
|
264
318
|
stem = Path(path).stem
|
|
265
319
|
m = _DATE_PREFIX.match(stem)
|
|
266
320
|
raw = m.group(1) if m else stem
|
|
267
321
|
slug = re.sub(r"[^a-z0-9_-]+", "-", raw.lower()).strip("-_") or "default"
|
|
268
322
|
|
|
269
|
-
|
|
270
|
-
|
|
323
|
+
# One list per derivation, so an unreachable store warns once, not per rung.
|
|
324
|
+
failures: list[str] = []
|
|
325
|
+
|
|
326
|
+
if _resolves(slug, failures):
|
|
327
|
+
return slug, True
|
|
271
328
|
|
|
272
329
|
stripped = _SUFFIX_N.sub("", slug)
|
|
273
|
-
if stripped and stripped != slug and _resolves(stripped):
|
|
330
|
+
if stripped and stripped != slug and _resolves(stripped, failures):
|
|
274
331
|
_warn(
|
|
275
332
|
f"project {slug!r} is unknown but {stripped!r} is a known project -- "
|
|
276
333
|
f"ingesting under {stripped!r} (stripped a trailing -N diary suffix)"
|
|
277
334
|
)
|
|
278
|
-
return stripped
|
|
335
|
+
return stripped, True
|
|
336
|
+
|
|
337
|
+
# Rung 3: drop trailing '-segments' one at a time, longest candidate first.
|
|
338
|
+
# Bounded by the segment count; the loop stops with one segment left, so a
|
|
339
|
+
# candidate is never empty.
|
|
340
|
+
segments = slug.split("-")
|
|
341
|
+
for cut in range(len(segments) - 1, 0, -1):
|
|
342
|
+
candidate = "-".join(segments[:cut])
|
|
343
|
+
if candidate == stripped and stripped != slug:
|
|
344
|
+
continue # rung 2 already asked about this exact name
|
|
345
|
+
if _resolves(candidate, failures):
|
|
346
|
+
_warn(
|
|
347
|
+
f"project {slug!r} is unknown but {candidate!r} is a known "
|
|
348
|
+
f"project -- ingesting under {candidate!r} (stripped a "
|
|
349
|
+
f"descriptive diary suffix)"
|
|
350
|
+
)
|
|
351
|
+
return candidate, True
|
|
352
|
+
|
|
353
|
+
return slug, False
|
|
354
|
+
|
|
279
355
|
|
|
280
|
-
|
|
356
|
+
def _project_from_path(path: str) -> str:
|
|
357
|
+
"""The derived slug alone, for callers that do not need the resolved flag."""
|
|
358
|
+
return _derive_project(path)[0]
|
|
281
359
|
|
|
282
360
|
|
|
283
361
|
def _canonical_source_ref(path: str) -> str:
|
|
@@ -337,16 +415,27 @@ def _summary(
|
|
|
337
415
|
}
|
|
338
416
|
|
|
339
417
|
|
|
340
|
-
def _report(path: str, summary: dict) -> None:
|
|
418
|
+
def _report(path: str, summary: dict, project: str, is_new: bool) -> None:
|
|
341
419
|
"""Print the one stdout summary. Callers can read this; stderr they ignore.
|
|
342
420
|
|
|
343
421
|
Only ever called for a diary that HAS a FACTS block, so a diary without one
|
|
344
422
|
stays silent on stdout as well as stderr.
|
|
423
|
+
|
|
424
|
+
The summary NAMES THE NAMESPACE IT WROTE TO. It did not, and that omission
|
|
425
|
+
is what let a whole class of misfiling stay invisible: every phantom-project
|
|
426
|
+
ingest printed a clean "N ingested" and exited 0, so the one number a reader
|
|
427
|
+
would have checked was correct and the one fact they needed was absent.
|
|
428
|
+
``NEW`` marks a namespace no fact has ever been stored under, which is the
|
|
429
|
+
single cheapest signal that a derivation went somewhere unintended.
|
|
430
|
+
|
|
431
|
+
Everything stays on ONE line on purpose. A clean run must not emit a second
|
|
432
|
+
line, and must not put the word 'skipped' anywhere but the summary.
|
|
345
433
|
"""
|
|
434
|
+
label = f"NEW project {project!r}" if is_new else f"project {project!r}"
|
|
346
435
|
print(
|
|
347
436
|
f"{_PREFIX} {summary['ingested']} ingested, "
|
|
348
|
-
f"{summary['duplicates']} duplicate, {len(summary['skipped'])} skipped
|
|
349
|
-
f"{path}"
|
|
437
|
+
f"{summary['duplicates']} duplicate, {len(summary['skipped'])} skipped "
|
|
438
|
+
f"into {label}: {path}"
|
|
350
439
|
)
|
|
351
440
|
for reason in summary["skipped"]:
|
|
352
441
|
print(f" skipped {reason}")
|
|
@@ -359,13 +448,22 @@ def _report(path: str, summary: dict) -> None:
|
|
|
359
448
|
)
|
|
360
449
|
|
|
361
450
|
|
|
362
|
-
def ingest_diary_file(
|
|
451
|
+
def ingest_diary_file(
|
|
452
|
+
path: str,
|
|
453
|
+
store: MemoryStore | None = None,
|
|
454
|
+
project: str | None = None,
|
|
455
|
+
) -> dict:
|
|
363
456
|
"""Parse and ingest the FACTS block from a saved diary markdown file.
|
|
364
457
|
|
|
365
458
|
Fail-open: returns a summary dict and never raises. Each problem prints one
|
|
366
459
|
stderr line, and a diary with a FACTS block also gets one stdout summary.
|
|
367
460
|
Returns ``{present, ingested, duplicates, attempted, skipped, errors,
|
|
368
|
-
store_failure, exit_code}``.
|
|
461
|
+
store_failure, exit_code, project}``.
|
|
462
|
+
|
|
463
|
+
``project`` overrides filename derivation entirely and silently: an explicit
|
|
464
|
+
name is a decision already made, so there is nothing to warn about. It is
|
|
465
|
+
the escape hatch for a project whose real name looks like another project's
|
|
466
|
+
name plus a suffix, which rung 3 of the derivation would otherwise shorten.
|
|
369
467
|
"""
|
|
370
468
|
try:
|
|
371
469
|
text = Path(path).read_text(encoding="utf-8")
|
|
@@ -385,7 +483,29 @@ def ingest_diary_file(path: str, store: MemoryStore | None = None) -> dict:
|
|
|
385
483
|
|
|
386
484
|
if store is None:
|
|
387
485
|
store = MemoryStore()
|
|
388
|
-
|
|
486
|
+
|
|
487
|
+
if project:
|
|
488
|
+
# An explicit name is a decision already made: no derivation, no warning.
|
|
489
|
+
resolved = True
|
|
490
|
+
else:
|
|
491
|
+
project, resolved = _derive_project(path)
|
|
492
|
+
|
|
493
|
+
# "NEW" means no fact has EVER been stored under this namespace, asked
|
|
494
|
+
# before this run writes any. Deliberately a different question from
|
|
495
|
+
# _resolves, which consults the five legacy project_dir tables: a namespace
|
|
496
|
+
# that only ever held diary facts is invisible there, so it could never
|
|
497
|
+
# stop being "unknown" no matter how much it accumulated. This check reads
|
|
498
|
+
# memory_facts itself, so a phantom is announced once and only once.
|
|
499
|
+
is_new = False
|
|
500
|
+
if not resolved:
|
|
501
|
+
try:
|
|
502
|
+
is_new = store.stats(project=project)["total"] == 0
|
|
503
|
+
except Exception as exc: # noqa: BLE001 -- never break the diary save
|
|
504
|
+
_warn(
|
|
505
|
+
f"could not check whether {project!r} is a new namespace "
|
|
506
|
+
f"({type(exc).__name__}); reporting it without the NEW marker"
|
|
507
|
+
)
|
|
508
|
+
|
|
389
509
|
source_ref = _canonical_source_ref(path)
|
|
390
510
|
|
|
391
511
|
skipped: list[str] = list(parsed.errors) # malformed lines, already warned
|
|
@@ -437,26 +557,64 @@ def ingest_diary_file(path: str, store: MemoryStore | None = None) -> dict:
|
|
|
437
557
|
skipped=skipped,
|
|
438
558
|
store_failure=store_failure,
|
|
439
559
|
)
|
|
440
|
-
|
|
560
|
+
summary["project"] = project
|
|
561
|
+
_report(path, summary, project, is_new)
|
|
441
562
|
return summary
|
|
442
563
|
|
|
443
564
|
|
|
565
|
+
def _parse_argv(args: list[str]) -> tuple[str | None, str | None]:
|
|
566
|
+
"""Split the CLI tail into ``(path, project)``. Never raises.
|
|
567
|
+
|
|
568
|
+
Accepts ``--project NAME`` and ``--project=NAME``. Unparseable input loses
|
|
569
|
+
nothing, so it warns and falls back to derivation rather than failing.
|
|
570
|
+
"""
|
|
571
|
+
path: str | None = None
|
|
572
|
+
project: str | None = None
|
|
573
|
+
i = 0
|
|
574
|
+
while i < len(args):
|
|
575
|
+
arg = args[i]
|
|
576
|
+
if arg == "--project":
|
|
577
|
+
if i + 1 < len(args) and args[i + 1] and not args[i + 1].startswith("--"):
|
|
578
|
+
project = args[i + 1]
|
|
579
|
+
i += 2
|
|
580
|
+
continue
|
|
581
|
+
_warn("--project needs a value; deriving the project from the filename")
|
|
582
|
+
i += 1
|
|
583
|
+
continue
|
|
584
|
+
if arg.startswith("--project="):
|
|
585
|
+
value = arg.split("=", 1)[1]
|
|
586
|
+
if value:
|
|
587
|
+
project = value
|
|
588
|
+
else:
|
|
589
|
+
_warn("--project needs a value; deriving the project from the filename")
|
|
590
|
+
i += 1
|
|
591
|
+
continue
|
|
592
|
+
if path is None:
|
|
593
|
+
path = arg
|
|
594
|
+
i += 1
|
|
595
|
+
return path, project
|
|
596
|
+
|
|
597
|
+
|
|
444
598
|
def main(argv: list[str]) -> int:
|
|
445
599
|
"""Hook entry point. Never raises; the exit code classifies the outcome.
|
|
446
600
|
|
|
447
601
|
0 = nothing lost, 1 = content total loss, 2 = store failure. See the
|
|
448
|
-
FAIL-OPEN CONTRACT in the module docstring.
|
|
602
|
+
FAIL-OPEN CONTRACT in the module docstring. Nothing added here changes an
|
|
603
|
+
exit code: every new warning path is still 0.
|
|
604
|
+
|
|
605
|
+
Usage: ``diary_ingest <diary.md> [--project NAME]``
|
|
449
606
|
"""
|
|
450
|
-
|
|
607
|
+
path, project = _parse_argv(list(argv[1:]))
|
|
608
|
+
if not path:
|
|
451
609
|
# A caller that passed no path lost nothing, because nothing was read.
|
|
452
610
|
_warn("no diary path provided; nothing to ingest")
|
|
453
611
|
return 0
|
|
454
612
|
try:
|
|
455
|
-
return ingest_diary_file(
|
|
613
|
+
return ingest_diary_file(path, project=project)["exit_code"]
|
|
456
614
|
except Exception as exc:
|
|
457
615
|
# Absolute backstop: still never raises, but this is the store or
|
|
458
616
|
# environment class of failure, so it is loud rather than silent.
|
|
459
|
-
_warn(f"unexpected error ingesting {
|
|
617
|
+
_warn(f"unexpected error ingesting {path!r}: {exc}")
|
|
460
618
|
print(
|
|
461
619
|
f"{_PREFIX} aborted, nothing ingested: {type(exc).__name__}: {exc}"
|
|
462
620
|
)
|
{memstack_skill_loader-4.17.0 → memstack_skill_loader-4.18.1}/src/memstack_skill_loader/memory_db.py
RENAMED
|
@@ -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=
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
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
|
|