superlocalmemory 3.4.21 → 3.4.23

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 (114) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +2 -2
  4. package/scripts/build_entry.py +1 -1
  5. package/scripts/release_manifest.py +2 -2
  6. package/skills/slm-build-graph/SKILL.md +1 -1
  7. package/skills/slm-list-recent/SKILL.md +1 -1
  8. package/skills/slm-recall/SKILL.md +3 -3
  9. package/skills/slm-remember/SKILL.md +1 -1
  10. package/skills/slm-status/SKILL.md +1 -1
  11. package/skills/slm-switch-profile/SKILL.md +1 -1
  12. package/src/superlocalmemory/__init__.py +3 -0
  13. package/src/superlocalmemory/cli/commands.py +40 -5
  14. package/src/superlocalmemory/cli/context_commands.py +1 -1
  15. package/src/superlocalmemory/cli/db_migrate.py +1 -1
  16. package/src/superlocalmemory/cli/escape_hatch.py +1 -1
  17. package/src/superlocalmemory/cli/main.py +3 -3
  18. package/src/superlocalmemory/core/context_cache.py +2 -2
  19. package/src/superlocalmemory/core/ram_lock.py +1 -1
  20. package/src/superlocalmemory/core/recall_pipeline.py +5 -5
  21. package/src/superlocalmemory/core/security_primitives.py +2 -2
  22. package/src/superlocalmemory/core/shadow_router.py +2 -2
  23. package/src/superlocalmemory/core/slm_disabled.py +1 -1
  24. package/src/superlocalmemory/core/slmignore.py +1 -1
  25. package/src/superlocalmemory/core/topic_signature.py +3 -3
  26. package/src/superlocalmemory/evolution/budget.py +1 -1
  27. package/src/superlocalmemory/evolution/llm_dispatch.py +2 -2
  28. package/src/superlocalmemory/hooks/_outcome_common.py +1 -1
  29. package/src/superlocalmemory/hooks/adapter_base.py +1 -1
  30. package/src/superlocalmemory/hooks/antigravity_adapter.py +1 -1
  31. package/src/superlocalmemory/hooks/context_payload.py +2 -2
  32. package/src/superlocalmemory/hooks/copilot_adapter.py +1 -1
  33. package/src/superlocalmemory/hooks/cross_platform_connector.py +1 -1
  34. package/src/superlocalmemory/hooks/cursor_adapter.py +1 -1
  35. package/src/superlocalmemory/hooks/hook_handlers.py +1 -1
  36. package/src/superlocalmemory/hooks/ide_connector.py +2 -2
  37. package/src/superlocalmemory/hooks/post_tool_async_hook.py +1 -1
  38. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +1 -1
  39. package/src/superlocalmemory/hooks/prewarm_auth.py +1 -1
  40. package/src/superlocalmemory/hooks/session_registry.py +1 -1
  41. package/src/superlocalmemory/hooks/stop_outcome_hook.py +1 -1
  42. package/src/superlocalmemory/hooks/sync_loop.py +2 -2
  43. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  44. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +1 -1
  45. package/src/superlocalmemory/learning/arm_catalog.py +1 -1
  46. package/src/superlocalmemory/learning/bandit.py +1 -1
  47. package/src/superlocalmemory/learning/bandit_cache.py +1 -1
  48. package/src/superlocalmemory/learning/consolidation_cycle.py +4 -4
  49. package/src/superlocalmemory/learning/consolidation_worker.py +1 -1
  50. package/src/superlocalmemory/learning/database.py +5 -5
  51. package/src/superlocalmemory/learning/dedup_hnsw.py +1 -1
  52. package/src/superlocalmemory/learning/ensemble.py +2 -2
  53. package/src/superlocalmemory/learning/fact_outcome_joins.py +1 -1
  54. package/src/superlocalmemory/learning/forgetting_scheduler.py +2 -2
  55. package/src/superlocalmemory/learning/hnsw_dedup.py +2 -2
  56. package/src/superlocalmemory/learning/labeler.py +3 -3
  57. package/src/superlocalmemory/learning/legacy_migration.py +1 -1
  58. package/src/superlocalmemory/learning/memory_merge.py +1 -1
  59. package/src/superlocalmemory/learning/model_cache.py +1 -1
  60. package/src/superlocalmemory/learning/model_rollback.py +2 -2
  61. package/src/superlocalmemory/learning/outcome_queue.py +2 -2
  62. package/src/superlocalmemory/learning/pattern_miner.py +1 -1
  63. package/src/superlocalmemory/learning/pattern_miner_constants.py +1 -1
  64. package/src/superlocalmemory/learning/ranker.py +3 -3
  65. package/src/superlocalmemory/learning/ranker_common.py +1 -1
  66. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +3 -3
  67. package/src/superlocalmemory/learning/ranker_retrain_online.py +4 -4
  68. package/src/superlocalmemory/learning/reward.py +1 -1
  69. package/src/superlocalmemory/learning/reward_archive.py +2 -2
  70. package/src/superlocalmemory/learning/reward_boost.py +2 -2
  71. package/src/superlocalmemory/learning/reward_proxy.py +4 -4
  72. package/src/superlocalmemory/learning/shadow_test.py +1 -1
  73. package/src/superlocalmemory/learning/signal_worker.py +2 -2
  74. package/src/superlocalmemory/learning/signals.py +2 -2
  75. package/src/superlocalmemory/learning/trigram_index.py +1 -1
  76. package/src/superlocalmemory/mcp/tools_context.py +1 -1
  77. package/src/superlocalmemory/mcp/tools_core.py +2 -2
  78. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +3 -3
  79. package/src/superlocalmemory/server/bandit_loops.py +2 -2
  80. package/src/superlocalmemory/server/middleware/__init__.py +2 -2
  81. package/src/superlocalmemory/server/middleware/security_headers.py +3 -3
  82. package/src/superlocalmemory/server/routes/brain.py +10 -10
  83. package/src/superlocalmemory/server/routes/prewarm.py +1 -1
  84. package/src/superlocalmemory/server/security_middleware.py +21 -3
  85. package/src/superlocalmemory/server/unified_daemon.py +111 -9
  86. package/src/superlocalmemory/skills/slm-build-graph/SKILL.md +423 -0
  87. package/src/superlocalmemory/skills/slm-list-recent/SKILL.md +348 -0
  88. package/src/superlocalmemory/skills/slm-recall/SKILL.md +343 -0
  89. package/src/superlocalmemory/skills/slm-remember/SKILL.md +194 -0
  90. package/src/superlocalmemory/skills/slm-show-patterns/SKILL.md +224 -0
  91. package/src/superlocalmemory/skills/slm-status/SKILL.md +363 -0
  92. package/src/superlocalmemory/skills/slm-switch-profile/SKILL.md +442 -0
  93. package/src/superlocalmemory/storage/migration_runner.py +4 -4
  94. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +1 -1
  95. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +2 -2
  96. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +1 -1
  97. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +1 -1
  98. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +1 -1
  99. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +1 -1
  100. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +1 -1
  101. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +1 -1
  102. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +2 -2
  103. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +1 -1
  104. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +1 -1
  105. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +2 -2
  106. package/src/superlocalmemory/storage/migrations/__init__.py +3 -3
  107. package/src/superlocalmemory/ui/index.html +4 -0
  108. package/src/superlocalmemory/ui/js/core.js +96 -1
  109. package/src/superlocalmemory.egg-info/PKG-INFO +655 -0
  110. package/src/superlocalmemory.egg-info/SOURCES.txt +426 -0
  111. package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
  112. package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
  113. package/src/superlocalmemory.egg-info/requires.txt +58 -0
  114. package/src/superlocalmemory.egg-info/top_level.txt +1 -0
