loop-memory 0.4.0__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.
Files changed (84) hide show
  1. loop_memory/__init__.py +62 -0
  2. loop_memory/backends/__init__.py +13 -0
  3. loop_memory/backends/embedding.py +82 -0
  4. loop_memory/backends/sentence_embedder.py +30 -0
  5. loop_memory/backends/vector_store.py +139 -0
  6. loop_memory/cli/__init__.py +0 -0
  7. loop_memory/cli/_common.py +68 -0
  8. loop_memory/cli/commands/__init__.py +13 -0
  9. loop_memory/cli/commands/cognitive.py +205 -0
  10. loop_memory/cli/commands/diag.py +346 -0
  11. loop_memory/cli/commands/graph.py +21 -0
  12. loop_memory/cli/commands/hooks.py +212 -0
  13. loop_memory/cli/commands/read.py +362 -0
  14. loop_memory/cli/commands/serve.py +147 -0
  15. loop_memory/cli/commands/write.py +138 -0
  16. loop_memory/cli/main.py +115 -0
  17. loop_memory/engine/__init__.py +0 -0
  18. loop_memory/engine/loop.py +247 -0
  19. loop_memory/engine/reflect.py +89 -0
  20. loop_memory/examples/__init__.py +0 -0
  21. loop_memory/examples/demo.py +39 -0
  22. loop_memory/export/__init__.py +39 -0
  23. loop_memory/export/memory_md.py +629 -0
  24. loop_memory/graph/__init__.py +0 -0
  25. loop_memory/graph/build.py +259 -0
  26. loop_memory/graph/extract.py +197 -0
  27. loop_memory/ingest/__init__.py +0 -0
  28. loop_memory/ingest/loader.py +782 -0
  29. loop_memory/ingest/pipeline.py +458 -0
  30. loop_memory/jobs/__init__.py +0 -0
  31. loop_memory/jobs/cognitive.py +353 -0
  32. loop_memory/jobs/compact.py +371 -0
  33. loop_memory/jobs/consolidate.py +95 -0
  34. loop_memory/jobs/contradiction.py +281 -0
  35. loop_memory/jobs/evolution.py +2021 -0
  36. loop_memory/jobs/graph.py +395 -0
  37. loop_memory/jobs/llm_compact_pass.py +24 -0
  38. loop_memory/jobs/llm_consolidate.py +980 -0
  39. loop_memory/jobs/scheduler.py +495 -0
  40. loop_memory/llm/__init__.py +0 -0
  41. loop_memory/llm/base.py +80 -0
  42. loop_memory/llm/openai_adapter.py +31 -0
  43. loop_memory/llm/providers.py +517 -0
  44. loop_memory/mcp/__init__.py +804 -0
  45. loop_memory/memory/__init__.py +0 -0
  46. loop_memory/memory/types.py +199 -0
  47. loop_memory/privacy/__init__.py +22 -0
  48. loop_memory/privacy/private.py +46 -0
  49. loop_memory/privacy/redact.py +188 -0
  50. loop_memory/py.typed +0 -0
  51. loop_memory/sdk.py +875 -0
  52. loop_memory/sdk_extensions.py +384 -0
  53. loop_memory/security/__init__.py +20 -0
  54. loop_memory/security/secrets.py +464 -0
  55. loop_memory/serve/__init__.py +0 -0
  56. loop_memory/serve/app.py +506 -0
  57. loop_memory/serve/handlers.py +316 -0
  58. loop_memory/serve/routes/_shared.py +59 -0
  59. loop_memory/serve/routes/admin.py +970 -0
  60. loop_memory/serve/routes/cognitive.py +64 -0
  61. loop_memory/serve/routes/export.py +65 -0
  62. loop_memory/serve/routes/graph.py +101 -0
  63. loop_memory/serve/routes/insights.py +702 -0
  64. loop_memory/serve/routes/memories.py +435 -0
  65. loop_memory/serve/routes/sessions.py +75 -0
  66. loop_memory/serve/routes/system.py +493 -0
  67. loop_memory/serve/routes/wiki.py +812 -0
  68. loop_memory/serve/static/__init__.py +0 -0
  69. loop_memory/serve/static/index.html +15 -0
  70. loop_memory/serve/watcher.py +451 -0
  71. loop_memory/storage/__init__.py +5 -0
  72. loop_memory/storage/retrieval.py +365 -0
  73. loop_memory/storage/sqlite_store.py +3627 -0
  74. loop_memory/wiki/__init__.py +41 -0
  75. loop_memory/wiki/backfill.py +143 -0
  76. loop_memory/wiki/classifier.py +238 -0
  77. loop_memory/wiki/prompts.py +295 -0
  78. loop_memory/wiki/scope.py +227 -0
  79. loop_memory-0.4.0.dist-info/METADATA +627 -0
  80. loop_memory-0.4.0.dist-info/RECORD +84 -0
  81. loop_memory-0.4.0.dist-info/WHEEL +5 -0
  82. loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
  83. loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
  84. loop_memory-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,41 @@
