superlocalmemory 3.4.7 → 3.4.9
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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +8 -0
- package/src/superlocalmemory/cli/daemon.py +1 -1
- package/src/superlocalmemory/cli/ingest_cmd.py +261 -0
- package/src/superlocalmemory/cli/main.py +20 -0
- package/src/superlocalmemory/core/consolidation_engine.py +2 -2
- package/src/superlocalmemory/encoding/entity_resolver.py +44 -5
- package/src/superlocalmemory/learning/assertion_miner.py +93 -0
- package/src/superlocalmemory/learning/engagement.py +2 -3
- package/src/superlocalmemory/parameterization/prompt_injector.py +2 -1
- package/src/superlocalmemory/server/routes/behavioral.py +7 -3
- package/src/superlocalmemory/server/routes/learning.py +20 -3
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.9",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
package/pyproject.toml
CHANGED
|
@@ -60,6 +60,8 @@ def dispatch(args: Namespace) -> None:
|
|
|
60
60
|
"serve": cmd_serve,
|
|
61
61
|
# V3.4.3 ingestion adapters
|
|
62
62
|
"adapters": cmd_adapters,
|
|
63
|
+
# V3.4.8 external observation ingestion
|
|
64
|
+
"ingest": cmd_ingest,
|
|
63
65
|
}
|
|
64
66
|
handler = handlers.get(args.command)
|
|
65
67
|
if handler:
|
|
@@ -144,6 +146,12 @@ def cmd_serve(args: Namespace) -> None:
|
|
|
144
146
|
# -- Ingestion Adapters (V3.4.3) ------------------------------------------
|
|
145
147
|
|
|
146
148
|
|
|
149
|
+
def cmd_ingest(args: Namespace) -> None:
|
|
150
|
+
"""Import external observations into SLM learning pipeline."""
|
|
151
|
+
from superlocalmemory.cli.ingest_cmd import cmd_ingest as _ingest
|
|
152
|
+
_ingest(args)
|
|
153
|
+
|
|
154
|
+
|
|
147
155
|
def cmd_adapters(args: Namespace) -> None:
|
|
148
156
|
"""Manage ingestion adapters (Gmail, Calendar, Transcript).
|
|
149
157
|
|
|
@@ -16,7 +16,7 @@ This module contains CLIENT functions used by CLI commands:
|
|
|
16
16
|
The actual daemon server code is in server/unified_daemon.py.
|
|
17
17
|
|
|
18
18
|
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
19
|
-
License:
|
|
19
|
+
License: AGPL-3.0-or-later
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""CLI handler for `slm ingest` — import external observations into SLM.
|
|
6
|
+
|
|
7
|
+
Supported sources:
|
|
8
|
+
--source ecc Import ECC (Everything Claude Code) session summaries
|
|
9
|
+
--source jsonl Import generic JSONL observations
|
|
10
|
+
|
|
11
|
+
Each imported record becomes a tool_event in the behavioral learning pipeline,
|
|
12
|
+
so the AssertionMiner can learn from them.
|
|
13
|
+
|
|
14
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import sqlite3
|
|
22
|
+
import sys
|
|
23
|
+
from argparse import Namespace
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
MEMORY_DIR = Path.home() / ".superlocalmemory"
|
|
30
|
+
MEMORY_DB = MEMORY_DIR / "memory.db"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_ingest(args: Namespace) -> None:
|
|
34
|
+
"""Ingest external observations into SLM tool_events table."""
|
|
35
|
+
source = getattr(args, "source", "ecc")
|
|
36
|
+
file_path = getattr(args, "file", "")
|
|
37
|
+
use_json = getattr(args, "json", False)
|
|
38
|
+
dry_run = getattr(args, "dry_run", False)
|
|
39
|
+
|
|
40
|
+
if source == "ecc":
|
|
41
|
+
result = _ingest_ecc(file_path, dry_run=dry_run)
|
|
42
|
+
elif source == "jsonl":
|
|
43
|
+
if not file_path:
|
|
44
|
+
_error("--file required for jsonl source", use_json)
|
|
45
|
+
return
|
|
46
|
+
result = _ingest_jsonl(file_path, dry_run=dry_run)
|
|
47
|
+
else:
|
|
48
|
+
_error(f"Unknown source: {source}", use_json)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
if use_json:
|
|
52
|
+
from superlocalmemory.cli.json_output import json_print
|
|
53
|
+
json_print("ingest", data=result, next_actions=[
|
|
54
|
+
{"command": "slm consolidate --cognitive", "description": "Run consolidation to mine assertions"},
|
|
55
|
+
])
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if result.get("error"):
|
|
59
|
+
print(f"Error: {result['error']}")
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
print(f"Ingested: {result['ingested']} events from {source}")
|
|
63
|
+
if result.get("skipped"):
|
|
64
|
+
print(f"Skipped: {result['skipped']} (duplicates/invalid)")
|
|
65
|
+
if dry_run:
|
|
66
|
+
print("(dry run — no data written)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _error(msg: str, use_json: bool) -> None:
|
|
70
|
+
if use_json:
|
|
71
|
+
from superlocalmemory.cli.json_output import json_print
|
|
72
|
+
json_print("ingest", error={"code": "INGEST_ERROR", "message": msg})
|
|
73
|
+
else:
|
|
74
|
+
print(f"Error: {msg}")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _ingest_ecc(file_path: str, *, dry_run: bool = False) -> dict:
|
|
79
|
+
"""Ingest ECC session summaries from transcript JSONL files.
|
|
80
|
+
|
|
81
|
+
Scans the Claude projects directory for session JSONL files and
|
|
82
|
+
extracts tool usage patterns from them.
|
|
83
|
+
"""
|
|
84
|
+
result = {"source": "ecc", "ingested": 0, "skipped": 0, "dry_run": dry_run}
|
|
85
|
+
|
|
86
|
+
# Find ECC session files
|
|
87
|
+
if file_path:
|
|
88
|
+
files = [Path(file_path)]
|
|
89
|
+
else:
|
|
90
|
+
# Auto-discover: scan Claude project session files
|
|
91
|
+
claude_dir = Path.home() / ".claude" / "projects"
|
|
92
|
+
if not claude_dir.exists():
|
|
93
|
+
result["error"] = f"Claude projects dir not found: {claude_dir}"
|
|
94
|
+
return result
|
|
95
|
+
files = sorted(claude_dir.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
96
|
+
# Limit to recent files (last 20)
|
|
97
|
+
files = files[:20]
|
|
98
|
+
|
|
99
|
+
if not files:
|
|
100
|
+
result["error"] = "No session files found"
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
result["files_scanned"] = len(files)
|
|
104
|
+
events = []
|
|
105
|
+
|
|
106
|
+
for fpath in files:
|
|
107
|
+
try:
|
|
108
|
+
with open(fpath) as f:
|
|
109
|
+
for line in f:
|
|
110
|
+
line = line.strip()
|
|
111
|
+
if not line:
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
record = json.loads(line)
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
result["skipped"] += 1
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Extract tool usage from ECC session records
|
|
120
|
+
extracted = _extract_tool_events_from_record(record)
|
|
121
|
+
events.extend(extracted)
|
|
122
|
+
except (OSError, PermissionError):
|
|
123
|
+
result["skipped"] += 1
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if dry_run:
|
|
127
|
+
result["ingested"] = len(events)
|
|
128
|
+
result["sample"] = events[:5]
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
# Write to tool_events table
|
|
132
|
+
if events:
|
|
133
|
+
ingested = _write_tool_events(events)
|
|
134
|
+
result["ingested"] = ingested
|
|
135
|
+
else:
|
|
136
|
+
result["ingested"] = 0
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_tool_events_from_record(record: dict) -> list[dict]:
|
|
142
|
+
"""Extract tool events from a single ECC/Claude session JSONL record."""
|
|
143
|
+
events = []
|
|
144
|
+
|
|
145
|
+
# Handle ECC summary format
|
|
146
|
+
if "type" in record:
|
|
147
|
+
rtype = record.get("type", "")
|
|
148
|
+
|
|
149
|
+
# Tool use records
|
|
150
|
+
if rtype == "assistant" and "content" in record:
|
|
151
|
+
content = record.get("content", [])
|
|
152
|
+
if isinstance(content, list):
|
|
153
|
+
for block in content:
|
|
154
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
155
|
+
tool_name = block.get("name", "unknown")
|
|
156
|
+
events.append({
|
|
157
|
+
"tool_name": tool_name,
|
|
158
|
+
"event_type": "complete",
|
|
159
|
+
"session_id": record.get("session_id", "ecc_import"),
|
|
160
|
+
"created_at": record.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
# Also extract from tool_use type directly
|
|
164
|
+
if isinstance(content, list):
|
|
165
|
+
for block in content:
|
|
166
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
167
|
+
tool_name = block.get("tool_use_id", "unknown")
|
|
168
|
+
is_error = block.get("is_error", False)
|
|
169
|
+
events.append({
|
|
170
|
+
"tool_name": tool_name,
|
|
171
|
+
"event_type": "error" if is_error else "complete",
|
|
172
|
+
"session_id": record.get("session_id", "ecc_import"),
|
|
173
|
+
"created_at": record.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
# Handle direct tool event format (from hook output)
|
|
177
|
+
if "tool_name" in record and "event_type" in record:
|
|
178
|
+
events.append({
|
|
179
|
+
"tool_name": record["tool_name"],
|
|
180
|
+
"event_type": record.get("event_type", "complete"),
|
|
181
|
+
"session_id": record.get("session_id", "ecc_import"),
|
|
182
|
+
"created_at": record.get("created_at", datetime.now(timezone.utc).isoformat()),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return events
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _ingest_jsonl(file_path: str, *, dry_run: bool = False) -> dict:
|
|
189
|
+
"""Ingest generic JSONL file with tool event records."""
|
|
190
|
+
result = {"source": "jsonl", "ingested": 0, "skipped": 0, "dry_run": dry_run}
|
|
191
|
+
|
|
192
|
+
fpath = Path(file_path)
|
|
193
|
+
if not fpath.exists():
|
|
194
|
+
result["error"] = f"File not found: {file_path}"
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
events = []
|
|
198
|
+
with open(fpath) as f:
|
|
199
|
+
for line in f:
|
|
200
|
+
line = line.strip()
|
|
201
|
+
if not line:
|
|
202
|
+
continue
|
|
203
|
+
try:
|
|
204
|
+
record = json.loads(line)
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
result["skipped"] += 1
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if "tool_name" not in record:
|
|
210
|
+
result["skipped"] += 1
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
events.append({
|
|
214
|
+
"tool_name": record["tool_name"],
|
|
215
|
+
"event_type": record.get("event_type", "complete"),
|
|
216
|
+
"session_id": record.get("session_id", "jsonl_import"),
|
|
217
|
+
"created_at": record.get("created_at", datetime.now(timezone.utc).isoformat()),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
if dry_run:
|
|
221
|
+
result["ingested"] = len(events)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
if events:
|
|
225
|
+
result["ingested"] = _write_tool_events(events)
|
|
226
|
+
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _write_tool_events(events: list[dict]) -> int:
|
|
231
|
+
"""Write tool events to SLM's memory.db tool_events table."""
|
|
232
|
+
db_path = MEMORY_DB
|
|
233
|
+
if not db_path.exists():
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
conn = sqlite3.connect(str(db_path), timeout=10)
|
|
237
|
+
count = 0
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
for ev in events:
|
|
241
|
+
try:
|
|
242
|
+
conn.execute(
|
|
243
|
+
"INSERT INTO tool_events "
|
|
244
|
+
"(session_id, profile_id, project_path, tool_name, event_type, "
|
|
245
|
+
" input_summary, output_summary, duration_ms, metadata, created_at) "
|
|
246
|
+
"VALUES (?, 'default', '', ?, ?, '', '', 0, '{}', ?)",
|
|
247
|
+
(
|
|
248
|
+
ev.get("session_id", "import"),
|
|
249
|
+
ev["tool_name"],
|
|
250
|
+
ev.get("event_type", "complete"),
|
|
251
|
+
ev.get("created_at", datetime.now(timezone.utc).isoformat()),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
count += 1
|
|
255
|
+
except sqlite3.Error:
|
|
256
|
+
continue
|
|
257
|
+
conn.commit()
|
|
258
|
+
finally:
|
|
259
|
+
conn.close()
|
|
260
|
+
|
|
261
|
+
return count
|
|
@@ -282,6 +282,26 @@ def main() -> None:
|
|
|
282
282
|
help="Subcommand: list, enable, disable, start, stop, status [name]",
|
|
283
283
|
)
|
|
284
284
|
|
|
285
|
+
# V3.4.8: External observation ingestion
|
|
286
|
+
ingest_p = sub.add_parser(
|
|
287
|
+
"ingest",
|
|
288
|
+
help="Import external observations (ECC, JSONL) into SLM learning",
|
|
289
|
+
)
|
|
290
|
+
ingest_p.add_argument(
|
|
291
|
+
"--source", default="ecc",
|
|
292
|
+
choices=["ecc", "jsonl"],
|
|
293
|
+
help="Source type: ecc (Claude Code sessions), jsonl (generic)",
|
|
294
|
+
)
|
|
295
|
+
ingest_p.add_argument(
|
|
296
|
+
"--file", default="",
|
|
297
|
+
help="Specific file to ingest (auto-discovers if not set)",
|
|
298
|
+
)
|
|
299
|
+
ingest_p.add_argument(
|
|
300
|
+
"--dry-run", action="store_true", default=False,
|
|
301
|
+
help="Preview without writing",
|
|
302
|
+
)
|
|
303
|
+
ingest_p.add_argument("--json", action="store_true", help="Output structured JSON")
|
|
304
|
+
|
|
285
305
|
args = parser.parse_args()
|
|
286
306
|
|
|
287
307
|
if not args.command:
|
|
@@ -151,7 +151,7 @@ class ConsolidationEngine:
|
|
|
151
151
|
except ImportError:
|
|
152
152
|
class CrossProjectAggregator:
|
|
153
153
|
def __init__(self, db): pass
|
|
154
|
-
def
|
|
154
|
+
def get_preferences(self, *a, **kw): return {}
|
|
155
155
|
try:
|
|
156
156
|
from superlocalmemory.parameterization.workflow_miner import WorkflowMiner
|
|
157
157
|
except ImportError:
|
|
@@ -169,7 +169,7 @@ class ConsolidationEngine:
|
|
|
169
169
|
wf_miner = WorkflowMiner(self._db)
|
|
170
170
|
extractor = PatternExtractor(self._db, beh_store, cross_proj, wf_miner, p_config)
|
|
171
171
|
generator = SoftPromptGenerator(p_config)
|
|
172
|
-
injector = PromptInjector(self._db)
|
|
172
|
+
injector = PromptInjector(self._db, generator, p_config)
|
|
173
173
|
lifecycle = PromptLifecycleManager(self._db, p_config)
|
|
174
174
|
hook = AutoParameterizeHook(extractor, generator, injector, lifecycle, p_config)
|
|
175
175
|
sp_result = hook.on_consolidation_complete(profile_id)
|
|
@@ -15,7 +15,7 @@ entity resolution ran but results were silently discarded.
|
|
|
15
15
|
d) LLM disambiguation (Mode B/C only)
|
|
16
16
|
|
|
17
17
|
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
18
|
-
License:
|
|
18
|
+
License: AGPL-3.0-or-later
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
@@ -112,20 +112,59 @@ def jaro_winkler(s1: str, s2: str, prefix_weight: float = 0.1) -> float:
|
|
|
112
112
|
return jaro + prefix * prefix_weight * (1.0 - jaro)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
_COMMON_WORDS = frozenset({
|
|
116
|
+
"april", "may", "june", "march", "august", "phase", "test", "gap",
|
|
117
|
+
"dashboard", "remaining", "session", "results", "tools", "projects",
|
|
118
|
+
"prompts", "integration", "cli", "engagement", "mode", "error",
|
|
119
|
+
"step", "fix", "build", "check", "run", "start", "stop", "config",
|
|
120
|
+
"status", "version", "query", "data", "file", "path", "node", "edge",
|
|
121
|
+
"table", "index", "schema", "model", "type", "class", "function",
|
|
122
|
+
"module", "package", "import", "export", "default", "pattern",
|
|
123
|
+
"memory", "profile", "context", "pipeline", "worker", "daemon",
|
|
124
|
+
"server", "client", "route", "endpoint", "handler", "hook",
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
|
|
115
128
|
def _guess_entity_type(name: str) -> str:
|
|
116
|
-
"""Heuristic entity type classification from name string.
|
|
129
|
+
"""Heuristic entity type classification from name string.
|
|
130
|
+
|
|
131
|
+
v3.4.8: Fixed false-positive "person" classification. Single capitalized
|
|
132
|
+
common words (April, Phase, Dashboard) are concepts, not people.
|
|
133
|
+
Only classify as "person" when it looks like a real human name.
|
|
134
|
+
"""
|
|
117
135
|
if any(m in name for m in _ORG_MARKERS):
|
|
118
136
|
return "organization"
|
|
119
137
|
if any(m in name for m in _PLACE_MARKERS):
|
|
120
138
|
return "place"
|
|
121
139
|
if any(m in name for m in _EVENT_MARKERS):
|
|
122
140
|
return "event"
|
|
123
|
-
|
|
141
|
+
|
|
142
|
+
# Filter out common words that aren't people
|
|
143
|
+
if name.lower() in _COMMON_WORDS:
|
|
144
|
+
return "concept"
|
|
145
|
+
|
|
146
|
+
# Two capitalized words = likely a person name (e.g. "Varun Bhardwaj")
|
|
124
147
|
if re.match(r"^[A-Z][a-z]+ [A-Z][a-z]+$", name):
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
# But not if either word is a common term
|
|
149
|
+
parts = name.lower().split()
|
|
150
|
+
if not any(p in _COMMON_WORDS for p in parts):
|
|
151
|
+
return "person"
|
|
152
|
+
|
|
153
|
+
# Single short capitalized word with no digits or dots = concept, not person
|
|
154
|
+
# "person" should only be assigned for real names, not generic terms
|
|
127
155
|
if re.match(r"^[A-Z][a-z]+$", name):
|
|
156
|
+
if name.lower() in _COMMON_WORDS:
|
|
157
|
+
return "concept"
|
|
158
|
+
# Only classify as person if it's a plausible first name
|
|
159
|
+
# (short word not in common terms — still a heuristic)
|
|
160
|
+
if len(name) <= 3:
|
|
161
|
+
return "concept"
|
|
128
162
|
return "person"
|
|
163
|
+
|
|
164
|
+
# Contains dots/slashes/hyphens = likely a technical term
|
|
165
|
+
if re.search(r"[./\-_]", name):
|
|
166
|
+
return "concept"
|
|
167
|
+
|
|
129
168
|
return "concept"
|
|
130
169
|
|
|
131
170
|
|
|
@@ -28,6 +28,8 @@ logger = logging.getLogger(__name__)
|
|
|
28
28
|
MIN_EVIDENCE = 3 # Minimum events to create an assertion
|
|
29
29
|
MAX_ASSERTIONS_PER_RUN = 20 # Cap assertions per mining cycle
|
|
30
30
|
REINFORCEMENT_NUDGE = 0.15 # Bayesian confidence increase
|
|
31
|
+
PROMOTION_MIN_PROJECTS = 2 # Minimum projects for cross-project promotion
|
|
32
|
+
PROMOTION_MIN_CONFIDENCE = 0.8 # Minimum avg confidence for promotion
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
class AssertionMiner:
|
|
@@ -74,6 +76,11 @@ class AssertionMiner:
|
|
|
74
76
|
results["created"] += s4.get("created", 0)
|
|
75
77
|
results["reinforced"] += s4.get("reinforced", 0)
|
|
76
78
|
|
|
79
|
+
# Strategy 5: Cross-project assertion promotion
|
|
80
|
+
s5 = self._promote_cross_project(conn, profile_id)
|
|
81
|
+
results["strategies"]["cross_project"] = s5
|
|
82
|
+
results["created"] += s5.get("promoted", 0)
|
|
83
|
+
|
|
77
84
|
conn.commit()
|
|
78
85
|
except Exception as exc:
|
|
79
86
|
logger.warning("Assertion mining failed: %s", exc)
|
|
@@ -263,6 +270,92 @@ class AssertionMiner:
|
|
|
263
270
|
|
|
264
271
|
return result
|
|
265
272
|
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
# Strategy 5: Cross-project assertion promotion
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def _promote_cross_project(
|
|
278
|
+
self, conn: sqlite3.Connection, profile_id: str,
|
|
279
|
+
) -> dict:
|
|
280
|
+
"""Promote assertions that appear in 2+ projects to global scope.
|
|
281
|
+
|
|
282
|
+
When the same trigger+action pattern is observed across multiple
|
|
283
|
+
project_paths with avg confidence >= 0.8, create a global assertion
|
|
284
|
+
(project_path='') so it applies everywhere.
|
|
285
|
+
"""
|
|
286
|
+
result = {"promoted": 0, "candidates": 0}
|
|
287
|
+
|
|
288
|
+
# Find assertions grouped by trigger+action across projects
|
|
289
|
+
rows = conn.execute(
|
|
290
|
+
"SELECT trigger_condition, action, category, "
|
|
291
|
+
"COUNT(DISTINCT project_path) AS project_count, "
|
|
292
|
+
"AVG(confidence) AS avg_confidence, "
|
|
293
|
+
"SUM(evidence_count) AS total_evidence "
|
|
294
|
+
"FROM behavioral_assertions "
|
|
295
|
+
"WHERE profile_id = ? AND project_path != '' "
|
|
296
|
+
"GROUP BY trigger_condition, action "
|
|
297
|
+
"HAVING COUNT(DISTINCT project_path) >= ?",
|
|
298
|
+
(profile_id, PROMOTION_MIN_PROJECTS),
|
|
299
|
+
).fetchall()
|
|
300
|
+
|
|
301
|
+
result["candidates"] = len(rows)
|
|
302
|
+
|
|
303
|
+
for row in rows:
|
|
304
|
+
avg_conf = row["avg_confidence"]
|
|
305
|
+
if avg_conf < PROMOTION_MIN_CONFIDENCE:
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
trigger = row["trigger_condition"]
|
|
309
|
+
action = row["action"]
|
|
310
|
+
category = row["category"]
|
|
311
|
+
total_ev = row["total_evidence"]
|
|
312
|
+
project_count = row["project_count"]
|
|
313
|
+
|
|
314
|
+
# Check if global assertion already exists
|
|
315
|
+
global_id = hashlib.sha256(
|
|
316
|
+
f"{profile_id}:{trigger}:{action}".encode()
|
|
317
|
+
).hexdigest()[:16]
|
|
318
|
+
|
|
319
|
+
existing = conn.execute(
|
|
320
|
+
"SELECT id FROM behavioral_assertions "
|
|
321
|
+
"WHERE id = ? AND project_path = ''",
|
|
322
|
+
(global_id,),
|
|
323
|
+
).fetchone()
|
|
324
|
+
|
|
325
|
+
if existing:
|
|
326
|
+
# Reinforce existing global assertion
|
|
327
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
328
|
+
conn.execute(
|
|
329
|
+
"UPDATE behavioral_assertions SET "
|
|
330
|
+
"confidence = MIN(0.95, confidence + ?), "
|
|
331
|
+
"evidence_count = ?, "
|
|
332
|
+
"reinforcement_count = reinforcement_count + 1, "
|
|
333
|
+
"last_reinforced_at = ?, updated_at = ? "
|
|
334
|
+
"WHERE id = ?",
|
|
335
|
+
(REINFORCEMENT_NUDGE, total_ev, now, now, global_id),
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
# Create new global assertion from cross-project evidence
|
|
339
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
340
|
+
promoted_conf = min(0.9, avg_conf)
|
|
341
|
+
conn.execute(
|
|
342
|
+
"INSERT INTO behavioral_assertions "
|
|
343
|
+
"(id, profile_id, project_path, trigger_condition, action, "
|
|
344
|
+
" category, confidence, evidence_count, source, "
|
|
345
|
+
" created_at, updated_at) "
|
|
346
|
+
"VALUES (?, ?, '', ?, ?, ?, ?, ?, 'cross_project', ?, ?)",
|
|
347
|
+
(global_id, profile_id, trigger, action,
|
|
348
|
+
category, round(promoted_conf, 4), total_ev, now, now),
|
|
349
|
+
)
|
|
350
|
+
result["promoted"] += 1
|
|
351
|
+
logger.info(
|
|
352
|
+
"Promoted assertion to global: '%s' → '%s' "
|
|
353
|
+
"(from %d projects, avg_conf=%.2f)",
|
|
354
|
+
trigger, action, project_count, avg_conf,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return result
|
|
358
|
+
|
|
266
359
|
# ------------------------------------------------------------------
|
|
267
360
|
# Upsert logic
|
|
268
361
|
# ------------------------------------------------------------------
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# SPDX-License-Identifier: Elastic-2.0
|
|
3
1
|
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
4
|
-
#
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
5
4
|
"""
|
|
6
5
|
EngagementTracker -- Local-only engagement metrics for V3 learning.
|
|
7
6
|
|
|
@@ -186,7 +186,8 @@ class PromptInjector:
|
|
|
186
186
|
)
|
|
187
187
|
max_version = 0
|
|
188
188
|
if version_rows:
|
|
189
|
-
|
|
189
|
+
row = version_rows[0]
|
|
190
|
+
max_version = (dict(row) if hasattr(row, "keys") else {"max_version": row[0]}).get("max_version", 0) or 0
|
|
190
191
|
new_version = max_version + 1
|
|
191
192
|
|
|
192
193
|
# Insert new prompt
|
|
@@ -226,11 +226,15 @@ async def get_soft_prompts():
|
|
|
226
226
|
conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
|
|
227
227
|
conn.row_factory = _sqlite3.Row
|
|
228
228
|
rows = conn.execute(
|
|
229
|
-
"SELECT
|
|
230
|
-
"
|
|
229
|
+
"SELECT prompt_id, category, content, confidence, effectiveness, "
|
|
230
|
+
"token_count, active, version, created_at "
|
|
231
|
+
"FROM soft_prompt_templates WHERE active = 1 ORDER BY category"
|
|
231
232
|
).fetchall()
|
|
232
233
|
conn.close()
|
|
233
|
-
return {"prompts": [dict(
|
|
234
|
+
return {"prompts": [dict(zip(
|
|
235
|
+
["prompt_id", "category", "content", "confidence", "effectiveness",
|
|
236
|
+
"token_count", "active", "version", "created_at"], r
|
|
237
|
+
)) for r in rows], "count": len(rows)}
|
|
234
238
|
except Exception as e:
|
|
235
239
|
logger.debug("get_soft_prompts error: %s", e)
|
|
236
240
|
return {"prompts": [], "count": 0, "error": str(e)}
|
|
@@ -143,12 +143,29 @@ async def learning_status():
|
|
|
143
143
|
"signals": signal_count,
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
# Engagement
|
|
146
|
+
# Engagement — v3.4.8: Fixed method name (was get_engagement_stats, actual is get_stats)
|
|
147
147
|
engagement = _get_engagement()
|
|
148
148
|
if engagement:
|
|
149
149
|
try:
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
stats = engagement.get_stats(active_profile)
|
|
151
|
+
health = engagement.get_health(active_profile)
|
|
152
|
+
active_days = stats.get("active_days", 0)
|
|
153
|
+
total_events = stats.get("total_events", 0)
|
|
154
|
+
memories_per_day = (
|
|
155
|
+
round(total_events / active_days, 1) if active_days > 0 else 0
|
|
156
|
+
)
|
|
157
|
+
result["engagement"] = {
|
|
158
|
+
"health_status": health.upper(),
|
|
159
|
+
"days_active": active_days,
|
|
160
|
+
"memories_per_day": memories_per_day,
|
|
161
|
+
"total_events": total_events,
|
|
162
|
+
"recall_count": stats.get("recall_count", 0),
|
|
163
|
+
"store_count": stats.get("store_count", 0),
|
|
164
|
+
"session_count": stats.get("session_count", 0),
|
|
165
|
+
"engagement_score": stats.get("engagement_score", 0),
|
|
166
|
+
}
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
logger.debug("engagement stats: %s", exc)
|
|
152
169
|
result["engagement"] = None
|
|
153
170
|
else:
|
|
154
171
|
result["engagement"] = None
|
|
@@ -17,6 +17,7 @@ src/superlocalmemory/attribution/watermark.py
|
|
|
17
17
|
src/superlocalmemory/cli/__init__.py
|
|
18
18
|
src/superlocalmemory/cli/commands.py
|
|
19
19
|
src/superlocalmemory/cli/daemon.py
|
|
20
|
+
src/superlocalmemory/cli/ingest_cmd.py
|
|
20
21
|
src/superlocalmemory/cli/json_output.py
|
|
21
22
|
src/superlocalmemory/cli/main.py
|
|
22
23
|
src/superlocalmemory/cli/migrate_cmd.py
|