@@ -1,10 +1,10 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
- # Part of SuperLocalMemory v3.4.21 — LLD-04 §4.1 + §3
3
+ # Part of SuperLocalMemory v3.4.22 — LLD-04 §4.1 + §3
4
4
 
5
5
  """``/api/v3/brain`` — unified Brain endpoint (LLD-04 v2).
6
6
 
7
- Merges the pre-3.4.21 Patterns / Learning / Behavioral dashboard tabs
7
+ Merges the pre-3.4.22 Patterns / Learning / Behavioral dashboard tabs
8
8
  into one auth-gated, honestly-labelled JSON payload. Every metric has
9
9
  an ``is_real`` (numeric counters) or ``is_real_ml`` (ML-derived) flag
10
10
  and points at the real table it was computed from. No metric is
@@ -24,7 +24,7 @@ Design notes (LLD-04 §7 hard rules):
24
24
  * **U2** — every metric section carries ``is_real`` or ``is_real_ml``.
25
25
  * **U3** — ``learning.phase`` never returns ``3`` without an active
26
26
  model row AND passing SHA check. Both conditions computed here.
27
- * **U4** — response shape must not contain the three pre-3.4.21
27
+ * **U4** — response shape must not contain the three pre-3.4.22
28
28
  fabricated metrics (24h hit-rate, avg age on hit, skill-evolution
29
29
  row counts). A static test greps this file to verify; therefore we
30
30
  never spell those names anywhere in this module.
@@ -64,7 +64,7 @@ router = APIRouter(prefix="/api/v3", tags=["brain"])
64
64
  # LLD-03 v2 stratum space = 4 query types × 3 entity bins × 4 time buckets.
65
65
  _STRATA_TOTAL: int = 48
66
66
 
67
- _VERSION: str = "3.4.21"
67
+ _VERSION: str = "3.4.23"
68
68
 
69
69
  # Banned metric names (LLD-04 U4). Kept as a tuple for grep visibility;
70
70
  # the source-level test asserts we don't accidentally reintroduce them.
@@ -246,7 +246,7 @@ def _compute_learning_status(profile_id: str,
246
246
  """Section ``learning`` — LLD-02 §4.10 phase truth + counters."""
247
247
  signals_total = _safe_count(lrn_db, "learning_signals", profile_id)
248
248
  features_total = _safe_count(lrn_db, "learning_features", profile_id)
249
- # Raw count of historic pre-v3.4.21 feedback rows (the source table)
249
+ # Raw count of historic pre-v3.4.22 feedback rows (the source table)
250
250
  legacy_feedback_rows = _safe_count(lrn_db, "learning_feedback", profile_id)
251
251
  # Count of rows actually copied forward into learning_signals by
252
252
  # legacy_migration.migrate_legacy_feedback (signal_type='legacy_feedback').
@@ -289,7 +289,7 @@ def _compute_learning_status(profile_id: str,
289
289
  "signals_last_hour": signals_last_hour,
290
290
  "features_total": features_total,
291
291
  "feature_count_expected": FEATURE_DIM,
292
- # Honest split (v3.4.21+): raw historic table count vs rows
292
+ # Honest split (v3.4.22+): raw historic table count vs rows
293
293
  # actually copied forward via the migrate-legacy flow.
294
294
  "legacy_feedback_rows": legacy_feedback_rows,
295
295
  "legacy_migrated_count": legacy_migrated,
@@ -356,7 +356,7 @@ def _top_query_types(
356
356
  ``query_type_od`` (features.py FEATURE_NAMES). We aggregate by the
357
357
  active one-hot slot. Missing table or zero rows → empty list.
358
358
  """
