smartmemory 1.4.26__tar.gz → 1.4.28__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 (102) hide show
  1. {smartmemory-1.4.26 → smartmemory-1.4.28}/CHANGELOG.md +20 -0
  2. {smartmemory-1.4.26 → smartmemory-1.4.28}/PKG-INFO +2 -2
  3. {smartmemory-1.4.26 → smartmemory-1.4.28}/pyproject.toml +2 -2
  4. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/cli.py +46 -1
  5. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/storage.py +9 -0
  6. smartmemory-1.4.28/smartmemory_app/warm.py +84 -0
  7. smartmemory-1.4.28/tests/integration/test_warm.py +104 -0
  8. {smartmemory-1.4.26 → smartmemory-1.4.28}/.github/dependabot.yml +0 -0
  9. {smartmemory-1.4.26 → smartmemory-1.4.28}/.github/workflows/publish.yml +0 -0
  10. {smartmemory-1.4.26 → smartmemory-1.4.28}/.github/workflows/sync-core.yml +0 -0
  11. {smartmemory-1.4.26 → smartmemory-1.4.28}/.gitignore +0 -0
  12. {smartmemory-1.4.26 → smartmemory-1.4.28}/LICENSE +0 -0
  13. {smartmemory-1.4.26 → smartmemory-1.4.28}/LICENSE.agpl-v3 +0 -0
  14. {smartmemory-1.4.26 → smartmemory-1.4.28}/LICENSE.header +0 -0
  15. {smartmemory-1.4.26 → smartmemory-1.4.28}/README.md +0 -0
  16. {smartmemory-1.4.26 → smartmemory-1.4.28}/plugin.json +0 -0
  17. {smartmemory-1.4.26 → smartmemory-1.4.28}/scripts/generate_seed_patterns.py +0 -0
  18. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/__init__.py +0 -0
  19. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/__main__.py +0 -0
  20. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/async_enrichment.py +0 -0
  21. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/cli_code.py +0 -0
  22. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/cli_mcp.py +0 -0
  23. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/config.py +0 -0
  24. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/daemon.py +0 -0
  25. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/ai.smartmemory.daemon.plist +0 -0
  26. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/ai.smartmemory.worker.plist +0 -0
  27. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seed_patterns.jsonl +0 -0
  28. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/ai-model.jsonl +0 -0
  29. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/concept.jsonl +0 -0
  30. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/database.jsonl +0 -0
  31. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/framework.jsonl +0 -0
  32. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/language.jsonl +0 -0
  33. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/manifest.json +0 -0
  34. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/organization.jsonl +0 -0
  35. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/platform.jsonl +0 -0
  36. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/protocol.jsonl +0 -0
  37. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/service.jsonl +0 -0
  38. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/data/seeds/tool.jsonl +0 -0
  39. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/enrichment_queue.py +0 -0
  40. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/enrichment_worker.py +0 -0
  41. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/event_sink.py +0 -0
  42. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/events_server.py +0 -0
  43. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/hooks/distill.sh +0 -0
  44. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/hooks/learn.sh +0 -0
  45. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/hooks/observe.sh +0 -0
  46. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/hooks/orient.sh +0 -0
  47. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/hooks/persist.sh +0 -0
  48. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/hooks/recall.sh +0 -0
  49. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/launch_metrics.py +0 -0
  50. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/lifecycle.py +0 -0
  51. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/lifecycle_api.py +0 -0
  52. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/lifecycle_config.py +0 -0
  53. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/local_api.py +0 -0
  54. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/patterns.py +0 -0
  55. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/recall_format.py +0 -0
  56. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/remote_backend.py +0 -0
  57. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/setup.py +0 -0
  58. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/setup_tui.py +0 -0
  59. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/skills/ingest.md +0 -0
  60. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/skills/orient.md +0 -0
  61. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/skills/remember.md +0 -0
  62. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/skills/search.md +0 -0
  63. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/assets/local-Clt8rYLQ.js +0 -0
  64. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/assets/local-ZCcXPKXN.css +0 -0
  65. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/icons/icon-192x192.svg +0 -0
  66. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/icons/icon-512x512.svg +0 -0
  67. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/index.html +0 -0
  68. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/logo.svg +0 -0
  69. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/static/viewer-logo.svg +0 -0
  70. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/sync.py +0 -0
  71. {smartmemory-1.4.26 → smartmemory-1.4.28}/smartmemory_app/viewer_server.py +0 -0
  72. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/__init__.py +0 -0
  73. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/conftest.py +0 -0
  74. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/__init__.py +0 -0
  75. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_async_enrichment_e2e.py +0 -0
  76. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_async_enrichment_sqlite_regression.py +0 -0
  77. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_cli_new_subcommands.py +0 -0
  78. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_daemon.py +0 -0
  79. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_entity_ruler_patterns.py +0 -0
  80. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_hook_recall.py +0 -0
  81. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_hook_scripts.py +0 -0
  82. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_local_api_integration.py +0 -0
  83. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_recall_confidence.py +0 -0
  84. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_recall_flow.py +0 -0
  85. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/integration/test_viewer_server.py +0 -0
  86. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/__init__.py +0 -0
  87. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_async_enrichment.py +0 -0
  88. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_cli.py +0 -0
  89. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_config.py +0 -0
  90. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_events_server.py +0 -0
  91. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_launchd.py +0 -0
  92. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_lifecycle.py +0 -0
  93. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_lite_api_contract.py +0 -0
  94. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_local_api.py +0 -0
  95. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_patterns.py +0 -0
  96. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_recall_format.py +0 -0
  97. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_remote_backend.py +0 -0
  98. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_server_tools.py +0 -0
  99. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_setup.py +0 -0
  100. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_setup_tui.py +0 -0
  101. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_stale_markers.py +0 -0
  102. {smartmemory-1.4.26 → smartmemory-1.4.28}/tests/unit/test_storage.py +0 -0
