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