359
- # E.1 (v3.4.21 perf): push the query-type classification into SQL via
359
+ # E.1 (v3.4.22 perf): push the query-type classification into SQL via
360
360
  # json_extract so we don't drag 10k rows' worth of JSON through Python.
361
361
  # SQLite's json1 extension treats json_extract as an ordinary scalar
362
362
  # function, so the whole aggregation becomes a single COUNT(*)
@@ -601,7 +601,7 @@ def _compute_cross_platform() -> dict:
601
601
  reports ``active: false`` with ``reason: error:<ExcName>`` rather
602
602
  than crashing the whole Brain endpoint (LLD-04 §2 — "honest, never
603
603
  fake"). An unimportable adapter means the install is missing Wave 2C
604
- components, which is legitimate for an older 3.4.20 → 3.4.21 upgrade
604
+ components, which is legitimate for an older 3.4.20 → 3.4.22 upgrade
605
605
  mid-migration.
606
606
  """
607
607
  out: dict = {}
@@ -871,7 +871,7 @@ def _action_outcomes_count(lrn_db: LearningDatabase,
871
871
  profile_id: str) -> int:
872
872
  """Row count in ``action_outcomes`` for ``profile_id``.
873
873
 
874
- ``action_outcomes`` ships in v3.4.21 (M006). While absent, returns 0.
874
+ ``action_outcomes`` ships in v3.4.22 (M006). While absent, returns 0.
875
875
  """
876
876
  try:
877
877
  conn = sqlite3.connect(lrn_db.path, timeout=5.0)
@@ -963,7 +963,7 @@ async def get_brain(profile_id: str = "default") -> dict:
963
963
  "outcomes_preview": {
964
964
  "action_outcomes_rows":
965
965
  0 if isinstance(outcomes_rows, Exception) else outcomes_rows,
966
- "ships_in": "3.4.21",
966
+ "ships_in": "3.4.22",
967
967
  },
968
968
  # S9-defer H-22: live tile data for the Reward / Shadow /
969
969
  # Evolution-Cost dashboard tiles. Each block is a honest-empty
@@ -1,6 +1,6 @@
1
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
- # Part of SuperLocalMemory v3.4.21 — LLD-01 §4.4 / §4.5
3
+ # Part of SuperLocalMemory v3.4.22 — LLD-01 §4.4 / §4.5
4
4
 
5
5
  """POST /internal/prewarm — populates the context cache for a session.
6
6
 
@@ -33,7 +33,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
33
33
  # Enable browser XSS filter (legacy, but doesn't hurt)
34
34
  response.headers["X-XSS-Protection"] = "1; mode=block"
35
35
 
36
- # Content Security Policy (v3.4.21 — vendored assets, no CDN hosts).
36
+ # Content Security Policy (v3.4.22 — vendored assets, no CDN hosts).
37
37
  # All Bootstrap/D3/Sigma/graphology/Inter assets ship locally under
38
38
  # /static/vendor/, so we drop every CDN host from the allow-list.
39
39
  # 'unsafe-inline' stays on script-src/style-src for the legacy inline
@@ -56,9 +56,27 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
56
56
  # Control referrer information leakage
57
57
  response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
58
58
 
59
- # Prevent caching of sensitive data (for API endpoints)
60
- if request.url.path.startswith("/api/"):
59
+ # v3.4.23: Cache-Control strategy
60
+ # ---------------------------------------------------------------
61
+ # Three classes of paths, three policies:
62
+ #
63
+ # /api/* -> no-store (sensitive data, never cache)
64
+ # index.html -> no-cache, must-revalidate (always revalidate)
65
+ # /static/* -> no-cache, must-revalidate (always revalidate
66
+ # with ETag; fast reloads but never stale-after-
67
+ # upgrade)
68
+ #
69
+ # Before v3.4.23 only /api/* had cache headers. Browsers then cached
70
+ # JS/CSS/HTML aggressively via default heuristics, and after a daemon
71
+ # upgrade the dashboard showed an infinite spinner because old cached
72
+ # JS was calling endpoints with stale response shapes. "no-cache"
73
+ # (not "no-store") still allows 304s on unchanged files, so reload
74
+ # cost stays low.
75
+ path = request.url.path
76
+ if path.startswith("/api/"):
61
77
  response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
62
78
  response.headers["Pragma"] = "no-cache"
79
+ elif path == "/" or path.endswith(".html") or path.startswith("/static/"):
80
+ response.headers["Cache-Control"] = "no-cache, must-revalidate"
63
81
 
64
82
  return response
@@ -369,7 +369,7 @@ async def lifespan(application: FastAPI):
369
369
  )
