superlocalmemory 3.8.0 → 3.8.2
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 +112 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- 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 +2 -2
- 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 +3 -5
- 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 +2 -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-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +3 -5
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +494 -9
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +72 -7
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +132 -45
- package/src/superlocalmemory/core/engine.py +29 -22
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +154 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
- package/src/superlocalmemory/core/recall_pipeline.py +42 -4
- package/src/superlocalmemory/core/store_pipeline.py +195 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +21 -5
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +342 -61
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +286 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +184 -20
- package/src/superlocalmemory/server/unified_daemon.py +732 -41
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +6 -8
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +65 -22
- package/src/superlocalmemory/ui/js/od-graph.js +46 -4
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +84 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- package/src/superlocalmemory/ui/js/od-settings.js +186 -63
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- package/plugin-src/skills/slm-scope/SKILL.md +0 -176
|
@@ -5,72 +5,308 @@
|
|
|
5
5
|
- AGPL-3.0-or-later
|
|
6
6
|
|
|
7
7
|
Routes: /api/behavioral/status, /api/behavioral/report-outcome
|
|
8
|
-
Uses V3 learning.behavioral.BehavioralPatternStore and
|
|
8
|
+
Uses V3 learning.behavioral.BehavioralPatternStore and direct telemetry reads.
|
|
9
9
|
"""
|
|
10
10
|
import json
|
|
11
11
|
import logging
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
12
15
|
|
|
13
|
-
from fastapi import APIRouter
|
|
16
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator
|
|
14
18
|
|
|
15
|
-
from .helpers import
|
|
19
|
+
from .helpers import MEMORY_DIR, get_active_profile
|
|
16
20
|
|
|
17
21
|
logger = logging.getLogger("superlocalmemory.routes.behavioral")
|
|
18
22
|
router = APIRouter()
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
_RECENT_OUTCOMES_LIMIT = 20
|
|
25
|
+
_REWARD_TIMELINE_DAYS = 182
|
|
26
|
+
_MAX_OUTCOME_FACT_IDS = 100
|
|
27
|
+
_MAX_FACT_ID_LENGTH = 200
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReportOutcomeRequest(BaseModel):
|
|
31
|
+
"""Bounded explicit outcome payload accepted from dashboard clients."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
memory_ids: list[StrictStr] = Field(
|
|
36
|
+
min_length=1,
|
|
37
|
+
max_length=_MAX_OUTCOME_FACT_IDS,
|
|
38
|
+
)
|
|
39
|
+
outcome: Literal["success", "failure", "partial"]
|
|
40
|
+
action_type: StrictStr = Field(default="other", max_length=80)
|
|
41
|
+
context: StrictStr = Field(default="", max_length=1000)
|
|
42
|
+
|
|
43
|
+
@field_validator("memory_ids")
|
|
44
|
+
@classmethod
|
|
45
|
+
def normalize_fact_ids(cls, value: list[str]) -> list[str]:
|
|
46
|
+
"""Strip and de-duplicate fact IDs while preserving request order."""
|
|
47
|
+
deduplicated: list[str] = []
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
for raw_fact_id in value:
|
|
50
|
+
fact_id = raw_fact_id.strip()
|
|
51
|
+
if not fact_id:
|
|
52
|
+
raise ValueError("memory_ids must not contain blank fact IDs")
|
|
53
|
+
if len(fact_id) > _MAX_FACT_ID_LENGTH:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"memory_ids entries must be at most "
|
|
56
|
+
f"{_MAX_FACT_ID_LENGTH} characters"
|
|
57
|
+
)
|
|
58
|
+
if fact_id not in seen:
|
|
59
|
+
seen.add(fact_id)
|
|
60
|
+
deduplicated.append(fact_id)
|
|
61
|
+
return deduplicated
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require_read(request: Request) -> None:
|
|
65
|
+
from superlocalmemory.access.rbac import Permission
|
|
66
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
67
|
+
|
|
68
|
+
require_permission(request, Permission.READ, profile=get_active_profile())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _require_write(request: Request) -> None:
|
|
72
|
+
from superlocalmemory.access.rbac import Permission
|
|
73
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
74
|
+
|
|
75
|
+
require_permission(request, Permission.WRITE, profile=get_active_profile())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _authorize_outcome_write(request: Request) -> None:
|
|
79
|
+
"""Run authorization before FastAPI validates the request body."""
|
|
80
|
+
_require_write(request)
|
|
81
|
+
request.state.outcome_write_authorized = True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_profile_fact_ids(
|
|
85
|
+
conn: sqlite3.Connection,
|
|
86
|
+
*,
|
|
87
|
+
profile_id: str,
|
|
88
|
+
fact_ids: list[str],
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Reject missing or foreign-profile facts before an outcome is stored."""
|
|
91
|
+
placeholders = ",".join("?" for _ in fact_ids)
|
|
92
|
+
rows = conn.execute(
|
|
93
|
+
"SELECT fact_id FROM atomic_facts "
|
|
94
|
+
f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
|
|
95
|
+
(profile_id, *fact_ids),
|
|
96
|
+
).fetchall()
|
|
97
|
+
if {str(row[0]) for row in rows} != set(fact_ids):
|
|
98
|
+
raise HTTPException(
|
|
99
|
+
status_code=422,
|
|
100
|
+
detail="Every memory_id must identify a fact in the active profile",
|
|
101
|
+
)
|
|
102
|
+
|
|
21
103
|
|
|
22
104
|
# Feature detection
|
|
23
105
|
BEHAVIORAL_AVAILABLE = False
|
|
24
106
|
try:
|
|
25
107
|
from superlocalmemory.learning.behavioral import BehavioralPatternStore
|
|
26
|
-
from superlocalmemory.learning.outcomes import OutcomeTracker
|
|
27
108
|
BEHAVIORAL_AVAILABLE = True
|
|
28
109
|
except ImportError as e:
|
|
29
110
|
logger.warning("V3 behavioral engine import failed: %s", e)
|
|
30
111
|
|
|
31
112
|
|
|
113
|
+
def _memory_db_path() -> Path:
|
|
114
|
+
"""Resolve at read time so tests and profile-scoped routes stay aligned."""
|
|
115
|
+
return MEMORY_DIR / "memory.db"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _learning_db_path() -> Path:
|
|
119
|
+
return MEMORY_DIR / "learning.db"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_cross_project_pattern(pattern: dict) -> bool:
|
|
123
|
+
metadata = pattern.get("metadata")
|
|
124
|
+
return (
|
|
125
|
+
isinstance(metadata, dict)
|
|
126
|
+
and bool(str(metadata.get("transferred_from") or "").strip())
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_action_outcomes(profile_id: str) -> dict:
|
|
131
|
+
"""Read bounded explicit/finalized outcome telemetry from ``memory.db``.
|
|
132
|
+
|
|
133
|
+
``OutcomeTracker`` requires a ``DatabaseManager`` and the outcome table
|
|
134
|
+
belongs to ``memory.db``. This read-only query deliberately does not
|
|
135
|
+
infer outcomes from recall hits, which are exposure signals rather than
|
|
136
|
+
evidence that the returned memory helped.
|
|
137
|
+
"""
|
|
138
|
+
empty = {
|
|
139
|
+
"total": 0,
|
|
140
|
+
"breakdown": {"success": 0, "failure": 0, "partial": 0},
|
|
141
|
+
"recent": [],
|
|
142
|
+
"reward": _empty_reward_telemetry(),
|
|
143
|
+
}
|
|
144
|
+
db_path = _memory_db_path()
|
|
145
|
+
if not db_path.exists():
|
|
146
|
+
return empty
|
|
147
|
+
try:
|
|
148
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)
|
|
149
|
+
conn.row_factory = sqlite3.Row
|
|
150
|
+
try:
|
|
151
|
+
columns = {
|
|
152
|
+
str(row["name"])
|
|
153
|
+
for row in conn.execute(
|
|
154
|
+
"PRAGMA table_info(action_outcomes)",
|
|
155
|
+
).fetchall()
|
|
156
|
+
}
|
|
157
|
+
rows = conn.execute(
|
|
158
|
+
"SELECT outcome, COUNT(*) AS count FROM action_outcomes "
|
|
159
|
+
"WHERE profile_id = ? "
|
|
160
|
+
"AND outcome IN ('success', 'failure', 'partial') "
|
|
161
|
+
"GROUP BY outcome",
|
|
162
|
+
(profile_id,),
|
|
163
|
+
).fetchall()
|
|
164
|
+
recent = conn.execute(
|
|
165
|
+
"SELECT outcome, context_json, timestamp FROM action_outcomes "
|
|
166
|
+
"WHERE profile_id = ? "
|
|
167
|
+
"AND outcome IN ('success', 'failure', 'partial') "
|
|
168
|
+
"ORDER BY timestamp DESC LIMIT ?",
|
|
169
|
+
(profile_id, _RECENT_OUTCOMES_LIMIT),
|
|
170
|
+
).fetchall()
|
|
171
|
+
reward = (
|
|
172
|
+
_query_reward_telemetry(conn, profile_id, columns)
|
|
173
|
+
if {"reward", "settled"}.issubset(columns)
|
|
174
|
+
else _empty_reward_telemetry()
|
|
175
|
+
)
|
|
176
|
+
finally:
|
|
177
|
+
conn.close()
|
|
178
|
+
except sqlite3.Error as exc:
|
|
179
|
+
logger.debug("action_outcomes telemetry unavailable: %s", exc)
|
|
180
|
+
return empty
|
|
181
|
+
breakdown = {"success": 0, "failure": 0, "partial": 0}
|
|
182
|
+
for row in rows:
|
|
183
|
+
if row["outcome"] in breakdown:
|
|
184
|
+
breakdown[row["outcome"]] = int(row["count"] or 0)
|
|
185
|
+
return {
|
|
186
|
+
"total": sum(breakdown.values()),
|
|
187
|
+
"breakdown": breakdown,
|
|
188
|
+
"recent": [_outcome_preview(row) for row in recent],
|
|
189
|
+
"reward": reward,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _empty_reward_telemetry() -> dict:
|
|
194
|
+
return {
|
|
195
|
+
"count": 0,
|
|
196
|
+
"average": None,
|
|
197
|
+
"distribution": {"positive": 0, "neutral": 0, "negative": 0},
|
|
198
|
+
"timeline": [],
|
|
199
|
+
"source": "memory.db:action_outcomes.reward",
|
|
200
|
+
"window_days": _REWARD_TIMELINE_DAYS,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _query_reward_telemetry(
|
|
205
|
+
conn: sqlite3.Connection,
|
|
206
|
+
profile_id: str,
|
|
207
|
+
columns: set[str],
|
|
208
|
+
) -> dict:
|
|
209
|
+
"""Aggregate numeric settled labels without materializing reward rows."""
|
|
210
|
+
settled_time = (
|
|
211
|
+
"COALESCE(settled_at, timestamp)"
|
|
212
|
+
if "settled_at" in columns
|
|
213
|
+
else "timestamp"
|
|
214
|
+
)
|
|
215
|
+
aggregate = conn.execute(
|
|
216
|
+
"SELECT COUNT(*) AS count, AVG(reward) AS average, "
|
|
217
|
+
"SUM(CASE WHEN reward > 0.6 THEN 1 ELSE 0 END) AS positive, "
|
|
218
|
+
"SUM(CASE WHEN reward < 0.4 THEN 1 ELSE 0 END) AS negative, "
|
|
219
|
+
"SUM(CASE WHEN reward >= 0.4 AND reward <= 0.6 "
|
|
220
|
+
"THEN 1 ELSE 0 END) AS neutral "
|
|
221
|
+
"FROM action_outcomes WHERE profile_id = ? AND settled = 1 "
|
|
222
|
+
"AND reward IS NOT NULL AND typeof(reward) IN ('integer', 'real')",
|
|
223
|
+
(profile_id,),
|
|
224
|
+
).fetchone()
|
|
225
|
+
timeline = conn.execute(
|
|
226
|
+
"WITH reward_days AS ("
|
|
227
|
+
f" SELECT substr({settled_time}, 1, 10) AS day,"
|
|
228
|
+
" reward"
|
|
229
|
+
" FROM action_outcomes"
|
|
230
|
+
" WHERE profile_id = ? AND settled = 1 AND reward IS NOT NULL"
|
|
231
|
+
" AND typeof(reward) IN ('integer', 'real')"
|
|
232
|
+
f"), latest AS (SELECT MAX(day) AS day FROM reward_days)"
|
|
233
|
+
" SELECT reward_days.day AS date, COUNT(*) AS count,"
|
|
234
|
+
" AVG(reward_days.reward) AS average"
|
|
235
|
+
" FROM reward_days, latest"
|
|
236
|
+
" WHERE reward_days.day >= date(latest.day, ?)"
|
|
237
|
+
" GROUP BY reward_days.day ORDER BY reward_days.day ASC LIMIT ?",
|
|
238
|
+
(
|
|
239
|
+
profile_id,
|
|
240
|
+
f"-{_REWARD_TIMELINE_DAYS - 1} days",
|
|
241
|
+
_REWARD_TIMELINE_DAYS,
|
|
242
|
+
),
|
|
243
|
+
).fetchall()
|
|
244
|
+
count = int(aggregate["count"] or 0)
|
|
245
|
+
return {
|
|
246
|
+
"count": count,
|
|
247
|
+
"average": (
|
|
248
|
+
round(float(aggregate["average"]), 4) if count else None
|
|
249
|
+
),
|
|
250
|
+
"distribution": {
|
|
251
|
+
"positive": int(aggregate["positive"] or 0),
|
|
252
|
+
"neutral": int(aggregate["neutral"] or 0),
|
|
253
|
+
"negative": int(aggregate["negative"] or 0),
|
|
254
|
+
},
|
|
255
|
+
"timeline": [
|
|
256
|
+
{
|
|
257
|
+
"date": str(row["date"]),
|
|
258
|
+
"count": int(row["count"] or 0),
|
|
259
|
+
"average": round(float(row["average"] or 0.0), 4),
|
|
260
|
+
}
|
|
261
|
+
for row in timeline
|
|
262
|
+
if row["date"]
|
|
263
|
+
],
|
|
264
|
+
"source": "memory.db:action_outcomes.reward",
|
|
265
|
+
"window_days": _REWARD_TIMELINE_DAYS,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _outcome_preview(row: sqlite3.Row) -> dict:
|
|
270
|
+
"""Return a safe, structured summary without exposing free-form notes."""
|
|
271
|
+
try:
|
|
272
|
+
context = json.loads(str(row["context_json"] or "{}"))
|
|
273
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
274
|
+
context = {}
|
|
275
|
+
action_type = context.get("action_type", "other")
|
|
276
|
+
return {
|
|
277
|
+
"outcome": str(row["outcome"] or "partial"),
|
|
278
|
+
"action_type": str(action_type)[:80],
|
|
279
|
+
"timestamp": str(row["timestamp"] or ""),
|
|
280
|
+
"source": "memory.db:action_outcomes",
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
32
284
|
@router.get("/api/behavioral/status")
|
|
33
|
-
|
|
285
|
+
def behavioral_status():
|
|
34
286
|
"""Get behavioral learning status for active profile."""
|
|
35
287
|
if not BEHAVIORAL_AVAILABLE:
|
|
36
288
|
return {"available": False, "message": "Behavioral engine not available"}
|
|
37
289
|
|
|
38
290
|
try:
|
|
39
291
|
profile = get_active_profile()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
recent_outcomes = []
|
|
46
|
-
try:
|
|
47
|
-
tracker = OutcomeTracker(db_path)
|
|
48
|
-
all_outcomes = tracker.get_outcomes(profile_id=profile, limit=50)
|
|
49
|
-
total_outcomes = len(all_outcomes)
|
|
50
|
-
for o in all_outcomes:
|
|
51
|
-
key = o.outcome if hasattr(o, 'outcome') else str(o)
|
|
52
|
-
if key in outcome_breakdown:
|
|
53
|
-
outcome_breakdown[key] += 1
|
|
54
|
-
recent_outcomes = [
|
|
55
|
-
{"outcome": o.outcome, "action_type": o.action_type,
|
|
56
|
-
"timestamp": o.timestamp}
|
|
57
|
-
for o in all_outcomes[:20]
|
|
58
|
-
if hasattr(o, 'outcome')
|
|
59
|
-
]
|
|
60
|
-
except Exception as exc:
|
|
61
|
-
logger.debug("outcome tracker: %s", exc)
|
|
292
|
+
outcome_data = _load_action_outcomes(profile)
|
|
293
|
+
total_outcomes = outcome_data["total"]
|
|
294
|
+
outcome_breakdown = outcome_data["breakdown"]
|
|
295
|
+
recent_outcomes = outcome_data["recent"]
|
|
296
|
+
reward_telemetry = outcome_data["reward"]
|
|
62
297
|
|
|
63
298
|
# Patterns
|
|
64
299
|
patterns = []
|
|
300
|
+
cross_project_patterns = []
|
|
65
301
|
cross_project_transfers = 0
|
|
66
302
|
try:
|
|
67
|
-
store = BehavioralPatternStore(
|
|
303
|
+
store = BehavioralPatternStore(str(_learning_db_path()))
|
|
68
304
|
patterns = store.get_patterns(profile_id=profile)
|
|
69
|
-
|
|
70
|
-
cross_project_transfers = len([
|
|
305
|
+
cross_project_patterns = [
|
|
71
306
|
p for p in patterns
|
|
72
|
-
if isinstance(p, dict) and p
|
|
73
|
-
]
|
|
307
|
+
if isinstance(p, dict) and _is_cross_project_pattern(p)
|
|
308
|
+
]
|
|
309
|
+
cross_project_transfers = len(cross_project_patterns)
|
|
74
310
|
except Exception as exc:
|
|
75
311
|
logger.warning("pattern store error: %s", exc)
|
|
76
312
|
|
|
@@ -79,9 +315,14 @@ async def behavioral_status():
|
|
|
79
315
|
"active_profile": profile,
|
|
80
316
|
"total_outcomes": total_outcomes,
|
|
81
317
|
"outcome_breakdown": outcome_breakdown,
|
|
318
|
+
"outcomes_source": "memory.db:action_outcomes",
|
|
319
|
+
"outcomes_are_finalized": True,
|
|
320
|
+
"outcomes_provenance": "explicit_reports_or_finalized_signals",
|
|
82
321
|
"patterns": patterns,
|
|
83
322
|
"cross_project_transfers": cross_project_transfers,
|
|
323
|
+
"cross_project_patterns": cross_project_patterns,
|
|
84
324
|
"recent_outcomes": recent_outcomes,
|
|
325
|
+
"reward_telemetry": reward_telemetry,
|
|
85
326
|
"stats": {
|
|
86
327
|
"success_count": outcome_breakdown.get("success", 0),
|
|
87
328
|
"failure_count": outcome_breakdown.get("failure", 0),
|
|
@@ -89,13 +330,16 @@ async def behavioral_status():
|
|
|
89
330
|
"patterns_count": len(patterns),
|
|
90
331
|
},
|
|
91
332
|
}
|
|
92
|
-
except Exception
|
|
333
|
+
except Exception:
|
|
93
334
|
logger.exception("behavioral_status error")
|
|
94
335
|
return {"available": False, "error": "Internal server error"}
|
|
95
336
|
|
|
96
337
|
|
|
97
|
-
@router.post(
|
|
98
|
-
|
|
338
|
+
@router.post(
|
|
339
|
+
"/api/behavioral/report-outcome",
|
|
340
|
+
dependencies=[Depends(_authorize_outcome_write)],
|
|
341
|
+
)
|
|
342
|
+
def report_outcome(request: Request, data: ReportOutcomeRequest):
|
|
99
343
|
"""Record an explicit dashboard-reported outcome.
|
|
100
344
|
|
|
101
345
|
Body: {
|
|
@@ -113,20 +357,18 @@ async def report_outcome(data: dict):
|
|
|
113
357
|
from ``outcome``:
|
|
114
358
|
success=1.0, failure=0.0, partial=0.5
|
|
115
359
|
"""
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return {"success": False, "error": f"outcome must be one of: {valid_outcomes}"}
|
|
360
|
+
if not getattr(request.state, "outcome_write_authorized", False):
|
|
361
|
+
_require_write(request)
|
|
362
|
+
if isinstance(data, dict):
|
|
363
|
+
# Preserve the long-standing direct-call API while applying the same
|
|
364
|
+
# constrained model used by FastAPI at the HTTP boundary.
|
|
365
|
+
data = ReportOutcomeRequest.model_validate(data)
|
|
366
|
+
memory_ids = data.memory_ids
|
|
367
|
+
outcome = data.outcome
|
|
368
|
+
action_type = data.action_type
|
|
369
|
+
context_note = data.context
|
|
127
370
|
|
|
128
371
|
import sqlite3
|
|
129
|
-
import time
|
|
130
372
|
import uuid
|
|
131
373
|
from datetime import datetime, timezone
|
|
132
374
|
|
|
@@ -146,6 +388,12 @@ async def report_outcome(data: dict):
|
|
|
146
388
|
conn = sqlite3.connect(str(memory_db_path), timeout=5.0)
|
|
147
389
|
try:
|
|
148
390
|
conn.execute("PRAGMA busy_timeout=5000")
|
|
391
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
392
|
+
_validate_profile_fact_ids(
|
|
393
|
+
conn,
|
|
394
|
+
profile_id=profile,
|
|
395
|
+
fact_ids=memory_ids,
|
|
396
|
+
)
|
|
149
397
|
conn.execute(
|
|
150
398
|
"INSERT INTO action_outcomes "
|
|
151
399
|
"(outcome_id, profile_id, query, fact_ids_json, outcome, "
|
|
@@ -163,6 +411,21 @@ async def report_outcome(data: dict):
|
|
|
163
411
|
finally:
|
|
164
412
|
conn.close()
|
|
165
413
|
|
|
414
|
+
try:
|
|
415
|
+
from superlocalmemory.learning.source_quality import (
|
|
416
|
+
update_source_quality_for_reward,
|
|
417
|
+
)
|
|
418
|
+
update_source_quality_for_reward(
|
|
419
|
+
memory_db_path=memory_db_path,
|
|
420
|
+
learning_db_path=_learning_db_path(),
|
|
421
|
+
profile_id=profile,
|
|
422
|
+
outcome_id=outcome_id,
|
|
423
|
+
fact_ids=[str(memory_id) for memory_id in memory_ids],
|
|
424
|
+
reward=reward,
|
|
425
|
+
)
|
|
426
|
+
except Exception as exc: # noqa: BLE001 - outcome write already committed
|
|
427
|
+
logger.debug("source-quality explicit outcome feed skipped: %s", exc)
|
|
428
|
+
|
|
166
429
|
return {
|
|
167
430
|
"success": True, "outcome_id": outcome_id,
|
|
168
431
|
"active_profile": profile,
|
|
@@ -172,7 +435,9 @@ async def report_outcome(data: dict):
|
|
|
172
435
|
f"memories (reward={reward})"
|
|
173
436
|
),
|
|
174
437
|
}
|
|
175
|
-
except
|
|
438
|
+
except HTTPException:
|
|
439
|
+
raise
|
|
440
|
+
except Exception:
|
|
176
441
|
logger.exception("report_outcome error")
|
|
177
442
|
return {"success": False, "error": "Internal server error"}
|
|
178
443
|
|
|
@@ -182,7 +447,11 @@ async def report_outcome(data: dict):
|
|
|
182
447
|
# --------------------------------------------------------------------------
|
|
183
448
|
|
|
184
449
|
@router.get("/api/behavioral/assertions")
|
|
185
|
-
|
|
450
|
+
def get_assertions(
|
|
451
|
+
min_confidence: float = Query(default=0.0, ge=0.0, le=1.0),
|
|
452
|
+
category: str = Query(default="", max_length=100),
|
|
453
|
+
limit: int = Query(default=50, ge=1, le=1000),
|
|
454
|
+
):
|
|
186
455
|
"""Get learned behavioral assertions for dashboard display."""
|
|
187
456
|
try:
|
|
188
457
|
import sqlite3 as _sqlite3
|
|
@@ -204,8 +473,12 @@ async def get_assertions(min_confidence: float = 0.0, category: str = "", limit:
|
|
|
204
473
|
query += " ORDER BY confidence DESC LIMIT ?"
|
|
205
474
|
params.append(limit)
|
|
206
475
|
|
|
207
|
-
|
|
208
|
-
conn.
|
|
476
|
+
# F8 fix: use try/finally so conn.close() is guaranteed even when
|
|
477
|
+
# conn.execute() raises (e.g. SQLITE_BUSY under dashboard burst load).
|
|
478
|
+
try:
|
|
479
|
+
rows = conn.execute(query, tuple(params)).fetchall()
|
|
480
|
+
finally:
|
|
481
|
+
conn.close()
|
|
209
482
|
|
|
210
483
|
assertions = [dict(r) for r in rows]
|
|
211
484
|
return {
|
|
@@ -213,18 +486,20 @@ async def get_assertions(min_confidence: float = 0.0, category: str = "", limit:
|
|
|
213
486
|
"count": len(assertions),
|
|
214
487
|
"active_profile": profile,
|
|
215
488
|
}
|
|
216
|
-
except Exception
|
|
489
|
+
except Exception:
|
|
217
490
|
logger.exception("get_assertions error")
|
|
218
491
|
return {"assertions": [], "count": 0, "error": "Internal server error"}
|
|
219
492
|
|
|
220
493
|
|
|
221
494
|
@router.get("/api/behavioral/tool-events")
|
|
222
|
-
|
|
495
|
+
def get_tool_events(
|
|
496
|
+
tool_name: str = "",
|
|
497
|
+
limit: int = Query(default=100, ge=1, le=1000),
|
|
498
|
+
):
|
|
223
499
|
"""Get recent tool events for dashboard display."""
|
|
224
500
|
try:
|
|
225
501
|
import sqlite3 as _sqlite3
|
|
226
502
|
profile = get_active_profile()
|
|
227
|
-
limit = min(int(limit), 1000)
|
|
228
503
|
conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
|
|
229
504
|
conn.row_factory = _sqlite3.Row
|
|
230
505
|
|
|
@@ -246,35 +521,40 @@ async def get_tool_events(tool_name: str = "", limit: int = 100):
|
|
|
246
521
|
return {"events": events, "count": len(events)}
|
|
247
522
|
finally:
|
|
248
523
|
conn.close()
|
|
249
|
-
except Exception
|
|
524
|
+
except Exception:
|
|
250
525
|
logger.exception("get_tool_events error")
|
|
251
526
|
return {"events": [], "count": 0, "error": "Internal server error"}
|
|
252
527
|
|
|
253
528
|
|
|
254
529
|
@router.get("/api/behavioral/soft-prompts")
|
|
255
|
-
|
|
530
|
+
def get_soft_prompts(request: Request):
|
|
256
531
|
"""Get active soft prompt templates for dashboard display."""
|
|
532
|
+
_require_read(request)
|
|
257
533
|
try:
|
|
258
534
|
import sqlite3 as _sqlite3
|
|
535
|
+
profile = get_active_profile()
|
|
259
536
|
conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
|
|
260
537
|
conn.row_factory = _sqlite3.Row
|
|
261
538
|
rows = conn.execute(
|
|
262
539
|
"SELECT prompt_id, category, content, confidence, effectiveness, "
|
|
263
540
|
"token_count, active, version, created_at "
|
|
264
|
-
"FROM soft_prompt_templates
|
|
541
|
+
"FROM soft_prompt_templates "
|
|
542
|
+
"WHERE profile_id = ? AND active = 1 "
|
|
543
|
+
"ORDER BY category, prompt_id",
|
|
544
|
+
(profile,),
|
|
265
545
|
).fetchall()
|
|
266
546
|
conn.close()
|
|
267
547
|
return {"prompts": [dict(zip(
|
|
268
548
|
["prompt_id", "category", "content", "confidence", "effectiveness",
|
|
269
549
|
"token_count", "active", "version", "created_at"], r
|
|
270
550
|
)) for r in rows], "count": len(rows)}
|
|
271
|
-
except Exception
|
|
551
|
+
except Exception:
|
|
272
552
|
logger.exception("get_soft_prompts error")
|
|
273
553
|
return {"prompts": [], "count": 0, "error": "Internal server error"}
|
|
274
554
|
|
|
275
555
|
|
|
276
556
|
@router.post("/api/v3/tool-event")
|
|
277
|
-
|
|
557
|
+
def log_tool_event_api(request: Request, data: dict):
|
|
278
558
|
"""Log a tool event via HTTP (called by PostToolUse hook).
|
|
279
559
|
|
|
280
560
|
Body (v3.4.10 enriched):
|
|
@@ -290,10 +570,11 @@ async def log_tool_event_api(data: dict):
|
|
|
290
570
|
All fields except tool_name are optional for backward compatibility.
|
|
291
571
|
Lightweight — no LLM, just an INSERT.
|
|
292
572
|
"""
|
|
573
|
+
_require_write(request)
|
|
293
574
|
try:
|
|
575
|
+
import os
|
|
294
576
|
import sqlite3 as _sqlite3
|
|
295
577
|
from datetime import datetime, timezone
|
|
296
|
-
import os
|
|
297
578
|
|
|
298
579
|
tool_name = data.get("tool_name", "unknown")
|
|
299
580
|
event_type = data.get("event_type", "complete")
|