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,452 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4.21 — LLD-06 §3
4
+
5
+ """AST-extract binary hook entry.
6
+
7
+ LLD reference: ``.backup/active-brain/lld/LLD-06-windows-binary-and-legacy-migration.md``
8
+ Section 3 (Binary Entry — no code duplication).
9
+
10
+ This generator reads the two canonical source modules
11
+ (``src/superlocalmemory/core/topic_signature.py`` and
12
+ ``src/superlocalmemory/core/context_cache.py``), extracts the functions
13
+ the hot-path binary needs (``compute_topic_signature`` and the small
14
+ read-only slice of ``read_entry_fast``'s logic), and emits a single
15
+ self-contained file that:
16
+
17
+ * imports stdlib only (json, sys, os, sqlite3, hashlib, hmac, re,
18
+ time, unicodedata)
19
+ * renames the public functions with a ``_`` prefix
20
+ * ships the ``main()`` contract from LLD-06 §3.2
21
+ * writes ``# source_sha256: <hex>`` so drift is detectable
22
+
23
+ The output is deterministic: same inputs → byte-identical bytes.
24
+ Generated content is meant to be consumed by PyInstaller at release
25
+ time (or called during tests to assert the contract). It is NOT
26
+ checked into the repo — callers invoke ``emit_entry`` to write it.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import ast
31
+ import hashlib
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ # stdlib modules the emitted file is allowed to import. Any other import
36
+ # in the final file is a bug — the static-check step rejects it.
37
+ ALLOWED_STDLIB_IMPORTS: frozenset[str] = frozenset({
38
+ "__future__",
39
+ "json", "sys", "os", "sqlite3", "hashlib", "hmac",
40
+ "re", "time", "unicodedata",
41
+ })
42
+
43
+ # Function names we extract by AST from each source module.
44
+ _TOPIC_FN_NAME = "compute_topic_signature"
45
+ _READER_FN_NAME = "read_entry_fast" # referenced for parity docs only
46
+
47
+ # Deny-list: the emitted file must not reference these packages.
48
+ _NONSTDLIB_DENY: tuple[str, ...] = (
49
+ "torch", "sentence_transformers", "fastapi", "uvicorn", "numpy",
50
+ "scipy", "lightgbm", "onnxruntime", "transformers", "httpx",
51
+ "mcp", "pydantic", "lark", "superlocalmemory",
52
+ )
53
+
54
+ BANNER_AUTOGEN = "# AUTO-GENERATED BY scripts/build_entry.py — DO NOT EDIT"
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class GenResult:
59
+ """Outcome of running the generator."""
60
+
61
+ text: str
62
+ source_sha256: str
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # AST helpers
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _load_source(path: Path) -> str:
70
+ return path.read_text(encoding="utf-8")
71
+
72
+
73
+ def _sha256_concat(paths: list[Path]) -> str:
74
+ h = hashlib.sha256()
75
+ for p in paths:
76
+ h.update(p.read_bytes())
77
+ return h.hexdigest()
78
+
79
+
80
+ def _extract_function(source: str, name: str) -> ast.FunctionDef:
81
+ """Return the top-level ``def name(...)`` node from ``source``."""
82
+ tree = ast.parse(source)
83
+ for node in tree.body:
84
+ if isinstance(node, ast.FunctionDef) and node.name == name:
85
+ return node
86
+ raise LookupError(f"function {name!r} not found in source")
87
+
88
+
89
+ def _extract_module_constants(
90
+ source: str, names: set[str]
91
+ ) -> list[ast.stmt]:
92
+ """Extract top-level assignments where a target matches ``names``.
93
+
94
+ Captures plain assignments (``X = ...``) and annotated assignments
95
+ (``X: T = ...``). Returns the statements in source order.
96
+ """
97
+ tree = ast.parse(source)
98
+ out: list[ast.stmt] = []
99
+ for node in tree.body:
100
+ if isinstance(node, ast.Assign):
101
+ for target in node.targets:
102
+ if isinstance(target, ast.Name) and target.id in names:
103
+ out.append(node)
104
+ break
105
+ elif isinstance(node, ast.AnnAssign):
106
+ if isinstance(node.target, ast.Name) and node.target.id in names:
107
+ out.append(node)
108
+ return out
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Emitted main()
113
+ # ---------------------------------------------------------------------------
114
+
115
+ _MAIN_SRC = '''
116
+
117
+ _TTL_SECONDS = 120
118
+ _HMAC_MATERIAL = b"active_brain_cache"
119
+ _HMAC_HEX_LEN = 32
120
+ _MAX_STDIN_BYTES = 65536
121
+ _MAX_PROMPT_CHARS = 8192
122
+ _MAX_SESSION_CHARS = 128
123
+
124
+
125
+ def _install_token_for(home_dir):
126
+ token_path = os.path.join(home_dir, ".install_token")
127
+ if not os.path.isfile(token_path):
128
+ return None
129
+ try:
130
+ with open(token_path, "r", encoding="utf-8") as fh:
131
+ token = fh.read().strip()
132
+ except OSError:
133
+ return None
134
+ return token or None
135
+
136
+
137
+ def _binding_hmac(token):
138
+ return hmac.new(
139
+ token.encode("utf-8"),
140
+ _HMAC_MATERIAL,
141
+ hashlib.sha256,
142
+ ).hexdigest()[:_HMAC_HEX_LEN]
143
+
144
+
145
+ def _read_entry_fast(session_id, topic_sig, db_path):
146
+ try:
147
+ home_dir = os.path.dirname(db_path)
148
+ token = _install_token_for(home_dir)
149
+ if token is None:
150
+ return None
151
+ conn = sqlite3.connect(
152
+ "file:" + db_path + "?mode=ro", uri=True, timeout=0.5,
153
+ )
154
+ try:
155
+ row = conn.execute(
156
+ "SELECT value FROM slm_meta WHERE key='install_token_hmac'",
157
+ ).fetchone()
158
+ if row is None:
159
+ return None
160
+ if not hmac.compare_digest(row[0], _binding_hmac(token)):
161
+ return None
162
+ now = int(time.time())
163
+ row = conn.execute(
164
+ "SELECT content FROM context_entries "
165
+ "WHERE session_id=? AND topic_sig=? "
166
+ " AND computed_at > ?",
167
+ (session_id, topic_sig, now - _TTL_SECONDS),
168
+ ).fetchone()
169
+ finally:
170
+ try:
171
+ conn.close()
172
+ except sqlite3.Error:
173
+ pass
174
+ if row is None:
175
+ return None
176
+ return {"content": row[0]}
177
+ except Exception:
178
+ return None
179
+
180
+
181
+ def main():
182
+ try:
183
+ data = sys.stdin.read(_MAX_STDIN_BYTES)
184
+ payload = json.loads(data) if data else {}
185
+ except (ValueError, UnicodeDecodeError):
186
+ sys.stdout.write("{}")
187
+ return 0
188
+
189
+ session_id = str(payload.get("session_id", ""))[:_MAX_SESSION_CHARS]
190
+ prompt = str(payload.get("prompt", ""))[:_MAX_PROMPT_CHARS]
191
+ if not session_id or not prompt:
192
+ sys.stdout.write("{}")
193
+ return 0
194
+
195
+ db_path = os.environ.get("SLM_CACHE_DB") or os.path.join(
196
+ os.path.expanduser("~"),
197
+ ".superlocalmemory",
198
+ "active_brain_cache.db",
199
+ )
200
+ if not os.path.isfile(db_path):
201
+ sys.stdout.write("{}")
202
+ return 0
203
+
204
+ try:
205
+ sig = _compute_topic_signature(prompt)
206
+ entry = _read_entry_fast(session_id, sig, db_path)
207
+ if entry is None:
208
+ sys.stdout.write("{}")
209
+ else:
210
+ out = {
211
+ "hookSpecificOutput": {
212
+ "hookEventName": "UserPromptSubmit",
213
+ "additionalContext": entry["content"],
214
+ }
215
+ }
216
+ sys.stdout.write(json.dumps(out, separators=(",", ":")))
217
+ except Exception:
218
+ sys.stdout.write("{}")
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
224
+ '''
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Generator
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def generate(
233
+ topic_signature_src: Path,
234
+ context_cache_src: Path,
235
+ ) -> GenResult:
236
+ """Produce the emitted hook binary entry as a string.
237
+
238
+ Deterministic: given the same input source files, returns identical
239
+ bytes. The ``source_sha256`` banner captures the concatenated SHA
240
+ so silent drift is rejected by the static-check step.
241
+ """
242
+ topic_src = _load_source(topic_signature_src)
243
+ cache_src = _load_source(context_cache_src)
244
+
245
+ fn_topic = _extract_function(topic_src, _TOPIC_FN_NAME)
246
+ # Inline helpers/constants referenced by compute_topic_signature.
247
+ topic_consts = _extract_module_constants(
248
+ topic_src,
249
+ {"_STOPWORDS", "MAX_SIG_INPUT_CHARS", "_SIG_LEN",
250
+ "_CAMEL_PASCAL", "_URL", "_PATH", "_QUOTED_DOUBLE",
251
+ "_QUOTED_SINGLE", "_WORD"},
252
+ )
253
+ fn_canon = _extract_function(topic_src, "_canon")
254
+
255
+ # Rename compute_topic_signature → _compute_topic_signature, keep
256
+ # call sites inside the function body consistent.
257
+ fn_topic_renamed = _clone_and_rename(fn_topic, "_compute_topic_signature")
258
+
259
+ consts_src = _unparse_many(topic_consts)
260
+ fn_canon_src = ast.unparse(fn_canon)
261
+ fn_topic_src = ast.unparse(fn_topic_renamed)
262
+
263
+ sha = _sha256_concat([topic_signature_src, context_cache_src])
264
+
265
+ pieces: list[str] = [
266
+ BANNER_AUTOGEN,
267
+ f"# source_sha256: {sha}",
268
+ "# Extracted from:",
269
+ f"# - {topic_signature_src.name} (compute_topic_signature + helpers)",
270
+ f"# - {context_cache_src.name} (read_entry_fast logic)",
271
+ "",
272
+ "from __future__ import annotations",
273
+ "",
274
+ "import hashlib",
275
+ "import hmac",
276
+ "import json",
277
+ "import os",
278
+ "import re",
279
+ "import sqlite3",
280
+ "import sys",
281
+ "import time",
282
+ "import unicodedata",
283
+ "",
284
+ "# --- extracted constants -----------------------------------------------",
285
+ consts_src,
286
+ "",
287
+ "# --- extracted helpers -------------------------------------------------",
288
+ fn_canon_src,
289
+ "",
290
+ "# --- extracted topic signature ----------------------------------------",
291
+ fn_topic_src,
292
+ "",
293
+ "# --- reader + main() (LLD-06 §3.2) ------------------------------------",
294
+ _MAIN_SRC.strip("\n"),
295
+ "",
296
+ ]
297
+ text = "\n".join(pieces) + "\n"
298
+ return GenResult(text=text, source_sha256=sha)
299
+
300
+
301
+ def _clone_and_rename(fn: ast.FunctionDef, new_name: str) -> ast.FunctionDef:
302
+ new_fn = ast.parse(ast.unparse(fn)).body[0]
303
+ assert isinstance(new_fn, ast.FunctionDef)
304
+ new_fn.name = new_name
305
+ return new_fn
306
+
307
+
308
+ def _unparse_many(nodes: list[ast.stmt]) -> str:
309
+ return "\n".join(ast.unparse(n) for n in nodes)
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Static checks on the emitted file
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ class GeneratorError(RuntimeError):
318
+ """Raised when the emitted file fails a static check."""
319
+
320
+
321
+ def static_check(text: str) -> None:
322
+ """Validate the emitted file. Raises :class:`GeneratorError` on fail.
323
+
324
+ Checks:
325
+ 1. ``ast.parse`` succeeds.
326
+ 2. Every import is from ``ALLOWED_STDLIB_IMPORTS``; no package on
327
+ the non-stdlib deny-list appears at all.
328
+ 3. No ``open(...)`` call in write mode anywhere (``w``, ``wb``,
329
+ ``a``, ``a+``, ``w+``, ``x``, ``x+``).
330
+ 4. No socket / urllib / http / requests imports (no network).
331
+ """
332
+ try:
333
+ tree = ast.parse(text)
334
+ except SyntaxError as exc:
335
+ raise GeneratorError(f"syntax error in emitted file: {exc}") from exc
336
+
337
+ _check_imports(tree)
338
+ _check_no_write_open(tree)
339
+ _check_no_network(tree)
340
+
341
+
342
+ def _check_imports(tree: ast.AST) -> None:
343
+ for node in ast.walk(tree):
344
+ if isinstance(node, ast.Import):
345
+ for alias in node.names:
346
+ root = alias.name.split(".")[0]
347
+ _reject_if_denied(root)
348
+ if root not in ALLOWED_STDLIB_IMPORTS:
349
+ raise GeneratorError(
350
+ f"non-stdlib import: {alias.name!r}",
351
+ )
352
+ elif isinstance(node, ast.ImportFrom):
353
+ module = (node.module or "").split(".")[0]
354
+ _reject_if_denied(module)
355
+ if module and module not in ALLOWED_STDLIB_IMPORTS:
356
+ raise GeneratorError(
357
+ f"non-stdlib import: from {node.module!r}",
358
+ )
359
+
360
+
361
+ def _reject_if_denied(name: str) -> None:
362
+ if name in _NONSTDLIB_DENY:
363
+ raise GeneratorError(
364
+ f"forbidden non-stdlib package referenced: {name!r}",
365
+ )
366
+
367
+
368
+ def _check_no_write_open(tree: ast.AST) -> None:
369
+ write_mode_chars = {"w", "a", "x", "+"}
370
+ for node in ast.walk(tree):
371
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \
372
+ and node.func.id == "open":
373
+ mode_arg = _open_mode(node)
374
+ if mode_arg is None:
375
+ continue # default mode 'r' — safe
376
+ for ch in mode_arg:
377
+ if ch in write_mode_chars:
378
+ raise GeneratorError(
379
+ f"write-mode open() not allowed: mode={mode_arg!r}",
380
+ )
381
+
382
+
383
+ def _open_mode(call: ast.Call) -> str | None:
384
+ # Positional: open(path, mode)
385
+ if len(call.args) >= 2:
386
+ arg = call.args[1]
387
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
388
+ return arg.value
389
+ for kw in call.keywords:
390
+ if kw.arg == "mode" and isinstance(kw.value, ast.Constant) \
391
+ and isinstance(kw.value.value, str):
392
+ return kw.value.value
393
+ return None
394
+
395
+
396
+ def _check_no_network(tree: ast.AST) -> None:
397
+ net_roots = {"socket", "urllib", "http", "requests", "httpx",
398
+ "ftplib", "smtplib", "telnetlib"}
399
+ for node in ast.walk(tree):
400
+ if isinstance(node, ast.Import):
401
+ for alias in node.names:
402
+ root = alias.name.split(".")[0]
403
+ if root in net_roots:
404
+ raise GeneratorError(
405
+ f"network import not allowed: {alias.name!r}",
406
+ )
407
+ elif isinstance(node, ast.ImportFrom):
408
+ root = (node.module or "").split(".")[0]
409
+ if root in net_roots:
410
+ raise GeneratorError(
411
+ f"network import not allowed: from {node.module!r}",
412
+ )
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Public CLI-ish entry (invoked from CI build script)
417
+ # ---------------------------------------------------------------------------
418
+
419
+
420
+ def emit_entry(
421
+ topic_signature_src: Path,
422
+ context_cache_src: Path,
423
+ dest: Path,
424
+ ) -> GenResult:
425
+ """Generate, static-check, then write the emitted file to ``dest``."""
426
+ result = generate(topic_signature_src, context_cache_src)
427
+ static_check(result.text)
428
+ dest.parent.mkdir(parents=True, exist_ok=True)
429
+ dest.write_text(result.text, encoding="utf-8")
430
+ return result
431
+
432
+
433
+ def default_source_paths(repo_root: Path) -> tuple[Path, Path]:
434
+ base = repo_root / "src" / "superlocalmemory" / "core"
435
+ return base / "topic_signature.py", base / "context_cache.py"
436
+
437
+
438
+ if __name__ == "__main__": # pragma: no cover — invoked from CI
439
+ import argparse
440
+
441
+ ap = argparse.ArgumentParser(description="AST-extract slm-hook entry")
442
+ ap.add_argument("--repo-root", type=Path, default=Path.cwd())
443
+ ap.add_argument(
444
+ "--dest", type=Path,
445
+ default=None,
446
+ help="output path (default: build/hook_binary_entry.py)",
447
+ )
448
+ ns = ap.parse_args()
449
+ topic, cache = default_source_paths(ns.repo_root)
450
+ dest = ns.dest or (ns.repo_root / "build" / "hook_binary_entry.py")
451
+ res = emit_entry(topic, cache, dest)
452
+ print(f"wrote {dest} (source_sha256={res.source_sha256[:12]}...)")
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Stage-5b contract gate — enforces LLD-00 integration contracts at CI time.
3
+ #
4
+ # Per IMPLEMENTATION-MANIFEST-v3.4.21-FINAL.md §P0.1 and LLD-00 §13.
5
+ # Scans src/ for retired patterns and contract violations. Exit 0 = clean,
6
+ # exit 1 = violation found (specific failure printed to stdout).
7
+ #
8
+ # Uses POSIX grep for portability (see MANIFEST-DEVIATION entry for P0.1).
9
+ set -euo pipefail
10
+
11
+ cd "$(git rev-parse --show-toplevel)"
12
+ SCAN_DIR="${SCAN_DIR:-src}"
13
+
14
+ FAIL=0
15
+ check() {
16
+ local pattern="$1" msg="$2"
17
+ [ -d "$SCAN_DIR" ] || return 0
18
+ local matches
19
+ matches="$(grep -rEn --include='*.py' --include='*.sql' \
20
+ --include='*.js' --include='*.ts' --include='*.sh' --include='*.toml' \
21
+ "$pattern" "$SCAN_DIR" 2>/dev/null || true)"
22
+ if [ -n "$matches" ]; then
23
+ echo "STAGE5B GATE FAILED: $msg"
24
+ echo " pattern: $pattern"
25
+ echo "$matches" | sed 's/^/ /'
26
+ FAIL=1
27
+ fi
28
+ }
29
+
30
+ # LLD-00 §1.2 — pending_observations retired, use pending_outcomes (LLD-08 §M007).
31
+ check "pending_observations" \
32
+ "LLD-00 §1.2 — pending_observations retired, use pending_outcomes"
33
+
34
+ # LLD-00 §2 — finalize_outcome takes outcome_id only (kwarg), not query_id.
35
+ check "finalize_outcome\(query_id" \
36
+ "LLD-00 §2 — wrong finalize_outcome signature"
37
+
38
+ # LLD-00 §3 — hook must validate HMAC marker, not substring-scan fact_id.
39
+ check " fid in response_text" \
40
+ "LLD-00 §3 — bare substring scan, use HMAC validator"
41
+
42
+ # MASTER-PLAN D2 + LLD-00 §5 — no Opus in any SLM-initiated LLM call.
43
+ check "claude-opus-4" \
44
+ "LLD-00 + MASTER-PLAN D2 — no Opus in SLM-initiated LLM calls"
45
+
46
+ # LLD-00 §1.1 + SEC-C-05 — action_outcomes writes must go through canonical API.
47
+ check "action_outcomes.*INSERT.*VALUES" \
48
+ "SEC-C-05 — every action_outcomes INSERT must include profile_id"
49
+
50
+ exit $FAIL
@@ -0,0 +1,187 @@
1
+ /**
2
+ * SuperLocalMemory postinstall — validation helpers.
3
+ *
4
+ * Extracted from scripts/postinstall-interactive.js in Stage 9 W4
5
+ * (H-ARC-03) to keep the main installer under the 800-LOC cap while
6
+ * preserving every check byte-for-byte.
7
+ *
8
+ * Exports:
9
+ * validateReplyFileSchema(obj) — H-09 schema gate
10
+ * validateHomePath(homeArg, home, opt) — H-10 + H-SEC-03 gate
11
+ * DENY_PREFIXES_POSIX — shared list (tests may read)
12
+ *
13
+ * Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
14
+ * Licensed under AGPL-3.0-or-later.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+
23
+
24
+ // ------------------------------------------------------------------------
25
+ // H-09 — Reply-file schema validation
26
+ // ------------------------------------------------------------------------
27
+
28
+ function validateReplyFileSchema(obj) {
29
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
30
+ return { ok: false, error: 'reply-file must decode to a JSON object' };
31
+ }
32
+ const schema = {
33
+ profile: { type: 'string', enum: ['minimal', 'light', 'balanced', 'power', 'custom'] },
34
+ home: { type: 'string' },
35
+ accept_default: { type: 'boolean' },
36
+ no_benchmark: { type: 'boolean' },
37
+ ram_ceiling_mb: { type: 'number', integer: true, min: 1 },
38
+ hot_path_hooks: { type: 'string' },
39
+ reranker: { type: 'string' },
40
+ context_injection_tokens: { type: 'number', integer: true, min: 0 },
41
+ skill_evolution_enabled: { type: 'boolean' },
42
+ evolution_llm: { type: 'string', enum: ['haiku', 'sonnet', 'ollama', 'skip'] },
43
+ online_retrain_cadence: { type: 'string' },
44
+ consolidation_cadence: { type: 'string' },
45
+ inline_entity_detection: { type: 'boolean' },
46
+ telemetry: { type: 'string' },
47
+ };
48
+ for (const key of Object.keys(obj)) {
49
+ if (!Object.prototype.hasOwnProperty.call(schema, key)) {
50
+ return { ok: false, error: 'unexpected key in reply-file: "' + key + '"' };
51
+ }
52
+ const rule = schema[key];
53
+ const val = obj[key];
54
+ if (rule.type === 'string') {
55
+ if (typeof val !== 'string') {
56
+ return { ok: false, error: 'reply-file key "' + key + '" must be a string' };
57
+ }
58
+ if (rule.enum && !rule.enum.includes(val)) {
59
+ return {
60
+ ok: false,
61
+ error: 'reply-file key "' + key + '" must be one of: ' + rule.enum.join('|'),
62
+ };
63
+ }
64
+ } else if (rule.type === 'boolean') {
65
+ if (typeof val !== 'boolean') {
66
+ return { ok: false, error: 'reply-file key "' + key + '" must be a boolean' };
67
+ }
68
+ } else if (rule.type === 'number') {
69
+ if (typeof val !== 'number' || Number.isNaN(val) || !Number.isFinite(val)) {
70
+ return { ok: false, error: 'reply-file key "' + key + '" must be a number' };
71
+ }
72
+ if (rule.integer && !Number.isInteger(val)) {
73
+ return { ok: false, error: 'reply-file key "' + key + '" must be an integer' };
74
+ }
75
+ if (rule.min !== undefined && val < rule.min) {
76
+ return { ok: false, error: 'reply-file key "' + key + '" must be >= ' + rule.min };
77
+ }
78
+ }
79
+ }
80
+ return { ok: true };
81
+ }
82
+
83
+
84
+ // ------------------------------------------------------------------------
85
+ // H-10 / H-SEC-03 — --home path validation
86
+ // ------------------------------------------------------------------------
87
+
88
+ const DENY_PREFIXES_POSIX = [
89
+ '/etc', '/usr', '/bin', '/sbin', '/boot', '/proc', '/sys', '/dev',
90
+ '/var/log', '/var/lib', '/var/spool', '/root',
91
+ ];
92
+
93
+ function _violatesDenyList(resolvedPath) {
94
+ if (process.platform === 'win32') return null;
95
+ for (const prefix of DENY_PREFIXES_POSIX) {
96
+ if (resolvedPath === prefix ||
97
+ resolvedPath.startsWith(prefix + path.sep)) {
98
+ return prefix;
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function validateHomePath(homeArg, userHomeDir, outsideOptIn) {
105
+ if (typeof homeArg !== 'string' || homeArg === '') {
106
+ return { ok: false, error: '--home must be a non-empty string' };
107
+ }
108
+ if (!path.isAbsolute(homeArg)) {
109
+ return { ok: false, error: '--home must be an absolute path (rule: not-absolute)' };
110
+ }
111
+ const segments = homeArg.split(path.sep);
112
+ if (segments.includes('..')) {
113
+ return { ok: false, error: '--home must not contain ".." segments (rule: dotdot-segment)' };
114
+ }
115
+
116
+ // S9-W2 H-SEC-03: resolve symlinks BEFORE the insideHome check.
117
+ let resolved = path.resolve(homeArg);
118
+ try {
119
+ if (fs.existsSync(resolved)) {
120
+ resolved = fs.realpathSync.native
121
+ ? fs.realpathSync.native(resolved)
122
+ : fs.realpathSync(resolved);
123
+ } else {
124
+ let anc = path.dirname(resolved);
125
+ const tail = [path.basename(resolved)];
126
+ while (anc !== path.dirname(anc) && !fs.existsSync(anc)) {
127
+ tail.unshift(path.basename(anc));
128
+ anc = path.dirname(anc);
129
+ }
130
+ if (fs.existsSync(anc)) {
131
+ const realAnc = fs.realpathSync.native
132
+ ? fs.realpathSync.native(anc)
133
+ : fs.realpathSync(anc);
134
+ resolved = path.join(realAnc, ...tail);
135
+ }
136
+ }
137
+ } catch (e) {
138
+ // realpathSync failure → keep lexical resolve; downstream checks apply.
139
+ }
140
+
141
+ const resolvedHome = path.resolve(userHomeDir || os.homedir());
142
+ const insideHome =
143
+ resolved === resolvedHome || resolved.startsWith(resolvedHome + path.sep);
144
+ if (!insideHome && !outsideOptIn) {
145
+ return {
146
+ ok: false,
147
+ error:
148
+ '--home resolves outside $HOME (' +
149
+ resolvedHome +
150
+ '); pass --home-outside-home to override (rule: outside-home)',
151
+ };
152
+ }
153
+
154
+ const forbidden = _violatesDenyList(resolved);
155
+ if (forbidden) {
156
+ return {
157
+ ok: false,
158
+ error:
159
+ '--home resolves to a system directory (' +
160
+ forbidden +
161
+ '); refusing (rule: deny-prefix)',
162
+ };
163
+ }
164
+
165
+ try {
166
+ const st = fs.lstatSync(resolved);
167
+ if (st.isSymbolicLink()) {
168
+ return {
169
+ ok: false,
170
+ error: '--home resolves to a symlink; refusing (rule: symlink)',
171
+ };
172
+ }
173
+ if (!st.isDirectory()) {
174
+ return { ok: false, error: '--home exists but is not a directory (rule: not-a-directory)' };
175
+ }
176
+ } catch (e) {
177
+ // Path does not yet exist — OK, caller will mkdirSync.
178
+ }
179
+ return { ok: true, resolved };
180
+ }
181
+
182
+
183
+ module.exports = {
184
+ validateReplyFileSchema,
185
+ validateHomePath,
186
+ DENY_PREFIXES_POSIX,
187
+ };