370
370
 
371
371
  # S9-DASH-02: start the outcome-queue worker so recall →
372
- # pending_outcomes is actually produced. Before v3.4.21 this
372
+ # pending_outcomes is actually produced. Before v3.4.22 this
373
373
  # producer had zero callers and the closed-loop pipeline was
374
374
  # dark. Worker drains at 250 ms cadence; one SQLite INSERT per
375
375
  # event via EngagementRewardModel.record_recall.
@@ -457,7 +457,7 @@ async def lifespan(application: FastAPI):
457
457
  if enable_legacy:
458
458
  asyncio.create_task(_start_legacy_redirect(_DEFAULT_PORT, _LEGACY_PORT))
459
459
 
460
- # V3.4.21 LLD-02: signal-worker background drainer (S8-SK-01 fix).
460
+ # V3.4.22 LLD-02: signal-worker background drainer (S8-SK-01 fix).
461
461
  # Without this, ``signals.enqueue`` fills a bounded queue and drops
462
462
  # silently after ~250 recalls — learning_signals never populates,
463
463
  # Phase 3 never activates, the whole Living Brain stays cold.
@@ -473,7 +473,7 @@ async def lifespan(application: FastAPI):
473
473
  logger.warning("signal_worker failed to start: %s", exc)
474
474
  application.state.signal_worker_started = False