@@ -3,6 +3,26 @@
3
3
  Notable, **user-facing** changes to the `smartmemory` distribution package. The wrapper is thin — it pins an exact `smartmemory-core` version and the two move in lockstep — so entries here highlight what a release *delivers* (features, fixes, security), not routine version-pin bumps. For full internal detail, see [`smartmemory-core`'s CHANGELOG](https://github.com/smart-memory/smart-memory-core/blob/main/CHANGELOG.md). Loosely follows [Keep a Changelog](https://keepachangelog.com); not every patch release gets an entry.
4
4
 
5
5
  ## [Unreleased]
6
+ ### Changed (auto, lockstep) — track smartmemory-core==1.4.28 (1.4.28)
7
+ - Version copied from smartmemory-core 1.4.28 release (single-source lockstep).
8
+
9
+ ### Changed (auto, lockstep) — track smartmemory-core==1.4.27 (1.4.27)
10
+ - Version copied from smartmemory-core 1.4.27 release (single-source lockstep).
11
+
12
+ ### Added — `smartmemory warm` + background model warming (DIST-LITE-WARMSTART-1)
13
+ - New `smartmemory warm` CLI command pre-loads the local embedder (and reranker) so the
14
+ first `add`/`search` is instant instead of paying a cold model load (~12s, or ~38s the
15
+ first time the model downloads). Run once after install or before a demo. `--no-reranker`
16
+ warms the embedder only.
17
+ - The local backend now **warms the embedder in the background at construction** (daemon
18
+ thread) so the user's first `add()` overlaps the model load instead of paying it inline.
19
+ Opt out with `SMARTMEMORY_NO_WARM=1`.
20
+ - Direct (no-daemon) CLI ops (`add`, `recall`) now print a one-time "First run: loading
21
+ local models…" notice before a cold load, so first-run isn't a silent hang. (The daemon
22
+ path already prints "loading models" at `start`.)
23
+ - Paired with the core reranker fix (non-blocking model load), this removes the
24
+ multi-second first-run hang from the local FREE path.
25
+
6
26
  ### Changed (auto, lockstep) — track smartmemory-core==1.4.26 (1.4.26)
7
27
  - Version copied from smartmemory-core 1.4.26 release (single-source lockstep).
8
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartmemory
3
- Version: 1.4.26
3
+ Version: 1.4.28
4
4
  License-File: LICENSE
5
5
  License-File: LICENSE.agpl-v3
6
6
  License-File: LICENSE.header
@@ -10,7 +10,7 @@ Requires-Dist: fastapi>=0.110
10
10
  Requires-Dist: filelock>=3.12
11
11
  Requires-Dist: httpx>=0.27
12
12
  Requires-Dist: keyring>=24.0
13
- Requires-Dist: smartmemory-core[lite]==1.4.26
13
+ Requires-Dist: smartmemory-core[lite]==1.4.28
14
14
  Requires-Dist: smartmemory-mcp>=0.2.0
