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.

Files changed (72) hide show
  1. {memstack_skill_loader-4.15.1/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.16.0}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/dashboard.html +21 -1
  5. memstack_skill_loader-4.16.0/src/memstack_skill_loader/diary_ingest.py +467 -0
  6. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/server.py +44 -3
  7. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  8. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_diary_ingest.py +249 -0
  9. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_tools.py +32 -0
  10. memstack_skill_loader-4.15.1/src/memstack_skill_loader/diary_ingest.py +0 -278
  11. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/MANIFEST.in +0 -0
  12. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/README.md +0 -0
  13. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/setup.cfg +0 -0
  14. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/__main__.py +0 -0
  15. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/agent_runner.py +0 -0
  16. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/categories.py +0 -0
  17. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/compression.py +0 -0
  18. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/config.py +0 -0
  19. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/dashboard.py +0 -0
  20. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/goal.py +0 -0
  21. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/goal_engine.py +0 -0
  22. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/__init__.py +0 -0
  23. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/deny_git.py +0 -0
  24. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/deny_manager_write.py +0 -0
  25. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/deny_reviewer_git.py +0 -0
  26. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/hooks/git_policy.py +0 -0
  27. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/index_meta.py +0 -0
  28. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/indexer.py +0 -0
  29. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/license.py +0 -0
  30. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/memory_db.py +0 -0
  31. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/memory_engine.py +0 -0
  32. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/__init__.py +0 -0
  33. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/_diag.py +0 -0
  34. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/body_parser.py +0 -0
  35. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/compressor.py +0 -0
  36. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/forwarder.py +0 -0
  37. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/pro_compressor.py +0 -0
  38. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/server.py +0 -0
  39. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/proxy/stats_tracker.py +0 -0
  40. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/search.py +0 -0
  41. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/session_context.py +0 -0
  42. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/skill_config.py +0 -0
  43. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/skill_descriptions.json +0 -0
  44. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/stats.py +0 -0
  45. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/tfidf_search.py +0 -0
  46. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader/version_check.py +0 -0
  47. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
  48. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  49. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  50. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  51. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
  52. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_advisor_strategy.py +0 -0
  53. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_body_parser_metric.py +0 -0
  54. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_builder_scope.py +0 -0
  55. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_code_read_savings.py +0 -0
  56. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_dashscan.py +0 -0
  57. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_failed_approaches.py +0 -0
  58. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_git_policy.py +0 -0
  59. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_index_meta_guard.py +0 -0
  60. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_license_environment.py +0 -0
  61. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_license_grace.py +0 -0
  62. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_engine.py +0 -0
  63. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_inspector.py +0 -0
  64. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_memory_scoring.py +0 -0
  65. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_pro_compressor.py +0 -0
  66. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_pro_skills_update.py +0 -0
  67. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_pro_source_resolution.py +0 -0
  68. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_project_dir_canonical.py +0 -0
  69. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_search_memory.py +0 -0
  70. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_session_context.py +0 -0
  71. {memstack_skill_loader-4.15.1 → memstack_skill_loader-4.16.0}/tests/test_skill_drift.py +0 -0
  72. {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.15.1
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.15.1"
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"
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.15.1"
3
+ __version__ = "4.16.0"
@@ -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 42 exclusive skills. Stored at ~/.memstack/license.json.</p>
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))
@@ -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 85 free skills.\n\n"
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 85 free skills."
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.15.1
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