475
475
 
476
- # V3.4.21 LLD-05: cross-platform adapter sync loop
476
+ # V3.4.22 LLD-05: cross-platform adapter sync loop
477
477
  if os.environ.get("SLM_CROSS_PLATFORM_SYNC_DISABLED", "").lower() not in ("1", "true"):
478
478
  try:
479
479
  from superlocalmemory.cli.context_commands import build_default_adapters
@@ -482,7 +482,7 @@ async def lifespan(application: FastAPI):
482
482
  except Exception as exc: # pragma: no cover — defensive
483
483
  logger.warning("cross-platform sync loop failed to start: %s", exc)
484
484
 
485
- # V3.4.21 LLD-03: bandit reward proxy settler + retention sweep loops
485
+ # V3.4.22 LLD-03: bandit reward proxy settler + retention sweep loops
486
486
  if os.environ.get("SLM_BANDIT_DISABLED", "0") != "1":
487
487
  try:
488
488
  from superlocalmemory.server.bandit_loops import (
@@ -495,9 +495,20 @@ async def lifespan(application: FastAPI):
495
495
  global _start_time
496
496
  _start_time = time.monotonic()
497
497
  _last_activity = time.monotonic()
498
- logger.info("Unified daemon ready on port %d (24/7 mode)" if idle_timeout <= 0
499
- else "Unified daemon ready on port %d (idle timeout: %ds)",
500
- _DEFAULT_PORT, idle_timeout)
498
+ # v3.4.23: pre-format the ready message. Previous code passed a ternary as
499
+ # the log format string with a fixed 2-arg tuple; when idle_timeout<=0 the
500
+ # chosen branch had only one %d, triggering a TypeError on every startup.
501
+ # Python's logging module then wrote the full stack to stderr. Because the
502
+ # call runs inside FastAPI's stacked merged_lifespan, each dump was ~30 KB
503
+ # and the error log grew to tens of MB within a day.
504
+ if idle_timeout <= 0:
505
+ _ready_msg = f"Unified daemon ready on port {_DEFAULT_PORT} (24/7 mode)"
506
+ else:
507
+ _ready_msg = (
508
+ f"Unified daemon ready on port {_DEFAULT_PORT} "
509
+ f"(idle timeout: {idle_timeout}s)"
510
+ )
511
+ logger.info(_ready_msg)
501
512
 
502
513
  yield
503
514
 
@@ -850,7 +861,18 @@ def _register_dashboard_routes(application: FastAPI) -> None:
850
861
  _data_io_mod.ws_manager = ws_manager
851
862
 
852
863
  # Root page
853
- from fastapi.responses import HTMLResponse
864
+ from fastapi.responses import HTMLResponse, JSONResponse
865
+
866
+ # v3.4.23: /api/version — dashboard polls this to detect daemon upgrades
867
+ # and auto-reload stale tabs (see ui/js/core.js::checkVersionFingerprint).
868
+ try:
869
+ from superlocalmemory import __version__ as _SLM_VERSION
870
+ except Exception: # pragma: no cover — defensive
871
+ _SLM_VERSION = "unknown"
872
+
873
+ @application.get("/api/version")
874
+ async def api_version():
875
+ return JSONResponse({"version": _SLM_VERSION})
854
876
 
855
877
  @application.get("/", response_class=HTMLResponse)
856
878
  async def root():
@@ -863,7 +885,11 @@ def _register_dashboard_routes(application: FastAPI) -> None:
863
885
  "<p><a href='/docs'>API Documentation</a></p>"
864
886
  "</body></html>"
865
887
  )
866
- return index_path.read_text()
888
+ # v3.4.23: substitute version placeholder so the dashboard can detect
889
+ # upgrades and auto-reload. Read fresh each request (daemon uptime is
890
+ # days, but we want zero caching surprises during development).
891
+ html = index_path.read_text()
892
+ return html.replace("__SLM_VERSION__", _SLM_VERSION)
867
893
 
868
894
  # Startup event for event listener
869
895
  @application.on_event("startup")
@@ -1066,6 +1092,13 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
1066
1092
  global _start_time
1067
1093
  import uvicorn
