memstack-skill-loader 4.15.1__tar.gz → 4.16.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.
Potentially problematic release.
This version of memstack-skill-loader might be problematic. Click here for more details.
- {memstack_skill_loader-4.15.1/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.16.0}/PKG-INFO +1 -1
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/pyproject.toml +1 -1
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/__init__.py +1 -1
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/dashboard.html +21 -1
- memstack_skill_loader-4.16.0/src/memstack_skill_loader/diary_ingest.py +467 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/server.py +44 -3
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_diary_ingest.py +249 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_tools.py +32 -0
- memstack_skill_loader-4.15.1/src/memstack_skill_loader/diary_ingest.py +0 -278
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/MANIFEST.in +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/README.md +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/setup.cfg +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/__main__.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/agent_runner.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/categories.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/compression.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/config.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/dashboard.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/goal.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/goal_engine.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/index_meta.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/indexer.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/license.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/memory_db.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/memory_engine.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/server.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/search.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/session_context.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/skill_config.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/skill_descriptions.json +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/stats.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/version_check.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_advisor_strategy.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_body_parser_metric.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_builder_scope.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_code_read_savings.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_dashscan.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_failed_approaches.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_git_policy.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_index_meta_guard.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_license_environment.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_license_grace.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_engine.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_inspector.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_scoring.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_pro_compressor.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_pro_skills_update.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_pro_source_resolution.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_project_dir_canonical.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_search_memory.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_session_context.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_skill_drift.py +0 -0
- {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/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.16.0
|
|
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
|
|
@@ -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.16.0"
|
|
8
8
|
description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -2141,7 +2141,7 @@
|
|
|
2141
2141
|
<button class="mcp-tools-save-btn" style="background:#30363d;" onclick="verifyLicenseKey()">Verify</button>
|
|
2142
2142
|
<span id="licenseKeyStatus" style="font-size:0.75rem;color:#484f58;"></span>
|
|
2143
2143
|
</div>
|
|
2144
|
-
<p style="color:#484f58;font-size:0.72rem;margin:0.35rem 0 0;">Unlocks Pro-tier compression and
|
|
2144
|
+
<p style="color:#484f58;font-size:0.72rem;margin:0.35rem 0 0;">Unlocks Pro-tier compression and <span id="settings-pro-skill-count">all</span> exclusive skills. Stored at ~/.memstack/license.json.</p>
|
|
2145
2145
|
</div>
|
|
2146
2146
|
</div>
|
|
2147
2147
|
|
|
@@ -5370,6 +5370,7 @@ async function loadSettings() {
|
|
|
5370
5370
|
loadGitWorkflowGlobal();
|
|
5371
5371
|
loadAgentModes();
|
|
5372
5372
|
loadLicenseStatus();
|
|
5373
|
+
loadProSkillCount();
|
|
5373
5374
|
settingsLoaded = true;
|
|
5374
5375
|
} catch (e) {
|
|
5375
5376
|
console.error('Settings load failed:', e);
|
|
@@ -5396,6 +5397,25 @@ async function loadLicenseStatus() {
|
|
|
5396
5397
|
}
|
|
5397
5398
|
}
|
|
5398
5399
|
|
|
5400
|
+
// Pro skill count in the licence blurb, derived from /api/skills rather than
|
|
5401
|
+
// written into the markup -- a literal here read "42" while the enforced count
|
|
5402
|
+
// was 44. The static text says "all", so a failed or index-less fetch degrades
|
|
5403
|
+
// to a sentence that is still true rather than to a stale number.
|
|
5404
|
+
async function loadProSkillCount() {
|
|
5405
|
+
const el = document.getElementById('settings-pro-skill-count');
|
|
5406
|
+
if (!el) return;
|
|
5407
|
+
try {
|
|
5408
|
+
const res = await fetch('/api/skills', {headers: AUTH_GET});
|
|
5409
|
+
const d = await res.json();
|
|
5410
|
+
// Absent/unreadable index responses omit display_pro_count entirely and
|
|
5411
|
+
// report pro_count 0; neither should overwrite the fallback.
|
|
5412
|
+
const n = d.display_pro_count;
|
|
5413
|
+
if (Number.isFinite(n) && n > 0) el.textContent = n;
|
|
5414
|
+
} catch (e) {
|
|
5415
|
+
/* keep the "all" fallback; a count is not worth a broken panel */
|
|
5416
|
+
}
|
|
5417
|
+
}
|
|
5418
|
+
|
|
5399
5419
|
async function saveLicenseKey() {
|
|
5400
5420
|
const input = document.getElementById('licenseKeyInput');
|
|
5401
5421
|
const statusEl = document.getElementById('licenseKeyStatus');
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Diary FACTS-block ingestion — the post-diary-save hook for the Memory Engine.
|
|
2
|
+
|
|
3
|
+
The diary skill appends a ``## FACTS`` block to the markdown backup it writes to
|
|
4
|
+
``memory/sessions/{date}-{project}.md``. After the save, the skill invokes this
|
|
5
|
+
module by path, exactly like the devlog-webhook:
|
|
6
|
+
|
|
7
|
+
python -m memstack_skill_loader.diary_ingest "<markdown-backup-path>"
|
|
8
|
+
|
|
9
|
+
Each fact line is stored through :class:`MemoryStore` with ``source_type='diary'``
|
|
10
|
+
and ``source_ref`` set to the diary file path, canonicalized to an absolute
|
|
11
|
+
forward-slash path so the same file re-ingested from any cwd/arg form dedupes.
|
|
12
|
+
|
|
13
|
+
FAIL-OPEN CONTRACT (do not "improve" away)
|
|
14
|
+
------------------------------------------
|
|
15
|
+
Ingestion must NEVER break a diary save, and it never raises: every failure is
|
|
16
|
+
caught and classified. It runs AFTER the markdown is written, so the diary is
|
|
17
|
+
already safe on disk before this starts.
|
|
18
|
+
|
|
19
|
+
What changed from "always exits 0": the exit code now classifies the outcome,
|
|
20
|
+
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 per skipped line.
|
|
22
|
+
Silence used to be the only output on success, which made a clean ingest
|
|
23
|
+
indistinguishable from a hook that never ran, a wrong path, or a block that
|
|
24
|
+
parsed to nothing.
|
|
25
|
+
|
|
26
|
+
exit 0 nothing was lost: no FACTS block at all (silent, nothing on stdout),
|
|
27
|
+
or at least one fact ingested, or an all-duplicates re-run of an
|
|
28
|
+
already-ingested diary (idempotency is success, not failure)
|
|
29
|
+
exit 1 content total loss: the block held at least one line, and zero facts
|
|
30
|
+
ingested AND zero duplicates. Every line was malformed or rejected.
|
|
31
|
+
exit 2 store failure: the store raised something that is NOT a
|
|
32
|
+
MemoryEngineError (IntegrityError, OperationalError), or an
|
|
33
|
+
unexpected error reached the backstop in ``main``.
|
|
34
|
+
|
|
35
|
+
Two classes of failure, deliberately separated:
|
|
36
|
+
|
|
37
|
+
* A BAD FACT (a malformed line, or a MemoryEngineError such as an invalid
|
|
38
|
+
subject or field) is that line's fault. It is skipped and counted, the rest of
|
|
39
|
+
the block still ingests, and on its own it does not change the exit code.
|
|
40
|
+
* A BAD STORE (any other exception) is not the line's fault. The loop stops at
|
|
41
|
+
the FIRST one: if the schema rejects row 1 it will reject row 20, and
|
|
42
|
+
continuing buries the one real cause under nineteen identical warnings.
|
|
43
|
+
|
|
44
|
+
Every problem still prints exactly one loud stderr line prefixed
|
|
45
|
+
``memory-ingest:``. A diary with no FACTS block stays completely silent on both
|
|
46
|
+
streams.
|
|
47
|
+
|
|
48
|
+
Idempotency: re-running on the same entry stores nothing new. Facts are deduped
|
|
49
|
+
by (source_ref, subject, claim) via ``MemoryStore.fact_exists``.
|
|
50
|
+
|
|
51
|
+
REASON STRINGS NEVER ECHO THE CLAIM (do not "improve" away either)
|
|
52
|
+
-----------------------------------------------------------------
|
|
53
|
+
Reason strings now reach stdout, not only stderr. The claim is the free-text
|
|
54
|
+
field, so it is where a pasted shell command or an API key would sit if one ever
|
|
55
|
+
landed in a FACTS block, and the pipe-overflow case is the single most likely
|
|
56
|
+
line to hold one. No reason string interpolates the claim. Subject and method
|
|
57
|
+
tokens are echoed only via :func:`_safe_subject` / :func:`_safe_method`, which
|
|
58
|
+
are ALLOWLISTS (dotted-path shape, bare word) rather than redaction patterns: a
|
|
59
|
+
value that does not match is described, never printed. See the secrets policy in
|
|
60
|
+
CLAUDE.md, which forbids denylist redaction for exactly this reason.
|
|
61
|
+
|
|
62
|
+
FACTS block format
|
|
63
|
+
------------------
|
|
64
|
+
A section whose header is ``## FACTS`` / ``FACTS:`` (case-insensitive). Each
|
|
65
|
+
non-blank, non-comment line until the next markdown header is one fact:
|
|
66
|
+
|
|
67
|
+
subject | claim | method [| entities]
|
|
68
|
+
|
|
69
|
+
- ``subject`` dotted-path (lowercase segments joined by '.')
|
|
70
|
+
- ``claim`` one assertion; must not contain '|'
|
|
71
|
+
- ``method`` one of verified / reported / inferred / assumed
|
|
72
|
+
- ``entities`` optional 4th field: comma-separated, optional ``entities:`` prefix
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import re
|
|
78
|
+
import sys
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
from typing import NamedTuple
|
|
81
|
+
|
|
82
|
+
from . import memory_engine
|
|
83
|
+
from .memory_engine import MemoryStore
|
|
84
|
+
from .memory_db import find_project_dirs_by_name
|
|
85
|
+
|
|
86
|
+
#: Prefix on every line this module emits, on either stream.
|
|
87
|
+
_PREFIX = "memory-ingest:"
|
|
88
|
+
#: Retained name for the stderr path (same value; kept so nothing referring to
|
|
89
|
+
#: the old constant breaks).
|
|
90
|
+
_STDERR_PREFIX = _PREFIX
|
|
91
|
+
|
|
92
|
+
#: Longest token ever echoed into a reason string.
|
|
93
|
+
_MAX_ECHO = 60
|
|
94
|
+
#: Allowlist shape for an echoable method token: a bare alphabetic word.
|
|
95
|
+
_SAFE_METHOD_TOKEN = re.compile(r"^[A-Za-z]{1,24}$")
|
|
96
|
+
|
|
97
|
+
_FACTS_HEADER = re.compile(r"^\s*#*\s*FACTS:?\s*$", re.IGNORECASE)
|
|
98
|
+
_MD_HEADER = re.compile(r"^\s*#{1,6}\s+\S")
|
|
99
|
+
_COMMENT = re.compile(r"^\s*<!--.*-->\s*$")
|
|
100
|
+
_ENTITIES_PREFIX = re.compile(r"^entities:\s*", re.IGNORECASE)
|
|
101
|
+
_DATE_PREFIX = re.compile(r"^\d{4}-\d{2}-\d{2}-(.+)$")
|
|
102
|
+
_SUFFIX_N = re.compile(r"-\d+$")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FactsParse(NamedTuple):
|
|
106
|
+
present: bool # was a FACTS header found at all?
|
|
107
|
+
facts: list[dict] # {subject, claim, method, entities}
|
|
108
|
+
errors: list[str] # one message per malformed line, naming it
|
|
109
|
+
linenos: list[int] # source line of each entry in `facts`, index-aligned
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def attempted(self) -> int:
|
|
113
|
+
"""Non-blank, non-comment lines the block actually offered."""
|
|
114
|
+
return len(self.facts) + len(self.errors)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _safe_subject(field: str) -> str:
|
|
118
|
+
"""Render the subject field for a reason string, or say why it is withheld.
|
|
119
|
+
|
|
120
|
+
ALLOWLIST, not a denylist. The value is echoed only when it matches the
|
|
121
|
+
dotted-path shape and fits in :data:`_MAX_ECHO`. A string of that shape
|
|
122
|
+
cannot contain a space, '=', '/', '+' or a quote, so it cannot carry a shell
|
|
123
|
+
fragment, a URL, or a base64 credential. Anything else is described.
|
|
124
|
+
"""
|
|
125
|
+
value = (field or "").strip()
|
|
126
|
+
if value and len(value) <= _MAX_ECHO and memory_engine._DOTTED_PATH.match(value):
|
|
127
|
+
return f"subject '{value}'"
|
|
128
|
+
return "subject not shown (not a dotted-path)"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _safe_method(field: str) -> str:
|
|
132
|
+
"""Render the method token for a reason string, or say why it is withheld.
|
|
133
|
+
|
|
134
|
+
Same allowlist reasoning as :func:`_safe_subject`. Echoing the token is what
|
|
135
|
+
makes a typo ('verifed') diagnosable at a glance; a bare alphabetic word
|
|
136
|
+
cannot hold a delimiter, a path, or a key.
|
|
137
|
+
"""
|
|
138
|
+
value = (field or "").strip()
|
|
139
|
+
if _SAFE_METHOD_TOKEN.match(value):
|
|
140
|
+
return f"'{value}'"
|
|
141
|
+
return "(not shown: not a bare word)"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_entities(field: str) -> list[str]:
|
|
145
|
+
field = _ENTITIES_PREFIX.sub("", field.strip())
|
|
146
|
+
return [e.strip() for e in field.split(",") if e.strip()]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_fact_line(raw: str, lineno: int):
|
|
150
|
+
"""Return (fact_dict, None) or (None, error_message).
|
|
151
|
+
|
|
152
|
+
Reason strings name the line number, what was parsed structurally (field
|
|
153
|
+
counts), and the subject/method tokens when those clear the allowlists. They
|
|
154
|
+
never interpolate the raw line or the claim: see the module docstring.
|
|
155
|
+
"""
|
|
156
|
+
parts = [p.strip() for p in raw.split("|")]
|
|
157
|
+
if len(parts) < 3:
|
|
158
|
+
if len(parts) == 1:
|
|
159
|
+
# No delimiter at all, so there is no subject field to name: parts[0]
|
|
160
|
+
# here IS the whole line, which is exactly what must not be echoed.
|
|
161
|
+
return None, (
|
|
162
|
+
f"line {lineno}: expected 'subject | claim | method "
|
|
163
|
+
f"[| entities]', got 1 field (no '|' delimiter found)"
|
|
164
|
+
)
|
|
165
|
+
return None, (
|
|
166
|
+
f"line {lineno}: expected 'subject | claim | method [| entities]', "
|
|
167
|
+
f"got {len(parts)} fields; {_safe_subject(parts[0])}"
|
|
168
|
+
)
|
|
169
|
+
if len(parts) > 4:
|
|
170
|
+
return None, (
|
|
171
|
+
f"line {lineno}: too many '|'-fields ({len(parts)}, max 4); subject "
|
|
172
|
+
f"and claim must not contain '|'; {_safe_subject(parts[0])}"
|
|
173
|
+
)
|
|
174
|
+
subject, claim, method = parts[0], parts[1], parts[2]
|
|
175
|
+
entities_field = parts[3] if len(parts) == 4 else ""
|
|
176
|
+
|
|
177
|
+
if not subject or not memory_engine._DOTTED_PATH.match(subject):
|
|
178
|
+
return None, f"line {lineno}: invalid subject (expected lowercase dotted-path)"
|
|
179
|
+
if not claim:
|
|
180
|
+
return None, f"line {lineno}: empty claim; {_safe_subject(subject)}"
|
|
181
|
+
method_lc = method.lower()
|
|
182
|
+
if method_lc not in memory_engine._VALID_METHODS:
|
|
183
|
+
return None, (
|
|
184
|
+
f"line {lineno}: invalid method {_safe_method(method)} (expected one "
|
|
185
|
+
f"of verified/reported/inferred/assumed); {_safe_subject(subject)}"
|
|
186
|
+
)
|
|
187
|
+
return {
|
|
188
|
+
"subject": subject,
|
|
189
|
+
"claim": claim,
|
|
190
|
+
"method": method_lc,
|
|
191
|
+
"entities": _parse_entities(entities_field),
|
|
192
|
+
}, None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse_facts_block(text: str) -> FactsParse:
|
|
196
|
+
"""Parse the FACTS block out of a diary markdown document.
|
|
197
|
+
|
|
198
|
+
Never raises. If no FACTS header is present, returns ``present=False`` with
|
|
199
|
+
empty facts/errors (silent-success case). Otherwise returns the good facts
|
|
200
|
+
and one error string per malformed line.
|
|
201
|
+
"""
|
|
202
|
+
lines = (text or "").splitlines()
|
|
203
|
+
start = None
|
|
204
|
+
for i, line in enumerate(lines):
|
|
205
|
+
if _FACTS_HEADER.match(line):
|
|
206
|
+
start = i + 1
|
|
207
|
+
break
|
|
208
|
+
if start is None:
|
|
209
|
+
return FactsParse(present=False, facts=[], errors=[], linenos=[])
|
|
210
|
+
|
|
211
|
+
facts: list[dict] = []
|
|
212
|
+
errors: list[str] = []
|
|
213
|
+
linenos: list[int] = []
|
|
214
|
+
for j in range(start, len(lines)):
|
|
215
|
+
raw = lines[j]
|
|
216
|
+
if _MD_HEADER.match(raw):
|
|
217
|
+
break # next section ends the block
|
|
218
|
+
if not raw.strip() or _COMMENT.match(raw):
|
|
219
|
+
continue
|
|
220
|
+
fact, err = _parse_fact_line(raw, j + 1)
|
|
221
|
+
if err:
|
|
222
|
+
errors.append(err)
|
|
223
|
+
else:
|
|
224
|
+
facts.append(fact)
|
|
225
|
+
# Kept alongside rather than inside the fact dict: the cross-repo
|
|
226
|
+
# drift guard compares parsed fact dicts from this repo and the
|
|
227
|
+
# skill's examples for equality, and a line number in there would
|
|
228
|
+
# make that guard fail on a blank line moving in the skill.
|
|
229
|
+
linenos.append(j + 1)
|
|
230
|
+
return FactsParse(present=True, facts=facts, errors=errors, linenos=linenos)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _resolves(name: str) -> bool:
|
|
234
|
+
"""True if the store already knows a project directory with this basename.
|
|
235
|
+
|
|
236
|
+
Any non-empty result counts. Two hits mean an ambiguous basename collision,
|
|
237
|
+
not "unknown" -- we only need to know the name exists, never which directory
|
|
238
|
+
it maps to. Fail-open: an unreachable store answers "unknown", which leaves
|
|
239
|
+
the raw slug in place rather than breaking the diary save.
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
return bool(find_project_dirs_by_name(name))
|
|
243
|
+
except Exception: # noqa: BLE001 -- never break the diary-save path
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _project_from_path(path: str) -> str:
|
|
248
|
+
"""Derive a dotted-path project slug from a diary filename.
|
|
249
|
+
|
|
250
|
+
``2026-07-20-memstack-skill-loader.md`` -> ``memstack-skill-loader``.
|
|
251
|
+
|
|
252
|
+
The diary skill writes ``{date}-{project}-N.md`` when a project already has a
|
|
253
|
+
diary for that date. Deriving the slug from the filename alone turned that
|
|
254
|
+
trailing ``-3`` into a separate project, stranding every ingested fact under
|
|
255
|
+
a phantom slug that no recall for the real project would ever reach.
|
|
256
|
+
|
|
257
|
+
So the raw name is checked against the store first, and only if it is unknown
|
|
258
|
+
do we try stripping a trailing ``-<digits>``. Checking raw first is what keeps
|
|
259
|
+
a project *genuinely* named with a trailing number (``proj-2``) resolving to
|
|
260
|
+
itself -- the suffix is only ever stripped when doing so turns an unknown name
|
|
261
|
+
into a known one. If neither resolves, the raw slug stands and downstream
|
|
262
|
+
auto-registration handles the genuinely-new project as before.
|
|
263
|
+
"""
|
|
264
|
+
stem = Path(path).stem
|
|
265
|
+
m = _DATE_PREFIX.match(stem)
|
|
266
|
+
raw = m.group(1) if m else stem
|
|
267
|
+
slug = re.sub(r"[^a-z0-9_-]+", "-", raw.lower()).strip("-_") or "default"
|
|
268
|
+
|
|
269
|
+
if _resolves(slug):
|
|
270
|
+
return slug
|
|
271
|
+
|
|
272
|
+
stripped = _SUFFIX_N.sub("", slug)
|
|
273
|
+
if stripped and stripped != slug and _resolves(stripped):
|
|
274
|
+
_warn(
|
|
275
|
+
f"project {slug!r} is unknown but {stripped!r} is a known project -- "
|
|
276
|
+
f"ingesting under {stripped!r} (stripped a trailing -N diary suffix)"
|
|
277
|
+
)
|
|
278
|
+
return stripped
|
|
279
|
+
|
|
280
|
+
return slug
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _canonical_source_ref(path: str) -> str:
|
|
284
|
+
"""Canonicalize a diary path into a stable dedupe key.
|
|
285
|
+
|
|
286
|
+
The Memory Engine dedupe key is ``(source_ref, subject, claim)`` compared as
|
|
287
|
+
raw strings. A diary saved once via a relative path and once via an absolute
|
|
288
|
+
path therefore produced two different ``source_ref`` strings and re-stored
|
|
289
|
+
every fact as a duplicate live row. Resolving to a real absolute path
|
|
290
|
+
(collapsing ``..`` segments and following symlinks) and emitting it with
|
|
291
|
+
forward slashes makes every path form of the same file collapse to one key.
|
|
292
|
+
|
|
293
|
+
Fail-open: this runs in the never-break-the-diary-save path, so if resolution
|
|
294
|
+
fails for any reason, fall back to the raw string rather than raise.
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
return Path(path).resolve().as_posix()
|
|
298
|
+
except OSError:
|
|
299
|
+
return str(path)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _warn(message: str) -> None:
|
|
303
|
+
print(f"{_PREFIX} {message}", file=sys.stderr)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _summary(
|
|
307
|
+
*,
|
|
308
|
+
present: bool,
|
|
309
|
+
ingested: int = 0,
|
|
310
|
+
duplicates: int = 0,
|
|
311
|
+
attempted: int = 0,
|
|
312
|
+
skipped: list[str] | None = None,
|
|
313
|
+
errors: list[str] | None = None,
|
|
314
|
+
store_failure: str | None = None,
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""Build the result dict, deriving the exit code from what was lost.
|
|
317
|
+
|
|
318
|
+
Total loss requires zero duplicates as well as zero ingested: a re-run of an
|
|
319
|
+
already-ingested diary ingests nothing and loses nothing, so it is a success.
|
|
320
|
+
"""
|
|
321
|
+
skipped = skipped or []
|
|
322
|
+
if store_failure is not None:
|
|
323
|
+
exit_code = 2
|
|
324
|
+
elif present and attempted > 0 and ingested == 0 and duplicates == 0:
|
|
325
|
+
exit_code = 1
|
|
326
|
+
else:
|
|
327
|
+
exit_code = 0
|
|
328
|
+
return {
|
|
329
|
+
"present": present,
|
|
330
|
+
"ingested": ingested,
|
|
331
|
+
"duplicates": duplicates,
|
|
332
|
+
"attempted": attempted,
|
|
333
|
+
"skipped": skipped,
|
|
334
|
+
"errors": errors if errors is not None else list(skipped),
|
|
335
|
+
"store_failure": store_failure,
|
|
336
|
+
"exit_code": exit_code,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _report(path: str, summary: dict) -> None:
|
|
341
|
+
"""Print the one stdout summary. Callers can read this; stderr they ignore.
|
|
342
|
+
|
|
343
|
+
Only ever called for a diary that HAS a FACTS block, so a diary without one
|
|
344
|
+
stays silent on stdout as well as stderr.
|
|
345
|
+
"""
|
|
346
|
+
print(
|
|
347
|
+
f"{_PREFIX} {summary['ingested']} ingested, "
|
|
348
|
+
f"{summary['duplicates']} duplicate, {len(summary['skipped'])} skipped: "
|
|
349
|
+
f"{path}"
|
|
350
|
+
)
|
|
351
|
+
for reason in summary["skipped"]:
|
|
352
|
+
print(f" skipped {reason}")
|
|
353
|
+
if summary["store_failure"] is not None:
|
|
354
|
+
print(f" STORE FAILURE, aborted at {summary['store_failure']}")
|
|
355
|
+
elif summary["exit_code"] == 1:
|
|
356
|
+
print(
|
|
357
|
+
f" TOTAL LOSS: no facts ingested from {summary['attempted']} "
|
|
358
|
+
f"line(s) in the FACTS block"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def ingest_diary_file(path: str, store: MemoryStore | None = None) -> dict:
|
|
363
|
+
"""Parse and ingest the FACTS block from a saved diary markdown file.
|
|
364
|
+
|
|
365
|
+
Fail-open: returns a summary dict and never raises. Each problem prints one
|
|
366
|
+
stderr line, and a diary with a FACTS block also gets one stdout summary.
|
|
367
|
+
Returns ``{present, ingested, duplicates, attempted, skipped, errors,
|
|
368
|
+
store_failure, exit_code}``.
|
|
369
|
+
"""
|
|
370
|
+
try:
|
|
371
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
# Unchanged: exit 0. We never got far enough to know whether this diary
|
|
374
|
+
# even had a FACTS block, so there is no loss to be loud about.
|
|
375
|
+
_warn(f"cannot read diary {path!r}: {exc}")
|
|
376
|
+
return _summary(present=False, errors=[str(exc)])
|
|
377
|
+
|
|
378
|
+
parsed = parse_facts_block(text)
|
|
379
|
+
if not parsed.present:
|
|
380
|
+
# No FACTS block: silent success (older diaries, empty sessions).
|
|
381
|
+
return _summary(present=False)
|
|
382
|
+
|
|
383
|
+
for err in parsed.errors:
|
|
384
|
+
_warn(err)
|
|
385
|
+
|
|
386
|
+
if store is None:
|
|
387
|
+
store = MemoryStore()
|
|
388
|
+
project = _project_from_path(path)
|
|
389
|
+
source_ref = _canonical_source_ref(path)
|
|
390
|
+
|
|
391
|
+
skipped: list[str] = list(parsed.errors) # malformed lines, already warned
|
|
392
|
+
ingested = 0
|
|
393
|
+
duplicates = 0
|
|
394
|
+
store_failure: str | None = None
|
|
395
|
+
|
|
396
|
+
for fact, lineno in zip(parsed.facts, parsed.linenos):
|
|
397
|
+
try:
|
|
398
|
+
if store.fact_exists(fact["subject"], fact["claim"], source_ref):
|
|
399
|
+
duplicates += 1
|
|
400
|
+
continue
|
|
401
|
+
store.store_fact(
|
|
402
|
+
project=project,
|
|
403
|
+
subject=fact["subject"],
|
|
404
|
+
claim=fact["claim"],
|
|
405
|
+
source_type="diary",
|
|
406
|
+
source_ref=source_ref,
|
|
407
|
+
method=fact["method"],
|
|
408
|
+
entities=fact["entities"] or None,
|
|
409
|
+
)
|
|
410
|
+
ingested += 1
|
|
411
|
+
except memory_engine.MemoryEngineError as exc:
|
|
412
|
+
# The FACT is bad, not the store (invalid subject/field). Same class
|
|
413
|
+
# as a malformed line: skip it, keep going, do not fail the run.
|
|
414
|
+
# These messages name the offending field but never the claim.
|
|
415
|
+
msg = (
|
|
416
|
+
f"line {lineno}: store rejected fact "
|
|
417
|
+
f"({_safe_subject(fact['subject'])}): {exc}"
|
|
418
|
+
)
|
|
419
|
+
_warn(msg)
|
|
420
|
+
skipped.append(msg)
|
|
421
|
+
except Exception as exc:
|
|
422
|
+
# The STORE is bad (IntegrityError, OperationalError, ...). Stop at
|
|
423
|
+
# the first one: if the schema rejects this row it rejects the rest,
|
|
424
|
+
# and nineteen more identical warnings hide the one real cause.
|
|
425
|
+
store_failure = f"line {lineno}: {type(exc).__name__}: {exc}"
|
|
426
|
+
_warn(
|
|
427
|
+
f"STORE FAILURE after {ingested} ingested, aborting: "
|
|
428
|
+
f"{store_failure}"
|
|
429
|
+
)
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
summary = _summary(
|
|
433
|
+
present=True,
|
|
434
|
+
ingested=ingested,
|
|
435
|
+
duplicates=duplicates,
|
|
436
|
+
attempted=parsed.attempted,
|
|
437
|
+
skipped=skipped,
|
|
438
|
+
store_failure=store_failure,
|
|
439
|
+
)
|
|
440
|
+
_report(path, summary)
|
|
441
|
+
return summary
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def main(argv: list[str]) -> int:
|
|
445
|
+
"""Hook entry point. Never raises; the exit code classifies the outcome.
|
|
446
|
+
|
|
447
|
+
0 = nothing lost, 1 = content total loss, 2 = store failure. See the
|
|
448
|
+
FAIL-OPEN CONTRACT in the module docstring.
|
|
449
|
+
"""
|
|
450
|
+
if len(argv) < 2 or not argv[1]:
|
|
451
|
+
# A caller that passed no path lost nothing, because nothing was read.
|
|
452
|
+
_warn("no diary path provided; nothing to ingest")
|
|
453
|
+
return 0
|
|
454
|
+
try:
|
|
455
|
+
return ingest_diary_file(argv[1])["exit_code"]
|
|
456
|
+
except Exception as exc:
|
|
457
|
+
# Absolute backstop: still never raises, but this is the store or
|
|
458
|
+
# environment class of failure, so it is loud rather than silent.
|
|
459
|
+
_warn(f"unexpected error ingesting {argv[1]!r}: {exc}")
|
|
460
|
+
print(
|
|
461
|
+
f"{_PREFIX} aborted, nothing ingested: {type(exc).__name__}: {exc}"
|
|
462
|
+
)
|
|
463
|
+
return 2
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
if __name__ == "__main__": # pragma: no cover
|
|
467
|
+
sys.exit(main(sys.argv))
|
{memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/server.py
RENAMED
|
@@ -18,6 +18,7 @@ from mcp.types import TextContent, Tool
|
|
|
18
18
|
from .config import load_config
|
|
19
19
|
from .license import (
|
|
20
20
|
MAX_KEY_LEN,
|
|
21
|
+
PRO_EXCLUSIVE_SKILLS,
|
|
21
22
|
clear_cache,
|
|
22
23
|
get_license_key,
|
|
23
24
|
is_pro_exclusive,
|
|
@@ -420,9 +421,45 @@ def _record_skill_usage(skill_name: str) -> None:
|
|
|
420
421
|
except (OSError, json.JSONDecodeError) as exc:
|
|
421
422
|
print(f"[memstack] failed to record skill usage: {exc}", file=sys.stderr)
|
|
422
423
|
|
|
424
|
+
|
|
425
|
+
def _free_skill_count() -> int | None:
|
|
426
|
+
"""Free-skill count from the catalog that ships with the package.
|
|
427
|
+
|
|
428
|
+
skill_descriptions.json covers every canonical slug (check_skill_drift F-1
|
|
429
|
+
enforces that as a blocking check) and PRO_EXCLUSIVE_SKILLS names the Pro
|
|
430
|
+
subset, so the difference is the free count.
|
|
431
|
+
|
|
432
|
+
Deliberately NOT derived from the skill index. The messages below fire on
|
|
433
|
+
the licence-blocked path, and since 4.15.0 the startup index build is
|
|
434
|
+
fail-open, so the server can be serving with no index at all. This count
|
|
435
|
+
describes the catalog rather than the index, which keeps it correct in
|
|
436
|
+
exactly the state where an index-derived number would be unavailable.
|
|
437
|
+
|
|
438
|
+
Returns None when the catalog cannot be read or is not shaped as expected,
|
|
439
|
+
so callers can drop the number rather than print a guess.
|
|
440
|
+
"""
|
|
441
|
+
try:
|
|
442
|
+
raw = (Path(__file__).parent / "skill_descriptions.json").read_text(encoding="utf-8")
|
|
443
|
+
descriptions = json.loads(raw)
|
|
444
|
+
except (OSError, ValueError):
|
|
445
|
+
return None
|
|
446
|
+
if not isinstance(descriptions, dict):
|
|
447
|
+
return None
|
|
448
|
+
free = len(descriptions) - len(PRO_EXCLUSIVE_SKILLS)
|
|
449
|
+
return free if free > 0 else None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
_FREE_SKILL_COUNT = _free_skill_count()
|
|
453
|
+
# "86 free skills" when the catalog is readable, "the free skills" when it is
|
|
454
|
+
# not. Never a hardcoded number: this string read "85" while the enforced
|
|
455
|
+
# count was 86, in a message shown at the moment a user is asked to activate.
|
|
456
|
+
_FREE_SKILLS_PHRASE = (
|
|
457
|
+
f"{_FREE_SKILL_COUNT} free skills" if _FREE_SKILL_COUNT else "the free skills"
|
|
458
|
+
)
|
|
459
|
+
|
|
423
460
|
_BLOCKED_MSG = (
|
|
424
461
|
"MemStack\u2122 grace period expired.\n\n"
|
|
425
|
-
"Run `activate_license` with your **email** to unlock
|
|
462
|
+
f"Run `activate_license` with your **email** to unlock {_FREE_SKILLS_PHRASE}.\n\n"
|
|
426
463
|
"Example: `activate_license(key=\"free\", email=\"you@example.com\")`\n\n"
|
|
427
464
|
"Already have a Pro key? Run `activate_license(key=\"your-key\", email=\"you@example.com\")`."
|
|
428
465
|
)
|
|
@@ -430,7 +467,7 @@ def _pro_skill_notice(skill_name: str) -> str:
|
|
|
430
467
|
return f"{skill_name} is a Pro skill. Details at memstack.pro"
|
|
431
468
|
_GRACE_EXPIRED_MSG = (
|
|
432
469
|
"\U0001f512 MemStack\u2122 grace period expired. "
|
|
433
|
-
"Run `activate_license` with your email to unlock
|
|
470
|
+
f"Run `activate_license` with your email to unlock {_FREE_SKILLS_PHRASE}."
|
|
434
471
|
)
|
|
435
472
|
_TAMPERED_MSG = (
|
|
436
473
|
"\U0001f512 MemStack\u2122 Pro: License file integrity check failed. "
|
|
@@ -1802,8 +1839,12 @@ async def _handle_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
|
1802
1839
|
# tag reflects the just-incremented count.
|
|
1803
1840
|
store.record_recall(h["id"], context=ctx)
|
|
1804
1841
|
fresh = store.get_fact(h["id"])
|
|
1842
|
+
# The id is what makes a recalled fact actionable: mem_inspect and
|
|
1843
|
+
# mem_correct both address by id, and it is the ONLY handle that is
|
|
1844
|
+
# unique by definition (nothing constrains subject to be). Rendered
|
|
1845
|
+
# as '#<id>' to match mem_inspect's chain format.
|
|
1805
1846
|
lines.append(
|
|
1806
|
-
f"- **{fresh['subject']}** — {fresh['claim']} "
|
|
1847
|
+
f"- #{fresh['id']} **{fresh['subject']}** — {fresh['claim']} "
|
|
1807
1848
|
f"{_provenance_tag(fresh)}"
|
|
1808
1849
|
)
|
|
1809
1850
|
return [TextContent(type="text", text="\n".join(lines))]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memstack-skill-loader
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.16.0
|
|
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
|