15
15
  Requires-Dist: textual>=8.0
16
16
  Requires-Dist: tomli-w>=1.0
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "smartmemory"
7
- version = "1.4.26"
7
+ version = "1.4.28"
8
8
  requires-python = ">=3.11"
9
9
  dependencies = [
10
- "smartmemory-core[lite]==1.4.26", # EXACT pin — wrapper and core versions move in lockstep
10
+ "smartmemory-core[lite]==1.4.28", # EXACT pin — wrapper and core versions move in lockstep
11
11
  "filelock>=3.12", # cross-process SQLite write locking
12
12
  "smartmemory-mcp>=0.2.0", # unified MCP server (PLAT-MCP-UNIFY-1)
13
13
  "httpx>=0.27", # remote API calls (DIST-LITE-5)
@@ -148,6 +148,26 @@ def restart_cmd(num_workers: int) -> None:
148
148
  click.echo("Daemon ready.")
149
149
 
150
150
 
151
+ @cli.command("warm")
152
+ @click.option("--no-reranker", is_flag=True, help="Warm only the embedder, skip the reranker model.")
153
+ def warm_cmd(no_reranker: bool) -> None:
154
+ """Pre-load the local models so the first add/search is instant.
155
+
156
+ The first add() otherwise pays a cold embedder load (~12s, or ~38s the first
157
+ time the model downloads) and the first search() pays a cold reranker load.
158
+ Run this once after install — or before a demo — to move that cost off the
159
+ user's first real call (DIST-LITE-WARMSTART-1).
160
+ """
161
+ import time
162
+
163
+ from smartmemory_app.warm import warm_models
164
+
165
+ click.echo("Warming local models (one-time; subsequent runs are cached)...")
166
+ t0 = time.perf_counter()
167
+ warm_models(reranker=not no_reranker)
168
+ click.echo(f"Models warm in {time.perf_counter() - t0:.1f}s. First add/search will now be fast.")
169
+
170
+
151
171
  @cli.command("status")
152
172
  def status_cmd() -> None:
153
173
  """Show SmartMemory daemon status."""
@@ -229,6 +249,28 @@ def _validate_memory_type(ctx, param, value: str) -> str:
229
249
  return value
230
250
 
231
251
 
252
+ _warm_notice_shown = False
253
+
254
+
255
+ def _warm_notice() -> None:
256
+ """Show a one-time notice if a direct (no-daemon) op is about to pay a cold model
257
+ load, so first-run isn't a silent multi-second hang (DIST-LITE-WARMSTART-1). The
258
+ daemon path already prints "loading models" at start; this covers direct CLI ops.
259
+ """
260
+ global _warm_notice_shown
261
+ if _warm_notice_shown:
262
+ return
263
+ from smartmemory_app.warm import is_warm
264
+
265
+ if not is_warm():
266
+ click.echo(
267
+ "First run: loading local models (~10–40s, one-time). "
268
+ "Tip: run 'smartmemory warm' to pre-load.",
269
+ err=True,
270
+ )
271
+ _warm_notice_shown = True
272
+
273
+
232
274
  @cli.command(
233
275
  "add",
234
276
  context_settings=dict(
@@ -278,7 +320,7 @@ def add_cmd(ctx, text: str, memory_type: str, as_whole: bool) -> None:
278
320
  chunks = (
279
321
  [raw.strip()]
280
322
  if as_whole
281
- else [l.strip() for l in raw.splitlines() if l.strip()]
323
+ else [ln.strip() for ln in raw.splitlines() if ln.strip()]
282
324
  )
283
325
  if not chunks:
284
326
  raise click.ClickException("Content cannot be empty.")
@@ -294,6 +336,7 @@ def add_cmd(ctx, text: str, memory_type: str, as_whole: bool) -> None:
294
336
  else:
295
337
  from smartmemory_app.storage import ingest
296
338
 
339
+ _warm_notice()
297
340
  ids.append(ingest(chunk, memory_type, properties=props))
298
341
  click.echo(f"Added {len(ids)} memories")
299
342
  for item_id in ids:
@@ -311,6 +354,7 @@ def add_cmd(ctx, text: str, memory_type: str, as_whole: bool) -> None:
311
354
  else:
312
355
  from smartmemory_app.storage import ingest
313
356
 
357
+ _warm_notice()
314
358
  click.echo(ingest(text, memory_type, properties=props))
315
359
 
316
360
 
@@ -353,6 +397,7 @@ def recall_cmd(
353
397
  else:
354
398
  from smartmemory_app.storage import recall
355
399
 
400
+ _warm_notice()
356
401
  context = recall(
357
402
  cwd,
358
403
  top_k,
@@ -107,6 +107,15 @@ def _get_local_memory(data_dir: str | None = None) -> "SmartMemory":
107
107
  event_sink=get_event_sink(), # DIST-LITE-3
108
108
  )
109
109
  atexit.register(_shutdown)
110
+ # DIST-LITE-WARMSTART-1: warm the local embedder in the background so the
111
+ # user's first add() overlaps the ~12s model load instead of paying it inline.
112
+ # Daemon thread, idempotent, opt out with SMARTMEMORY_NO_WARM=1.
113
+ try:
114
+ from smartmemory_app.warm import warm_models_background
115
+
116
+ warm_models_background()
117
+ except Exception:
118
+ pass
110
119
  return _memory
111
120
 
112
121
 
@@ -0,0 +1,84 @@
1
+ """Model pre-warming for the local FREE path (DIST-LITE-WARMSTART-1).
2
+
3
+ The first ``add()`` pays a cold embedder load (~12s, or ~38s the first time the
4
+ model is downloaded) and the first ``search()`` would pay a cold reranker load.
5
+ These helpers load the models ahead of (or in parallel with) the user's first real
6
+ call so the one-command "wow" moment isn't a multi-second hang.
7
+
8
+ - ``warm_models()`` — foreground; used by the ``smartmemory warm`` CLI and install
9
+ prefetch, where a visible one-time wait is acceptable.
10
+ - ``warm_models_background()`` — fire-and-forget daemon thread; used at local
11
+ construction so the embedder load overlaps construction + user think-time. Opt out
12
+ with ``SMARTMEMORY_NO_WARM=1``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import threading
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _warm_started = False
24
+ _warm_lock = threading.Lock()
25
+
26
+
27
+ def is_warm() -> bool:
28
+ """True if the local embedder model is already resident in this process.
29
+
30
+ Used to decide whether a foreground op is about to pay a cold load, so the
31
+ caller can show a progress notice instead of a silent hang.
32
+ """
33
+ try:
34
+ from smartmemory.plugins.embedding import EmbeddingService
35
+
36
+ return EmbeddingService._st_model is not None or EmbeddingService._pinned_local_model is not None
37
+ except Exception:
38
+ return False
39
+
40
+
41
+ def warm_models(*, reranker: bool = True) -> None:
42
+ """Synchronously load the local embedder (and optionally the reranker).
43
+
44
+ Never raises — a warm failure must not break the caller; the model will simply
45
+ load lazily on first use as before.
46
+ """
47
+ try:
48
+ from smartmemory.plugins.embedding import EmbeddingService
49
+
50
+ if EmbeddingService().warm():
51
+ logger.debug("Embedder warmed")
52
+ except Exception as e:
53
+ logger.debug("Embedder warm skipped: %s", e)
54
+
55
+ if reranker:
56
+ try:
57
+ from smartmemory.search.rerank import CrossEncoderReranker
58
+
59
+ # block=True here is intentional: the CLI/prefetch path WANTS to wait.
60
+ CrossEncoderReranker.get_model(block=True)
61
+ except Exception as e:
62
+ logger.debug("Reranker warm skipped: %s", e)
63
+
64
+
65
+ def warm_models_background(*, reranker: bool = False) -> None:
66
+ """Kick a one-time daemon thread to warm models without blocking the caller.
67
+
68
+ Defaults to embedder-only (every ``add()`` needs it; the reranker already
69
+ background-loads lazily on first search). Opt out with ``SMARTMEMORY_NO_WARM=1``.
70
+ Idempotent — only the first call per process starts a thread.
71
+ """
72
+ global _warm_started
73
+ if os.environ.get("SMARTMEMORY_NO_WARM"):
74
+ return
75
+ with _warm_lock:
76
+ if _warm_started:
77
+ return
78
+ _warm_started = True
79
+ threading.Thread(
80
+ target=warm_models,
81
+ kwargs={"reranker": reranker},
82
+ name="smartmemory-warm",
83
+ daemon=True,
84
+ ).start()
@@ -0,0 +1,104 @@
1
+ """DIST-LITE-WARMSTART-1 — background model warming orchestration.
2
+
3
+ Tests the *scheduling* contract (opt-out + warm-once), not the model load itself
4
+ (the embedder/reranker loads are verified separately and would require a model
5
+ download here). Marked integration: exercises real threads.
6
+ """
7
+
8
+ import threading
9
+
10
+ import pytest
11
+
12
+ import smartmemory_app.warm as warm
13
+
14
+ pytestmark = pytest.mark.integration
15
+
16
+
17
+ def _join_warm_threads(timeout=5):
18
+ for t in threading.enumerate():
19
+ if t.name == "smartmemory-warm":
20
+ t.join(timeout=timeout)
21
+
22
+
23
+ def test_background_warm_opts_out(monkeypatch):
24
+ """SMARTMEMORY_NO_WARM=1 schedules nothing — no thread, no state change."""
25
+ monkeypatch.setenv("SMARTMEMORY_NO_WARM", "1")
26
+ warm._warm_started = False
27
+ before = threading.active_count()
28
+
29
+ warm.warm_models_background()
30
+
31
+ assert warm._warm_started is False
32
+ assert threading.active_count() == before
33
+
34
+
35
+ def test_background_warm_runs_once(monkeypatch):
36
+ """Repeated calls schedule the warm work exactly once per process."""
37
+ monkeypatch.delenv("SMARTMEMORY_NO_WARM", raising=False)
38
+ warm._warm_started = False
39
+ calls = []
40
+ # Substitute the downstream loader (verified for real elsewhere) so this test
41
+ # asserts the SCHEDULER, deterministically and without a model download.
42
+ monkeypatch.setattr(warm, "warm_models", lambda **kwargs: calls.append(kwargs))
43
+
44
+ warm.warm_models_background()
45
+ warm.warm_models_background()
46
+ warm.warm_models_background()
47
+ _join_warm_threads()
48
+
49
+ assert warm._warm_started is True
50
+ assert len(calls) == 1 # only the first call did work
51
+
52
+
53
+ def test_warm_models_never_raises(monkeypatch):
54
+ """warm_models() must swallow loader failures — a warm error can't break callers."""
55
+ import smartmemory.plugins.embedding as emb
56
+
57
+ def boom(self):
58
+ raise RuntimeError("simulated model load failure")
59
+
60
+ monkeypatch.setattr(emb.EmbeddingService, "warm", boom)
61
+ # reranker=False so we don't trigger a real cross-encoder download here.
62
+ warm.warm_models(reranker=False) # must not raise
63
+
64
+
65
+ def test_is_warm_false_when_unloaded(monkeypatch):
66
+ """is_warm() reports False when no local model is resident."""
67
+ import smartmemory.plugins.embedding as emb
68
+
69
+ monkeypatch.setattr(emb.EmbeddingService, "_st_model", None, raising=False)
70
+ monkeypatch.setattr(emb.EmbeddingService, "_pinned_local_model", None, raising=False)
71
+ assert warm.is_warm() is False
72
+
73
+
74
+ def test_cli_warm_notice_prints_once_when_cold(monkeypatch, capsys):
75
+ """The direct-CLI cold-load notice fires exactly once, only when not warm."""
76
+ from smartmemory_app import cli
77
+
78
+ monkeypatch.setattr("smartmemory_app.warm.is_warm", lambda: False)
79
+ cli._warm_notice_shown = False
80
+
81
+ cli._warm_notice()
82
+ cli._warm_notice()
83
+
84
+ err = capsys.readouterr().err
85
+ assert err.count("First run: loading local models") == 1
86
+
87
+
88
+ def test_add_cmd_direct_path_emits_notice(monkeypatch):
89
+ """The real `add` command, on the direct (no-daemon) path with a cold model,
90
+ emits the warm notice before the blocking ingest."""
91
+ from click.testing import CliRunner
92
+
93
+ from smartmemory_app import cli
94
+
95
+ monkeypatch.setattr(cli, "_daemon_request", lambda *a, **k: None) # force direct path
96
+ monkeypatch.setattr("smartmemory_app.storage.ingest", lambda *a, **k: "id-123")
97
+ monkeypatch.setattr("smartmemory_app.warm.is_warm", lambda: False)
98
+ cli._warm_notice_shown = False
99
+
100
+ r = CliRunner(mix_stderr=False).invoke(cli.cli, ["add", "hello world"])
101
+
102
+ assert r.exit_code == 0, r.output
103
+ assert "id-123" in r.stdout
104
+ assert "First run: loading local models" in r.stderr
File without changes
File without changes
File without changes
File without changes