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