superlocalmemory 3.8.14 → 4.0.0
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/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -137
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +32 -5
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -424
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/{M034_scene_fact_members.py → M039_scene_fact_members.py} +15 -5
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -4
- package/src/superlocalmemory/storage/schema.py +10 -2
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -12,9 +12,11 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import hashlib
|
|
15
16
|
import json
|
|
16
17
|
import logging
|
|
17
18
|
import sqlite3
|
|
19
|
+
import warnings
|
|
18
20
|
from datetime import datetime, timezone
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
from typing import Optional
|
|
@@ -61,6 +63,44 @@ CREATE TABLE IF NOT EXISTS evolution_cycle_state (
|
|
|
61
63
|
updated_at TEXT,
|
|
62
64
|
PRIMARY KEY (profile_id, key)
|
|
63
65
|
);
|
|
66
|
+
|
|
67
|
+
-- Phase 2: append-only status-transition log (LLD Decision B2).
|
|
68
|
+
-- Each state change for a record produces one new row here.
|
|
69
|
+
-- The BEFORE UPDATE trigger enforces immutability at the DB layer.
|
|
70
|
+
CREATE TABLE IF NOT EXISTS skill_evolution_transitions (
|
|
71
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
record_id TEXT NOT NULL,
|
|
73
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
74
|
+
from_status TEXT NOT NULL,
|
|
75
|
+
to_status TEXT NOT NULL,
|
|
76
|
+
transitioned_at TEXT NOT NULL,
|
|
77
|
+
actor_id TEXT DEFAULT '',
|
|
78
|
+
reason TEXT DEFAULT '',
|
|
79
|
+
prev_hash TEXT DEFAULT '',
|
|
80
|
+
transition_hash TEXT NOT NULL,
|
|
81
|
+
metadata TEXT DEFAULT '{}'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_evo_trans_record
|
|
85
|
+
ON skill_evolution_transitions(record_id, seq);
|
|
86
|
+
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_evo_trans_profile
|
|
88
|
+
ON skill_evolution_transitions(profile_id, transitioned_at);
|
|
89
|
+
|
|
90
|
+
-- DB-enforced append-only: any UPDATE on this table is a bug (LLD Decision B2).
|
|
91
|
+
CREATE TRIGGER IF NOT EXISTS no_update_evo_transitions
|
|
92
|
+
BEFORE UPDATE ON skill_evolution_transitions
|
|
93
|
+
BEGIN
|
|
94
|
+
SELECT RAISE(ABORT, 'skill_evolution_transitions is append-only');
|
|
95
|
+
END;
|
|
96
|
+
|
|
97
|
+
-- Audit P1-3: DELETE must also be forbidden — otherwise the hash chain can be
|
|
98
|
+
-- silently truncated/erased without a DB abort, breaking the immutable-log claim.
|
|
99
|
+
CREATE TRIGGER IF NOT EXISTS no_delete_evo_transitions
|
|
100
|
+
BEFORE DELETE ON skill_evolution_transitions
|
|
101
|
+
BEGIN
|
|
102
|
+
SELECT RAISE(ABORT, 'skill_evolution_transitions is append-only');
|
|
103
|
+
END;
|
|
64
104
|
"""
|
|
65
105
|
|
|
66
106
|
# Anti-loop budget
|
|
@@ -223,11 +263,194 @@ class EvolutionStore:
|
|
|
223
263
|
for k in recovered:
|
|
224
264
|
del self._addressed_degradations[k]
|
|
225
265
|
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# Phase 2: append-only transition log
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def insert_record(self, record: EvolutionRecord, profile_id: str) -> None:
|
|
271
|
+
"""INSERT a new evolution record (CANDIDATE status).
|
|
272
|
+
|
|
273
|
+
Unlike save_record, this uses plain INSERT — NOT INSERT OR REPLACE.
|
|
274
|
+
Raises sqlite3.IntegrityError if a row with the same id already exists.
|
|
275
|
+
Call this exactly once per candidate (CRIT-1: never reuse record_id).
|
|
276
|
+
"""
|
|
277
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
278
|
+
try:
|
|
279
|
+
conn.execute(
|
|
280
|
+
"INSERT INTO skill_evolution_log "
|
|
281
|
+
"(id, profile_id, skill_name, parent_skill_id, evolution_type, "
|
|
282
|
+
" trigger_type, generation, status, mutation_summary, evidence, "
|
|
283
|
+
" original_content, evolved_content, content_diff, "
|
|
284
|
+
" blind_verified, rejection_reason, created_at, completed_at) "
|
|
285
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
286
|
+
(
|
|
287
|
+
record.id,
|
|
288
|
+
profile_id,
|
|
289
|
+
record.skill_name,
|
|
290
|
+
record.parent_skill_id,
|
|
291
|
+
record.evolution_type.value,
|
|
292
|
+
record.trigger.value,
|
|
293
|
+
record.generation,
|
|
294
|
+
record.status.value,
|
|
295
|
+
record.mutation_summary,
|
|
296
|
+
json.dumps(list(record.evidence)),
|
|
297
|
+
record.original_content,
|
|
298
|
+
record.evolved_content,
|
|
299
|
+
record.content_diff,
|
|
300
|
+
1 if record.blind_verified else 0,
|
|
301
|
+
record.rejection_reason,
|
|
302
|
+
record.created_at,
|
|
303
|
+
record.completed_at,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
conn.commit()
|
|
307
|
+
finally:
|
|
308
|
+
conn.close()
|
|
309
|
+
|
|
310
|
+
def append_transition(
|
|
311
|
+
self,
|
|
312
|
+
record_id: str,
|
|
313
|
+
profile_id: str,
|
|
314
|
+
from_status: EvolutionStatus,
|
|
315
|
+
to_status: EvolutionStatus,
|
|
316
|
+
*,
|
|
317
|
+
actor_id: str = "",
|
|
318
|
+
reason: str = "",
|
|
319
|
+
metadata: dict | None = None,
|
|
320
|
+
) -> str:
|
|
321
|
+
"""Append an immutable status-transition row. Returns transition_hash.
|
|
322
|
+
|
|
323
|
+
Hash linkage (audit P2-3): transition_hash = SHA-256 over a canonical,
|
|
324
|
+
pipe-delimited payload covering EVERY persisted field —
|
|
325
|
+
``prev_hash | record_id | from_status | to_status | ts | actor_id |
|
|
326
|
+
reason | metadata_str``. Delimiters remove concatenation ambiguity and
|
|
327
|
+
including reason+metadata makes those columns tamper-evident too.
|
|
328
|
+
|
|
329
|
+
prev_hash is the transition_hash of the most recent row for this
|
|
330
|
+
record_id, or 'genesis' for the first transition.
|
|
331
|
+
|
|
332
|
+
Concurrency (audit P1-5): the read-prev-then-insert is wrapped in a
|
|
333
|
+
single ``BEGIN IMMEDIATE`` transaction so two concurrent writers cannot
|
|
334
|
+
both read the same prev_hash and fork the chain.
|
|
335
|
+
|
|
336
|
+
Never calls UPDATE or DELETE. Raises ValueError if from_status == to_status.
|
|
337
|
+
"""
|
|
338
|
+
if from_status == to_status:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"append_transition: from_status == to_status == {from_status!r}; "
|
|
341
|
+
"no-op transitions are not allowed in the append-only log."
|
|
342
|
+
)
|
|
343
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
344
|
+
metadata_str = json.dumps(metadata or {}, sort_keys=True)
|
|
345
|
+
|
|
346
|
+
# isolation_level=None + explicit BEGIN IMMEDIATE = one atomic
|
|
347
|
+
# select-prev-then-insert critical section per call (P1-5).
|
|
348
|
+
conn = sqlite3.connect(self._db_path, timeout=10, isolation_level=None)
|
|
349
|
+
try:
|
|
350
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
351
|
+
# Find prev_hash for this record_id (genesis if first)
|
|
352
|
+
row = conn.execute(
|
|
353
|
+
"SELECT transition_hash FROM skill_evolution_transitions "
|
|
354
|
+
"WHERE record_id = ? AND profile_id = ? "
|
|
355
|
+
"ORDER BY seq DESC LIMIT 1",
|
|
356
|
+
(record_id, profile_id),
|
|
357
|
+
).fetchone()
|
|
358
|
+
prev_hash = row[0] if row else "genesis"
|
|
359
|
+
|
|
360
|
+
# Canonical, delimited payload over ALL persisted fields (P2-3).
|
|
361
|
+
payload = "|".join((
|
|
362
|
+
prev_hash, record_id,
|
|
363
|
+
from_status.value, to_status.value,
|
|
364
|
+
ts, actor_id, reason, metadata_str,
|
|
365
|
+
))
|
|
366
|
+
transition_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
367
|
+
|
|
368
|
+
conn.execute(
|
|
369
|
+
"INSERT INTO skill_evolution_transitions "
|
|
370
|
+
"(record_id, profile_id, from_status, to_status, "
|
|
371
|
+
" transitioned_at, actor_id, reason, prev_hash, "
|
|
372
|
+
" transition_hash, metadata) "
|
|
373
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
374
|
+
(
|
|
375
|
+
record_id,
|
|
376
|
+
profile_id,
|
|
377
|
+
from_status.value,
|
|
378
|
+
to_status.value,
|
|
379
|
+
ts,
|
|
380
|
+
actor_id,
|
|
381
|
+
reason,
|
|
382
|
+
prev_hash,
|
|
383
|
+
transition_hash,
|
|
384
|
+
metadata_str,
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
conn.execute("COMMIT")
|
|
388
|
+
return transition_hash
|
|
389
|
+
except Exception:
|
|
390
|
+
try:
|
|
391
|
+
conn.execute("ROLLBACK")
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
raise
|
|
395
|
+
finally:
|
|
396
|
+
conn.close()
|
|
397
|
+
|
|
398
|
+
def get_latest_status(
|
|
399
|
+
self, record_id: str, profile_id: str,
|
|
400
|
+
) -> EvolutionStatus | None:
|
|
401
|
+
"""Return the to_status of the highest-seq transition for record_id.
|
|
402
|
+
|
|
403
|
+
Returns None if no transitions exist for this record_id / profile_id.
|
|
404
|
+
"""
|
|
405
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
406
|
+
try:
|
|
407
|
+
row = conn.execute(
|
|
408
|
+
"SELECT to_status FROM skill_evolution_transitions "
|
|
409
|
+
"WHERE record_id = ? AND profile_id = ? "
|
|
410
|
+
"ORDER BY seq DESC LIMIT 1",
|
|
411
|
+
(record_id, profile_id),
|
|
412
|
+
).fetchone()
|
|
413
|
+
if row is None:
|
|
414
|
+
return None
|
|
415
|
+
return EvolutionStatus(row[0])
|
|
416
|
+
finally:
|
|
417
|
+
conn.close()
|
|
418
|
+
|
|
419
|
+
def get_transitions(self, record_id: str, profile_id: str) -> list[dict]:
|
|
420
|
+
"""Return all transition rows for record_id ordered by seq ASC."""
|
|
421
|
+
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
422
|
+
conn.row_factory = sqlite3.Row
|
|
423
|
+
try:
|
|
424
|
+
rows = conn.execute(
|
|
425
|
+
"SELECT seq, record_id, profile_id, from_status, to_status, "
|
|
426
|
+
"transitioned_at, actor_id, reason, prev_hash, transition_hash, "
|
|
427
|
+
"metadata "
|
|
428
|
+
"FROM skill_evolution_transitions "
|
|
429
|
+
"WHERE record_id = ? AND profile_id = ? "
|
|
430
|
+
"ORDER BY seq ASC",
|
|
431
|
+
(record_id, profile_id),
|
|
432
|
+
).fetchall()
|
|
433
|
+
return [dict(r) for r in rows]
|
|
434
|
+
finally:
|
|
435
|
+
conn.close()
|
|
436
|
+
|
|
226
437
|
# ------------------------------------------------------------------
|
|
227
438
|
# CRUD
|
|
228
439
|
# ------------------------------------------------------------------
|
|
229
440
|
|
|
230
441
|
def save_record(self, record: EvolutionRecord, profile_id: str) -> None:
|
|
442
|
+
"""DEPRECATED: use insert_record() for new records and append_transition() for state changes.
|
|
443
|
+
|
|
444
|
+
Retained for backward-compatibility with existing tests and callers.
|
|
445
|
+
Uses INSERT OR REPLACE — mutable semantics, not append-only.
|
|
446
|
+
Will be removed in a future cleanup pass (not Phase 2 scope).
|
|
447
|
+
"""
|
|
448
|
+
warnings.warn(
|
|
449
|
+
"EvolutionStore.save_record() is deprecated; "
|
|
450
|
+
"use insert_record() + append_transition() instead.",
|
|
451
|
+
DeprecationWarning,
|
|
452
|
+
stacklevel=2,
|
|
453
|
+
)
|
|
231
454
|
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
232
455
|
try:
|
|
233
456
|
conn.execute(
|
|
@@ -306,13 +529,30 @@ class EvolutionStore:
|
|
|
306
529
|
conn.close()
|
|
307
530
|
|
|
308
531
|
def count_attempts(self, skill_name: str, profile_id: str) -> int:
|
|
532
|
+
"""Count non-successful evolution attempts for a skill.
|
|
533
|
+
|
|
534
|
+
Audit P0-2: with the Phase-2 append-only model, ``skill_evolution_log``
|
|
535
|
+
rows are frozen at 'candidate' by ``insert_record`` and never updated, so
|
|
536
|
+
the legacy ``status NOT IN ('promoted')`` filter counted successes too —
|
|
537
|
+
permanently disabling a skill after MAX_ATTEMPTS_PER_SKILL improvements.
|
|
538
|
+
The current status is now read from the append-only transitions log
|
|
539
|
+
(latest ``to_status`` per record), and success states are excluded,
|
|
540
|
+
mirroring the pre-Phase-2 exclusion of 'promoted'. Legacy rows with no
|
|
541
|
+
transitions fall back to their frozen log status via COALESCE.
|
|
542
|
+
"""
|
|
543
|
+
success = ("promoted", "verified_quarantined", "approved", "active")
|
|
544
|
+
marks = ",".join("?" for _ in success)
|
|
309
545
|
conn = sqlite3.connect(self._db_path, timeout=10)
|
|
310
546
|
try:
|
|
311
547
|
row = conn.execute(
|
|
312
|
-
"SELECT COUNT(*) FROM skill_evolution_log "
|
|
313
|
-
"WHERE skill_name = ? AND profile_id = ? "
|
|
314
|
-
"AND
|
|
315
|
-
(
|
|
548
|
+
"SELECT COUNT(*) FROM skill_evolution_log l "
|
|
549
|
+
"WHERE l.skill_name = ? AND l.profile_id = ? "
|
|
550
|
+
"AND COALESCE("
|
|
551
|
+
" (SELECT t.to_status FROM skill_evolution_transitions t "
|
|
552
|
+
" WHERE t.record_id = l.id AND t.profile_id = l.profile_id "
|
|
553
|
+
" ORDER BY t.seq DESC LIMIT 1), l.status"
|
|
554
|
+
f") NOT IN ({marks})",
|
|
555
|
+
(skill_name, profile_id, *success),
|
|
316
556
|
).fetchone()
|
|
317
557
|
return row[0] if row else 0
|
|
318
558
|
finally:
|
|
@@ -158,6 +158,7 @@ ALLOWED_LLM_MODELS: frozenset[str] = frozenset({
|
|
|
158
158
|
"claude-sonnet-4-6",
|
|
159
159
|
"ollama:llama3",
|
|
160
160
|
"ollama:qwen2.5",
|
|
161
|
+
"openai:gpt-4o-mini",
|
|
161
162
|
})
|
|
162
163
|
|
|
163
164
|
FORBIDDEN_MODEL_SUBSTRINGS: tuple[str, ...] = ("opus", "gpt-4-turbo")
|
|
@@ -301,6 +302,43 @@ def _call_claude_api_backend(
|
|
|
301
302
|
return ""
|
|
302
303
|
|
|
303
304
|
|
|
305
|
+
def _call_openai_api_backend(
|
|
306
|
+
prompt: str, *, model: str, max_tokens: int,
|
|
307
|
+
) -> str:
|
|
308
|
+
"""Call the OpenAI Chat Completions API directly.
|
|
309
|
+
|
|
310
|
+
The API model id is derived from the allow-listed name by stripping
|
|
311
|
+
the ``"openai:"`` prefix — e.g. ``"openai:gpt-4o-mini"`` → ``"gpt-4o-mini"``.
|
|
312
|
+
Requires ``OPENAI_API_KEY`` in the environment. Returns empty string on
|
|
313
|
+
any transport or SDK failure (fail-closed).
|
|
314
|
+
"""
|
|
315
|
+
openai_model = model.split(":", 1)[1] if model.startswith("openai:") else model
|
|
316
|
+
try:
|
|
317
|
+
import openai as _openai # type: ignore[import-not-found]
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
logger.debug("openai sdk unavailable: %s", exc)
|
|
320
|
+
return ""
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
client = _openai.OpenAI()
|
|
324
|
+
completion = client.chat.completions.create(
|
|
325
|
+
model=openai_model,
|
|
326
|
+
max_tokens=max_tokens,
|
|
327
|
+
messages=[{"role": "user", "content": prompt}],
|
|
328
|
+
)
|
|
329
|
+
choices = getattr(completion, "choices", None)
|
|
330
|
+
if choices and len(choices) > 0:
|
|
331
|
+
msg = getattr(choices[0], "message", None)
|
|
332
|
+
if msg:
|
|
333
|
+
content = getattr(msg, "content", None)
|
|
334
|
+
if isinstance(content, str):
|
|
335
|
+
return content
|
|
336
|
+
return ""
|
|
337
|
+
except Exception as exc: # noqa: BLE001
|
|
338
|
+
logger.debug("OpenAI API backend failed: %s", exc)
|
|
339
|
+
return ""
|
|
340
|
+
|
|
341
|
+
|
|
304
342
|
# ---------------------------------------------------------------------------
|
|
305
343
|
# Backend registry — dispatches by (allow-listed) model id
|
|
306
344
|
# ---------------------------------------------------------------------------
|
|
@@ -337,6 +375,8 @@ def _pick_backend(model: str) -> Callable[..., str]:
|
|
|
337
375
|
"""
|
|
338
376
|
if model.startswith("ollama:"):
|
|
339
377
|
return _call_ollama_backend
|
|
378
|
+
if model.startswith("openai:"):
|
|
379
|
+
return _call_openai_api_backend
|
|
340
380
|
if model.startswith("claude-"):
|
|
341
381
|
# Claude CLI path is an alternative — selected when an explicit
|
|
342
382
|
# env flag is set. Default path is the Anthropic API backend.
|
|
@@ -39,6 +39,7 @@ CHEAPEST_CLAUDE = "claude-haiku-4-5"
|
|
|
39
39
|
QUALITY_CLAUDE = "claude-sonnet-4-6"
|
|
40
40
|
CHEAPEST_OLLAMA = "ollama:llama3"
|
|
41
41
|
ALT_OLLAMA = "ollama:qwen2.5"
|
|
42
|
+
CHEAPEST_OPENAI = "openai:gpt-4o-mini"
|
|
42
43
|
|
|
43
44
|
# Short-name → allow-listed model id. Single source of truth for aliasing;
|
|
44
45
|
# ``skill_evolver`` re-exports these for backward compatibility.
|
|
@@ -48,6 +49,8 @@ _MODEL_ALIASES: dict[str, str] = {
|
|
|
48
49
|
"ollama": CHEAPEST_OLLAMA,
|
|
49
50
|
"ollama:llama3": CHEAPEST_OLLAMA,
|
|
50
51
|
"ollama:qwen2.5": ALT_OLLAMA,
|
|
52
|
+
"openai": CHEAPEST_OPENAI,
|
|
53
|
+
"openai:gpt-4o-mini": CHEAPEST_OPENAI,
|
|
51
54
|
CHEAPEST_CLAUDE: CHEAPEST_CLAUDE,
|
|
52
55
|
QUALITY_CLAUDE: QUALITY_CLAUDE,
|
|
53
56
|
}
|
|
@@ -78,11 +81,18 @@ class ResolvedModels:
|
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
def _cheapest_for_backend(backend: str) -> str:
|
|
81
|
-
"""Lowest-cost allow-listed model for a detected backend.
|
|
84
|
+
"""Lowest-cost allow-listed model for a detected backend.
|
|
85
|
+
|
|
86
|
+
The configured backend is authoritative: when the caller explicitly
|
|
87
|
+
names a provider, models are drawn from that provider only —
|
|
88
|
+
never silently downgraded to a different vendor's offering.
|
|
89
|
+
"""
|
|
82
90
|
if backend == "ollama":
|
|
83
91
|
return CHEAPEST_OLLAMA
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
if backend == "openai":
|
|
93
|
+
return CHEAPEST_OPENAI
|
|
94
|
+
# claude CLI, anthropic API, and any non-Ollama/non-OpenAI backend
|
|
95
|
+
# route through the cheapest Claude model.
|
|
86
96
|
return CHEAPEST_CLAUDE
|
|
87
97
|
|
|
88
98
|
|
|
@@ -106,6 +116,8 @@ def _independent_verifier(
|
|
|
106
116
|
"""Pick a cheap verifier that differs from the generator when possible.
|
|
107
117
|
|
|
108
118
|
- Ollama generator → the *other* local model (both free, distinct).
|
|
119
|
+
- OpenAI generator → same OpenAI model (single-provider constraint;
|
|
120
|
+
independence flag will be False — caller logs the condition).
|
|
109
121
|
- Claude generator + local Ollama up → free local verifier.
|
|
110
122
|
- Claude generator, no Ollama → the cheapest *different* Claude tier if
|
|
111
123
|
the generator was a premium model; otherwise reuse the cheapest model
|
|
@@ -114,6 +126,9 @@ def _independent_verifier(
|
|
|
114
126
|
"""
|
|
115
127
|
if mutation.startswith("ollama:"):
|
|
116
128
|
return ALT_OLLAMA if mutation != ALT_OLLAMA else CHEAPEST_OLLAMA
|
|
129
|
+
if mutation.startswith("openai:"):
|
|
130
|
+
# Honor the configured provider: stay within OpenAI models.
|
|
131
|
+
return CHEAPEST_OPENAI
|
|
117
132
|
if ollama_available:
|
|
118
133
|
return CHEAPEST_OLLAMA
|
|
119
134
|
if mutation != CHEAPEST_CLAUDE:
|
|
@@ -97,6 +97,9 @@ _SKILL_DENY_PATTERNS: tuple[str, ...] = (
|
|
|
97
97
|
"os.environ", "subprocess", "exec(", "eval(", "__import__", "import os",
|
|
98
98
|
"import subprocess", "pickle.loads", "curl ", "wget ", "rm -rf",
|
|
99
99
|
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "AWS_SECRET", "/.ssh/", ".install_token",
|
|
100
|
+
# Semantic exfiltration patterns — skills must not instruct unauthorized
|
|
101
|
+
# data transfer or bypass of user consent.
|
|
102
|
+
"without consent", "exfiltrat",
|
|
100
103
|
)
|
|
101
104
|
|
|
102
105
|
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later — see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SkillActivator — atomic activation of quarantined evolved skills.
|
|
6
|
+
|
|
7
|
+
Copies a VERIFIED_QUARANTINED skill artifact into the live skill directory
|
|
8
|
+
(~/.claude/skills/{skill_name}/SKILL.md), retains the prior artifact as a
|
|
9
|
+
.bak file, and provides a tested rollback path.
|
|
10
|
+
|
|
11
|
+
Invariants:
|
|
12
|
+
1. The destination directory is a child of live_root — path traversal raises ValueError.
|
|
13
|
+
2. The backup is written BEFORE the live copy is overwritten.
|
|
14
|
+
3. Activation is atomic via a .tmp file → os.replace (POSIX atomic on same fs).
|
|
15
|
+
4. Rollback restores from the .bak file and leaves the live copy unchanged.
|
|
16
|
+
5. Only the quarantined artifact identified by quarantine_dir_name is copied;
|
|
17
|
+
no arbitrary file can be activated.
|
|
18
|
+
|
|
19
|
+
CRIT-2 note: skill_name and quarantine_dir_name are ALWAYS different strings:
|
|
20
|
+
- skill_name: the original skill identifier (e.g. "brainstorming")
|
|
21
|
+
- quarantine_dir_name: the sanitized quarantine subdir (e.g. "brainstorming-vabc12")
|
|
22
|
+
Both are required; neither defaults from the other.
|
|
23
|
+
|
|
24
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from superlocalmemory.infra.data_root import canonical_data_root
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
LIVE_SKILLS_ROOT: Path = Path.home() / ".claude" / "skills"
|
|
42
|
+
BACKUP_ROOT: Path = canonical_data_root() / "skill_backups"
|
|
43
|
+
QUARANTINE_ROOT: Path = canonical_data_root() / "quarantine" / "skills"
|
|
44
|
+
|
|
45
|
+
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_-]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SkillActivationError(Exception):
|
|
49
|
+
"""Raised when activation fails after writing the backup.
|
|
50
|
+
|
|
51
|
+
The caller must invoke rollback() after receiving this error to restore
|
|
52
|
+
the previous live artifact.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SkillActivator:
|
|
57
|
+
"""Activates a quarantined evolved skill into the live skill directory."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
live_root: Path | None = None,
|
|
63
|
+
backup_root: Path | None = None,
|
|
64
|
+
quarantine_root: Path | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._live_root = live_root or LIVE_SKILLS_ROOT
|
|
67
|
+
self._backup_root = backup_root or BACKUP_ROOT
|
|
68
|
+
self._quarantine_root = quarantine_root or QUARANTINE_ROOT
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Path helpers
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _safe_skill_dir(self, skill_name: str, base: Path) -> Path:
|
|
75
|
+
"""Return base / sanitized_name, raising ValueError on traversal.
|
|
76
|
+
|
|
77
|
+
Two-layer check (fail-fast + belt-and-suspenders):
|
|
78
|
+
1. Reject the raw skill_name if it contains '..' or is absolute.
|
|
79
|
+
2. After sanitization, resolve the target path and verify it remains
|
|
80
|
+
inside base. This catches any edge case the regex misses.
|
|
81
|
+
"""
|
|
82
|
+
# Fail-fast: detect traversal in raw name before sanitization.
|
|
83
|
+
raw = Path(skill_name)
|
|
84
|
+
if raw.is_absolute() or ".." in raw.parts:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"skill_name {skill_name!r} contains path traversal sequences"
|
|
87
|
+
)
|
|
88
|
+
safe = _SAFE_NAME_RE.sub("-", skill_name).lower()[:50] or "skill"
|
|
89
|
+
target = (base / safe).resolve()
|
|
90
|
+
base_resolved = base.resolve()
|
|
91
|
+
# Audit P2-5: is_relative_to is not prefix-fragile (a sibling like
|
|
92
|
+
# ".../skills-evil" cannot masquerade as being under ".../skills").
|
|
93
|
+
if not target.is_relative_to(base_resolved):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"skill_name {skill_name!r} escapes sandbox: {target}"
|
|
96
|
+
)
|
|
97
|
+
return target
|
|
98
|
+
|
|
99
|
+
def _quarantine_path(self, quarantine_dir_name: str) -> Path:
|
|
100
|
+
"""Return the SKILL.md path inside quarantine for a given dir name.
|
|
101
|
+
|
|
102
|
+
quarantine_dir_name is the sanitized directory name (CRIT-2), e.g.
|
|
103
|
+
'brainstorming-vabc12'. It is NOT the skill_name.
|
|
104
|
+
|
|
105
|
+
Hardened (audit P0-1 / P1-4): reject absolute paths and '..' traversal,
|
|
106
|
+
then resolve() and require containment inside the quarantine root. The
|
|
107
|
+
resolve() step also defeats a symlinked quarantine dir that would
|
|
108
|
+
otherwise redirect the read outside the sandbox and inject arbitrary
|
|
109
|
+
content into a live skill.
|
|
110
|
+
"""
|
|
111
|
+
raw = Path(quarantine_dir_name)
|
|
112
|
+
if raw.is_absolute() or ".." in raw.parts:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"quarantine_dir_name {quarantine_dir_name!r} contains path traversal"
|
|
115
|
+
)
|
|
116
|
+
root = self._quarantine_root.resolve()
|
|
117
|
+
q_dir = (self._quarantine_root / quarantine_dir_name).resolve()
|
|
118
|
+
if not q_dir.is_relative_to(root):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"quarantine_dir_name {quarantine_dir_name!r} escapes sandbox: {q_dir}"
|
|
121
|
+
)
|
|
122
|
+
q_path = (q_dir / "SKILL.md").resolve()
|
|
123
|
+
if not q_path.is_relative_to(root):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"quarantine artifact escapes sandbox: {q_path}"
|
|
126
|
+
)
|
|
127
|
+
return q_path
|
|
128
|
+
|
|
129
|
+
def _live_path(self, skill_name: str) -> Path:
|
|
130
|
+
return self._safe_skill_dir(skill_name, self._live_root) / "SKILL.md"
|
|
131
|
+
|
|
132
|
+
def _backup_path(self, skill_name: str) -> Path:
|
|
133
|
+
return self._safe_skill_dir(skill_name, self._backup_root) / "SKILL.md.bak"
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Public API
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def activate(
|
|
140
|
+
self,
|
|
141
|
+
skill_name: str,
|
|
142
|
+
quarantine_dir_name: str,
|
|
143
|
+
*,
|
|
144
|
+
actor_id: str = "",
|
|
145
|
+
) -> dict:
|
|
146
|
+
"""Atomically activate a quarantined skill.
|
|
147
|
+
|
|
148
|
+
Steps:
|
|
149
|
+
1. Resolve quarantine_path using quarantine_dir_name and verify it exists.
|
|
150
|
+
2. Read quarantine content and compute SHA-256.
|
|
151
|
+
3. Write backup of existing live SKILL.md (if any) to backup_root.
|
|
152
|
+
4. Write quarantine content to live path via .tmp → os.replace.
|
|
153
|
+
5. Return activation metadata.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
skill_name: The original skill identifier (CRIT-2), e.g. "brainstorming".
|
|
157
|
+
quarantine_dir_name: The sanitized quarantine subdir, e.g. "brainstorming-vabc12".
|
|
158
|
+
actor_id: Who triggered activation (e.g. "auto" or a user id).
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
FileNotFoundError: Quarantine artifact does not exist.
|
|
162
|
+
ValueError: Path traversal detected in skill_name.
|
|
163
|
+
SkillActivationError: Any OS error after the backup was already written.
|
|
164
|
+
"""
|
|
165
|
+
q_path = self._quarantine_path(quarantine_dir_name)
|
|
166
|
+
live_path = self._live_path(skill_name) # raises ValueError on traversal
|
|
167
|
+
backup_path = self._backup_path(skill_name)
|
|
168
|
+
|
|
169
|
+
if not q_path.exists():
|
|
170
|
+
raise FileNotFoundError(
|
|
171
|
+
f"Quarantine artifact not found: {q_path}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
content = q_path.read_bytes()
|
|
175
|
+
content_hash = hashlib.sha256(content).hexdigest()
|
|
176
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
177
|
+
|
|
178
|
+
# Step 3: backup existing live artifact BEFORE any overwrite
|
|
179
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
backup_written = False
|
|
181
|
+
if live_path.exists():
|
|
182
|
+
shutil.copy2(live_path, backup_path)
|
|
183
|
+
backup_written = True
|
|
184
|
+
logger.info(
|
|
185
|
+
"skill_activator: backed up %s → %s",
|
|
186
|
+
live_path, backup_path,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Step 4: atomic write via .tmp → rename (same directory = same fs)
|
|
190
|
+
live_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
tmp_path = live_path.with_suffix(".tmp")
|
|
192
|
+
try:
|
|
193
|
+
tmp_path.write_bytes(content)
|
|
194
|
+
os.replace(tmp_path, live_path)
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
try:
|
|
197
|
+
tmp_path.unlink(missing_ok=True)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
raise SkillActivationError(
|
|
201
|
+
f"atomic write failed for {live_path}: {exc}"
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
logger.info(
|
|
205
|
+
"skill_activator: activated %s @ %s (sha256=%s, actor=%s)",
|
|
206
|
+
skill_name, live_path, content_hash[:12], actor_id,
|
|
207
|
+
)
|
|
208
|
+
return {
|
|
209
|
+
"skill_name": skill_name,
|
|
210
|
+
"live_path": str(live_path),
|
|
211
|
+
"backup_path": str(backup_path) if backup_written else None,
|
|
212
|
+
"content_hash": content_hash,
|
|
213
|
+
"activated_at": ts,
|
|
214
|
+
"actor_id": actor_id,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def rollback(self, skill_name: str) -> dict:
|
|
218
|
+
"""Restore the live SKILL.md from the backup written by activate().
|
|
219
|
+
|
|
220
|
+
If no backup exists, removes the live file (the skill was new).
|
|
221
|
+
Never raises on missing backup — logs a warning instead.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
skill_name: The original skill identifier, same value used in activate().
|
|
225
|
+
"""
|
|
226
|
+
live_path = self._live_path(skill_name)
|
|
227
|
+
backup_path = self._backup_path(skill_name)
|
|
228
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
229
|
+
|
|
230
|
+
if backup_path.exists():
|
|
231
|
+
# Audit P2-4: restore atomically (tmp → os.replace) so a crash
|
|
232
|
+
# mid-rollback cannot leave a torn live SKILL.md.
|
|
233
|
+
live_path.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
tmp_path = live_path.with_suffix(".tmp")
|
|
235
|
+
tmp_path.write_bytes(backup_path.read_bytes())
|
|
236
|
+
os.replace(tmp_path, live_path)
|
|
237
|
+
logger.info(
|
|
238
|
+
"skill_activator: rolled back %s ← %s",
|
|
239
|
+
live_path, backup_path,
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
"skill_name": skill_name,
|
|
243
|
+
"rolled_back": True,
|
|
244
|
+
"restored_from": str(backup_path),
|
|
245
|
+
"rolled_back_at": ts,
|
|
246
|
+
}
|
|
247
|
+
elif live_path.exists():
|
|
248
|
+
live_path.unlink()
|
|
249
|
+
logger.warning(
|
|
250
|
+
"skill_activator: no backup for %s; removed live file",
|
|
251
|
+
skill_name,
|
|
252
|
+
)
|
|
253
|
+
return {
|
|
254
|
+
"skill_name": skill_name,
|
|
255
|
+
"rolled_back": True,
|
|
256
|
+
"restored_from": None,
|
|
257
|
+
"note": "no backup; live file removed",
|
|
258
|
+
"rolled_back_at": ts,
|
|
259
|
+
}
|
|
260
|
+
else:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"skill_activator: rollback called but no live file for %s",
|
|
263
|
+
skill_name,
|
|
264
|
+
)
|
|
265
|
+
return {
|
|
266
|
+
"skill_name": skill_name,
|
|
267
|
+
"rolled_back": False,
|
|
268
|
+
"note": "nothing to rollback",
|
|
269
|
+
"rolled_back_at": ts,
|
|
270
|
+
}
|