1068
1094
 
1095
+ # v3.4.23: rotate oversized logs before anything else so both the CLI
1096
+ # path (`slm serve`) and the LaunchAgent path (__main__) are covered.
1097
+ try:
1098
+ rotate_oversized_logs()
1099
+ except Exception:
1100
+ pass # never block startup on log housekeeping
1101
+
1069
1102
  _PID_FILE.parent.mkdir(parents=True, exist_ok=True)
1070
1103
  _PID_FILE.write_text(str(os.getpid()))
1071
1104
  _PORT_FILE.write_text(str(port))
@@ -1094,11 +1127,80 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
1094
1127
  _PORT_FILE.unlink(missing_ok=True)
1095
1128
 
1096
1129
 
1130
+ # ---------------------------------------------------------------------------
1131
+ # v3.4.23 — Startup log rotation
1132
+ # ---------------------------------------------------------------------------
1133
+ # The LaunchAgent plist redirects stdout/stderr to daemon.log and
1134
+ # daemon-error.log. Those files are managed by launchd, not Python, so
1135
+ # Python's RotatingFileHandler cannot prune them. If any bug ever writes
1136
+ # large amounts of data to stderr (the v3.4.22 logger-format bug produced
1137
+ # ~30 KB per startup and the file grew to 69 MB), end users end up with a
1138
+ # disk-eating log they never knew existed.
1139
+ #
1140
+ # rotate_oversized_logs() is a belt-and-suspenders guard: every time the
1141
+ # daemon starts, if either log exceeds MAX_LOG_BYTES we rename the current
1142
+ # file to ".1" (keeping one rotated copy) and truncate the original so
1143
+ # launchd's open file descriptor keeps working. This is cheap, stateless,
1144
+ # and independent of whatever caused the overflow.
1145
+ # ---------------------------------------------------------------------------
1146
+
1147
+ _MAX_LOG_BYTES = 10 * 1024 * 1024 # 10 MB
1148
+
1149
+
1150
+ def rotate_oversized_logs(log_dir: Optional[Path] = None,
1151
+ max_bytes: int = _MAX_LOG_BYTES) -> None:
1152
+ """Rotate daemon.log and daemon-error.log at startup if oversized.
1153
+
1154
+ Keeps one rotated copy (.1). Safe under concurrent start attempts:
1155
+ rename is atomic on POSIX, and truncation is idempotent.
1156
+ """
1157
+ log_dir = log_dir or (Path.home() / ".superlocalmemory" / "logs")
1158
+ try:
1159
+ log_dir.mkdir(parents=True, exist_ok=True)
1160
+ except Exception:
1161
+ return
1162
+ for name in ("daemon.log", "daemon-error.log", "daemon.json.log"):
1163
+ path = log_dir / name
1164
+ try:
1165
+ if not path.exists() or path.stat().st_size <= max_bytes:
1166
+ continue
1167
+ rotated = log_dir / f"{name}.1"
1168
+ try:
1169
+ if rotated.exists():
1170
+ rotated.unlink()
1171
+ except Exception:
1172
+ pass
1173
+ try:
1174
+ path.rename(rotated)
1175
+ except Exception:
1176
+ # If rename fails (e.g., file is the open stderr fd under
1177
+ # launchd), fall back to truncation so we at least reclaim
1178
+ # disk without breaking the redirect.
1179
+ try:
1180
+ with open(path, "w"):
1181
+ pass
1182
+ except Exception:
1183
+ pass
1184
+ continue
1185
+ # Re-create the original path as empty so launchd's redirect
1186
+ # keeps appending to a fresh file.
1187
+ try:
1188
+ path.touch()
1189
+ except Exception:
1190
+ pass
1191
+ except Exception:
1192
+ # Log rotation must never prevent daemon startup.
1193
+ continue
1194
+
1195
+
1097
1196
  # ---------------------------------------------------------------------------
1098
1197
  # CLI entry point
1099
1198
  # ---------------------------------------------------------------------------
1100
1199
 
1101
1200
  if __name__ == "__main__":
1201
+ # Rotate first, then configure logging, so the first log line lands in a
1202
+ # freshly-sized file.
1203
+ rotate_oversized_logs()
1102
1204
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
1103
1205
  port = _DEFAULT_PORT
1104
1206
  for arg in sys.argv: