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.
- package/CHANGELOG.md +35 -0
- package/README.md +42 -34
- package/bin/slm +11 -0
- package/bin/slm.bat +12 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/scripts/build-slm-hook.ps1 +40 -0
- package/scripts/build-slm-hook.sh +45 -0
- package/scripts/build_entry.py +452 -0
- package/scripts/ci/stage5b_gate.sh +50 -0
- package/scripts/postinstall/validation.js +187 -0
- package/scripts/postinstall-interactive.js +756 -0
- package/scripts/postinstall_binary.js +287 -0
- package/scripts/release_manifest.py +273 -0
- package/scripts/slm-hook.spec +56 -0
- package/skills/slm-build-graph/SKILL.md +423 -0
- package/skills/slm-list-recent/SKILL.md +348 -0
- package/skills/slm-recall/SKILL.md +343 -0
- package/skills/slm-remember/SKILL.md +194 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/skills/slm-status/SKILL.md +363 -0
- package/skills/slm-switch-profile/SKILL.md +442 -0
- package/src/superlocalmemory/cli/commands.py +219 -79
- package/src/superlocalmemory/cli/context_commands.py +192 -0
- package/src/superlocalmemory/cli/daemon.py +15 -1
- package/src/superlocalmemory/cli/db_migrate.py +80 -0
- package/src/superlocalmemory/cli/escape_hatch.py +220 -0
- package/src/superlocalmemory/cli/main.py +72 -1
- package/src/superlocalmemory/core/context_cache.py +397 -0
- package/src/superlocalmemory/core/embeddings.py +8 -2
- package/src/superlocalmemory/core/engine.py +38 -2
- package/src/superlocalmemory/core/engine_wiring.py +1 -1
- package/src/superlocalmemory/core/ram_lock.py +111 -0
- package/src/superlocalmemory/core/recall_pipeline.py +433 -3
- package/src/superlocalmemory/core/recall_worker.py +8 -3
- package/src/superlocalmemory/core/security_primitives.py +635 -0
- package/src/superlocalmemory/core/shadow_router.py +319 -0
- package/src/superlocalmemory/core/slm_disabled.py +87 -0
- package/src/superlocalmemory/core/slmignore.py +125 -0
- package/src/superlocalmemory/core/topic_signature.py +143 -0
- package/src/superlocalmemory/core/worker_pool.py +14 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +2 -2
- package/src/superlocalmemory/evolution/budget.py +321 -0
- package/src/superlocalmemory/evolution/llm_dispatch.py +508 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +144 -94
- package/src/superlocalmemory/hooks/_outcome_common.py +506 -0
- package/src/superlocalmemory/hooks/adapter_base.py +317 -0
- package/src/superlocalmemory/hooks/antigravity_adapter.py +192 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +33 -1
- package/src/superlocalmemory/hooks/context_payload.py +312 -0
- package/src/superlocalmemory/hooks/copilot_adapter.py +154 -0
- package/src/superlocalmemory/hooks/cross_platform_connector.py +90 -0
- package/src/superlocalmemory/hooks/cursor_adapter.py +195 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +109 -8
- package/src/superlocalmemory/hooks/ide_connector.py +25 -2
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +165 -0
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +223 -0
- package/src/superlocalmemory/hooks/prewarm_auth.py +170 -0
- package/src/superlocalmemory/hooks/session_registry.py +186 -0
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +134 -0
- package/src/superlocalmemory/hooks/sync_loop.py +114 -0
- package/src/superlocalmemory/hooks/user_prompt_hook.py +128 -0
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +202 -0
- package/src/superlocalmemory/infra/backup.py +3 -3
- package/src/superlocalmemory/infra/cloud_backup.py +2 -2
- package/src/superlocalmemory/infra/event_bus.py +2 -2
- package/src/superlocalmemory/infra/webhook_dispatcher.py +3 -3
- package/src/superlocalmemory/learning/arm_catalog.py +99 -0
- package/src/superlocalmemory/learning/bandit.py +526 -0
- package/src/superlocalmemory/learning/bandit_cache.py +133 -0
- package/src/superlocalmemory/learning/behavioral.py +53 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +381 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +188 -520
- package/src/superlocalmemory/learning/database.py +256 -0
- package/src/superlocalmemory/learning/dedup_hnsw.py +413 -0
- package/src/superlocalmemory/learning/ensemble.py +300 -0
- package/src/superlocalmemory/learning/fact_outcome_joins.py +207 -0
- package/src/superlocalmemory/learning/forgetting_scheduler.py +55 -0
- package/src/superlocalmemory/learning/hnsw_dedup.py +69 -0
- package/src/superlocalmemory/learning/labeler.py +87 -0
- package/src/superlocalmemory/learning/legacy_migration.py +277 -0
- package/src/superlocalmemory/learning/memory_merge.py +160 -0
- package/src/superlocalmemory/learning/model_cache.py +269 -0
- package/src/superlocalmemory/learning/model_rollback.py +278 -0
- package/src/superlocalmemory/learning/outcome_queue.py +284 -0
- package/src/superlocalmemory/learning/pattern_miner.py +415 -0
- package/src/superlocalmemory/learning/pattern_miner_constants.py +47 -0
- package/src/superlocalmemory/learning/ranker.py +225 -81
- package/src/superlocalmemory/learning/ranker_common.py +163 -0
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +202 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +411 -0
- package/src/superlocalmemory/learning/reward.py +777 -0
- package/src/superlocalmemory/learning/reward_archive.py +210 -0
- package/src/superlocalmemory/learning/reward_boost.py +201 -0
- package/src/superlocalmemory/learning/reward_proxy.py +326 -0
- package/src/superlocalmemory/learning/shadow_test.py +524 -0
- package/src/superlocalmemory/learning/signal_worker.py +270 -0
- package/src/superlocalmemory/learning/signals.py +314 -0
- package/src/superlocalmemory/learning/trigram_index.py +547 -0
- package/src/superlocalmemory/mcp/server.py +5 -5
- package/src/superlocalmemory/mcp/tools_context.py +183 -0
- package/src/superlocalmemory/mcp/tools_core.py +92 -27
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +13 -0
- package/src/superlocalmemory/retrieval/engine.py +52 -0
- package/src/superlocalmemory/retrieval/reranker.py +4 -2
- package/src/superlocalmemory/server/api.py +2 -2
- package/src/superlocalmemory/server/bandit_loops.py +140 -0
- package/src/superlocalmemory/server/middleware/__init__.py +11 -0
- package/src/superlocalmemory/server/middleware/security_headers.py +144 -0
- package/src/superlocalmemory/server/routes/backup.py +36 -13
- package/src/superlocalmemory/server/routes/behavioral.py +50 -19
- package/src/superlocalmemory/server/routes/brain.py +1234 -0
- package/src/superlocalmemory/server/routes/data_io.py +4 -4
- package/src/superlocalmemory/server/routes/events.py +2 -2
- package/src/superlocalmemory/server/routes/helpers.py +1 -1
- package/src/superlocalmemory/server/routes/learning.py +192 -7
- package/src/superlocalmemory/server/routes/memories.py +189 -1
- package/src/superlocalmemory/server/routes/prewarm.py +171 -0
- package/src/superlocalmemory/server/routes/profiles.py +3 -3
- package/src/superlocalmemory/server/routes/token.py +88 -0
- package/src/superlocalmemory/server/routes/ws.py +5 -5
- package/src/superlocalmemory/server/security_middleware.py +13 -7
- package/src/superlocalmemory/server/ui.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +335 -3
- package/src/superlocalmemory/storage/migration_runner.py +545 -0
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +67 -0
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +132 -0
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +38 -0
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +46 -0
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +75 -0
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +75 -0
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +63 -0
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +75 -0
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +87 -0
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +72 -0
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +55 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +81 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/ui/css/brain.css +409 -0
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +645 -0
- package/src/superlocalmemory/ui/index.html +459 -1345
- package/src/superlocalmemory/ui/js/brain.js +1321 -0
- package/src/superlocalmemory/ui/js/clusters.js +123 -4
- package/src/superlocalmemory/ui/js/init.js +48 -39
- package/src/superlocalmemory/ui/js/memories.js +88 -2
- package/src/superlocalmemory/ui/js/modal.js +71 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +101 -88
- package/src/superlocalmemory/ui/js/trust-dashboard.js +168 -25
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +2018 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +7 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +6 -0
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +2 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +8 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +1 -0
- package/src/superlocalmemory/ui/js/behavioral.js +0 -447
- package/src/superlocalmemory/ui/js/graph-core.js +0 -447
- package/src/superlocalmemory/ui/js/graph-interactions.js +0 -351
- package/src/superlocalmemory/ui/js/learning.js +0 -435
- package/src/superlocalmemory/ui/js/patterns.js +0 -93
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -647
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -335
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -58
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -0,0 +1,317 @@
|
|
|
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-05 §2, §4.4, §9.3
|
|
4
|
+
|
|
5
|
+
"""Cross-platform adapter base — shared Protocol and atomic write primitive.
|
|
6
|
+
|
|
7
|
+
LLD reference: ``.backup/active-brain/lld/LLD-05-cross-platform-adapters.md``
|
|
8
|
+
Sections: 2 (component catalog), 4.4 (atomic write), 9.3 (sync log contract).
|
|
9
|
+
|
|
10
|
+
Every adapter (Cursor, Antigravity, Copilot) implements the ``Adapter``
|
|
11
|
+
Protocol. The shared ``_atomic_write`` primitive enforces the hard-rule
|
|
12
|
+
matrix (A1–A3, A7): ``safe_resolve`` + ``O_NOFOLLOW`` + tempfile +
|
|
13
|
+
``os.replace`` + durable content-hash skip via ``cross_platform_sync_log``.
|
|
14
|
+
|
|
15
|
+
Hard rules enforced here:
|
|
16
|
+
- A1: every write goes through ``safe_resolve``; POSIX uses ``O_NOFOLLOW``.
|
|
17
|
+
- A2: atomic replace via tempfile → ``os.replace``.
|
|
18
|
+
- A3: durable content-hash skip — previous ``content_sha256`` comes from
|
|
19
|
+
the DB, so this survives daemon restarts.
|
|
20
|
+
- A7: sync log records ``target_path_sha256`` (full 64-hex, no truncation)
|
|
21
|
+
+ ``target_basename``; never the raw absolute path string.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import os
|
|
28
|
+
import sqlite3
|
|
29
|
+
import sys
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Protocol, runtime_checkable
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Constants
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
HARD_BYTES_CAP = 4096
|
|
41
|
+
COPILOT_SOFT_BYTES = 2048
|
|
42
|
+
TRUNCATION_MARKER = b"\n<!-- truncated -->"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Protocol
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class Adapter(Protocol):
|
|
52
|
+
"""Contract every cross-platform adapter implements."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def target_path(self) -> Path: ...
|
|
58
|
+
|
|
59
|
+
def is_active(self) -> bool: ...
|
|
60
|
+
|
|
61
|
+
def sync(self) -> bool: ...
|
|
62
|
+
|
|
63
|
+
def disable(self) -> None: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Sync-log helpers (LLD-07 M004 canonical columns)
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def path_sha256(path: Path) -> str:
|
|
72
|
+
"""SHA-256 of the absolute path string, full 64-hex (never truncated)."""
|
|
73
|
+
return hashlib.sha256(str(path.resolve() if path.exists()
|
|
74
|
+
else path).encode("utf-8")).hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _now_iso() -> str:
|
|
78
|
+
return datetime.now(timezone.utc).isoformat()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ensure_memory_log(db_path: Path) -> None:
|
|
82
|
+
"""Lazily create ``cross_platform_sync_log`` if a test-mode memory.db is
|
|
83
|
+
fresh. Production code goes through the migration runner, but tests can
|
|
84
|
+
hand us an empty DB; this keeps adapters usable without pre-running
|
|
85
|
+
migrations."""
|
|
86
|
+
conn = sqlite3.connect(str(db_path))
|
|
87
|
+
try:
|
|
88
|
+
conn.executescript(
|
|
89
|
+
"CREATE TABLE IF NOT EXISTS cross_platform_sync_log ("
|
|
90
|
+
" adapter_name TEXT NOT NULL,"
|
|
91
|
+
" profile_id TEXT NOT NULL,"
|
|
92
|
+
" target_path_sha256 TEXT NOT NULL,"
|
|
93
|
+
" target_basename TEXT NOT NULL,"
|
|
94
|
+
" last_sync_at TEXT NOT NULL,"
|
|
95
|
+
" bytes_written INTEGER NOT NULL,"
|
|
96
|
+
" content_sha256 TEXT NOT NULL,"
|
|
97
|
+
" success INTEGER NOT NULL,"
|
|
98
|
+
" error_msg TEXT,"
|
|
99
|
+
" PRIMARY KEY (adapter_name, target_path_sha256));"
|
|
100
|
+
)
|
|
101
|
+
conn.commit()
|
|
102
|
+
finally:
|
|
103
|
+
conn.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def sync_log_last_content_sha256(
|
|
107
|
+
db_path: Path, adapter_name: str, target_path_sha256: str,
|
|
108
|
+
) -> str | None:
|
|
109
|
+
"""Read last successful ``content_sha256`` for this (adapter, path)."""
|
|
110
|
+
if not db_path.exists():
|
|
111
|
+
return None
|
|
112
|
+
_ensure_memory_log(db_path)
|
|
113
|
+
conn = sqlite3.connect(str(db_path))
|
|
114
|
+
try:
|
|
115
|
+
row = conn.execute(
|
|
116
|
+
"SELECT content_sha256, success FROM cross_platform_sync_log "
|
|
117
|
+
"WHERE adapter_name = ? AND target_path_sha256 = ?",
|
|
118
|
+
(adapter_name, target_path_sha256),
|
|
119
|
+
).fetchone()
|
|
120
|
+
except sqlite3.Error:
|
|
121
|
+
return None
|
|
122
|
+
finally:
|
|
123
|
+
conn.close()
|
|
124
|
+
if row is None:
|
|
125
|
+
return None
|
|
126
|
+
content_hash, success = row
|
|
127
|
+
if not success:
|
|
128
|
+
return None
|
|
129
|
+
return content_hash
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sync_log_record(
|
|
133
|
+
db_path: Path,
|
|
134
|
+
*,
|
|
135
|
+
adapter_name: str,
|
|
136
|
+
profile_id: str,
|
|
137
|
+
target_path_sha256: str,
|
|
138
|
+
target_basename: str,
|
|
139
|
+
bytes_written: int,
|
|
140
|
+
content_sha256: str,
|
|
141
|
+
success: bool,
|
|
142
|
+
error_msg: str | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Upsert the canonical M004 row. Rule A7: hash is full 64 hex, no raw path.
|
|
145
|
+
|
|
146
|
+
Raises ``ValueError`` if caller attempts to sneak a raw absolute path into
|
|
147
|
+
``target_path_sha256`` (defence in depth for the CI grep guard).
|
|
148
|
+
"""
|
|
149
|
+
if len(target_path_sha256) != 64:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"target_path_sha256 must be 64 hex chars, got {len(target_path_sha256)}"
|
|
152
|
+
)
|
|
153
|
+
if os.sep in target_path_sha256 or "/" in target_path_sha256:
|
|
154
|
+
raise ValueError("target_path_sha256 must be a hash, not a raw path")
|
|
155
|
+
_ensure_memory_log(db_path)
|
|
156
|
+
conn = sqlite3.connect(str(db_path))
|
|
157
|
+
try:
|
|
158
|
+
conn.execute(
|
|
159
|
+
"INSERT INTO cross_platform_sync_log ("
|
|
160
|
+
"adapter_name, profile_id, target_path_sha256, target_basename, "
|
|
161
|
+
"last_sync_at, bytes_written, content_sha256, success, error_msg"
|
|
162
|
+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
163
|
+
"ON CONFLICT(adapter_name, target_path_sha256) DO UPDATE SET "
|
|
164
|
+
" profile_id = excluded.profile_id,"
|
|
165
|
+
" target_basename = excluded.target_basename,"
|
|
166
|
+
" last_sync_at = excluded.last_sync_at,"
|
|
167
|
+
" bytes_written = excluded.bytes_written,"
|
|
168
|
+
" content_sha256 = excluded.content_sha256,"
|
|
169
|
+
" success = excluded.success,"
|
|
170
|
+
" error_msg = excluded.error_msg",
|
|
171
|
+
(
|
|
172
|
+
adapter_name, profile_id, target_path_sha256, target_basename,
|
|
173
|
+
_now_iso(), bytes_written, content_sha256,
|
|
174
|
+
1 if success else 0, error_msg,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
conn.commit()
|
|
178
|
+
finally:
|
|
179
|
+
conn.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Atomic write with durable content-hash skip (A1, A2, A3, A7)
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_posix() -> bool:
|
|
188
|
+
return os.name == "posix"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, slots=True)
|
|
192
|
+
class WriteResult:
|
|
193
|
+
"""Outcome of ``atomic_write``."""
|
|
194
|
+
wrote: bool
|
|
195
|
+
bytes_written: int
|
|
196
|
+
content_sha256: str
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def atomic_write(
|
|
200
|
+
resolved_path: Path,
|
|
201
|
+
content: bytes,
|
|
202
|
+
*,
|
|
203
|
+
adapter_name: str,
|
|
204
|
+
profile_id: str,
|
|
205
|
+
sync_log_db: Path,
|
|
206
|
+
posix_mode: int = 0o600,
|
|
207
|
+
windows_mode: int = 0o644,
|
|
208
|
+
) -> WriteResult:
|
|
209
|
+
"""Atomic write with durable content-hash skip.
|
|
210
|
+
|
|
211
|
+
``resolved_path`` MUST already be a ``safe_resolve`` output. Passing a
|
|
212
|
+
raw user-controlled path bypasses the hard-rule A1 guarantee — adapters
|
|
213
|
+
are responsible for calling ``safe_resolve`` first (enforced by tests).
|
|
214
|
+
"""
|
|
215
|
+
new_hash = hashlib.sha256(content).hexdigest()
|
|
216
|
+
target_sha = path_sha256(resolved_path)
|
|
217
|
+
prev = sync_log_last_content_sha256(sync_log_db, adapter_name, target_sha)
|
|
218
|
+
|
|
219
|
+
if prev == new_hash and resolved_path.exists():
|
|
220
|
+
# Durable skip — no write, no new sync-log row (the prior row still
|
|
221
|
+
# reflects on-disk truth).
|
|
222
|
+
return WriteResult(wrote=False, bytes_written=0, content_sha256=new_hash)
|
|
223
|
+
|
|
224
|
+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
tmp = resolved_path.with_suffix(resolved_path.suffix + ".slm-tmp")
|
|
226
|
+
|
|
227
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
228
|
+
if hasattr(os, "O_NOFOLLOW") and _is_posix():
|
|
229
|
+
flags |= os.O_NOFOLLOW # SEC — POSIX refuses symlinks
|
|
230
|
+
|
|
231
|
+
mode = posix_mode if _is_posix() else windows_mode
|
|
232
|
+
fd = os.open(str(tmp), flags, mode)
|
|
233
|
+
try:
|
|
234
|
+
os.write(fd, content)
|
|
235
|
+
try:
|
|
236
|
+
os.fsync(fd)
|
|
237
|
+
except OSError: # pragma: no cover — exotic FS
|
|
238
|
+
pass
|
|
239
|
+
finally:
|
|
240
|
+
os.close(fd)
|
|
241
|
+
os.replace(tmp, resolved_path)
|
|
242
|
+
|
|
243
|
+
if _is_posix():
|
|
244
|
+
try:
|
|
245
|
+
os.chmod(resolved_path, posix_mode)
|
|
246
|
+
except OSError: # pragma: no cover
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
sync_log_record(
|
|
250
|
+
sync_log_db,
|
|
251
|
+
adapter_name=adapter_name,
|
|
252
|
+
profile_id=profile_id,
|
|
253
|
+
target_path_sha256=target_sha,
|
|
254
|
+
target_basename=resolved_path.name,
|
|
255
|
+
bytes_written=len(content),
|
|
256
|
+
content_sha256=new_hash,
|
|
257
|
+
success=True,
|
|
258
|
+
error_msg=None,
|
|
259
|
+
)
|
|
260
|
+
return WriteResult(wrote=True, bytes_written=len(content),
|
|
261
|
+
content_sha256=new_hash)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def record_disable(
|
|
265
|
+
resolved_path: Path,
|
|
266
|
+
*,
|
|
267
|
+
adapter_name: str,
|
|
268
|
+
profile_id: str,
|
|
269
|
+
sync_log_db: Path,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Log a disable row (LLD-05 §9.4). File deletion is the caller's job."""
|
|
272
|
+
target_sha = path_sha256(resolved_path)
|
|
273
|
+
sync_log_record(
|
|
274
|
+
sync_log_db,
|
|
275
|
+
adapter_name=adapter_name,
|
|
276
|
+
profile_id=profile_id,
|
|
277
|
+
target_path_sha256=target_sha,
|
|
278
|
+
target_basename=resolved_path.name,
|
|
279
|
+
bytes_written=0,
|
|
280
|
+
content_sha256="0" * 64,
|
|
281
|
+
success=True,
|
|
282
|
+
error_msg="disabled_by_user",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def truncate_to_cap(content: bytes, *, cap: int = HARD_BYTES_CAP) -> bytes:
|
|
287
|
+
"""Truncate ``content`` to ``cap`` bytes, appending the marker.
|
|
288
|
+
|
|
289
|
+
Adapters call this AFTER domain-level truncation (trim sections) and
|
|
290
|
+
treat the marker as a last-resort safety net — not the primary cap
|
|
291
|
+
mechanism. Kept in the base module because all three adapters need it.
|
|
292
|
+
"""
|
|
293
|
+
if len(content) <= cap:
|
|
294
|
+
return content
|
|
295
|
+
marker = TRUNCATION_MARKER
|
|
296
|
+
# Leave room for the marker in the cap.
|
|
297
|
+
head = content[: max(0, cap - len(marker))]
|
|
298
|
+
return head + marker
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
__all__ = (
|
|
302
|
+
"Adapter",
|
|
303
|
+
"HARD_BYTES_CAP",
|
|
304
|
+
"COPILOT_SOFT_BYTES",
|
|
305
|
+
"TRUNCATION_MARKER",
|
|
306
|
+
"WriteResult",
|
|
307
|
+
"atomic_write",
|
|
308
|
+
"path_sha256",
|
|
309
|
+
"record_disable",
|
|
310
|
+
"sync_log_last_content_sha256",
|
|
311
|
+
"sync_log_record",
|
|
312
|
+
"truncate_to_cap",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# Internal platform flag used by adapters to choose permissions.
|
|
317
|
+
IS_POSIX = _is_posix()
|
|
@@ -0,0 +1,192 @@
|
|
|
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-05 §5
|
|
4
|
+
|
|
5
|
+
"""Antigravity adapter — writes SKILL.md under singular ``.agent/skills/``.
|
|
6
|
+
|
|
7
|
+
LLD-05 §5. Verified path (verification-2026-04-17.md claim 4, Google
|
|
8
|
+
Codelabs): workspace = ``.agent/skills/slm-memory-adapter/SKILL.md``,
|
|
9
|
+
global = ``~/.gemini/antigravity/skills/slm-memory/SKILL.md``.
|
|
10
|
+
|
|
11
|
+
Hard rules covered here:
|
|
12
|
+
- A1 / A3 / A4 / A7: via ``adapter_base.atomic_write``.
|
|
13
|
+
- A6: singular ``.agent`` and canonical global path enforced by constants.
|
|
14
|
+
|
|
15
|
+
CI grep guards (banned substrings in this file and in ``hooks/``) are
|
|
16
|
+
documented in LLD-05 §13 — do not reproduce the literal banned tokens here
|
|
17
|
+
(otherwise a docstring trips the very grep guard that protects the module).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from superlocalmemory.core.security_primitives import (
|
|
27
|
+
PathTraversalError,
|
|
28
|
+
safe_resolve,
|
|
29
|
+
)
|
|
30
|
+
from superlocalmemory.hooks.adapter_base import (
|
|
31
|
+
HARD_BYTES_CAP,
|
|
32
|
+
WriteResult,
|
|
33
|
+
atomic_write,
|
|
34
|
+
record_disable,
|
|
35
|
+
truncate_to_cap,
|
|
36
|
+
)
|
|
37
|
+
from superlocalmemory.hooks.context_payload import (
|
|
38
|
+
ContextPayload,
|
|
39
|
+
RecallFn,
|
|
40
|
+
build_payload,
|
|
41
|
+
format_decisions,
|
|
42
|
+
format_entities,
|
|
43
|
+
format_memories,
|
|
44
|
+
format_topics,
|
|
45
|
+
truncate_payload_for_cap,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
NAME_WORKSPACE = "antigravity_workspace"
|
|
51
|
+
NAME_GLOBAL = "antigravity_global"
|
|
52
|
+
|
|
53
|
+
# Canonical singular paths (Google Codelabs). Do NOT change to plural.
|
|
54
|
+
# Built from pieces so the CI grep for banned plural forms stays clean.
|
|
55
|
+
_AGENT = ".agent"
|
|
56
|
+
_SKILLS = "skills"
|
|
57
|
+
_WORKSPACE_SKILL_NAME = "slm-memory-adapter"
|
|
58
|
+
_GLOBAL_SKILL_NAME = "slm-memory"
|
|
59
|
+
|
|
60
|
+
WORKSPACE_REL = f"{_AGENT}/{_SKILLS}/{_WORKSPACE_SKILL_NAME}/SKILL.md"
|
|
61
|
+
GLOBAL_REL = f".gemini/antigravity/{_SKILLS}/{_GLOBAL_SKILL_NAME}/SKILL.md"
|
|
62
|
+
|
|
63
|
+
_FRONTMATTER = (
|
|
64
|
+
"---\n"
|
|
65
|
+
"name: slm-memory-adapter\n"
|
|
66
|
+
"description: \"Surfaces SuperLocalMemory context "
|
|
67
|
+
"(topics, entities, decisions) at the start of every "
|
|
68
|
+
"Antigravity conversation.\"\n"
|
|
69
|
+
"---\n"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_BODY_TEMPLATE = (
|
|
73
|
+
"\n# SLM Memory Adapter\n\n"
|
|
74
|
+
"_Auto-generated by SuperLocalMemory v{version}. Last sync: {generated_at}._\n\n"
|
|
75
|
+
"## Preferences\n{topics}\n\n"
|
|
76
|
+
"## Entities\n{entities}\n\n"
|
|
77
|
+
"## Recent decisions\n{decisions}\n\n"
|
|
78
|
+
"## Project memories\n{memories}\n"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def render_antigravity(payload: ContextPayload) -> bytes:
|
|
83
|
+
body = _BODY_TEMPLATE.format(
|
|
84
|
+
version=payload.version,
|
|
85
|
+
generated_at=payload.generated_at,
|
|
86
|
+
topics=format_topics(payload),
|
|
87
|
+
entities=format_entities(payload),
|
|
88
|
+
decisions=format_decisions(payload),
|
|
89
|
+
memories=format_memories(payload),
|
|
90
|
+
)
|
|
91
|
+
return (_FRONTMATTER + body).encode("utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AntigravityAdapter:
|
|
95
|
+
"""Workspace OR global Antigravity adapter."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
scope: str,
|
|
101
|
+
base_dir: Path,
|
|
102
|
+
sync_log_db: Path,
|
|
103
|
+
recall_fn: RecallFn,
|
|
104
|
+
profile_id: str = "default",
|
|
105
|
+
) -> None:
|
|
106
|
+
if scope not in ("workspace", "global"):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"scope must be 'workspace'|'global', got {scope!r}"
|
|
109
|
+
)
|
|
110
|
+
self._scope = scope
|
|
111
|
+
self._base_dir = Path(base_dir)
|
|
112
|
+
self._sync_log_db = Path(sync_log_db)
|
|
113
|
+
self._recall_fn = recall_fn
|
|
114
|
+
self._profile_id = profile_id
|
|
115
|
+
self.name = NAME_WORKSPACE if scope == "workspace" else NAME_GLOBAL
|
|
116
|
+
self._inactive_until_retry = False
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def target_path(self) -> Path:
|
|
120
|
+
rel = WORKSPACE_REL if self._scope == "workspace" else GLOBAL_REL
|
|
121
|
+
return safe_resolve(self._base_dir, rel)
|
|
122
|
+
|
|
123
|
+
def is_active(self) -> bool:
|
|
124
|
+
if os.environ.get("SLM_ANTIGRAVITY_DISABLED") == "1":
|
|
125
|
+
return False
|
|
126
|
+
if self._inactive_until_retry:
|
|
127
|
+
return False
|
|
128
|
+
if os.environ.get("SLM_ANTIGRAVITY_FORCE") == "1":
|
|
129
|
+
return True
|
|
130
|
+
if os.environ.get("SLM_ADAPTER_FORCE_ANTIGRAVITY") == "1":
|
|
131
|
+
return True
|
|
132
|
+
home = Path.home()
|
|
133
|
+
candidates = (
|
|
134
|
+
home / ".gemini" / "antigravity",
|
|
135
|
+
home / "Library" / "Application Support" / "Google" / "Antigravity",
|
|
136
|
+
)
|
|
137
|
+
return any(p.is_dir() for p in candidates)
|
|
138
|
+
|
|
139
|
+
def sync(self) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
resolved = self.target_path
|
|
142
|
+
except PathTraversalError:
|
|
143
|
+
logger.warning("antigravity: path-traversal refused")
|
|
144
|
+
self._inactive_until_retry = True
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# For builder purposes, treat workspace scope as project.
|
|
148
|
+
scope_for_builder = "project" if self._scope == "workspace" else "global"
|
|
149
|
+
payload = build_payload(
|
|
150
|
+
self._profile_id, scope_for_builder, self._base_dir,
|
|
151
|
+
recall_fn=self._recall_fn,
|
|
152
|
+
)
|
|
153
|
+
rendered = truncate_payload_for_cap(
|
|
154
|
+
payload, hard_cap=HARD_BYTES_CAP, render=render_antigravity,
|
|
155
|
+
)
|
|
156
|
+
rendered = truncate_to_cap(rendered, cap=HARD_BYTES_CAP)
|
|
157
|
+
|
|
158
|
+
result: WriteResult = atomic_write(
|
|
159
|
+
resolved, rendered,
|
|
160
|
+
adapter_name=self.name,
|
|
161
|
+
profile_id=self._profile_id,
|
|
162
|
+
sync_log_db=self._sync_log_db,
|
|
163
|
+
)
|
|
164
|
+
return result.wrote
|
|
165
|
+
|
|
166
|
+
def disable(self) -> None:
|
|
167
|
+
try:
|
|
168
|
+
resolved = self.target_path
|
|
169
|
+
except PathTraversalError:
|
|
170
|
+
return
|
|
171
|
+
if resolved.exists():
|
|
172
|
+
try:
|
|
173
|
+
resolved.unlink()
|
|
174
|
+
except OSError: # pragma: no cover
|
|
175
|
+
pass
|
|
176
|
+
record_disable(
|
|
177
|
+
resolved,
|
|
178
|
+
adapter_name=self.name,
|
|
179
|
+
profile_id=self._profile_id,
|
|
180
|
+
sync_log_db=self._sync_log_db,
|
|
181
|
+
)
|
|
182
|
+
self._inactive_until_retry = True
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = (
|
|
186
|
+
"AntigravityAdapter",
|
|
187
|
+
"GLOBAL_REL",
|
|
188
|
+
"NAME_GLOBAL",
|
|
189
|
+
"NAME_WORKSPACE",
|
|
190
|
+
"WORKSPACE_REL",
|
|
191
|
+
"render_antigravity",
|
|
192
|
+
)
|
|
@@ -112,6 +112,32 @@ def _hook_definitions(include_gate: bool = False) -> dict[str, list]:
|
|
|
112
112
|
"timeout": 5000,
|
|
113
113
|
}
|
|
114
114
|
],
|
|
115
|
+
},
|
|
116
|
+
# LLD-09 Track A.2 — outcome-population on every tool use.
|
|
117
|
+
# Matches all host tools (SLM MCP tools are excluded via
|
|
118
|
+
# matcher negation below; actual filtering happens in the
|
|
119
|
+
# hook by checking tool_name against SLM's own prefixes).
|
|
120
|
+
{
|
|
121
|
+
"matcher": _GATED_TOOLS,
|
|
122
|
+
"hooks": [
|
|
123
|
+
{
|
|
124
|
+
"type": "command",
|
|
125
|
+
"command": _wrap_python_cmd("post_tool_outcome"),
|
|
126
|
+
"timeout": 5000,
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
"UserPromptSubmit": [
|
|
132
|
+
# LLD-09 Track A.2 — re-query detection.
|
|
133
|
+
{
|
|
134
|
+
"hooks": [
|
|
135
|
+
{
|
|
136
|
+
"type": "command",
|
|
137
|
+
"command": _wrap_python_cmd("user_prompt_rehash"),
|
|
138
|
+
"timeout": 5000,
|
|
139
|
+
}
|
|
140
|
+
]
|
|
115
141
|
}
|
|
116
142
|
],
|
|
117
143
|
"Stop": [
|
|
@@ -121,7 +147,13 @@ def _hook_definitions(include_gate: bool = False) -> dict[str, list]:
|
|
|
121
147
|
"type": "command",
|
|
122
148
|
"command": _wrap_python_cmd("stop"),
|
|
123
149
|
"timeout": 10000,
|
|
124
|
-
}
|
|
150
|
+
},
|
|
151
|
+
# LLD-09 Track A.2 — finalize pending outcomes at end.
|
|
152
|
+
{
|
|
153
|
+
"type": "command",
|
|
154
|
+
"command": _wrap_python_cmd("stop_outcome"),
|
|
155
|
+
"timeout": 10000,
|
|
156
|
+
},
|
|
125
157
|
]
|
|
126
158
|
}
|
|
127
159
|
],
|