1
+ """Per-page knowledge classification (security scope routing) and
2
+ default-scope derivation helpers.
3
+
4
+ These are intentionally lightweight (no third-party deps, no model
5
+ calls by default) so they can run on every wiki upsert without
6
+ blocking the request thread.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from .classifier import Classification, classify_page, pattern_classify
11
+ from .scope import (
12
+ CLIENT_SCOPE_TOKENS,
13
+ DEFAULT_CLIENT_SCOPE,
14
+ VALID_SCOPE_TOKENS,
15
+ auto_scope_config,
16
+ build_scope_audit,
17
+ derive_default_scope,
18
+ derive_scope_from_sources,
19
+ normalise_scope,
20
+ parse_scope,
21
+ source_token,
22
+ )
23
+
24
+ __all__ = [
25
+ "Classification",
26
+ "classify_page",
27
+ "pattern_classify",
28
+ "CLIENT_SCOPE_TOKENS",
29
+ "DEFAULT_CLIENT_SCOPE",
30
+ "VALID_SCOPE_TOKENS",
31
+ "auto_scope_config",
32
+ "build_scope_audit",
33
+ "derive_default_scope",
34
+ "derive_scope_from_sources",
35
+ "normalise_scope",
36
+ "parse_scope",
37
+ "source_token",
38
+ ]
39
+
40
+ from .backfill import reclassify_legacy_pages
41
+ __all__.append("reclassify_legacy_pages")
@@ -0,0 +1,143 @@
1
+ """One-shot re-classify legacy wiki pages.
2
+
3
+ Existing pages created under the SCHEMA_VERSION<=7 rule "default to
4
+ global" kept ``scope='global'`` regardless of whether the content was
5
+ actually a cross-client universal security best practice. Now that the
6
+ auto-classifier is in place, those legacy rows are out of step with the
7
+ new contract ("security knowledge that's universally applicable →
8
+ global; everything else → per-source scope").
9
+
10
+ This module rewrites those legacy rows on demand. Pages whose content
11
+ fails to qualify as auto-global are downgraded to a per-source scope
12
+ derived from evidence memory sources (falling back to ``codex``), and
13
+ the original global scope + manual-override history are preserved in
14
+ ``auto_classification.history`` so the audit trail still shows what
15
+ changed and why.
16
+
17
+ The backfill is deterministic and opt-in; nothing on the hot path
18
+ imports this module, so it never runs without an explicit CLI / admin
19
+ POST.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from .classifier import classify_page
26
+ from .scope import (
27
+ auto_scope_config,
28
+ build_scope_audit,
29
+ derive_scope_from_sources,
30
+ )
31
+
32
+
33
+ def reclassify_legacy_pages(store: Any, *, batch: int = 200) -> dict:
34
+ """Re-classify pages whose ``auto_classification`` is NULL.
35
+
36
+ Pages with ``auto_classification`` set already went through the
37
+ classifier under the new contract and are left alone; only
38
+ legacy rows are touched. The result dict summarises what
39
+ changed so the caller can show a diff to the user.
40
+
41
+ Parameters
42
+ ----------
43
+ store : MemoryStore
44
+ Backing store. ``list_wiki_pages`` / ``upsert_wiki_page`` /
45
+ ``get_wiki_page`` are used; no other methods are required.
46
+ batch : int
47
+ Maximum number of rows to scan. Pages are visited in updated_at
48
+ DESC order so the most-recently-changed legacy rows get
49
+ priority.
50
+ """
51
+ cfg = auto_scope_config(store)
52
+ pages = store.list_wiki_pages(limit=batch)
53
+ legacy = [
54
+ p for p in pages
55
+ if (p.get("scope") or "global") == "global"
56
+ and p.get("auto_classification") is None
57
+ ]
58
+ summary = {
59
+ "scanned": len(pages),
60
+ "legacy_global": len(legacy),
61
+ "kept_global": 0,
62
+ "downgraded": 0,
63
+ "skipped": 0,
64
+ "items": [],
65
+ }
66
+ for page in legacy:
67
+ classification = classify_page(
68
+ title=page.get("title") or "",
69
+ body=page.get("body") or "",
70
+ summary=page.get("summary") or "",
71
+ tags=page.get("tags") or [],
72
+ evidence_sources=[],
73
+ mode=cfg["mode"] if cfg["enabled"] else "off",
74
+ )
75
+ if classification.auto_global:
76
+ summary["kept_global"] += 1
77
+ summary["items"].append({
78
+ "id": page["id"],
79
+ "slug": page["slug"],
80
+ "title": page.get("title"),
81
+ "decision": "kept-global",
82
+ "scope": "global",
83
+ "reasons": classification.reasons[:4],
84
+ })
85
+ continue
86
+ # Derive the per-source scope from any evidence memories.
87
+ evidence_ids = page.get("evidence_ids") or []
88
+ new_scope = derive_scope_from_sources(
89
+ source_hint=None,
90
+ evidence_sources=[],
91
+ fallback="codex",
92
+ )
93
+ # Build the audit so future calls keep history.
94
+ audit = build_scope_audit(
95
+ classification,
96
+ scope=new_scope,
97
+ decision="legacy-backfill",
98
+ enabled=bool(cfg["enabled"]),
99
+ mode=str(cfg["mode"]),
100
+ existing={"auto_classification": None},
101
+ )
102
+ # Force a history anchor on the original global scope so the
103
+ # audit captures the change.
104
+ audit.setdefault("history", []).append({
105
+ "scope_decision": "legacy-explicit",
106
+ "scope_applied": "global",
107
+ "at_backfill": True,
108
+ })
109
+ try:
110
+ store.upsert_wiki_page(
111
+ slug=page["slug"],
112
+ title=page.get("title") or "",
113
+ body=page.get("body") or "",
114
+ summary=page.get("summary") or "",
115
+ tags=page.get("tags") or [],
116
+ importance=page.get("importance") or 0.5,
117
+ evidence_ids=evidence_ids,
118
+ run_id=page.get("run_id"),
119
+ scope=new_scope,
120
+ auto_classification=audit,
121
+ )
122
+ summary["downgraded"] += 1
123
+ summary["items"].append({
124
+ "id": page["id"],
125
+ "slug": page["slug"],
126
+ "title": page.get("title"),
127
+ "decision": "legacy-backfill",
128
+ "scope": new_scope,
129
+ "reasons": classification.reasons[:4],
130
+ })
131
+ except Exception as e:
132
+ summary["skipped"] += 1
133
+ summary["items"].append({
134
+ "id": page["id"],
135
+ "slug": page["slug"],
136
+ "title": page.get("title"),
137
+ "decision": "skipped",
138
+ "error": str(e),
139
+ })
140
+ return summary
141
+
142
+
143
+ __all__ = ["reclassify_legacy_pages"]
@@ -0,0 +1,238 @@
1
+ """Cheap, explainable classification for distilled wiki knowledge.
2
+
3
+ The classifier deliberately runs without an LLM. It is used on write paths,
4
+ where a network call would make the dashboard fragile and would turn a local
5
+ privacy decision into an external data disclosure. The result is a
6
+ recommendation, never an instruction to override an explicit user scope.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ import re
12
+ from typing import Iterable
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Classification:
17
+ """Explainable classification and scope recommendation for one page."""
18
+
19
+ kind: str
20
+ is_security: bool
21
+ is_universal: bool
22
+ auto_global: bool
23
+ confidence: float
24
+ reasons: list[str] = field(default_factory=list)
25
+ scope_hint: str = "per-source"
26
+ security_hits: int = 0
27
+ universal_hits: int = 0
28
+ personalization_hits: int = 0
29
+ incident_hits: int = 0
30
+ mode: str = "pattern"
31
+
32
+ def to_dict(self) -> dict:
33
+ """Return a JSON-serialisable audit representation."""
34
+ return asdict(self)
35
+
36
+
37
+ _SECURITY_PATTERNS: tuple[tuple[str, str], ...] = (
38
+ ("security-domain", r"安全|\bsecurity\b|secure coding"),
39
+ ("credentials", r"凭证|\bcredentials?\b|用户名和密码|账号密码"),
40
+ ("secrets", r"密钥|机密|秘密|\bsecrets?\b|敏感信息|敏感数据"),
41
+ ("passwords", r"密码|口令|\bpasswords?\b|\bpasswd\b|\bpassphrase\b"),
42
+ ("tokens", r"令牌|访问令牌|\btokens?\b|\bbearer\b"),
43
+ ("api-keys", r"api[ _-]?key|access[ _-]?key|api密钥"),
44
+ ("private-keys", r"private[ _-]?key|私钥|ssh key|ssh密钥"),
45
+ ("injection", r"sql[ _-]?injection|sql注入|命令注入|注入攻击|参数化\s*(?:查询|sql)|(?:parameter|parametr)i[sz]ed? (?:sql|quer(?:y|ies))"),
46
+ ("web-vulnerabilities",
47
+ r"\bxss\b|跨站脚本|\bcsrf\b|跨站请求伪造|\bssrf\b|\brce\b|远程代码执行|remote code execution"),
48
+ ("vulnerabilities", r"漏洞|vulnerability|security flaw|安全缺陷|安全漏洞"),
49
+ ("cve", r"\bcve[- ]?\d{4}[- ]?\d{3,}\b"),
50
+ ("authentication", r"鉴权|认证|身份验证|\bauthentication\b|\bauthorization\b|授权|访问控制|access control"),
51
+ ("encryption", r"加密|解密|\bencryption\b|decrypt(?:ion)?|\btls\b|\bhttps\b"),
52
+ ("integrity", r"哈希|散列|\b(?:sha[-_ ]?1|sha[-_ ]?256|sha[-_ ]?512|md5|hmac|bcrypt|argon2|pbkdf2|scrypt)\b|签名校验|完整性校验|\bintegrity\b"),
53
+ ("privacy", r"隐私|个人信息|\bprivacy\b|personal data|\bpii\b|\bgdpr\b"),
54
+ ("compliance", r"合规|\bcompliance\b|监管要求|数据保护"),
55
+ ("incidents", r"泄露|外泄|泄密|leak(?:ed)?|breach|被盗|入侵|phishing|钓鱼|恶意软件|malware"),
56
+ ("least-privilege", r"最小权限|least privilege|principle of least privilege"),
57
+ )
58
+
59
+ _UNIVERSAL_PATTERNS: tuple[tuple[str, str], ...] = (
60
+ ("must", r"必须|务必|务须|\bmust\b|\bshall\b"),
61
+ ("never", r"切勿|请勿|绝不要|永远不要|禁止|不要|\bnever\b|do not|don't"),
62
+ ("always", r"始终|总是|\balways\b"),
63
+ ("recommendation", r"推荐|建议|推荐做法|\brecommended\b|\brecommend\b|\bshould\b"),
64
+ ("best-practice", r"最佳实践|安全实践|best[ _-]practice|secure practice"),
65
+ ("universal", r"通用|普适|一般情况下|无论|不论|所有客户端|各客户端|全局|\bglobal\b|universal|generic|regardless|all clients|every client|any user"),
66
+ ("rotation-practice", r"定期轮换|定期更换|rotate(?:d|s)?\s+(?:api[ _-]?keys?|credentials?|secrets?)|quarterly rotation|key rotation"),
67
+ ("parameterized-query", r"参数化\s*(?:查询|sql)|(?:parameter|parametr)i[sz]ed(?:\s+sql)?(?:\s+quer(?:y|ies))?|prepared statements?"),
68
+ ("safe-handling", r"不要粘贴|切勿粘贴|不要提交.*(?:密钥|secret|token)|never paste|do not commit|never commit"),
69
+ )
70
+
71
+ _PERSONAL_PATTERNS: tuple[tuple[str, str], ...] = (
72
+ ("first-person", r"\b(?:i|me|my|mine|we|our|私の|我|我的|本人)\b"),
73
+ ("family-detail", r"妻子|老婆|丈夫|老公|家人|孩子|生日|wife|husband|birthday|family"),
74
+ ("specific-date", r"\b(?:19|20)\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\b"),
75
+ ("issue-or-instance", r"(?:bug|issue|ticket|工单|事故|incident)\s*#?\d{2,}|仅本项目|项目内部|内部项目|this repo|this project|internal only"),
76
+ ("local-path", r"(?:/Users/|/home/|~/|c:\\|d:\\)[^\s]+"),
77
+ ("secret-value", r"(?:api[ _-]?key|access[ _-]?key|token|password|secret|密钥|令牌|密码|口令)\s*(?:is|=|:|:)\s*[A-Za-z0-9_\-/.]{8,}"),
78
+ ("known-secret-format", r"\b(?:sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{16,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{20,})\b"),
79
+ )
80
+
81
+
82
+ def _as_text(value: object) -> str:
83
+ if value is None:
84
+ return ""
85
+ if isinstance(value, (list, tuple, set)):
86
+ return " ".join(str(item) for item in value if item is not None)
87
+ return str(value)
88
+
89
+
90
+ def _hits(text: str, patterns: Iterable[tuple[str, str]]) -> list[str]:
91
+ return [label for label, pattern in patterns if re.search(pattern, text, re.IGNORECASE)]
92
+
93
+
94
+ def _confidence(
95
+ security_hits: int,
96
+ universal_hits: int,
97
+ personalization_hits: int,
98
+ incident_hits: int,
99
+ auto_global: bool,
100
+ ) -> float:
101
+ value = 0.38
102
+ value += min(security_hits, 5) * 0.09
103
+ value += min(universal_hits, 4) * 0.08
104
+ value += min(incident_hits, 2) * 0.03
105
+ value -= min(personalization_hits, 3) * 0.16
106
+ if auto_global:
107
+ value += 0.08
108
+ return round(max(0.05, min(0.99, value)), 3)
109
+
110
+
111
+ def pattern_classify(
112
+ title: str = "",
113
+ body: str = "",
114
+ summary: str = "",
115
+ tags: Iterable[object] | None = None,
116
+ evidence_sources: Iterable[object] | None = None,
117
+ *,
118
+ mode: str = "pattern",
119
+ ) -> Classification:
120
+ """Classify a page using deterministic multilingual patterns.
121
+
122
+ ``evidence_sources`` is used only as an explanatory signal. A page is
123
+ never promoted solely because it appeared in several clients; repeated
124
+ personal information must remain client-scoped.
125
+ """
126
+ selected_mode = str(mode or "pattern").strip().lower()
127
+ if selected_mode not in {"pattern", "llm", "off"}:
128
+ selected_mode = "pattern"
129
+ text = " ".join(
130
+ part for part in (
131
+ _as_text(title),
132
+ _as_text(summary),
133
+ _as_text(body),
134
+ _as_text(tags),
135
+ ) if part
136
+ )
137
+ security_labels = _hits(text, _SECURITY_PATTERNS)
138
+ universal_labels = _hits(text, _UNIVERSAL_PATTERNS)
139
+ personal_labels = _hits(text, _PERSONAL_PATTERNS)
140
+ incident_labels = [label for label in security_labels if label in {"incidents", "cve", "vulnerabilities"}]
141
+
142
+ security_hits = len(security_labels)
143
+ universal_hits = len(universal_labels)
144
+ personalization_hits = len(personal_labels)
145
+ incident_hits = len(incident_labels)
146
+ is_security = security_hits > 0
147
+ personal_block = personalization_hits >= 2 or "secret-value" in personal_labels or "known-secret-format" in personal_labels or (
148
+ is_security and personalization_hits >= 1 and "first-person" in personal_labels
149
+ )
150
+ is_universal = universal_hits > 0 and not personal_block
151
+
152
+ if selected_mode == "off":
153
+ auto_global = False
154
+ else:
155
+ strong_practice = any(
156
+ label in universal_labels
157
+ for label in ("best-practice", "rotation-practice", "parameterized-query", "safe-handling")
158
+ )
159
+ auto_global = bool(
160
+ is_security
161
+ and not personal_block
162
+ and (
163
+ (security_hits >= 2 and universal_hits >= 1)
164
+ or (security_hits >= 1 and universal_hits >= 2)
165
+ or (security_hits >= 1 and strong_practice)
166
+ )
167
+ )
168
+
169
+ if personal_block:
170
+ kind = "personal"
171
+ elif is_security and incident_hits and not auto_global:
172
+ kind = "security-incident"
173
+ elif is_security:
174
+ kind = "security-rule"
175
+ elif is_universal:
176
+ kind = "best-practice"
177
+ else:
178
+ kind = "general"
179
+
180
+ reasons: list[str] = []
181
+ reasons.extend(f"security:{label}" for label in security_labels)
182
+ reasons.extend(f"universal:{label}" for label in universal_labels)
183
+ reasons.extend(f"personalization:{label}" for label in personal_labels)
184
+ if evidence_sources:
185
+ known_sources = sorted({str(source).strip().lower() for source in evidence_sources if str(source).strip()})
186
+ if len(known_sources) > 1:
187
+ reasons.append(f"evidence-sources:{','.join(known_sources[:6])}")
188
+ if personal_block:
189
+ reasons.append("personalization-blocks-global")
190
+ if auto_global:
191
+ reasons.append("security-best-practice-is-cross-client")
192
+ elif is_security:
193
+ reasons.append("security-content-needs-source-scope-unless-universal")
194
+ if selected_mode == "llm":
195
+ reasons.append("llm-mode-not-enabled-in-pattern-classifier")
196
+
197
+ return Classification(
198
+ kind=kind,
199
+ is_security=is_security,
200
+ is_universal=is_universal,
201
+ auto_global=auto_global,
202
+ confidence=_confidence(
203
+ security_hits,
204
+ universal_hits,
205
+ personalization_hits,
206
+ incident_hits,
207
+ auto_global,
208
+ ),
209
+ reasons=reasons,
210
+ scope_hint="global" if auto_global else "per-source",
211
+ security_hits=security_hits,
212
+ universal_hits=universal_hits,
213
+ personalization_hits=personalization_hits,
214
+ incident_hits=incident_hits,
215
+ mode=selected_mode,
216
+ )
217
+
218
+
219
+ def classify_page(
220
+ title: str = "",
221
+ body: str = "",
222
+ summary: str = "",
223
+ tags: Iterable[object] | None = None,
224
+ evidence_sources: Iterable[object] | None = None,
225
+ mode: str = "pattern",
226
+ ) -> Classification:
227
+ """Public classifier entry point used by API and consolidation jobs."""
228
+ return pattern_classify(
229
+ title=title,
230
+ body=body,
231
+ summary=summary,
232
+ tags=tags,
233
+ evidence_sources=evidence_sources,
234
+ mode=mode,
235
+ )
236
+
237
+
238
+ __all__ = ["Classification", "classify_page", "pattern_classify"]