superlocalmemory 4.0.10 → 4.1.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +170 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- 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 +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- 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 +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +263 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +186 -5
- package/src/superlocalmemory/storage/_migration_internals.py +31 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +12 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -20,7 +20,7 @@ from typing import Callable
|
|
|
20
20
|
from mcp.types import ToolAnnotations
|
|
21
21
|
|
|
22
22
|
from superlocalmemory.core.admission import admits
|
|
23
|
-
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
23
|
+
from superlocalmemory.core.config import CANONICAL_LIST_LIMIT, CANONICAL_RECALL_LIMIT
|
|
24
24
|
from superlocalmemory.core.operation_request import OperationKind
|
|
25
25
|
from superlocalmemory.infra.data_root import state_path
|
|
26
26
|
from superlocalmemory.mcp._daemon_proxy import daemon_unavailable_error
|
|
@@ -29,6 +29,19 @@ from superlocalmemory.mcp.shared import authorize_mcp_mutation
|
|
|
29
29
|
logger = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _projection_queue_depth(db: object) -> int:
|
|
33
|
+
"""Facts queued for the graph and vector projections, or 0 when there are none.
|
|
34
|
+
|
|
35
|
+
Imported inside the function: this module is loaded on every MCP start over
|
|
36
|
+
stdio, where import cost is startup latency a user feels.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from superlocalmemory.storage import projection_outbox
|
|
40
|
+
return projection_outbox.depth(db)
|
|
41
|
+
except Exception:
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
32
45
|
async def _runtime_profile(get_engine: Callable, explicit: str = "") -> str:
|
|
33
46
|
"""Resolve an MCP default profile from daemon runtime truth."""
|
|
34
47
|
if explicit:
|
|
@@ -425,6 +438,9 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
425
438
|
"score_contract_version": result.get("score_contract_version", "2"),
|
|
426
439
|
"calibration_status": result.get("calibration_status", "uncalibrated"),
|
|
427
440
|
"calibration_id": result.get("calibration_id"),
|
|
441
|
+
# Quote this back to report_outcome and the report ties to
|
|
442
|
+
# this exact answer instead of being matched by guesswork.
|
|
443
|
+
"query_id": result.get("query_id", ""),
|
|
428
444
|
"answer_confidence": result.get("answer_confidence"),
|
|
429
445
|
"abstained": result.get("abstained", False),
|
|
430
446
|
"abstention_reason": result.get("abstention_reason"),
|
|
@@ -436,7 +452,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
436
452
|
|
|
437
453
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
438
454
|
@admits(OperationKind.RECALL)
|
|
439
|
-
async def search(query: str, limit: int =
|
|
455
|
+
async def search(query: str, limit: int = CANONICAL_RECALL_LIMIT) -> dict:
|
|
440
456
|
"""Full-text search across memories using FTS5 with BM25 ranking."""
|
|
441
457
|
try:
|
|
442
458
|
engine = get_engine()
|
|
@@ -457,6 +473,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
457
473
|
return {"success": False, "error": str(exc)}
|
|
458
474
|
|
|
459
475
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
476
|
+
@admits(OperationKind.RECALL)
|
|
460
477
|
async def fetch(fact_ids: str) -> dict:
|
|
461
478
|
"""Fetch full details for specific fact IDs (comma-separated)."""
|
|
462
479
|
try:
|
|
@@ -484,7 +501,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
484
501
|
return {"success": False, "error": str(exc)}
|
|
485
502
|
|
|
486
503
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
487
|
-
|
|
504
|
+
@admits(OperationKind.RECALL)
|
|
505
|
+
async def list_recent(limit: int = CANONICAL_LIST_LIMIT) -> dict:
|
|
488
506
|
"""List most recently stored memories, newest first."""
|
|
489
507
|
try:
|
|
490
508
|
engine = get_engine()
|
|
@@ -511,6 +529,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
511
529
|
async def get_status() -> dict:
|
|
512
530
|
"""Get memory system status: fact count, entity count, mode, profile, db size."""
|
|
513
531
|
try:
|
|
532
|
+
# Same source the HTTP surface reads, imported here rather than at
|
|
533
|
+
# module scope: that module costs ~260ms and MCP starts over stdio.
|
|
534
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
535
|
+
|
|
514
536
|
import asyncio
|
|
515
537
|
import os
|
|
516
538
|
|
|
@@ -540,6 +562,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
540
562
|
"profile_generation": int(
|
|
541
563
|
daemon_status.get("profile_generation", 0)
|
|
542
564
|
),
|
|
565
|
+
"version": SLM_VERSION,
|
|
566
|
+
"projection_queue_depth": int(
|
|
567
|
+
daemon_status.get("projection_queue_depth", 0)
|
|
568
|
+
),
|
|
543
569
|
}
|
|
544
570
|
|
|
545
571
|
engine = get_engine()
|
|
@@ -576,6 +602,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
576
602
|
"entity_count": entity_count,
|
|
577
603
|
"edge_count": edge_count,
|
|
578
604
|
"profile_generation": 0,
|
|
605
|
+
"version": SLM_VERSION,
|
|
606
|
+
"projection_queue_depth": _projection_queue_depth(engine._db),
|
|
579
607
|
}
|
|
580
608
|
except Exception as exc:
|
|
581
609
|
logger.exception("get_status failed")
|
|
@@ -36,6 +36,7 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
36
36
|
memory_ids: str,
|
|
37
37
|
outcome: str,
|
|
38
38
|
context: str = "",
|
|
39
|
+
recall_query_id: str = "",
|
|
39
40
|
) -> dict:
|
|
40
41
|
"""Report outcome of using recalled memories.
|
|
41
42
|
|
|
@@ -46,6 +47,10 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
46
47
|
memory_ids: Comma-separated list of fact/memory IDs.
|
|
47
48
|
outcome: One of 'success', 'failure', 'partial'.
|
|
48
49
|
context: Optional freetext context about the outcome.
|
|
50
|
+
recall_query_id: The ``query_id`` that came back with the recall
|
|
51
|
+
this report is about. Passing it ties the report to that exact
|
|
52
|
+
answer; leaving it out falls back to matching on which memories
|
|
53
|
+
overlap, within a time window.
|
|
49
54
|
"""
|
|
50
55
|
try:
|
|
51
56
|
engine = get_engine()
|
|
@@ -54,7 +59,20 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
54
59
|
"update",
|
|
55
60
|
mutation_source="mcp-report-outcome",
|
|
56
61
|
)
|
|
57
|
-
from superlocalmemory.learning.outcomes import
|
|
62
|
+
from superlocalmemory.learning.outcomes import (
|
|
63
|
+
VALID_OUTCOMES,
|
|
64
|
+
OutcomeTracker,
|
|
65
|
+
)
|
|
66
|
+
if outcome not in VALID_OUTCOMES:
|
|
67
|
+
# Answered here rather than as a stack trace: the caller is an
|
|
68
|
+
# assistant that can correct itself if told what is allowed.
|
|
69
|
+
return {
|
|
70
|
+
"success": False,
|
|
71
|
+
"error": (
|
|
72
|
+
f"outcome must be one of {sorted(VALID_OUTCOMES)}, "
|
|
73
|
+
f"not {outcome!r}"
|
|
74
|
+
),
|
|
75
|
+
}
|
|
58
76
|
tracker = OutcomeTracker(engine._db)
|
|
59
77
|
ids = [mid.strip() for mid in memory_ids.split(",") if mid.strip()]
|
|
60
78
|
ctx = {"note": context} if context else None
|
|
@@ -64,6 +82,7 @@ def register_v28_tools(server, get_engine: Callable) -> None:
|
|
|
64
82
|
outcome=outcome,
|
|
65
83
|
profile_id=engine.profile_id,
|
|
66
84
|
context=ctx,
|
|
85
|
+
recall_query_id=str(recall_query_id or "").strip(),
|
|
67
86
|
)
|
|
68
87
|
|
|
69
88
|
# v3.4.7: Bridge outcomes → learning signals for two-way learning.
|
|
@@ -66,7 +66,17 @@ _BEHAVIORAL_TYPE_MAP: dict[str, str] = {
|
|
|
66
66
|
"query_type": "workflow_pattern",
|
|
67
67
|
"time_of_day": "workflow_pattern",
|
|
68
68
|
"refinement": "communication_style",
|
|
69
|
-
"interest"
|
|
69
|
+
# An "interest" is a word that shows up often in this user's memories. It is
|
|
70
|
+
# NOT a statement about their tooling, and calling it one produced the
|
|
71
|
+
# single worst thing this subsystem has shipped: a prompt injected on every
|
|
72
|
+
# turn reading "the user's preferred technology stack includes: test, gate,
|
|
73
|
+
# practices, compliance, projects, while, their, processing, data".
|
|
74
|
+
#
|
|
75
|
+
# Every one of those words is a real and frequent topic for this user. The
|
|
76
|
+
# values were right; the claim about them was false. Measured on a live
|
|
77
|
+
# store, `_store_patterns` holds correct tech_preference rows alongside
|
|
78
|
+
# these — Node.js, Go, Git, pip — so the two kinds were never the same kind.
|
|
79
|
+
"interest": "topic_interest",
|
|
70
80
|
"archival": "avoidance",
|
|
71
81
|
}
|
|
72
82
|
|
|
@@ -90,6 +100,9 @@ class PatternCategory(str, Enum):
|
|
|
90
100
|
WORKFLOW_PATTERN = "workflow_pattern"
|
|
91
101
|
PROJECT_CONTEXT = "project_context"
|
|
92
102
|
DECISION_HISTORY = "decision_history"
|
|
103
|
+
# Topics that come up a lot in this user's memories. Deliberately separate
|
|
104
|
+
# from TECH_PREFERENCE: a frequent word is not a tooling choice.
|
|
105
|
+
TOPIC_INTEREST = "topic_interest"
|
|
93
106
|
AVOIDANCE = "avoidance"
|
|
94
107
|
CUSTOM = "custom"
|
|
95
108
|
|
|
@@ -53,6 +53,11 @@ CATEGORY_TEMPLATES: dict[str, str] = {
|
|
|
53
53
|
"Current active project: {project_name}. "
|
|
54
54
|
"Key context: {context_summary}."
|
|
55
55
|
),
|
|
56
|
+
# Says only what is actually known — that these subjects recur — rather
|
|
57
|
+
# than inferring a preference, a project or a tool choice from them.
|
|
58
|
+
"topic_interest": (
|
|
59
|
+
"Subjects that come up often in the user's notes: {topics}."
|
|
60
|
+
),
|
|
56
61
|
"decision_history": (
|
|
57
62
|
"Recent key decisions: {decisions}. "
|
|
58
63
|
"These reflect the user's current direction."
|
|
@@ -75,6 +80,7 @@ CATEGORY_PRIORITY_ORDER: list[str] = [
|
|
|
75
80
|
"behavioral", # v3.4.7: behavioral assertions after communication style
|
|
76
81
|
"workflow_pattern",
|
|
77
82
|
"project_context",
|
|
83
|
+
"topic_interest",
|
|
78
84
|
"decision_history",
|
|
79
85
|
"avoidance",
|
|
80
86
|
]
|
|
@@ -105,6 +111,82 @@ class SoftPromptTemplate:
|
|
|
105
111
|
# SoftPromptGenerator class
|
|
106
112
|
# ---------------------------------------------------------------------------
|
|
107
113
|
|
|
114
|
+
def _empty_words() -> frozenset[str]:
|
|
115
|
+
"""Words that say nothing about a person when listed as a preference.
|
|
116
|
+
|
|
117
|
+
Read from the list the miner already filters on, so there is one definition
|
|
118
|
+
to extend rather than two that drift.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
from superlocalmemory.learning.pattern_miner_constants import STOPWORDS
|
|
122
|
+
|
|
123
|
+
return frozenset(STOPWORDS)
|
|
124
|
+
except Exception: # pragma: no cover — the check degrades to "keep it"
|
|
125
|
+
return frozenset()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_EMPTY_WORDS = _empty_words()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_substantive(category: str, values: dict[str, str]) -> bool:
|
|
132
|
+
"""Whether a rendered prompt says anything at all about the user.
|
|
133
|
+
|
|
134
|
+
Deliberately weak, and it got that way by being wrong in the other
|
|
135
|
+
direction first. The original version required a ``tech_preference`` claim to
|
|
136
|
+
name something from a fixed vocabulary of technologies — which discarded
|
|
137
|
+
``Node.js``, ``Git``, ``pip``, ``npm``, ``zig`` and every stack the list did
|
|
138
|
+
not happen to enumerate. Those were the GENUINE rows on a live store. A
|
|
139
|
+
filter that silently drops real preferences to catch fake ones is a worse
|
|
140
|
+
failure than the one it was added for, because nothing reports it.
|
|
141
|
+
|
|
142
|
+
What it was actually added for no longer arrives here. The live nonsense —
|
|
143
|
+
"preferred technology stack includes: test, gate, practices, compliance,
|
|
144
|
+
projects, while, their, processing, data" — came from word-frequency topics
|
|
145
|
+
being mapped onto this category, and they are now their own category, where
|
|
146
|
+
the same words form a true statement. This is the remaining floor: a prompt
|
|
147
|
+
built entirely out of words that appear in most English sentences says
|
|
148
|
+
nothing, whatever category it lands in.
|
|
149
|
+
|
|
150
|
+
Length is checked per TERM and only against that word list, never as a
|
|
151
|
+
minimum: "CTO", "AWS", "npm" and "R" are all shorter than a threshold would
|
|
152
|
+
allow and all mean something.
|
|
153
|
+
"""
|
|
154
|
+
filled = [
|
|
155
|
+
v.strip() for v in values.values()
|
|
156
|
+
if isinstance(v, str) and v.strip()
|
|
157
|
+
]
|
|
158
|
+
if not filled:
|
|
159
|
+
return False
|
|
160
|
+
terms = [
|
|
161
|
+
t.strip().lower()
|
|
162
|
+
for value in filled
|
|
163
|
+
for t in value.replace(";", ",").split(",")
|
|
164
|
+
if t.strip()
|
|
165
|
+
]
|
|
166
|
+
if not terms:
|
|
167
|
+
return False
|
|
168
|
+
return any(term not in _EMPTY_WORDS for term in terms)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fix_stutter(content: str) -> str:
|
|
172
|
+
"""Remove the duplicated conjunction where a template meets its value.
|
|
173
|
+
|
|
174
|
+
``"The user typically {workflow_description}"`` was rendering as "The user
|
|
175
|
+
typically When when using X" — the template supplies the lead-in and the
|
|
176
|
+
value already starts with its own. Observed on 19 of 34 stored prompts.
|
|
177
|
+
"""
|
|
178
|
+
import re as _re
|
|
179
|
+
|
|
180
|
+
for word in ("when", "typically", "prefers", "often", "usually"):
|
|
181
|
+
content = _re.sub(
|
|
182
|
+
rf"\b({word})\s+{word}\b", r"\1", content, flags=_re.IGNORECASE,
|
|
183
|
+
)
|
|
184
|
+
# "typically When when" collapses to "typically When"; then the lead-in and
|
|
185
|
+
# the value's own opener are adjacent duplicates of different case.
|
|
186
|
+
content = _re.sub(r"\btypically\s+When\b", "typically, when", content)
|
|
187
|
+
return content
|
|
188
|
+
|
|
189
|
+
|
|
108
190
|
class SoftPromptGenerator:
|
|
109
191
|
"""Convert extracted pattern assertions into natural language soft prompts.
|
|
110
192
|
|
|
@@ -243,12 +325,25 @@ class SoftPromptGenerator:
|
|
|
243
325
|
|
|
244
326
|
# Clean up
|
|
245
327
|
content = self._clean_content(content)
|
|
328
|
+
content = _fix_stutter(content)
|
|
246
329
|
|
|
247
330
|
# Filter PII
|
|
248
331
|
content = self._pii_filter.filter_text(content)
|
|
249
332
|
if not content.strip():
|
|
250
333
|
return None
|
|
251
334
|
|
|
335
|
+
# A prompt that says nothing must not be injected. These go into the
|
|
336
|
+
# model's context on every turn, so an empty claim is not neutral — it
|
|
337
|
+
# spends the budget and asserts something false. Measured on a live
|
|
338
|
+
# store: 15 of 34 stored prompts read "the user's preferred technology
|
|
339
|
+
# stack includes: data, processing, their, projects, test", built from
|
|
340
|
+
# common words that happened to appear near a technology keyword.
|
|
341
|
+
if not _is_substantive(category, values):
|
|
342
|
+
logger.debug(
|
|
343
|
+
"soft prompt for %r dropped: no substantive values", category,
|
|
344
|
+
)
|
|
345
|
+
return None
|
|
346
|
+
|
|
252
347
|
# Trim to 100 tokens per category
|
|
253
348
|
content = self._trim_to_tokens(content, 100)
|
|
254
349
|
|
|
@@ -305,6 +400,9 @@ class SoftPromptGenerator:
|
|
|
305
400
|
elif category == "workflow_pattern":
|
|
306
401
|
values["workflow_description"] = "; ".join(pat_values)
|
|
307
402
|
|
|
403
|
+
elif category == "topic_interest":
|
|
404
|
+
values["topics"] = ", ".join(pat_values)
|
|
405
|
+
|
|
308
406
|
elif category == "project_context":
|
|
309
407
|
values["project_name"] = pat_values[0] if pat_values else ""
|
|
310
408
|
values["context_summary"] = ", ".join(pat_values[1:])
|
|
@@ -54,6 +54,65 @@ def tokenize(text: str) -> list[str]:
|
|
|
54
54
|
return [t for t in tokens if t not in _STOPWORDS]
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
#: Saturation constant for the BM25 -> [0,1) transform. Chosen from measurement,
|
|
58
|
+
#: not taste: raw scores on a live store run ~2.5 to ~15 across query
|
|
59
|
+
#: lengths, so k = 5 puts an ordinary match near 0.4 and a strong one near 0.7,
|
|
60
|
+
#: leaving headroom at both ends rather than pinning everything to one corner.
|
|
61
|
+
_BM25_SATURATION = 5.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _to_unit_scale(scored: list[tuple[str, float]]) -> list[tuple[str, float]]:
|
|
65
|
+
"""Map BM25 scores into [0, 1) without disturbing order or magnitude.
|
|
66
|
+
|
|
67
|
+
WHY ANY OF THIS. ``engine.apply_channel_weights`` re-scores a candidate as
|
|
68
|
+
``sum(channel_scores[ch] * weights[ch])`` — a SUM of raw channel scores.
|
|
69
|
+
Every other channel is bounded: semantic cosine and Fisher-Rao are [0, 1],
|
|
70
|
+
the temporal proximity score is Gaussian on [0, 1]. BM25 is not. Measured on
|
|
71
|
+
a live store, one query at a time::
|
|
72
|
+
|
|
73
|
+
"slm release" max 2.845
|
|
74
|
+
"memory" max 3.865
|
|
75
|
+
"the release ships once both audits are clean" max 10.150
|
|
76
|
+
|
|
77
|
+
So the sum was decided by a scale rather than by evidence, and any weight
|
|
78
|
+
the bandit converged on was a correction for that scale — wrong the moment
|
|
79
|
+
the query length changed.
|
|
80
|
+
|
|
81
|
+
WHY NOT DIVIDE BY THE BATCH MAXIMUM. That was the first implementation here
|
|
82
|
+
and it is wrong, which an existing test caught before it shipped:
|
|
83
|
+
``test_real_fts5_exact_hit_keeps_bounded_slot`` exists to prove *"a real
|
|
84
|
+
sub-1.0 FTS5 hit remains visible under semantic pressure"*. Dividing by the
|
|
85
|
+
batch maximum makes the best result exactly 1.0 **whatever it scored**, so a
|
|
86
|
+
query with one weak lexical match reports full confidence and outranks a
|
|
87
|
+
semantic channel it should lose to. Batch-relative scaling manufactures
|
|
88
|
+
confidence out of an empty batch, and it also makes a fact's score depend on
|
|
89
|
+
which other facts happened to come back — the same query returning a
|
|
90
|
+
different number on a different day, which HARD-RULES RULE 6 puts above
|
|
91
|
+
speed.
|
|
92
|
+
|
|
93
|
+
A saturating transform has neither problem: ``s / (s + k)`` is strictly
|
|
94
|
+
increasing, so order is untouched; it is bounded below 1.0, so nothing is
|
|
95
|
+
ever certain; and it depends only on the score itself, so it is repeatable.
|
|
96
|
+
Applied to the measurements above: 2.676 -> 0.35, 2.845 -> 0.36,
|
|
97
|
+
3.865 -> 0.44, 10.150 -> 0.67.
|
|
98
|
+
|
|
99
|
+
FUSION IS UNAFFECTED, verified by reading ``fusion.weighted_rrf`` rather
|
|
100
|
+
than assuming: it computes ``fused += w / (k + rank)`` and keeps the score
|
|
101
|
+
only for reporting. Rescaling a value nothing divides by cannot move a
|
|
102
|
+
fused rank.
|
|
103
|
+
|
|
104
|
+
Non-positive scores map to 0.0. FTS5's ``bm25()`` is <= 0 and is negated
|
|
105
|
+
here, so a value at or below zero means "no lexical evidence", and that is
|
|
106
|
+
what it should contribute to a sum.
|
|
107
|
+
"""
|
|
108
|
+
if not scored:
|
|
109
|
+
return scored
|
|
110
|
+
return [
|
|
111
|
+
(fid, (s / (s + _BM25_SATURATION)) if s > 0.0 else 0.0)
|
|
112
|
+
for fid, s in scored
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
57
116
|
class BM25Channel:
|
|
58
117
|
"""Persistent BM25Plus index for keyword retrieval.
|
|
59
118
|
|
|
@@ -300,10 +359,10 @@ class BM25Channel:
|
|
|
300
359
|
# Falls back to rank_bm25 ONLY if the FTS5 table is genuinely
|
|
301
360
|
# unavailable (raises) — e.g. a pre-FTS legacy DB.
|
|
302
361
|
try:
|
|
303
|
-
return self._fts5_search(
|
|
362
|
+
return _to_unit_scale(self._fts5_search(
|
|
304
363
|
query, profile_id, top_k,
|
|
305
364
|
include_global=include_global, include_shared=include_shared,
|
|
306
|
-
)
|
|
365
|
+
))
|
|
307
366
|
except Exception as exc: # pragma: no cover — legacy/missing FTS table
|
|
308
367
|
logger.debug(
|
|
309
368
|
"BM25 FTS5 path unavailable, using rank_bm25 fallback: %s", exc,
|
|
@@ -338,7 +397,9 @@ class BM25Channel:
|
|
|
338
397
|
scored.append((self._fact_ids[i], bonus))
|
|
339
398
|
|
|
340
399
|
scored.sort(key=lambda x: (-x[1], x[0]))
|
|
341
|
-
|
|
400
|
+
# Same rescale as the FTS5 path. This fallback applies a 1.5x exact
|
|
401
|
+
# phrase bonus, so its raw ceiling is higher still.
|
|
402
|
+
return _to_unit_scale(scored[:top_k])
|
|
342
403
|
|
|
343
404
|
def update_fact(self, fact_id: str, new_content: str, profile_id: str) -> None:
|
|
344
405
|
"""Replace a fact's representation in the live index and persist new tokens.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""What happened to each retrieval channel on one recall.
|
|
5
|
+
|
|
6
|
+
WHY THIS EXISTS
|
|
7
|
+
---------------
|
|
8
|
+
A channel that crashes on every query and a channel that correctly found
|
|
9
|
+
nothing produced the same observable result: absence. Both simply had no entry
|
|
10
|
+
in the fused candidate map, and the only trace was a log line on a machine
|
|
11
|
+
nobody is reading.
|
|
12
|
+
|
|
13
|
+
That matters because the channels are not interchangeable. Lexical search
|
|
14
|
+
finding nothing for a conceptual question is the system working. Lexical search
|
|
15
|
+
raising on every question is an outage that looks, from the outside, like a
|
|
16
|
+
store with nothing in it — the user sees weaker answers and has no way to tell
|
|
17
|
+
which of the two they are getting.
|
|
18
|
+
|
|
19
|
+
There is a third case the absence hid, and it is the worst of them: a channel
|
|
20
|
+
that never ran. When the query embedding is unavailable, three of the five
|
|
21
|
+
channels are never even dispatched. Nothing in the answer said so.
|
|
22
|
+
|
|
23
|
+
WHY MORE THAN "ok / empty / error"
|
|
24
|
+
----------------------------------
|
|
25
|
+
Because the remedies differ, and a status whose remedy is ambiguous is a status
|
|
26
|
+
nobody acts on. ``error`` is a bug to fix. ``timeout`` is a capacity or data-size
|
|
27
|
+
problem. ``no_embedding`` means the embedding provider is down and several
|
|
28
|
+
channels are silently offline together. ``disabled`` and ``not_configured`` are
|
|
29
|
+
someone's deliberate choice and must not read as faults — which is the point:
|
|
30
|
+
without naming them, an operator reading a list of missing channels cannot tell
|
|
31
|
+
their own configuration apart from a failure.
|
|
32
|
+
|
|
33
|
+
STRINGS, NOT AN ENUM
|
|
34
|
+
--------------------
|
|
35
|
+
This crosses the MCP and HTTP surfaces, where it is JSON either way. A str-valued
|
|
36
|
+
constant serialises without a custom encoder and compares equal to what a client
|
|
37
|
+
sends back.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import Literal
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ALL_STATUSES",
|
|
46
|
+
"CHANNEL_NAMES",
|
|
47
|
+
"ChannelStatus",
|
|
48
|
+
"DISABLED",
|
|
49
|
+
"EMPTY",
|
|
50
|
+
"ERROR",
|
|
51
|
+
"NOT_CONFIGURED",
|
|
52
|
+
"NO_CANDIDATES",
|
|
53
|
+
"NO_EMBEDDING",
|
|
54
|
+
"OK",
|
|
55
|
+
"TIMEOUT",
|
|
56
|
+
"is_fault",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
ChannelStatus = Literal[
|
|
60
|
+
"ok",
|
|
61
|
+
"empty",
|
|
62
|
+
"error",
|
|
63
|
+
"timeout",
|
|
64
|
+
"disabled",
|
|
65
|
+
"not_configured",
|
|
66
|
+
"no_embedding",
|
|
67
|
+
"no_candidates",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
#: Ran and contributed candidates.
|
|
71
|
+
OK: ChannelStatus = "ok"
|
|
72
|
+
#: Ran, contributed nothing. A legitimate answer, not a fault.
|
|
73
|
+
EMPTY: ChannelStatus = "empty"
|
|
74
|
+
#: Raised. This answer is missing whatever this channel alone could see.
|
|
75
|
+
ERROR: ChannelStatus = "error"
|
|
76
|
+
#: Abandoned at the hang guard. The answer is incomplete, not merely late.
|
|
77
|
+
TIMEOUT: ChannelStatus = "timeout"
|
|
78
|
+
#: Excluded by configuration or by a per-recall ablation flag.
|
|
79
|
+
DISABLED: ChannelStatus = "disabled"
|
|
80
|
+
#: No such channel on this engine — not built, or its dependency is absent.
|
|
81
|
+
NOT_CONFIGURED: ChannelStatus = "not_configured"
|
|
82
|
+
#: Needed the query embedding, which was unavailable. Several channels fail
|
|
83
|
+
#: together this way, and none of them individually did anything wrong.
|
|
84
|
+
NO_EMBEDDING: ChannelStatus = "no_embedding"
|
|
85
|
+
#: Had nothing to work on. Distinct from ``empty``, which claims a search
|
|
86
|
+
#: happened: a channel that re-scores other channels' candidates never searched
|
|
87
|
+
#: at all when there were none, and if the reason there were none is that the
|
|
88
|
+
#: others failed, calling this "found nothing" hides the actual fault.
|
|
89
|
+
NO_CANDIDATES: ChannelStatus = "no_candidates"
|
|
90
|
+
|
|
91
|
+
ALL_STATUSES: frozenset[str] = frozenset({
|
|
92
|
+
OK, EMPTY, ERROR, TIMEOUT, DISABLED, NOT_CONFIGURED, NO_EMBEDDING,
|
|
93
|
+
NO_CANDIDATES,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
#: Every channel a recall can report on, so a caller can tell "this channel had
|
|
97
|
+
#: no status recorded" (a gap in the reporting) from "this channel reported that
|
|
98
|
+
#: it did nothing" (an answer). A missing key is a bug; ``empty`` is not.
|
|
99
|
+
CHANNEL_NAMES: tuple[str, ...] = (
|
|
100
|
+
"semantic",
|
|
101
|
+
"bm25",
|
|
102
|
+
"temporal",
|
|
103
|
+
"hopfield",
|
|
104
|
+
"spreading_activation",
|
|
105
|
+
"entity_graph",
|
|
106
|
+
"profile",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
#: Statuses that mean the answer is worse than it should have been. ``empty``,
|
|
110
|
+
#: ``disabled`` and ``not_configured`` are deliberately absent: the first is a
|
|
111
|
+
#: valid finding and the other two are somebody's decision.
|
|
112
|
+
_FAULTS: frozenset[str] = frozenset({ERROR, TIMEOUT, NO_EMBEDDING})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_fault(status: str | None) -> bool:
|
|
116
|
+
"""Whether this status means the answer was degraded."""
|
|
117
|
+
return (status or "") in _FAULTS
|