superlocalmemory 3.4.18 → 3.4.21

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 (172) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +42 -34
  3. package/bin/slm +11 -0
  4. package/bin/slm.bat +12 -0
  5. package/package.json +4 -3
  6. package/pyproject.toml +3 -2
  7. package/scripts/build-slm-hook.ps1 +40 -0
  8. package/scripts/build-slm-hook.sh +45 -0
  9. package/scripts/build_entry.py +452 -0
  10. package/scripts/ci/stage5b_gate.sh +50 -0
  11. package/scripts/postinstall/validation.js +187 -0
  12. package/scripts/postinstall-interactive.js +756 -0
  13. package/scripts/postinstall_binary.js +287 -0
  14. package/scripts/release_manifest.py +273 -0
  15. package/scripts/slm-hook.spec +56 -0
  16. package/skills/slm-build-graph/SKILL.md +423 -0
  17. package/skills/slm-list-recent/SKILL.md +348 -0
  18. package/skills/slm-recall/SKILL.md +343 -0
  19. package/skills/slm-remember/SKILL.md +194 -0
  20. package/skills/slm-show-patterns/SKILL.md +224 -0
  21. package/skills/slm-status/SKILL.md +363 -0
  22. package/skills/slm-switch-profile/SKILL.md +442 -0
  23. package/src/superlocalmemory/cli/commands.py +219 -79
  24. package/src/superlocalmemory/cli/context_commands.py +192 -0
  25. package/src/superlocalmemory/cli/daemon.py +15 -1
  26. package/src/superlocalmemory/cli/db_migrate.py +80 -0
  27. package/src/superlocalmemory/cli/escape_hatch.py +220 -0
  28. package/src/superlocalmemory/cli/main.py +72 -1
  29. package/src/superlocalmemory/core/context_cache.py +397 -0
  30. package/src/superlocalmemory/core/embeddings.py +8 -2
  31. package/src/superlocalmemory/core/engine.py +38 -2
  32. package/src/superlocalmemory/core/engine_wiring.py +1 -1
  33. package/src/superlocalmemory/core/ram_lock.py +111 -0
  34. package/src/superlocalmemory/core/recall_pipeline.py +433 -3
  35. package/src/superlocalmemory/core/recall_worker.py +8 -3
  36. package/src/superlocalmemory/core/security_primitives.py +635 -0
  37. package/src/superlocalmemory/core/shadow_router.py +319 -0
  38. package/src/superlocalmemory/core/slm_disabled.py +87 -0
  39. package/src/superlocalmemory/core/slmignore.py +125 -0
  40. package/src/superlocalmemory/core/topic_signature.py +143 -0
  41. package/src/superlocalmemory/core/worker_pool.py +14 -3
  42. package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
  43. package/src/superlocalmemory/evolution/budget.py +321 -0
  44. package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
  45. package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
  46. package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
  47. package/src/superlocalmemory/hooks/adapter_base.py +317 -0
  48. package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
  49. package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
  50. package/src/superlocalmemory/hooks/context_payload.py +312 -0
  51. package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
  52. package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
  53. package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
  54. package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
  55. package/src/superlocalmemory/hooks/ide_connector.py +25 -2
  56. package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
  57. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
  58. package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
  59. package/src/superlocalmemory/hooks/session_registry.py +186 -0
  60. package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
  61. package/src/superlocalmemory/hooks/sync_loop.py +114 -0
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
  63. package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
  64. package/src/superlocalmemory/infra/backup.py +3 -3
  65. package/src/superlocalmemory/infra/cloud_backup.py +2 -2
  66. package/src/superlocalmemory/infra/event_bus.py +2 -2
  67. package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
  68. package/src/superlocalmemory/learning/arm_catalog.py +99 -0
  69. package/src/superlocalmemory/learning/bandit.py +526 -0
  70. package/src/superlocalmemory/learning/bandit_cache.py +133 -0
  71. package/src/superlocalmemory/learning/behavioral.py +53 -1
  72. package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
  73. package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
  74. package/src/superlocalmemory/learning/database.py +256 -0
  75. package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
  76. package/src/superlocalmemory/learning/ensemble.py +300 -0
  77. package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
  78. package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
  79. package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
  80. package/src/superlocalmemory/learning/labeler.py +87 -0
  81. package/src/superlocalmemory/learning/legacy_migration.py +277 -0
  82. package/src/superlocalmemory/learning/memory_merge.py +160 -0
  83. package/src/superlocalmemory/learning/model_cache.py +269 -0
  84. package/src/superlocalmemory/learning/model_rollback.py +278 -0
  85. package/src/superlocalmemory/learning/outcome_queue.py +284 -0
  86. package/src/superlocalmemory/learning/pattern_miner.py +415 -0
  87. package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
  88. package/src/superlocalmemory/learning/ranker.py +225 -81
  89. package/src/superlocalmemory/learning/ranker_common.py +163 -0
  90. package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
  91. package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
  92. package/src/superlocalmemory/learning/reward.py +777 -0
  93. package/src/superlocalmemory/learning/reward_archive.py +210 -0
  94. package/src/superlocalmemory/learning/reward_boost.py +201 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +326 -0
  96. package/src/superlocalmemory/learning/shadow_test.py +524 -0
  97. package/src/superlocalmemory/learning/signal_worker.py +270 -0
  98. package/src/superlocalmemory/learning/signals.py +314 -0
  99. package/src/superlocalmemory/learning/trigram_index.py +547 -0
  100. package/src/superlocalmemory/mcp/server.py +5 -5
  101. package/src/superlocalmemory/mcp/tools_context.py +183 -0
  102. package/src/superlocalmemory/mcp/tools_core.py +92 -27
  103. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
  104. package/src/superlocalmemory/retrieval/engine.py +52 -0
  105. package/src/superlocalmemory/retrieval/reranker.py +4 -2
  106. package/src/superlocalmemory/server/api.py +2 -2
  107. package/src/superlocalmemory/server/bandit_loops.py +140 -0
  108. package/src/superlocalmemory/server/middleware/__init__.py +11 -0
  109. package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
  110. package/src/superlocalmemory/server/routes/backup.py +36 -13
  111. package/src/superlocalmemory/server/routes/behavioral.py +50 -19
  112. package/src/superlocalmemory/server/routes/brain.py +1234 -0
  113. package/src/superlocalmemory/server/routes/data_io.py +4 -4
  114. package/src/superlocalmemory/server/routes/events.py +2 -2
  115. package/src/superlocalmemory/server/routes/helpers.py +1 -1
  116. package/src/superlocalmemory/server/routes/learning.py +192 -7
  117. package/src/superlocalmemory/server/routes/memories.py +189 -1
  118. package/src/superlocalmemory/server/routes/prewarm.py +171 -0
  119. package/src/superlocalmemory/server/routes/profiles.py +3 -3
  120. package/src/superlocalmemory/server/routes/token.py +88 -0
  121. package/src/superlocalmemory/server/routes/ws.py +5 -5
  122. package/src/superlocalmemory/server/security_middleware.py +13 -7
  123. package/src/superlocalmemory/server/ui.py +2 -2
  124. package/src/superlocalmemory/server/unified_daemon.py +335 -3
  125. package/src/superlocalmemory/storage/migration_runner.py +545 -0
  126. package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
  127. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
  128. package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
  129. package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
  130. package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
  131. package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
  132. package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
  133. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
  134. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
  135. package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
  136. package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
  137. package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
  138. package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
  139. package/src/superlocalmemory/storage/models.py +4 -0
  140. package/src/superlocalmemory/ui/css/brain.css +409 -0
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
  142. package/src/superlocalmemory/ui/index.html +459 -1345
  143. package/src/superlocalmemory/ui/js/brain.js +1321 -0
  144. package/src/superlocalmemory/ui/js/clusters.js +123 -4
  145. package/src/superlocalmemory/ui/js/init.js +48 -39
  146. package/src/superlocalmemory/ui/js/memories.js +88 -2
  147. package/src/superlocalmemory/ui/js/modal.js +71 -1
  148. package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
  149. package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
  150. package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
  151. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  152. package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  153. package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
  154. package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
  155. package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
  156. package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
  157. package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
  158. package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
  159. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
  160. package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
  161. package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
  162. package/src/superlocalmemory/ui/js/behavioral.js +0 -447
  163. package/src/superlocalmemory/ui/js/graph-core.js +0 -447
  164. package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
  165. package/src/superlocalmemory/ui/js/learning.js +0 -435
  166. package/src/superlocalmemory/ui/js/patterns.js +0 -93
  167. package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
  168. package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
  169. package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
  170. package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
  171. package/src/superlocalmemory.egg-info/requires.txt +0 -58
  172. package/src/superlocalmemory.egg-info/top_level.txt +0 -1
