agentcache-core 0.9.9__py3-none-any.whl
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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- agentcache_core-0.9.9.dist-info/top_level.txt +1 -0
agentcache/core/infer.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Type inference, text extraction, and synthetic compression helpers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def clip_embed_input(text: str) -> str:
|
|
9
|
+
EMBED_MAX_CHARS = 16000
|
|
10
|
+
if len(text) <= EMBED_MAX_CHARS:
|
|
11
|
+
return text
|
|
12
|
+
return text[:EMBED_MAX_CHARS]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def infer_type(tool_name: Optional[str], hook_type: str) -> str:
|
|
16
|
+
if hook_type == "post_tool_failure":
|
|
17
|
+
return "error"
|
|
18
|
+
if hook_type == "prompt_submit":
|
|
19
|
+
return "conversation"
|
|
20
|
+
if hook_type in ("subagent_stop", "task_completed"):
|
|
21
|
+
return "subagent"
|
|
22
|
+
if hook_type == "notification":
|
|
23
|
+
return "notification"
|
|
24
|
+
|
|
25
|
+
if not tool_name:
|
|
26
|
+
return "other"
|
|
27
|
+
|
|
28
|
+
n = re.sub(r"([a-z])([A-Z])", r"\1_\2", tool_name)
|
|
29
|
+
n = re.sub(r"[-\s]+", "_", n).lower()
|
|
30
|
+
|
|
31
|
+
def has_word(word: str) -> bool:
|
|
32
|
+
return (
|
|
33
|
+
bool(re.search(rf"(^|_){word}(_|$)", n))
|
|
34
|
+
or n == word
|
|
35
|
+
or n.endswith(word)
|
|
36
|
+
or n.startswith(word)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if any(has_word(w) for w in ["fetch", "http", "web"]):
|
|
40
|
+
return "web_fetch"
|
|
41
|
+
if any(has_word(w) for w in ["grep", "search", "glob", "find"]):
|
|
42
|
+
return "search"
|
|
43
|
+
if any(has_word(w) for w in ["bash", "shell", "exec", "run"]):
|
|
44
|
+
return "command_run"
|
|
45
|
+
if any(has_word(w) for w in ["edit", "update", "patch", "replace"]):
|
|
46
|
+
return "file_edit"
|
|
47
|
+
if any(has_word(w) for w in ["write", "create"]):
|
|
48
|
+
return "file_write"
|
|
49
|
+
if any(has_word(w) for w in ["read", "view"]):
|
|
50
|
+
return "file_read"
|
|
51
|
+
if any(has_word(w) for w in ["task", "agent"]):
|
|
52
|
+
return "subagent"
|
|
53
|
+
return "other"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_files(input_data: Any) -> List[str]:
|
|
57
|
+
if not input_data or not isinstance(input_data, dict):
|
|
58
|
+
return []
|
|
59
|
+
out = set()
|
|
60
|
+
for key in ["file_path", "filepath", "path", "filePath", "file", "pattern"]:
|
|
61
|
+
v = input_data.get(key)
|
|
62
|
+
if isinstance(v, str) and 0 < len(v) < 512:
|
|
63
|
+
out.add(v)
|
|
64
|
+
return list(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stringify_for_narrative(v: Any) -> str:
|
|
68
|
+
if v is None:
|
|
69
|
+
return ""
|
|
70
|
+
if isinstance(v, str):
|
|
71
|
+
return v
|
|
72
|
+
try:
|
|
73
|
+
return json.dumps(v)
|
|
74
|
+
except Exception:
|
|
75
|
+
return str(v)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def vector_index_add_guarded(
|
|
79
|
+
obs_id: str, session_id: str, text: str, context: Dict[str, Any]
|
|
80
|
+
) -> bool:
|
|
81
|
+
from .. import legacy as _legacy
|
|
82
|
+
|
|
83
|
+
if _legacy._search_service is None:
|
|
84
|
+
return False
|
|
85
|
+
ep = _legacy._search_service.embedding_provider
|
|
86
|
+
vi = _legacy._search_service.vector
|
|
87
|
+
if not vi or not ep:
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
clipped = clip_embed_input(text)
|
|
91
|
+
embedding = ep.embed(clipped)
|
|
92
|
+
if len(embedding) != ep.dimensions:
|
|
93
|
+
print(
|
|
94
|
+
f"[vector-index] Dimension mismatch: expected {ep.dimensions}, got {len(embedding)}"
|
|
95
|
+
)
|
|
96
|
+
return False
|
|
97
|
+
vi.add(obs_id, session_id, embedding)
|
|
98
|
+
return True
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(f"[vector-index] Embed failed: {e}")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_synthetic_compression(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
105
|
+
tool_name = raw.get("toolName") or raw.get("hookType")
|
|
106
|
+
input_str = stringify_for_narrative(raw.get("toolInput"))
|
|
107
|
+
output_str = stringify_for_narrative(raw.get("toolOutput"))
|
|
108
|
+
prompt_str = raw.get("userPrompt") or ""
|
|
109
|
+
|
|
110
|
+
parts = [s for s in [prompt_str, input_str, output_str] if len(s) > 0]
|
|
111
|
+
narrative = " | ".join(parts)
|
|
112
|
+
if len(narrative) > 400:
|
|
113
|
+
narrative = narrative[:399] + "…"
|
|
114
|
+
|
|
115
|
+
title = tool_name or "observation"
|
|
116
|
+
if len(title) > 80:
|
|
117
|
+
title = title[:79] + "…"
|
|
118
|
+
|
|
119
|
+
subtitle = None
|
|
120
|
+
if input_str:
|
|
121
|
+
subtitle = input_str
|
|
122
|
+
if len(subtitle) > 120:
|
|
123
|
+
subtitle = subtitle[:119] + "…"
|
|
124
|
+
|
|
125
|
+
res = {
|
|
126
|
+
"id": raw["id"],
|
|
127
|
+
"sessionId": raw["sessionId"],
|
|
128
|
+
"timestamp": raw["timestamp"],
|
|
129
|
+
"type": infer_type(raw.get("toolName"), raw["hookType"]),
|
|
130
|
+
"title": title,
|
|
131
|
+
"subtitle": subtitle,
|
|
132
|
+
"facts": [],
|
|
133
|
+
"narrative": narrative,
|
|
134
|
+
"concepts": [],
|
|
135
|
+
"files": extract_files(raw.get("toolInput")),
|
|
136
|
+
"importance": 5,
|
|
137
|
+
"confidence": 0.3,
|
|
138
|
+
}
|
|
139
|
+
for k in ["modality", "imageData", "agentId"]:
|
|
140
|
+
if raw.get(k) is not None:
|
|
141
|
+
res[k] = raw[k]
|
|
142
|
+
return res
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KV scope key registry.
|
|
3
|
+
|
|
4
|
+
Single source of truth for every SQLite scope string used in the system.
|
|
5
|
+
Import this module wherever a KV scope key is needed — routes, stores, workers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KV:
|
|
10
|
+
# ---- Folder memory scopes ----
|
|
11
|
+
|
|
12
|
+
# Global index of all (folder_path, agent_id) pairs known to the system.
|
|
13
|
+
# Key = "{safe_folder_path}:{agent_id}", value = FolderIndexEntry dict.
|
|
14
|
+
folders = "mem:folders"
|
|
15
|
+
|
|
16
|
+
# Lookup index for O(1) observation hydration.
|
|
17
|
+
# Scope = "mem:obs_lookup", Key = obs_id, Value = {"folderPath": ..., "agentId": ...}
|
|
18
|
+
obs_lookup = "mem:obs_lookup"
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def folder_obs(folder_path: str, agent_id: str) -> str:
|
|
22
|
+
"""Per-(folder, agent) observations scope.
|
|
23
|
+
Key = obs_id, value = FolderObservation dict.
|
|
24
|
+
"""
|
|
25
|
+
safe_path = folder_path.replace("\\", "/").strip("/")
|
|
26
|
+
safe_agent = agent_id.strip()
|
|
27
|
+
return f"mem:folder:{safe_path}:{safe_agent}"
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def folder_meta(folder_path: str, agent_id: str) -> str:
|
|
31
|
+
"""Per-(folder, agent) metadata scope.
|
|
32
|
+
Key = "meta", value = FolderMeta dict (obsCount, lastUpdated, summary).
|
|
33
|
+
"""
|
|
34
|
+
safe_path = folder_path.replace("\\", "/").strip("/")
|
|
35
|
+
safe_agent = agent_id.strip()
|
|
36
|
+
return f"mem:foldermeta:{safe_path}:{safe_agent}"
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def obs_dedup(folder_path: str, agent_id: str) -> str:
|
|
40
|
+
"""Deduplication index scope for (folder, agent) pairs.
|
|
41
|
+
Key = SHA-256 fingerprint hex of normalized text.
|
|
42
|
+
Value = {"obsId": str, "timestamp": str}
|
|
43
|
+
"""
|
|
44
|
+
safe_path = folder_path.replace("\\", "/").strip("/")
|
|
45
|
+
safe_agent = agent_id.strip()
|
|
46
|
+
return f"mem:obs_dedup:{safe_path}:{safe_agent}"
|
|
47
|
+
|
|
48
|
+
# ---- Global / shared scopes ----
|
|
49
|
+
|
|
50
|
+
# Long-term memories.
|
|
51
|
+
memories = "mem:memories"
|
|
52
|
+
|
|
53
|
+
# BM25 index shards.
|
|
54
|
+
bm25Index = "mem:index:bm25"
|
|
55
|
+
|
|
56
|
+
# Audit log.
|
|
57
|
+
audit = "mem:audit"
|
|
58
|
+
|
|
59
|
+
# Graph edges.
|
|
60
|
+
relations = "mem:relations"
|
|
61
|
+
|
|
62
|
+
# ---- Legacy scopes (read-only; kept for migration and backward compat) ----
|
|
63
|
+
|
|
64
|
+
# Legacy session store.
|
|
65
|
+
sessions = "mem:sessions"
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def observations(session_id: str) -> str:
|
|
69
|
+
"""Legacy per-session observations scope."""
|
|
70
|
+
return f"mem:obs:{session_id}"
|
|
71
|
+
|
|
72
|
+
# Lessons — confidence-scored learning entries.
|
|
73
|
+
lessons = "mem:lessons"
|
|
74
|
+
|
|
75
|
+
# Legacy summary / profile / slot / image-ref scopes.
|
|
76
|
+
summaries = "mem:summaries"
|
|
77
|
+
profiles = "mem:profiles"
|
|
78
|
+
slots = "mem:slots"
|
|
79
|
+
imageRefs = "mem:image-refs"
|
|
80
|
+
|
|
81
|
+
# Global (cross-project) pinned slots.
|
|
82
|
+
globalSlots = "mem:global-slots"
|
|
83
|
+
|
|
84
|
+
# Semantic and procedural memory scopes (used by consolidate).
|
|
85
|
+
semantic = "mem:semantic"
|
|
86
|
+
procedural = "mem:procedural"
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Lessons learned — save, recall, reinforce, decay."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from ..db import StateKV
|
|
7
|
+
from ..storage.paths import fingerprint_id
|
|
8
|
+
from .audit_log import safe_audit
|
|
9
|
+
from .config import commit_if_enabled, get_agent_id
|
|
10
|
+
from .kv_scopes import KV
|
|
11
|
+
from .privacy import strip_private_data
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def reinforce_lesson(lesson: Dict[str, Any]) -> None:
|
|
15
|
+
now = (
|
|
16
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
17
|
+
)
|
|
18
|
+
lesson["reinforcements"] = lesson.get("reinforcements", 0) + 1
|
|
19
|
+
conf = lesson.get("confidence", 0.5)
|
|
20
|
+
lesson["confidence"] = min(1.0, conf + 0.1 * (1 - conf))
|
|
21
|
+
lesson["lastReinforcedAt"] = now
|
|
22
|
+
lesson["updatedAt"] = now
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lesson_save(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
26
|
+
content = data.get("content")
|
|
27
|
+
if not content or not content.strip():
|
|
28
|
+
return {"success": False, "error": "content is required"}
|
|
29
|
+
content = strip_private_data(content)
|
|
30
|
+
context_str = strip_private_data(data.get("context") or "")
|
|
31
|
+
|
|
32
|
+
agent_id = data.get("agentId") or get_agent_id()
|
|
33
|
+
fp = fingerprint_id("lsn", content)
|
|
34
|
+
existing = kv.get(KV.lessons, fp)
|
|
35
|
+
|
|
36
|
+
if existing and not existing.get("deleted"):
|
|
37
|
+
reinforce_lesson(existing)
|
|
38
|
+
if context_str and not existing.get("context"):
|
|
39
|
+
existing["context"] = context_str
|
|
40
|
+
kv.set(KV.lessons, existing["id"], existing)
|
|
41
|
+
safe_audit(kv, "lesson_strengthen", "mem::lesson-save", [existing["id"]])
|
|
42
|
+
|
|
43
|
+
commit_if_enabled(
|
|
44
|
+
kv, f"Strengthen lesson: {existing.get('content', '')[:60]}", agent_id
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return {"success": True, "action": "strengthened", "lesson": existing}
|
|
48
|
+
|
|
49
|
+
confidence = data.get("confidence")
|
|
50
|
+
if not isinstance(confidence, (int, float)) or confidence < 0 or confidence > 1:
|
|
51
|
+
confidence = 0.5
|
|
52
|
+
|
|
53
|
+
now = (
|
|
54
|
+
datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
55
|
+
)
|
|
56
|
+
lesson = {
|
|
57
|
+
"id": fp,
|
|
58
|
+
"content": content.strip(),
|
|
59
|
+
"context": context_str.strip(),
|
|
60
|
+
"confidence": confidence,
|
|
61
|
+
"reinforcements": 0,
|
|
62
|
+
"source": data.get("source") or "manual",
|
|
63
|
+
"sourceIds": data.get("sourceIds") or [],
|
|
64
|
+
"project": data.get("project"),
|
|
65
|
+
"tags": data.get("tags") or [],
|
|
66
|
+
"createdAt": now,
|
|
67
|
+
"updatedAt": now,
|
|
68
|
+
"decayRate": 0.05,
|
|
69
|
+
}
|
|
70
|
+
kv.set(KV.lessons, lesson["id"], lesson)
|
|
71
|
+
safe_audit(kv, "lesson_save", "mem::lesson-save", [lesson["id"]])
|
|
72
|
+
|
|
73
|
+
commit_if_enabled(kv, f"Create lesson: {lesson['content'][:60]}", agent_id)
|
|
74
|
+
|
|
75
|
+
return {"success": True, "action": "created", "lesson": lesson}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lesson_list(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
79
|
+
limit = data.get("limit") or 50
|
|
80
|
+
min_confidence = data.get("minConfidence") or 0.0
|
|
81
|
+
all_lessons = kv.list(KV.lessons)
|
|
82
|
+
|
|
83
|
+
lessons = [
|
|
84
|
+
les
|
|
85
|
+
for les in all_lessons
|
|
86
|
+
if not les.get("deleted") and les.get("confidence", 0.5) >= min_confidence
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
project = data.get("project")
|
|
90
|
+
if project:
|
|
91
|
+
lessons = [les for les in lessons if les.get("project") == project]
|
|
92
|
+
source = data.get("source")
|
|
93
|
+
if source:
|
|
94
|
+
lessons = [les for les in lessons if les.get("source") == source]
|
|
95
|
+
|
|
96
|
+
lessons.sort(key=lambda x: x.get("confidence", 0.5), reverse=True)
|
|
97
|
+
return {"success": True, "lessons": lessons[:limit]}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def lesson_recall(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
101
|
+
query = data.get("query")
|
|
102
|
+
if not query or not query.strip():
|
|
103
|
+
return {"success": False, "error": "query is required"}
|
|
104
|
+
|
|
105
|
+
query_lower = query.lower()
|
|
106
|
+
min_confidence = data.get("minConfidence") or 0.1
|
|
107
|
+
limit = data.get("limit") or 10
|
|
108
|
+
|
|
109
|
+
all_lessons = kv.list(KV.lessons)
|
|
110
|
+
lessons = [
|
|
111
|
+
les
|
|
112
|
+
for les in all_lessons
|
|
113
|
+
if not les.get("deleted") and les.get("confidence", 0.5) >= min_confidence
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
project = data.get("project")
|
|
117
|
+
if project:
|
|
118
|
+
lessons = [les for les in lessons if les.get("project") == project]
|
|
119
|
+
|
|
120
|
+
scored = []
|
|
121
|
+
terms = [t for t in query_lower.split() if len(t) > 1]
|
|
122
|
+
|
|
123
|
+
for les in lessons:
|
|
124
|
+
text = f"{les.get('content', '')} {les.get('context', '')} {' '.join(les.get('tags') or [])}".lower()
|
|
125
|
+
match_count = sum(1 for t in terms if t in text)
|
|
126
|
+
if match_count == 0:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
relevance = match_count / len(terms)
|
|
130
|
+
baseline = les.get("lastReinforcedAt") or les.get("createdAt")
|
|
131
|
+
import dateutil.parser
|
|
132
|
+
|
|
133
|
+
dt = dateutil.parser.parse(baseline)
|
|
134
|
+
days = (
|
|
135
|
+
datetime.datetime.now(datetime.timezone.utc)
|
|
136
|
+
- dt.replace(tzinfo=datetime.timezone.utc)
|
|
137
|
+
).total_seconds() / (3600 * 24)
|
|
138
|
+
recency_boost = 1 / (1 + days * 0.01)
|
|
139
|
+
score = les.get("confidence", 0.5) * relevance * recency_boost
|
|
140
|
+
scored.append({"lesson": les, "score": score})
|
|
141
|
+
|
|
142
|
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
|
143
|
+
results = []
|
|
144
|
+
for s in scored[:limit]:
|
|
145
|
+
item = dict(s["lesson"])
|
|
146
|
+
item["score"] = round(s["score"], 3)
|
|
147
|
+
results.append(item)
|
|
148
|
+
|
|
149
|
+
safe_audit(
|
|
150
|
+
kv,
|
|
151
|
+
"lesson_recall",
|
|
152
|
+
"mem::lesson-recall",
|
|
153
|
+
[],
|
|
154
|
+
{"query": query, "resultCount": len(results)},
|
|
155
|
+
)
|
|
156
|
+
return {"success": True, "lessons": results}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def lesson_strengthen(kv: StateKV, lesson_id: str) -> Dict[str, Any]:
|
|
160
|
+
lesson = kv.get(KV.lessons, lesson_id)
|
|
161
|
+
if not lesson or lesson.get("deleted"):
|
|
162
|
+
return {"success": False, "error": "lesson not found"}
|
|
163
|
+
|
|
164
|
+
reinforce_lesson(lesson)
|
|
165
|
+
kv.set(KV.lessons, lesson["id"], lesson)
|
|
166
|
+
safe_audit(kv, "lesson_strengthen", "mem::lesson-strengthen", [lesson["id"]])
|
|
167
|
+
|
|
168
|
+
commit_if_enabled(
|
|
169
|
+
kv, f"Strengthen lesson: {lesson.get('content', '')[:60]}", get_agent_id()
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {"success": True, "lesson": lesson}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def lesson_decay_sweep(kv: StateKV) -> Dict[str, Any]:
|
|
176
|
+
all_lessons = kv.list(KV.lessons)
|
|
177
|
+
decayed = 0
|
|
178
|
+
soft_deleted = 0
|
|
179
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
180
|
+
timestamp = now.isoformat().replace("+00:00", "Z")
|
|
181
|
+
|
|
182
|
+
for les in all_lessons:
|
|
183
|
+
if les.get("deleted"):
|
|
184
|
+
continue
|
|
185
|
+
baseline_str = (
|
|
186
|
+
les.get("lastDecayedAt") or les.get("lastReinforcedAt") or les["createdAt"]
|
|
187
|
+
)
|
|
188
|
+
import dateutil.parser
|
|
189
|
+
|
|
190
|
+
dt = dateutil.parser.parse(baseline_str)
|
|
191
|
+
weeks = (now - dt.replace(tzinfo=datetime.timezone.utc)).total_seconds() / (
|
|
192
|
+
3600 * 24 * 7
|
|
193
|
+
)
|
|
194
|
+
if weeks < 1.0:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
decay = les.get("decayRate", 0.05) * weeks
|
|
198
|
+
new_conf = max(0.05, les.get("confidence", 0.5) - decay)
|
|
199
|
+
|
|
200
|
+
if new_conf != les.get("confidence"):
|
|
201
|
+
before = les.get("confidence", 0.5)
|
|
202
|
+
les["confidence"] = round(new_conf, 3)
|
|
203
|
+
les["lastDecayedAt"] = timestamp
|
|
204
|
+
les["updatedAt"] = timestamp
|
|
205
|
+
|
|
206
|
+
if les["confidence"] <= 0.1 and les.get("reinforcements", 0) == 0:
|
|
207
|
+
les["deleted"] = True
|
|
208
|
+
soft_deleted += 1
|
|
209
|
+
else:
|
|
210
|
+
decayed += 1
|
|
211
|
+
|
|
212
|
+
kv.set(KV.lessons, les["id"], les)
|
|
213
|
+
safe_audit(
|
|
214
|
+
kv,
|
|
215
|
+
"lesson_strengthen",
|
|
216
|
+
"mem::lesson-decay-sweep",
|
|
217
|
+
[les["id"]],
|
|
218
|
+
{
|
|
219
|
+
"action": "soft-delete" if les.get("deleted") else "decay",
|
|
220
|
+
"actor": "system",
|
|
221
|
+
"reason": "decay-sweep",
|
|
222
|
+
"before": {"confidence": before, "deleted": False},
|
|
223
|
+
"after": {
|
|
224
|
+
"confidence": les["confidence"],
|
|
225
|
+
"deleted": bool(les.get("deleted")),
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if decayed > 0 or soft_deleted > 0:
|
|
231
|
+
commit_if_enabled(
|
|
232
|
+
kv,
|
|
233
|
+
f"Lesson decay sweep: decayed {decayed}, soft-deleted {soft_deleted}",
|
|
234
|
+
"system",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
"success": True,
|
|
239
|
+
"decayed": decayed,
|
|
240
|
+
"softDeleted": soft_deleted,
|
|
241
|
+
"total": len(all_lessons),
|
|
242
|
+
}
|