superlocalmemory 3.8.13 → 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 -121
- 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 +36 -9
- 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/scene_builder.py +115 -13
- 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 -417
- 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/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- 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
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Operational Recovery & Admin Remediation helpers (Wave-3 resilience slice).
|
|
5
|
+
|
|
6
|
+
Provides two primary functions used by HTTP endpoints, MCP tools, and CLI:
|
|
7
|
+
|
|
8
|
+
list_failed_operations(db_path, profile_id=None) -> dict
|
|
9
|
+
- dead_letter: ingestion ops that exhausted automatic retries (M031)
|
|
10
|
+
- degraded_manifests: completion_manifests in DEGRADED state
|
|
11
|
+
- exhausted_obligations: projection_obligations FAILED with attempts >= MAX
|
|
12
|
+
|
|
13
|
+
resolve_operation(db_path, engine, operation_id, action) -> dict
|
|
14
|
+
action ∈ {"retry", "force_reconcile", "cancel"}
|
|
15
|
+
- retry: re-enqueue a dead-lettered ingestion op via IngestionCommand.retry()
|
|
16
|
+
- force_reconcile: immediate obligation redrive (bypasses 30s throttle)
|
|
17
|
+
- cancel: mark op terminally cancelled and remove from failure surfaces
|
|
18
|
+
|
|
19
|
+
Design constraints (NON-NEGOTIABLE):
|
|
20
|
+
- No schema migration — surface via existing tables only
|
|
21
|
+
- Pure reads in list_failed_operations (no mutation)
|
|
22
|
+
- Additive & backward-compatible — healthy-path unaffected
|
|
23
|
+
- Immutable return dicts; explicit error handling; no silent swallowing
|
|
24
|
+
|
|
25
|
+
Part of SuperLocalMemory V4 | Wave-3: Operational Recovery
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import sqlite3
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Maximum automatic materialization/apply attempts (mirrors service.py + ingestion_command.py)
|
|
39
|
+
_MAX_ATTEMPTS = 10
|
|
40
|
+
|
|
41
|
+
_VALID_ACTIONS = frozenset({"retry", "force_reconcile", "cancel"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Public API — list_failed_operations
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def list_failed_operations(
|
|
50
|
+
db_path: str | Path,
|
|
51
|
+
profile_id: str | None = None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""Collect all failure-surface items from memory.db.
|
|
54
|
+
|
|
55
|
+
Returns an immutable-shaped dict:
|
|
56
|
+
{
|
|
57
|
+
"dead_letter": [{"category": "dead_letter", "operation_id": ..., ...}],
|
|
58
|
+
"degraded_manifests": [...],
|
|
59
|
+
"exhausted_obligations": [...],
|
|
60
|
+
"total": int,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
Each entry carries at minimum: category, operation_id, profile_id, error/state,
|
|
64
|
+
attempts (where applicable), and when (unix epoch float).
|
|
65
|
+
|
|
66
|
+
Raises nothing — all errors are logged and returned as empty lists.
|
|
67
|
+
"""
|
|
68
|
+
db_path = Path(db_path)
|
|
69
|
+
dead_letter: list[dict] = []
|
|
70
|
+
degraded_manifests: list[dict] = []
|
|
71
|
+
exhausted_obligations: list[dict] = []
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
conn = sqlite3.connect(str(db_path))
|
|
75
|
+
conn.row_factory = sqlite3.Row
|
|
76
|
+
try:
|
|
77
|
+
dead_letter = _fetch_dead_letter(conn, profile_id)
|
|
78
|
+
degraded_manifests = _fetch_degraded_manifests(conn, profile_id)
|
|
79
|
+
exhausted_obligations = _fetch_exhausted_obligations(conn, profile_id)
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
except Exception as exc: # noqa: BLE001
|
|
83
|
+
logger.error("list_failed_operations: DB query failed: %s", exc)
|
|
84
|
+
|
|
85
|
+
total = len(dead_letter) + len(degraded_manifests) + len(exhausted_obligations)
|
|
86
|
+
return {
|
|
87
|
+
"dead_letter": dead_letter,
|
|
88
|
+
"degraded_manifests": degraded_manifests,
|
|
89
|
+
"exhausted_obligations": exhausted_obligations,
|
|
90
|
+
"total": total,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fetch_dead_letter(
|
|
95
|
+
conn: sqlite3.Connection,
|
|
96
|
+
profile_id: str | None,
|
|
97
|
+
) -> list[dict]:
|
|
98
|
+
"""Read dead_letter_operations rows (M031 table)."""
|
|
99
|
+
try:
|
|
100
|
+
if profile_id is not None:
|
|
101
|
+
rows = conn.execute(
|
|
102
|
+
"SELECT original_op_id, error, attempt_count, profile_id, "
|
|
103
|
+
"dead_lettered_at FROM dead_letter_operations WHERE profile_id = ? "
|
|
104
|
+
"ORDER BY dead_lettered_at DESC LIMIT 200",
|
|
105
|
+
(profile_id,),
|
|
106
|
+
).fetchall()
|
|
107
|
+
else:
|
|
108
|
+
rows = conn.execute(
|
|
109
|
+
"SELECT original_op_id, error, attempt_count, profile_id, "
|
|
110
|
+
"dead_lettered_at FROM dead_letter_operations "
|
|
111
|
+
"ORDER BY dead_lettered_at DESC LIMIT 200",
|
|
112
|
+
).fetchall()
|
|
113
|
+
except sqlite3.Error:
|
|
114
|
+
# Table may not exist on older DBs — fail gracefully
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
result: list[dict] = []
|
|
118
|
+
for row in rows:
|
|
119
|
+
entry = {
|
|
120
|
+
"category": "dead_letter",
|
|
121
|
+
"operation_id": row["original_op_id"],
|
|
122
|
+
"error": row["error"] or "",
|
|
123
|
+
"attempts": row["attempt_count"] or 0,
|
|
124
|
+
"profile_id": row["profile_id"] or "",
|
|
125
|
+
"when": row["dead_lettered_at"],
|
|
126
|
+
"what_happened": "Ingestion failed after maximum retries — needs manual action.",
|
|
127
|
+
}
|
|
128
|
+
result.append(entry)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fetch_degraded_manifests(
|
|
133
|
+
conn: sqlite3.Connection,
|
|
134
|
+
profile_id: str | None,
|
|
135
|
+
) -> list[dict]:
|
|
136
|
+
"""Read completion_manifests rows in DEGRADED state (M033 table)."""
|
|
137
|
+
try:
|
|
138
|
+
if profile_id is not None:
|
|
139
|
+
rows = conn.execute(
|
|
140
|
+
"SELECT operation_id, profile_id, state, updated_at "
|
|
141
|
+
"FROM completion_manifests WHERE state = 'DEGRADED' AND profile_id = ? "
|
|
142
|
+
"ORDER BY updated_at DESC LIMIT 200",
|
|
143
|
+
(profile_id,),
|
|
144
|
+
).fetchall()
|
|
145
|
+
else:
|
|
146
|
+
rows = conn.execute(
|
|
147
|
+
"SELECT operation_id, profile_id, state, updated_at "
|
|
148
|
+
"FROM completion_manifests WHERE state = 'DEGRADED' "
|
|
149
|
+
"ORDER BY updated_at DESC LIMIT 200",
|
|
150
|
+
).fetchall()
|
|
151
|
+
except sqlite3.Error:
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
result: list[dict] = []
|
|
155
|
+
for row in rows:
|
|
156
|
+
entry = {
|
|
157
|
+
"category": "degraded_manifest",
|
|
158
|
+
"operation_id": row["operation_id"],
|
|
159
|
+
"state": row["state"],
|
|
160
|
+
"profile_id": row["profile_id"] or "",
|
|
161
|
+
"when": row["updated_at"],
|
|
162
|
+
"what_happened": "Operation completed partially — some projections did not apply.",
|
|
163
|
+
}
|
|
164
|
+
result.append(entry)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fetch_exhausted_obligations(
|
|
169
|
+
conn: sqlite3.Connection,
|
|
170
|
+
profile_id: str | None,
|
|
171
|
+
) -> list[dict]:
|
|
172
|
+
"""Read projection_obligations rows that are FAILED and exhausted (M033 table).
|
|
173
|
+
|
|
174
|
+
Exhausted = state='failed' AND attempts >= _MAX_ATTEMPTS AND NOT admin-cancelled.
|
|
175
|
+
We exclude admin-cancelled obligations (detail contains 'admin_cancel') so they
|
|
176
|
+
don't resurface after cancellation.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
if profile_id is not None:
|
|
180
|
+
rows = conn.execute(
|
|
181
|
+
"SELECT DISTINCT operation_id, profile_id, "
|
|
182
|
+
"MAX(attempts) AS attempts, MAX(updated_at) AS updated_at, detail "
|
|
183
|
+
"FROM projection_obligations "
|
|
184
|
+
"WHERE state = 'failed' AND attempts >= ? AND profile_id = ? "
|
|
185
|
+
"GROUP BY operation_id "
|
|
186
|
+
"ORDER BY updated_at DESC LIMIT 200",
|
|
187
|
+
(_MAX_ATTEMPTS, profile_id),
|
|
188
|
+
).fetchall()
|
|
189
|
+
else:
|
|
190
|
+
rows = conn.execute(
|
|
191
|
+
"SELECT DISTINCT operation_id, profile_id, "
|
|
192
|
+
"MAX(attempts) AS attempts, MAX(updated_at) AS updated_at, detail "
|
|
193
|
+
"FROM projection_obligations "
|
|
194
|
+
"WHERE state = 'failed' AND attempts >= ? "
|
|
195
|
+
"GROUP BY operation_id "
|
|
196
|
+
"ORDER BY updated_at DESC LIMIT 200",
|
|
197
|
+
(_MAX_ATTEMPTS,),
|
|
198
|
+
).fetchall()
|
|
199
|
+
except sqlite3.Error:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
result: list[dict] = []
|
|
203
|
+
for row in rows:
|
|
204
|
+
# Skip admin-cancelled obligations
|
|
205
|
+
detail_raw = row["detail"] if row["detail"] is not None else ""
|
|
206
|
+
if '"admin_cancel"' in detail_raw or '"admin_cancelled"' in detail_raw:
|
|
207
|
+
continue
|
|
208
|
+
entry = {
|
|
209
|
+
"category": "exhausted_obligation",
|
|
210
|
+
"operation_id": row["operation_id"],
|
|
211
|
+
"attempts": row["attempts"] or 0,
|
|
212
|
+
"profile_id": row["profile_id"] or "",
|
|
213
|
+
"when": row["updated_at"],
|
|
214
|
+
"what_happened": (
|
|
215
|
+
"Background sync failed after maximum retries — "
|
|
216
|
+
"use Force Re-sync or Cancel."
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
result.append(entry)
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Public API — resolve_operation
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_operation(
|
|
229
|
+
db_path: str | Path,
|
|
230
|
+
engine: Any,
|
|
231
|
+
operation_id: str,
|
|
232
|
+
action: str,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Resolve a failed/stuck operation.
|
|
235
|
+
|
|
236
|
+
Parameters
|
|
237
|
+
----------
|
|
238
|
+
db_path
|
|
239
|
+
Path to memory.db (used for DLQ + obligation queries).
|
|
240
|
+
engine
|
|
241
|
+
The running engine instance (may be None for cancel-only operations).
|
|
242
|
+
operation_id
|
|
243
|
+
The operation to act upon.
|
|
244
|
+
action
|
|
245
|
+
One of: "retry", "force_reconcile", "cancel".
|
|
246
|
+
|
|
247
|
+
Returns a dict: {"success": bool, "action": str, "operation_id": str, ...}
|
|
248
|
+
Raises ValueError for invalid action.
|
|
249
|
+
"""
|
|
250
|
+
if action not in _VALID_ACTIONS:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"invalid action {action!r}; must be one of {sorted(_VALID_ACTIONS)}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
db_path = Path(db_path)
|
|
256
|
+
|
|
257
|
+
if action == "cancel":
|
|
258
|
+
return _action_cancel(db_path, operation_id)
|
|
259
|
+
if action == "retry":
|
|
260
|
+
return _action_retry(db_path, engine, operation_id)
|
|
261
|
+
if action == "force_reconcile":
|
|
262
|
+
return _action_force_reconcile(db_path, engine, operation_id)
|
|
263
|
+
|
|
264
|
+
# Unreachable — guarded by _VALID_ACTIONS check above
|
|
265
|
+
raise ValueError(f"unhandled action {action!r}") # pragma: no cover
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _action_cancel(db_path: Path, operation_id: str) -> dict:
|
|
269
|
+
"""Cancel an operation: remove from DLQ, mark obligations as admin-cancelled."""
|
|
270
|
+
found = False
|
|
271
|
+
try:
|
|
272
|
+
conn = sqlite3.connect(str(db_path))
|
|
273
|
+
try:
|
|
274
|
+
# Remove from dead_letter_operations
|
|
275
|
+
cursor = conn.execute(
|
|
276
|
+
"DELETE FROM dead_letter_operations WHERE original_op_id = ?",
|
|
277
|
+
(operation_id,),
|
|
278
|
+
)
|
|
279
|
+
if cursor.rowcount > 0:
|
|
280
|
+
found = True
|
|
281
|
+
|
|
282
|
+
# Mark exhausted obligations as cancelled (so they don't resurface)
|
|
283
|
+
import json
|
|
284
|
+
cancel_detail = json.dumps({"admin_cancel": True, "cancelled_at": time.time()})
|
|
285
|
+
cursor2 = conn.execute(
|
|
286
|
+
"UPDATE projection_obligations SET state = 'failed', "
|
|
287
|
+
"detail = ?, attempts = MAX(attempts, ?) "
|
|
288
|
+
"WHERE operation_id = ? AND state = 'failed'",
|
|
289
|
+
(cancel_detail, _MAX_ATTEMPTS, operation_id),
|
|
290
|
+
)
|
|
291
|
+
if cursor2.rowcount > 0:
|
|
292
|
+
found = True
|
|
293
|
+
|
|
294
|
+
# Mark degraded manifest if present
|
|
295
|
+
cursor3 = conn.execute(
|
|
296
|
+
"UPDATE completion_manifests SET state = 'FAILED' "
|
|
297
|
+
"WHERE operation_id = ? AND state = 'DEGRADED'",
|
|
298
|
+
(operation_id,),
|
|
299
|
+
)
|
|
300
|
+
if cursor3.rowcount > 0:
|
|
301
|
+
found = True
|
|
302
|
+
|
|
303
|
+
conn.commit()
|
|
304
|
+
finally:
|
|
305
|
+
conn.close()
|
|
306
|
+
except Exception as exc: # noqa: BLE001
|
|
307
|
+
logger.error("resolve_operation cancel %s: %s", operation_id, exc)
|
|
308
|
+
return {
|
|
309
|
+
"success": False,
|
|
310
|
+
"action": "cancel",
|
|
311
|
+
"operation_id": operation_id,
|
|
312
|
+
"reason": str(exc),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if not found:
|
|
316
|
+
return {
|
|
317
|
+
"success": False,
|
|
318
|
+
"action": "cancel",
|
|
319
|
+
"operation_id": operation_id,
|
|
320
|
+
"reason": "not_found — operation not in any failure surface",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
"success": True,
|
|
325
|
+
"action": "cancel",
|
|
326
|
+
"operation_id": operation_id,
|
|
327
|
+
"message": "Operation cancelled. It will no longer appear in the attention list.",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _action_retry(db_path: Path, engine: Any, operation_id: str) -> dict:
|
|
332
|
+
"""Retry a dead-lettered ingestion operation via IngestionCommand.retry()."""
|
|
333
|
+
# Check that the operation exists in DLQ
|
|
334
|
+
try:
|
|
335
|
+
conn = sqlite3.connect(str(db_path))
|
|
336
|
+
try:
|
|
337
|
+
row = conn.execute(
|
|
338
|
+
"SELECT id FROM dead_letter_operations WHERE original_op_id = ? LIMIT 1",
|
|
339
|
+
(operation_id,),
|
|
340
|
+
).fetchone()
|
|
341
|
+
finally:
|
|
342
|
+
conn.close()
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
logger.error("resolve_operation retry DLQ check %s: %s", operation_id, exc)
|
|
345
|
+
return {
|
|
346
|
+
"success": False,
|
|
347
|
+
"action": "retry",
|
|
348
|
+
"operation_id": operation_id,
|
|
349
|
+
"reason": str(exc),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if row is None:
|
|
353
|
+
return {
|
|
354
|
+
"success": False,
|
|
355
|
+
"action": "retry",
|
|
356
|
+
"operation_id": operation_id,
|
|
357
|
+
"reason": "not_found — operation not in dead-letter queue",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Attempt retry via IngestionCommand
|
|
361
|
+
if engine is not None:
|
|
362
|
+
try:
|
|
363
|
+
# Build a fully-wired ingestion command from the live engine
|
|
364
|
+
# (db + queryable writer + materializer). Constructing
|
|
365
|
+
# IngestionCommand directly would miss the required
|
|
366
|
+
# write_queryable/materialize collaborators.
|
|
367
|
+
from superlocalmemory.core.engine_ingestion import (
|
|
368
|
+
build_engine_ingestion_command,
|
|
369
|
+
)
|
|
370
|
+
from superlocalmemory.core.ingestion_command import IngestionState
|
|
371
|
+
cmd = build_engine_ingestion_command(engine)
|
|
372
|
+
operation = cmd.retry(operation_id)
|
|
373
|
+
# G-10: only remove from DLQ if the retry actually moved the
|
|
374
|
+
# operation out of FAILED state. If it is still FAILED, leave
|
|
375
|
+
# the DLQ row so it surfaces again on the next /ops list call
|
|
376
|
+
# and the operator can investigate.
|
|
377
|
+
if operation.state is IngestionState.FAILED:
|
|
378
|
+
return {
|
|
379
|
+
"success": False,
|
|
380
|
+
"action": "retry",
|
|
381
|
+
"operation_id": operation_id,
|
|
382
|
+
"reason": (
|
|
383
|
+
"retry_still_failed — operation remains in FAILED state;"
|
|
384
|
+
" DLQ row preserved for further investigation"
|
|
385
|
+
),
|
|
386
|
+
}
|
|
387
|
+
# Operation moved out of FAILED → remove from DLQ
|
|
388
|
+
conn = sqlite3.connect(str(db_path))
|
|
389
|
+
try:
|
|
390
|
+
conn.execute(
|
|
391
|
+
"DELETE FROM dead_letter_operations WHERE original_op_id = ?",
|
|
392
|
+
(operation_id,),
|
|
393
|
+
)
|
|
394
|
+
conn.commit()
|
|
395
|
+
finally:
|
|
396
|
+
conn.close()
|
|
397
|
+
return {
|
|
398
|
+
"success": True,
|
|
399
|
+
"action": "retry",
|
|
400
|
+
"operation_id": operation_id,
|
|
401
|
+
"message": f"Operation re-queued for retry (state: {operation.state.value}).",
|
|
402
|
+
}
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
logger.error("resolve_operation retry %s: %s", operation_id, exc)
|
|
405
|
+
return {
|
|
406
|
+
"success": False,
|
|
407
|
+
"action": "retry",
|
|
408
|
+
"operation_id": operation_id,
|
|
409
|
+
"reason": str(exc),
|
|
410
|
+
}
|
|
411
|
+
else:
|
|
412
|
+
# No engine — best effort: just clear the DLQ row so it doesn't resurface
|
|
413
|
+
try:
|
|
414
|
+
conn = sqlite3.connect(str(db_path))
|
|
415
|
+
try:
|
|
416
|
+
conn.execute(
|
|
417
|
+
"DELETE FROM dead_letter_operations WHERE original_op_id = ?",
|
|
418
|
+
(operation_id,),
|
|
419
|
+
)
|
|
420
|
+
conn.commit()
|
|
421
|
+
finally:
|
|
422
|
+
conn.close()
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
logger.warning("resolve_operation retry (no engine) %s: %s", operation_id, exc)
|
|
425
|
+
return {
|
|
426
|
+
"success": True,
|
|
427
|
+
"action": "retry",
|
|
428
|
+
"operation_id": operation_id,
|
|
429
|
+
"message": "DLQ entry cleared. Daemon will retry on next cycle.",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _action_force_reconcile(db_path: Path, engine: Any, operation_id: str) -> dict:
|
|
434
|
+
"""Force an immediate projection reconcile for this operation_id."""
|
|
435
|
+
if engine is None:
|
|
436
|
+
return {
|
|
437
|
+
"success": False,
|
|
438
|
+
"action": "force_reconcile",
|
|
439
|
+
"operation_id": operation_id,
|
|
440
|
+
"reason": "engine not available — daemon must be running for force_reconcile",
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
from superlocalmemory.core.transactions.concrete_owners import (
|
|
445
|
+
build_transaction_service,
|
|
446
|
+
)
|
|
447
|
+
from superlocalmemory.server.unified_daemon import _context_for_operation
|
|
448
|
+
|
|
449
|
+
db_obj = getattr(engine, "_db", None)
|
|
450
|
+
if db_obj is None:
|
|
451
|
+
return {
|
|
452
|
+
"success": False,
|
|
453
|
+
"action": "force_reconcile",
|
|
454
|
+
"operation_id": operation_id,
|
|
455
|
+
"reason": "engine._db not available",
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
context = _context_for_operation(engine, operation_id)
|
|
459
|
+
if context is None:
|
|
460
|
+
return {
|
|
461
|
+
"success": False,
|
|
462
|
+
"action": "force_reconcile",
|
|
463
|
+
"operation_id": operation_id,
|
|
464
|
+
"reason": "not_found — no canonical record for this operation",
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
service = build_transaction_service(engine)
|
|
468
|
+
service.reconcile_operation(db_obj, context)
|
|
469
|
+
return {
|
|
470
|
+
"success": True,
|
|
471
|
+
"action": "force_reconcile",
|
|
472
|
+
"operation_id": operation_id,
|
|
473
|
+
"message": "Reconcile triggered immediately (30s throttle bypassed).",
|
|
474
|
+
}
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
logger.error("resolve_operation force_reconcile %s: %s", operation_id, exc)
|
|
477
|
+
return {
|
|
478
|
+
"success": False,
|
|
479
|
+
"action": "force_reconcile",
|
|
480
|
+
"operation_id": operation_id,
|
|
481
|
+
"reason": str(exc),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# Counts helpers (for /health and /status surface)
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def get_failure_counts(db_path: str | Path) -> dict[str, int]:
|
|
491
|
+
"""Fast count query for /health and /status surface.
|
|
492
|
+
|
|
493
|
+
Returns {"dead_letter_count": int, "degraded_operations": int,
|
|
494
|
+
"exhausted_obligations": int}.
|
|
495
|
+
All counts default to 0 on error (never raises).
|
|
496
|
+
"""
|
|
497
|
+
db_path = Path(db_path)
|
|
498
|
+
counts = {"dead_letter_count": 0, "degraded_operations": 0, "exhausted_obligations": 0}
|
|
499
|
+
try:
|
|
500
|
+
conn = sqlite3.connect(str(db_path))
|
|
501
|
+
conn.row_factory = sqlite3.Row
|
|
502
|
+
try:
|
|
503
|
+
try:
|
|
504
|
+
row = conn.execute(
|
|
505
|
+
"SELECT COUNT(*) AS c FROM dead_letter_operations"
|
|
506
|
+
).fetchone()
|
|
507
|
+
counts["dead_letter_count"] = int(row["c"]) if row else 0
|
|
508
|
+
except sqlite3.Error:
|
|
509
|
+
pass
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
row = conn.execute(
|
|
513
|
+
"SELECT COUNT(*) AS c FROM completion_manifests WHERE state = 'DEGRADED'"
|
|
514
|
+
).fetchone()
|
|
515
|
+
counts["degraded_operations"] = int(row["c"]) if row else 0
|
|
516
|
+
except sqlite3.Error:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
row = conn.execute(
|
|
521
|
+
"SELECT COUNT(DISTINCT operation_id) AS c "
|
|
522
|
+
"FROM projection_obligations "
|
|
523
|
+
"WHERE state = 'failed' AND attempts >= ? "
|
|
524
|
+
"AND (detail IS NULL OR detail NOT LIKE '%admin_cancel%')",
|
|
525
|
+
(_MAX_ATTEMPTS,),
|
|
526
|
+
).fetchone()
|
|
527
|
+
counts["exhausted_obligations"] = int(row["c"]) if row else 0
|
|
528
|
+
except sqlite3.Error:
|
|
529
|
+
pass
|
|
530
|
+
finally:
|
|
531
|
+
conn.close()
|
|
532
|
+
except Exception as exc: # noqa: BLE001
|
|
533
|
+
logger.debug("get_failure_counts: %s", exc)
|
|
534
|
+
|
|
535
|
+
return counts
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
__all__ = [
|
|
539
|
+
"list_failed_operations",
|
|
540
|
+
"resolve_operation",
|
|
541
|
+
"get_failure_counts",
|
|
542
|
+
]
|
|
@@ -786,6 +786,7 @@ def run_recall(
|
|
|
786
786
|
include_global: bool = False,
|
|
787
787
|
include_shared: bool = False,
|
|
788
788
|
window: str | tuple[str, str] | None = None,
|
|
789
|
+
as_of: str | None = None,
|
|
789
790
|
) -> RecallResponse:
|
|
790
791
|
"""Recall relevant facts for a query.
|
|
791
792
|
|
|
@@ -799,6 +800,11 @@ def run_recall(
|
|
|
799
800
|
to the client-driven-agentic default (see ``resolve_hot_path_fast``): the
|
|
800
801
|
agent hot path skips the internal round and delegates refinement to the
|
|
801
802
|
calling LLM. ``fast=False`` forces the internal agentic round.
|
|
803
|
+
|
|
804
|
+
``as_of``: Optional ISO 8601 datetime string for point-in-time time-travel
|
|
805
|
+
recall. When set, the bi-temporal validity filter demotes facts that were
|
|
806
|
+
not yet valid or had already expired at that point. Default ``None`` leaves
|
|
807
|
+
all existing behaviour unchanged.
|
|
802
808
|
"""
|
|
803
809
|
m = mode or config.mode
|
|
804
810
|
|
|
@@ -829,6 +835,7 @@ def run_recall(
|
|
|
829
835
|
include_global=include_global,
|
|
830
836
|
include_shared=include_shared,
|
|
831
837
|
window=window,
|
|
838
|
+
as_of=as_of,
|
|
832
839
|
)
|
|
833
840
|
_mark("retrieval(chan+rerank)")
|
|
834
841
|
|