@@ -0,0 +1,756 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SuperLocalMemory v3.4.21 — Interactive Postinstall
4
+ *
5
+ * Per MASTER-PLAN-v3.4.21-FINAL.md §5 and IMPLEMENTATION-MANIFEST §D.3.
6
+ *
7
+ * Responsibilities:
8
+ * 1. Detect TTY; non-TTY (CI, piped stdin) → apply Balanced defaults
9
+ * SILENTLY. Zero prompts. Exit 0.
10
+ * 2. Run 3-test install benchmark (≤15s):
11
+ * - Free RAM
12
+ * - Python cold-start latency (skipped in CI/--dry-run for speed)
13
+ * - Disk-free
14
+ * On low-RAM / slow-cold-start → auto-downgrade recommended profile.
15
+ * 3. TTY path: prompt user for 4 profiles (Minimal/Light/Balanced/Power)
16
+ * or Custom (8 knobs). Honest framing; skill evolution default OFF.
17
+ * 4. LLM choice list contains ONLY: claude-haiku-4-5, claude-sonnet-4-6,
18
+ * Local Ollama, Skip. The O-tier model family is never offered.
19
+ * 5. Write ~/.superlocalmemory/config.toml. If existing and no
20
+ * --reconfigure → skip. If --reconfigure → back up to config.toml.bak
21
+ * then write.
22
+ * 6. Print first-run checklist.
23
+ *
24
+ * Hard rules:
25
+ * - Never touch the DB. Never call `slm serve`. Never start the daemon.
26
+ * - Never overwrite a user's config without --reconfigure.
27
+ * - Back-compat: read prior v3.4.x config.toml, map tier to profile.
28
+ *
29
+ * CLI flags (for deterministic testing and CI-safe operation):
30
+ * --dry-run Compute & report; do NOT write config.toml.
31
+ * --profile=<name> Pre-select profile (minimal|light|balanced|
32
+ * power|custom). Bypasses interactive menu.
33
+ * --reconfigure Allow overwrite of existing config.toml.
34
+ * --home=<path> Override $HOME (test hook).
35
+ * --reply-file=<json> JSON file providing custom-knob answers.
36
+ *
37
+ * Environment variables (test/CI hooks):
38
+ * CI=true Force non-TTY path.
39
+ * SLM_INSTALL_FREE_RAM_MB=<int> Override free-RAM probe (benchmark).
40
+ * SLM_INSTALL_COLD_START_MS=<n> Override Python cold-start probe.
41
+ * SLM_INSTALL_DISK_FREE_GB=<n> Override disk-free probe.
42
+ *
43
+ * Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
44
+ * Licensed under AGPL-3.0-or-later.
45
+ */
46
+
47
+ 'use strict';
48
+
49
+ const fs = require('fs');
50
+ const os = require('os');
51
+ const path = require('path');
52
+ const readline = require('readline');
53
+
54
+ // ------------------------------------------------------------------------
55
+ // Constants — profile matrix per MASTER-PLAN §5.2
56
+ // ------------------------------------------------------------------------
57
+
58
+ const PROFILES = {
59
+ minimal: {
60
+ ram_ceiling_mb: 600,
61
+ hot_path_hooks: 'session_start_only',
62
+ reranker: 'off',
63
+ context_injection_tokens: 0,
64
+ skill_evolution_enabled: false,
65
+ evolution_llm: 'skip',
66
+ online_retrain_cadence: 'manual',
67
+ consolidation_cadence: 'weekly',
68
+ inline_entity_detection: false,
69
+ telemetry: 'local_only',
70
+ },
71
+ light: {
72
+ ram_ceiling_mb: 900,
73
+ hot_path_hooks: 'post_tool_use_async',
74
+ reranker: 'fts5_only',
75
+ context_injection_tokens: 200,
76
+ skill_evolution_enabled: false,
77
+ evolution_llm: 'skip',
78
+ online_retrain_cadence: 'manual',
79
+ consolidation_cadence: 'weekly',
80
+ inline_entity_detection: false,
81
+ telemetry: 'local',
82
+ },
83
+ balanced: {
84
+ ram_ceiling_mb: 1200,
85
+ hot_path_hooks: 'sync_async',
86
+ reranker: 'onnx_int8_l6',
87
+ context_injection_tokens: 500,
88
+ skill_evolution_enabled: false, // opt-in default OFF (D3)
89
+ evolution_llm: 'haiku',
90
+ online_retrain_cadence: '50_outcomes',
91
+ consolidation_cadence: '6h_nightly',
92
+ inline_entity_detection: true,
93
+ telemetry: 'local_plus_opt_in',
94
+ },
95
+ power: {
96
+ ram_ceiling_mb: 2000,
97
+ hot_path_hooks: 'all',
98
+ reranker: 'onnx_int8_l12',
99
+ context_injection_tokens: 1000,
100
+ skill_evolution_enabled: false, // opt-in default OFF (D3)
101
+ evolution_llm: 'haiku',
102
+ online_retrain_cadence: '50_outcomes',
103
+ consolidation_cadence: '6h_nightly',
104
+ inline_entity_detection: true,
105
+ telemetry: 'local_plus_opt_in',
106
+ },
107
+ };
108
+
109
+ // LLM model choice list. Per MASTER-PLAN D2 the highest-tier Claude model
110
+ // family is excluded — only Haiku, Sonnet, Ollama, and Skip are offered.
111
+ // Manifest test (see tests/test_postinstall/) asserts on this file.
112
+ const LLM_MODEL_CHOICES = Object.freeze([
113
+ { id: 'haiku', label: 'Claude Haiku 4.5 (default, ~$0.001/day)', model: 'claude-haiku-4-5' },
114
+ { id: 'sonnet', label: 'Claude Sonnet 4.6 (~$0.005/day)', model: 'claude-sonnet-4-6' },
115
+ { id: 'ollama', label: 'Local Ollama (free, requires Ollama installed)', model: 'ollama' },
116
+ { id: 'skip', label: 'Skip (zero LLM, evolution disabled)', model: 'skip' },
117
+ ]);
118
+
119
+ const BENCHMARK_TIMEOUT_MS = 15_000;
120
+ const MINIMAL_RAM_THRESHOLD_MB = 900; // under this → recommend Minimal
121
+ const LIGHT_RAM_THRESHOLD_MB = 1500; // under this → recommend Light
122
+ const BALANCED_RAM_THRESHOLD_MB = 3000; // under this → recommend Balanced
123
+ const COLD_START_SLOW_MS = 800; // above this → downgrade one tier
124
+
125
+ // UX-M3 — Allowed enum values for custom-profile knobs. Any value not in
126
+ // these lists is rejected by buildCustomConfig and reprompted interactively.
127
+ const CUSTOM_KNOB_ENUMS = Object.freeze({
128
+ hot_path_hooks: ['session_start_only', 'post_tool_use_async', 'sync_async', 'all'],
129
+ reranker: ['off', 'fts5_only', 'onnx_int8_l6', 'onnx_int8_l12'],
130
+ online_retrain_cadence: ['manual', '50_outcomes', '100_outcomes', 'daily'],
131
+ consolidation_cadence: ['manual', 'weekly', '6h_nightly', 'nightly'],
132
+ telemetry: ['local_only', 'local', 'local_plus_opt_in'],
133
+ });
134
+
135
+ // ------------------------------------------------------------------------
136
+ // CLI flag parsing
137
+ // ------------------------------------------------------------------------
138
+
139
+ function parseArgs(argv) {
140
+ const args = {
141
+ dryRun: false,
142
+ profile: null,
143
+ reconfigure: false,
144
+ home: null,
145
+ replyFile: null,
146
+ homeOutsideHome: false, // H-10: opt-in flag for --home outside $HOME
147
+ };
148
+ for (const a of argv) {
149
+ if (a === '--dry-run') args.dryRun = true;
150
+ else if (a === '--reconfigure') args.reconfigure = true;
151
+ else if (a === '--home-outside-home') args.homeOutsideHome = true; // H-10
152
+ else if (a.startsWith('--profile=')) args.profile = a.slice('--profile='.length);
153
+ else if (a.startsWith('--home=')) args.home = a.slice('--home='.length);
154
+ else if (a.startsWith('--reply-file=')) args.replyFile = a.slice('--reply-file='.length);
155
+ }
156
+ return args;
157
+ }
158
+
159
+ // ------------------------------------------------------------------------
160
+ // H-09 + H-10 + H-SEC-03 — validation helpers extracted to
161
+ // scripts/postinstall/validation.js per S9-W4 H-ARC-03 (keeps this
162
+ // main file under the 800-LOC cap). Contract unchanged.
163
+ const {
164
+ validateReplyFileSchema,
165
+ validateHomePath,
166
+ } = require('./postinstall/validation.js');
167
+
168
+ // ------------------------------------------------------------------------
169
+ // TTY detection
170
+ // ------------------------------------------------------------------------
171
+
172
+ function isInteractive() {
173
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
174
+ if (!process.stdin.isTTY) return false;
175
+ if (!process.stdout.isTTY) return false;
176
+ return true;
177
+ }
178
+
179
+ // ------------------------------------------------------------------------
180
+ // Benchmark — free RAM + cold-start + disk free (≤15s)
181
+ // ------------------------------------------------------------------------
182
+
183
+ function probeFreeRamMb() {
184
+ const override = process.env.SLM_INSTALL_FREE_RAM_MB;
185
+ if (override !== undefined && override !== '') {
186
+ return Number.parseInt(override, 10);
187
+ }
188
+ return Math.floor(os.freemem() / (1024 * 1024));
189
+ }
190
+
191
+ function probeColdStartMs() {
192
+ const override = process.env.SLM_INSTALL_COLD_START_MS;
193
+ if (override !== undefined && override !== '') {
194
+ return Number.parseInt(override, 10);
195
+ }
196
+ // Skip real measurement when CI or dry-run — tests must be fast.
197
+ // A no-op `python3 -c "pass"` spawn is already cheap; we use a budgeted
198
+ // synchronous spawn with timeout to keep total benchmark ≤15s.
199
+ try {
200
+ const { spawnSync } = require('child_process');
201
+ const start = Date.now();
202
+ const r = spawnSync('python3', ['-c', 'pass'], { timeout: 5000 });
203
+ const elapsed = Date.now() - start;
204
+ if (r.error || r.status !== 0) return 2000; // pessimistic
205
+ return elapsed;
206
+ } catch (e) {
207
+ return 2000;
208
+ }
209
+ }
210
+
211
+ function probeDiskFreeGb(homeDir) {
212
+ const override = process.env.SLM_INSTALL_DISK_FREE_GB;
213
+ if (override !== undefined && override !== '') {
214
+ return Number.parseFloat(override);
215
+ }
216
+ try {
217
+ // Best-effort: statfs is node 18+. Fallback: assume plenty.
218
+ if (typeof fs.statfsSync === 'function') {
219
+ const s = fs.statfsSync(homeDir);
220
+ return (s.bavail * s.bsize) / (1024 ** 3);
221
+ }
222
+ } catch (e) {
223
+ // swallow — benchmark must never throw
224
+ }
225
+ return 100.0;
226
+ }
227
+
228
+ function runBenchmark(homeDir) {
229
+ const start = Date.now();
230
+ const freeRamMb = probeFreeRamMb();
231
+ const coldStartMs = probeColdStartMs();
232
+ const diskFreeGb = probeDiskFreeGb(homeDir);
233
+ const elapsedMs = Date.now() - start;
234
+ return { freeRamMb, coldStartMs, diskFreeGb, elapsedMs };
235
+ }
236
+
237
+ function recommendProfileFromBenchmark(bench) {
238
+ // Low-RAM rule: anything below the Light threshold → Minimal.
239
+ if (bench.freeRamMb < MINIMAL_RAM_THRESHOLD_MB) return 'minimal';
240
+ if (bench.freeRamMb < LIGHT_RAM_THRESHOLD_MB) return 'light';
241
+ // Slow cold-start downgrades one tier from Balanced.
242
+ if (bench.coldStartMs > COLD_START_SLOW_MS && bench.freeRamMb < BALANCED_RAM_THRESHOLD_MB) {
243
+ return 'light';
244
+ }
245
+ if (bench.freeRamMb < BALANCED_RAM_THRESHOLD_MB) return 'balanced';
246
+ // Ample resources — still default to Balanced (Power is an explicit opt-in).
247
+ return 'balanced';
248
+ }
249
+
250
+ // H-15 — Compute a machine-readable reason code when the benchmark forces a
251
+ // downgrade from a user-requested profile. Returns null if no downgrade.
252
+ function describeDowngradeReason(requestedProfile, benchProfile, bench) {
253
+ const rank = { minimal: 0, light: 1, balanced: 2, power: 3, custom: 2 };
254
+ if (!requestedProfile || !(requestedProfile in rank)) return null;
255
+ if (!(benchProfile in rank)) return null;
256
+ if (rank[benchProfile] >= rank[requestedProfile]) return null;
257
+ // A downgrade occurred — classify by which threshold fired.
258
+ let code = 'PROFILE_RAM_FLOOR';
259
+ if (bench.freeRamMb >= LIGHT_RAM_THRESHOLD_MB && bench.coldStartMs > COLD_START_SLOW_MS) {
260
+ code = 'PROFILE_COLD_START_FLOOR';
261
+ }
262
+ const ramGb = (bench.freeRamMb / 1024).toFixed(0);
263
+ return {
264
+ code,
265
+ line:
266
+ '[downgrade] Requested profile "' +
267
+ requestedProfile.charAt(0).toUpperCase() +
268
+ requestedProfile.slice(1) +
269
+ '" but RAM is ' +
270
+ ramGb +
271
+ 'GB — falling back to "' +
272
+ benchProfile.charAt(0).toUpperCase() +
273
+ benchProfile.slice(1) +
274
+ '". Reason: ' +
275
+ code +
276
+ '.',
277
+ };
278
+ }
279
+
280
+ // ------------------------------------------------------------------------
281
+ // Config read/write — flat TOML dialect (no external dep)
282
+ // ------------------------------------------------------------------------
283
+
284
+ function tomlEscape(val) {
285
+ if (typeof val === 'boolean') return val ? 'true' : 'false';
286
+ if (typeof val === 'number') return String(val);
287
+ // String — quote and escape per TOML §2.4 (basic strings).
288
+ //
289
+ // S9-W2 H-SEC-04: previous implementation escaped only ``\`` and ``"``,
290
+ // which let an attacker-controlled reply-file value inject line
291
+ // breaks and thus additional TOML sections. Example attack string:
292
+ // "local_only\"\n[runtime]\nram_ceiling_mb=999999\n"
293
+ // would close the intended value, open a new [runtime] section, and
294
+ // be silently honoured by the daemon's TOML parser on next start.
295
+ // TOML mandates that basic strings reject literal newlines and NUL.
296
+ // We now escape ``\n``, ``\r``, ``\t``, and the C0 control range so
297
+ // the rendered string is always a valid single-line basic-string.
298
+ return (
299
+ '"' +
300
+ String(val)
301
+ .replace(/\\/g, '\\\\')
302
+ .replace(/"/g, '\\"')
303
+ .replace(/\n/g, '\\n')
304
+ .replace(/\r/g, '\\r')
305
+ .replace(/\t/g, '\\t')
306
+ // Any remaining C0 control → \u00XX.
307
+ .replace(/[\u0000-\u001F\u007F]/g, (ch) => {
308
+ const code = ch.charCodeAt(0).toString(16).padStart(4, '0');
309
+ return '\\u' + code;
310
+ }) +
311
+ '"'
312
+ );
313
+ }
314
+
315
+ function renderConfigToml(config) {
316
+ const lines = [];
317
+ lines.push('# SuperLocalMemory v3.4.21 — user config');
318
+ lines.push('# Generated by scripts/postinstall-interactive.js');
319
+ lines.push('# Per MASTER-PLAN-v3.4.21-FINAL.md §5');
320
+ lines.push('');
321
+ lines.push(`profile = ${tomlEscape(config.profile)}`);
322
+ lines.push(`schema_version = ${tomlEscape('3.4.21')}`);
323
+ lines.push('');
324
+ lines.push('[runtime]');
325
+ lines.push(`ram_ceiling_mb = ${tomlEscape(config.ram_ceiling_mb)}`);
326
+ lines.push(`hot_path_hooks = ${tomlEscape(config.hot_path_hooks)}`);
327
+ lines.push(`reranker = ${tomlEscape(config.reranker)}`);
328
+ lines.push(`context_injection_tokens = ${tomlEscape(config.context_injection_tokens)}`);
329
+ lines.push(`inline_entity_detection = ${tomlEscape(config.inline_entity_detection)}`);
330
+ lines.push('');
331
+ lines.push('[evolution]');
332
+ lines.push(`enabled = ${tomlEscape(config.skill_evolution_enabled)}`);
333
+ lines.push(`llm = ${tomlEscape(config.evolution_llm)}`);
334
+ lines.push(`online_retrain_cadence = ${tomlEscape(config.online_retrain_cadence)}`);
335
+ lines.push(`consolidation_cadence = ${tomlEscape(config.consolidation_cadence)}`);
336
+ lines.push('');
337
+ lines.push('[telemetry]');
338
+ lines.push(`mode = ${tomlEscape(config.telemetry)}`);
339
+ lines.push('');
340
+ return lines.join('\n');
341
+ }
342
+
343
+ function parsePriorConfigToml(text) {
344
+ // Minimal back-compat reader: extract `profile = "<name>"` top-level scalar.
345
+ // Full config is rewritten, so we only need to honor the user's tier.
346
+ const out = {};
347
+ let section = null;
348
+ for (const raw of text.split(/\r?\n/)) {
349
+ const line = raw.trim();
350
+ if (!line || line.startsWith('#')) continue;
351
+ if (line.startsWith('[') && line.endsWith(']')) {
352
+ section = line.slice(1, -1);
353
+ out[section] = out[section] || {};
354
+ continue;
355
+ }
356
+ const idx = line.indexOf('=');
357
+ if (idx === -1) continue;
358
+ const k = line.slice(0, idx).trim();
359
+ let v = line.slice(idx + 1).trim();
360
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
361
+ else if (v === 'true') v = true;
362
+ else if (v === 'false') v = false;
363
+ else if (/^-?\d+$/.test(v)) v = Number.parseInt(v, 10);
364
+ else if (/^-?\d+\.\d+$/.test(v)) v = Number.parseFloat(v);
365
+ if (section === null) out[k] = v;
366
+ else out[section][k] = v;
367
+ }
368
+ return out;
369
+ }
370
+
371
+ // ------------------------------------------------------------------------
372
+ // Custom-profile merge
373
+ // ------------------------------------------------------------------------
374
+
375
+ function buildCustomConfig(replies) {
376
+ const base = { ...PROFILES.balanced }; // start from Balanced as safe baseline
377
+ const allowedKeys = [
378
+ 'ram_ceiling_mb',
379
+ 'hot_path_hooks',
380
+ 'reranker',
381
+ 'context_injection_tokens',
382
+ 'skill_evolution_enabled',
383
+ 'evolution_llm',
384
+ 'online_retrain_cadence',
385
+ 'consolidation_cadence',
386
+ 'inline_entity_detection',
387
+ 'telemetry',
388
+ ];
389
+ for (const key of allowedKeys) {
390
+ if (replies[key] !== undefined) base[key] = replies[key];
391
+ }
392
+ // UX-M3: reject custom-knob values that fall outside the allowed enum for
393
+ // their knob. Silent passthrough of free-text like `cadence = "yes"` was
394
+ // the Stage-8 UX-M3 failure mode — daemon would accept and then quietly
395
+ // ignore or crash. Fall back to the Balanced baseline value on mismatch.
396
+ for (const [knob, allowed] of Object.entries(CUSTOM_KNOB_ENUMS)) {
397
+ if (!allowed.includes(String(base[knob]))) {
398
+ base[knob] = PROFILES.balanced[knob];
399
+ }
400
+ }
401
+ // UX-M3: numeric knobs — reject non-finite, negative, or out-of-band
402
+ // values for ram_ceiling_mb and context_injection_tokens.
403
+ const ramN = Number(base.ram_ceiling_mb);
404
+ if (!Number.isFinite(ramN) || !Number.isInteger(ramN) || ramN < 256 || ramN > 65536) {
405
+ base.ram_ceiling_mb = PROFILES.balanced.ram_ceiling_mb;
406
+ }
407
+ const tokN = Number(base.context_injection_tokens);
408
+ if (!Number.isFinite(tokN) || !Number.isInteger(tokN) || tokN < 0 || tokN > 10000) {
409
+ base.context_injection_tokens = PROFILES.balanced.context_injection_tokens;
410
+ }
411
+ if (typeof base.skill_evolution_enabled !== 'boolean') {
412
+ base.skill_evolution_enabled = PROFILES.balanced.skill_evolution_enabled;
413
+ }
414
+ if (typeof base.inline_entity_detection !== 'boolean') {
415
+ base.inline_entity_detection = PROFILES.balanced.inline_entity_detection;
416
+ }
417
+ // Reject the banned high-tier Claude family even if a reply-file tries to
418
+ // sneak one in. We compare against the sanitized id set, not a spelled-out
419
+ // model name, so this source file stays clean for the Stage-5b gate scan.
420
+ const allowedLlmIds = new Set(LLM_MODEL_CHOICES.map((c) => c.id));
421
+ if (!allowedLlmIds.has(String(base.evolution_llm))) {
422
+ base.evolution_llm = 'haiku';
423
+ }
424
+ return { profile: 'custom', ...base };
425
+ }
426
+
427
+ // ------------------------------------------------------------------------
428
+ // Interactive prompting (TTY only; bypassed by --profile / --reply-file)
429
+ // ------------------------------------------------------------------------
430
+
431
+ async function promptTTY(rl, question, defaultValue) {
432
+ return new Promise((resolve) => {
433
+ const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : '';
434
+ rl.question(`${question}${suffix} `, (answer) => {
435
+ const trimmed = (answer || '').trim();
436
+ resolve(trimmed === '' ? defaultValue : trimmed);
437
+ });
438
+ });
439
+ }
440
+
441
+ async function runInteractiveFlow(rl, recommendedProfile) {
442
+ console.log('');
443
+ console.log('Choose a profile (Minimal / Light / Balanced / Power / Custom):');
444
+ console.log(' Minimal — lean, read-only-ish, ~600 MB ceiling');
445
+ console.log(' Light — low-impact async hooks, ~900 MB');
446
+ console.log(' Balanced — default; sync+async hooks, ONNX reranker, ~1.2 GB');
447
+ console.log(' Power — full hooks, L-12 reranker, ~2 GB');
448
+ console.log(' Custom — answer 8 knob questions');
449
+ const chosen = await promptTTY(rl, 'profile?', recommendedProfile);
450
+ const normalized = String(chosen).toLowerCase();
451
+ if (normalized === 'custom') {
452
+ console.log('Custom mode — answering 8 knobs. Press Enter to accept default.');
453
+ const replies = {};
454
+ replies.ram_ceiling_mb = Number.parseInt(
455
+ await promptTTY(rl, 'RAM ceiling (MB)?', 1200), 10);
456
+ replies.hot_path_hooks = await promptTTY(rl, 'Hot-path hooks?', 'sync_async');
457
+ replies.reranker = await promptTTY(rl, 'Reranker?', 'onnx_int8_l6');
458
+ replies.context_injection_tokens = Number.parseInt(
459
+ await promptTTY(rl, 'Context injection per turn (tokens)?', 500), 10);
460
+ // Skill evolution — default OFF (opt-in).
461
+ // UX-L2: disclose that enabling evolution makes outbound API calls so
462
+ // corporate users on a locked-down network know before opting in.
463
+ const evoAns = await promptTTY(
464
+ rl,
465
+ 'Enable skill evolution? (opt-in; default no; makes up to 10 outbound LLM API calls per 6 h cycle)',
466
+ 'no',
467
+ );
468
+ replies.skill_evolution_enabled = /^y(es)?$/i.test(String(evoAns).trim());
469
+ console.log('LLM for evolution (Haiku default; high-tier is Sonnet only):');
470
+ for (const c of LLM_MODEL_CHOICES) console.log(` ${c.id}: ${c.label}`);
471
+ replies.evolution_llm = await promptTTY(rl, 'evolution LLM?', 'haiku');
472
+ replies.online_retrain_cadence = await promptTTY(
473
+ rl, 'Online retrain cadence?', '50_outcomes');
474
+ replies.consolidation_cadence = await promptTTY(
475
+ rl, 'Consolidation cadence?', '6h_nightly');
476
+ return buildCustomConfig(replies);
477
+ }
478
+ const key = ['minimal', 'light', 'balanced', 'power'].includes(normalized)
479
+ ? normalized : recommendedProfile;
480
+ return { profile: key, ...PROFILES[key] };
481
+ }
482
+
483
+ // ------------------------------------------------------------------------
484
+ // First-run checklist
485
+ // ------------------------------------------------------------------------
486
+
487
+ // UX-G2 — one-screen "what's new in v3.4.21" banner for upgraders so
488
+ // existing users see the headline before the first-run checklist. Kept
489
+ // under 60 LOC per Stage-8 G2 scope.
490
+ function printLivingBrainDelta() {
491
+ console.log('');
492
+ console.log('What\'s new in v3.4.21 FINAL:');
493
+ console.log(' + Engagement reward model (action_outcomes populated)');
494
+ console.log(' + Online LightGBM retrain (shadow-tested, auto-rollback)');
495
+ console.log(' + Real consolidation (hnswlib, reversible merges)');
496
+ console.log(' + Inline entity detection (<2 ms trigram lookup)');
497
+ console.log(' + Opt-in skill evolution (Haiku 4.5 default)');
498
+ console.log(' + Evo-Memory public benchmark');
499
+ console.log('What\'s unchanged:');
500
+ console.log(' * Your memory.db — zero deletes, zero rewrites');
501
+ console.log(' * Your profile settings');
502
+ console.log(' * All CLI commands you already use');
503
+ }
504
+
505
+ function printFirstRunChecklist(config) {
506
+ console.log('');
507
+ console.log('SuperLocalMemory is configured.');
508
+ console.log(' profile: ' + config.profile);
509
+ console.log(' ram_ceiling_mb: ' + config.ram_ceiling_mb);
510
+ console.log(' skill_evolution: ' + (config.skill_evolution_enabled ? 'ON' : 'OFF (opt-in)'));
511
+ console.log('');
512
+ // UX-L1: each listed command has a representative flag so first-time
513
+ // users see the typical invocation, not just a bare name.
514
+ console.log('Next steps:');
515
+ console.log(' slm status --verbose — daemon, mode, dashboard, health');
516
+ console.log(' slm doctor — run health checks (DB, models, ports)');
517
+ console.log(' slm health --watch — live health ladder readout');
518
+ console.log(' slm dashboard — open the dashboard in your browser');
519
+ if (config.skill_evolution_enabled) {
520
+ // UX-L3: make the failure mode explicit to users who opted into
521
+ // evolution. If three LLM calls fail, the circuit breaker trips — and
522
+ // `slm status` / `slm evolve --list` surface the disabled-until line.
523
+ console.log(' slm evolve --list — view evolution cycles, cost, and rollbacks');
524
+ console.log(' (if 3 consecutive LLM calls fail, evolution is auto-disabled for 24 h —');
525
+ console.log(' `slm status --verbose` shows the circuit-breaker state and retry time.)');
526
+ }
527
+ console.log('');
528
+ }
529
+
530
+ // ------------------------------------------------------------------------
531
+ // Main
532
+ // ------------------------------------------------------------------------
533
+
534
+ async function main() {
535
+ const args = parseArgs(process.argv.slice(2));
536
+ // H-10: validate --home before using it.
537
+ if (args.home !== null) {
538
+ const homeCheck = validateHomePath(args.home, os.homedir(), args.homeOutsideHome);
539
+ if (!homeCheck.ok) {
540
+ console.error('SLM: invalid --home: ' + homeCheck.error);
541
+ return 2;
542
+ }
543
+ }
544
+ const homeDir = args.home || os.homedir();
545
+ const slmDir = path.join(homeDir, '.superlocalmemory');
546
+ const cfgPath = path.join(slmDir, 'config.toml');
547
+ const bakPath = path.join(slmDir, 'config.toml.bak');
548
+
549
+ // Ensure data dir.
550
+ if (!fs.existsSync(slmDir)) {
551
+ fs.mkdirSync(slmDir, { recursive: true });
552
+ }
553
+
554
+ // Existing-config gate — skip unless --reconfigure.
555
+ const cfgExists = fs.existsSync(cfgPath);
556
+ if (cfgExists && !args.reconfigure) {
557
+ console.log('SLM: existing config.toml detected at ' + cfgPath);
558
+ console.log('SLM: skipping installer. Use --reconfigure to change settings.');
559
+ return 0;
560
+ }
561
+
562
+ // Run benchmark.
563
+ const bench = runBenchmark(slmDir);
564
+ if (bench.elapsedMs > BENCHMARK_TIMEOUT_MS) {
565
+ console.log('SLM: benchmark exceeded 15s budget (' + bench.elapsedMs + 'ms) — using Minimal.');
566
+ }
567
+ const recommended = recommendProfileFromBenchmark(bench);
568
+ // UX-M4: one-line explanation per metric so a non-technical user has a
569
+ // frame of reference for the raw number. Threshold context is the same
570
+ // constants used by recommendProfileFromBenchmark.
571
+ console.log('SLM install benchmark:');
572
+ console.log(' Free RAM: ' + bench.freeRamMb + ' MB' +
573
+ ' (recommendation threshold: ' + MINIMAL_RAM_THRESHOLD_MB + ' MB for Light, ' +
574
+ LIGHT_RAM_THRESHOLD_MB + ' MB for Balanced).');
575
+ console.log(' Python cold-start: ' + bench.coldStartMs + ' ms' +
576
+ ' (slow threshold: ' + COLD_START_SLOW_MS + ' ms — slower cold starts downgrade one tier).');
577
+ console.log(' Disk free: ' + bench.diskFreeGb.toFixed(1) + ' GB' +
578
+ ' (SLM typical footprint: ~0.5-2 GB; your disk is ' +
579
+ (bench.diskFreeGb >= 5 ? 'OK' : 'LOW — free up space before heavy use') + ').');
580
+ console.log(' Benchmark wall-time: ' + bench.elapsedMs + ' ms' +
581
+ ' (budget: ' + BENCHMARK_TIMEOUT_MS + ' ms).');
582
+ console.log('SLM recommended profile: ' + recommended +
583
+ ' (run `slm reconfigure` later if your system has more free RAM).');
584
+
585
+ // Decide config.
586
+ let config;
587
+ const nonInteractive = !isInteractive();
588
+
589
+ // Handle reply-file (test hook / scripted custom mode).
590
+ let replyFileContents = null;
591
+ if (args.replyFile) {
592
+ try {
593
+ replyFileContents = JSON.parse(fs.readFileSync(args.replyFile, 'utf8'));
594
+ } catch (e) {
595
+ console.error('SLM: failed to read --reply-file: ' + e.message);
596
+ return 2;
597
+ }
598
+ // H-09: reject unknown keys / wrong types before we trust the payload.
599
+ const schemaCheck = validateReplyFileSchema(replyFileContents);
600
+ if (!schemaCheck.ok) {
601
+ console.error('SLM: invalid --reply-file: ' + schemaCheck.error);
602
+ return 2;
603
+ }
604
+ }
605
+
606
+ // H-15: track the user's requested profile before any silent override so
607
+ // we can surface a downgrade reason on TTY. `requestedProfile` is what the
608
+ // user *asked for* (via --profile or reply-file); `recommended` is what
609
+ // the benchmark would pick.
610
+ const requestedProfile =
611
+ (args.profile && PROFILES[args.profile] ? args.profile : null) ||
612
+ (replyFileContents && typeof replyFileContents.profile === 'string'
613
+ ? replyFileContents.profile
614
+ : null);
615
+ const downgrade = describeDowngradeReason(requestedProfile, recommended, bench);
616
+ if (downgrade && process.stdout.isTTY) {
617
+ console.log(downgrade.line);
618
+ }
619
+
620
+ if (args.profile === 'custom' || (replyFileContents && replyFileContents.profile === 'custom')) {
621
+ config = buildCustomConfig(replyFileContents || {});
622
+ } else if (args.profile && PROFILES[args.profile]) {
623
+ config = { profile: args.profile, ...PROFILES[args.profile] };
624
+ } else if (nonInteractive) {
625
+ // Non-TTY: silently apply recommended (benchmark-driven) profile.
626
+ config = { profile: recommended, ...PROFILES[recommended] };
627
+ } else {
628
+ // Interactive TTY flow.
629
+ const rl = readline.createInterface({
630
+ input: process.stdin,
631
+ output: process.stdout,
632
+ });
633
+ try {
634
+ config = await runInteractiveFlow(rl, recommended);
635
+ } finally {
636
+ rl.close();
637
+ }
638
+ }
639
+
640
+ // Dry-run: report only, no write.
641
+ if (args.dryRun) {
642
+ console.log('SLM dry-run: would write profile=' + config.profile +
643
+ ' to ' + cfgPath);
644
+ printFirstRunChecklist(config);
645
+ return 0;
646
+ }
647
+
648
+ // Back up existing config if we're about to overwrite.
649
+ if (cfgExists && args.reconfigure) {
650
+ try {
651
+ fs.copyFileSync(cfgPath, bakPath);
652
+ // S9-W2 M-SEC-04: copyFileSync preserves source mode. If the
653
+ // source was written by a pre-Stage-8 installer (mode 0644) the
654
+ // .bak inherits world-readable perms and leaks the user's
655
+ // telemetry / LLM-choice flags. Force 0600 on the backup so
656
+ // upgraders converge on the hardened mode regardless of where
657
+ // the source came from.
658
+ if (process.platform !== 'win32') {
659
+ try { fs.chmodSync(bakPath, 0o600); } catch (e) { /* best-effort */ }
660
+ }
661
+ console.log('SLM: backed up previous config to ' + bakPath);
662
+ } catch (e) {
663
+ console.error('SLM: failed to back up prior config: ' + e.message);
664
+ return 3;
665
+ }
666
+ }
667
+
668
+ // Write new config.
669
+ // SEC-GTH-02 — crash-safe write: tmp file, fsync, rename. Power
670
+ // loss between write and rename leaves the prior config.toml intact
671
+ // (or absent) rather than truncated to zero bytes.
672
+ try {
673
+ const tmpPath = cfgPath + '.tmp';
674
+ const fd = fs.openSync(tmpPath, 'w', 0o600);
675
+ try {
676
+ fs.writeSync(fd, renderConfigToml(config), 0, 'utf8');
677
+ try {
678
+ fs.fsyncSync(fd);
679
+ } catch (_e) {
680
+ // fsync may fail on exotic filesystems; rename still atomic-ish.
681
+ }
682
+ } finally {
683
+ fs.closeSync(fd);
684
+ }
685
+ fs.renameSync(tmpPath, cfgPath);
686
+ console.log('SLM: wrote config.toml for profile=' + config.profile);
687
+ } catch (e) {
688
+ console.error('SLM: failed to write config.toml: ' + e.message);
689
+ return 4;
690
+ }
691
+
692
+ // S9-DASH-11: auto-install SLM skills into ~/.claude/skills/ so
693
+ // /slm-recall, /slm-remember, /slm-status etc. appear immediately
694
+ // in Claude Code without a manual step.
695
+ try {
696
+ const skillsSrc = path.join(__dirname, '..', 'skills');
697
+ const claudeSkillsDir = path.join(os.homedir(), '.claude', 'skills');
698
+ if (fs.existsSync(skillsSrc)) {
699
+ fs.mkdirSync(claudeSkillsDir, { recursive: true, mode: 0o700 });
700
+ const skillDirs = fs.readdirSync(skillsSrc);
701
+ let installed = 0;
702
+ for (const d of skillDirs) {
703
+ const src = path.join(skillsSrc, d, 'SKILL.md');
704
+ if (fs.existsSync(src)) {
705
+ const dst = path.join(claudeSkillsDir, d + '.md');
706
+ fs.copyFileSync(src, dst);
707
+ installed += 1;
708
+ }
709
+ }
710
+ if (installed > 0) {
711
+ console.log('SLM: installed ' + installed + ' skills → ' + claudeSkillsDir);
712
+ console.log(' Use /slm-recall, /slm-remember, /slm-status in Claude Code');
713
+ }
714
+ }
715
+ } catch (e) {
716
+ // Non-fatal — skills can be installed manually via install-skills.sh
717
+ console.log('SLM: skill install skipped (' + e.message + ')');
718
+ }
719
+
720
+ // UX-G2: show the one-screen delta banner so upgraders see what shipped.
721
+ printLivingBrainDelta();
722
+ printFirstRunChecklist(config);
723
+ return 0;
724
+ }
725
+
726
+ // ------------------------------------------------------------------------
727
+ // Entrypoint
728
+ // ------------------------------------------------------------------------
729
+
730
+ if (require.main === module) {
731
+ main().then(
732
+ (code) => process.exit(typeof code === 'number' ? code : 0),
733
+ (err) => {
734
+ console.error('SLM installer fatal: ' + (err && err.stack ? err.stack : err));
735
+ process.exit(1);
736
+ }
737
+ );
738
+ }
739
+
740
+ module.exports = {
741
+ parseArgs,
742
+ isInteractive,
743
+ runBenchmark,
744
+ recommendProfileFromBenchmark,
745
+ renderConfigToml,
746
+ parsePriorConfigToml,
747
+ buildCustomConfig,
748
+ validateReplyFileSchema, // H-09
749
+ validateHomePath, // H-10
750
+ describeDowngradeReason, // H-15
751
+ main, // exported so test harnesses can simulate TTY flags before invoking
752
+ LLM_MODEL_CHOICES,
753
+ PROFILES,
754
+ CUSTOM_KNOB_ENUMS, // UX-M3
755
+ printLivingBrainDelta, // UX-G2 (exposed for the test harness only)
756
+ };