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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Module-level handler functions used by ``serve.app.create_app``.
|
|
2
|
+
|
|
3
|
+
These used to be inline closures inside ``create_app`` (so the file
|
|
4
|
+
ballooned to 960 lines and was hard to unit-test). Pulling them out
|
|
5
|
+
keeps the FastAPI app definition small and lets us test the logic
|
|
6
|
+
in isolation.
|
|
7
|
+
|
|
8
|
+
Each function is a *pure* transform: it takes a ``MemoryStore`` and
|
|
9
|
+
the parsed request arguments, and returns a JSON-serialisable dict
|
|
10
|
+
(or raises ``HTTPException``). The endpoint closure in ``app.py``
|
|
11
|
+
just wires the HTTP signature to the handler.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from fastapi import HTTPException
|
|
23
|
+
|
|
24
|
+
PIPELINE_STAGES = ("ingest", "score", "cluster", "distill", "wiki", "memo")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pipeline_dashboard(store) -> dict:
|
|
28
|
+
"""5-stage data flow: ingest -> score -> cluster -> distill -> wiki -> memo."""
|
|
29
|
+
runs = store.latest_pipeline_runs(limit=120)
|
|
30
|
+
by_stage: dict = {}
|
|
31
|
+
for r in runs:
|
|
32
|
+
by_stage.setdefault(r["stage"], []).append(r)
|
|
33
|
+
stages_out = []
|
|
34
|
+
for stage in PIPELINE_STAGES:
|
|
35
|
+
entries = by_stage.get(stage, [])
|
|
36
|
+
entries.sort(key=lambda x: -x["started_at"])
|
|
37
|
+
stages_out.append({
|
|
38
|
+
"stage": stage,
|
|
39
|
+
"runs": entries[:5],
|
|
40
|
+
"last_in": entries[0]["in_count"] if entries else 0,
|
|
41
|
+
"last_out": entries[0]["out_count"] if entries else 0,
|
|
42
|
+
"last_at": entries[0]["started_at"] if entries else None,
|
|
43
|
+
"last_note": entries[0].get("note", "") if entries else "",
|
|
44
|
+
})
|
|
45
|
+
try:
|
|
46
|
+
stats = store.stats()
|
|
47
|
+
except Exception:
|
|
48
|
+
stats = {}
|
|
49
|
+
return {
|
|
50
|
+
"stages": stages_out,
|
|
51
|
+
"totals": {
|
|
52
|
+
"memories": stats.get("memories", 0),
|
|
53
|
+
"wiki_pages": stats.get("wiki_pages", 0),
|
|
54
|
+
"wiki_avg_importance": stats.get("wiki_avg_importance", 0),
|
|
55
|
+
"avg_score": stats.get("avg_score", 0),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pipeline_stage_items(store, stage: str, limit: int = 50) -> dict:
|
|
61
|
+
"""Drill-down for a single stage: latest run + touched memories."""
|
|
62
|
+
runs = store.latest_pipeline_runs(limit=200)
|
|
63
|
+
runs = [r for r in runs if r["stage"] == stage]
|
|
64
|
+
if not runs:
|
|
65
|
+
return {"stage": stage, "run": None, "items": []}
|
|
66
|
+
run = runs[0]
|
|
67
|
+
try:
|
|
68
|
+
stats = json.loads(run.get("stats_json") or "{}")
|
|
69
|
+
except Exception:
|
|
70
|
+
stats = {}
|
|
71
|
+
evidence = stats.get("evidence_ids") or []
|
|
72
|
+
items = []
|
|
73
|
+
if isinstance(evidence, list) and evidence:
|
|
74
|
+
for mid in evidence[:limit]:
|
|
75
|
+
m = store.get_memory(mid)
|
|
76
|
+
if m is not None:
|
|
77
|
+
items.append({
|
|
78
|
+
"id": m.id, "kind": m.kind, "text": (m.text or "")[:300],
|
|
79
|
+
"importance": m.importance, "score": m.score,
|
|
80
|
+
"tags": list(m.tags or []),
|
|
81
|
+
})
|
|
82
|
+
else:
|
|
83
|
+
for m in store.list_memories(limit=limit):
|
|
84
|
+
items.append({
|
|
85
|
+
"id": m.id, "kind": m.kind, "text": (m.text or "")[:300],
|
|
86
|
+
"importance": m.importance, "score": m.score,
|
|
87
|
+
"tags": list(m.tags or []),
|
|
88
|
+
})
|
|
89
|
+
return {
|
|
90
|
+
"stage": stage,
|
|
91
|
+
"run": {
|
|
92
|
+
"id": run["id"],
|
|
93
|
+
"started_at": run["started_at"],
|
|
94
|
+
"finished_at": run.get("finished_at"),
|
|
95
|
+
"in_count": run["in_count"],
|
|
96
|
+
"out_count": run["out_count"],
|
|
97
|
+
"note": run.get("note", ""),
|
|
98
|
+
"stats": stats,
|
|
99
|
+
},
|
|
100
|
+
"items": items,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def llm_test(store, body: dict, scheduler=None) -> dict:
|
|
105
|
+
"""Smoke-test the LLM provider without writing to the store.
|
|
106
|
+
|
|
107
|
+
Accepts ``api_key`` in the body for one-off testing. The key is
|
|
108
|
+
stored in a *temporary* secret-backend account and deleted as soon as
|
|
109
|
+
the test finishes. The structured response lets the UI show *why*
|
|
110
|
+
a key failed (status, provider_code, hint) instead of a wall of
|
|
111
|
+
raw JSON.
|
|
112
|
+
|
|
113
|
+
If ``scheduler`` is passed, the test result is recorded on the
|
|
114
|
+
scheduler so the top-bar model chip dot can reflect connectivity
|
|
115
|
+
state in real time.
|
|
116
|
+
"""
|
|
117
|
+
from ..llm.providers import (
|
|
118
|
+
build_provider, default_config, validate_config, LLMHttpError,
|
|
119
|
+
)
|
|
120
|
+
from ..security import delete_secret, set_secret
|
|
121
|
+
# Merge the body over the saved config so callers can override the
|
|
122
|
+
# model/base_url without re-saving, and pass an ephemeral api_key in
|
|
123
|
+
# the body to test before committing. When the body is empty, the
|
|
124
|
+
# test runs against whatever the user has currently saved.
|
|
125
|
+
saved = store.get_setting("llm_consolidator", default_config()) or {}
|
|
126
|
+
body = dict(body or {})
|
|
127
|
+
body.setdefault("provider", saved.get("provider"))
|
|
128
|
+
body.setdefault("model", saved.get("model"))
|
|
129
|
+
body.setdefault("base_url", saved.get("base_url"))
|
|
130
|
+
body.setdefault("api_key_account", saved.get("api_key_account"))
|
|
131
|
+
body.setdefault("api_key_set", saved.get("api_key_set"))
|
|
132
|
+
provider = (body.get("provider") or "echo").lower()
|
|
133
|
+
raw_key = body.pop("api_key", None)
|
|
134
|
+
ephemeral_account = None
|
|
135
|
+
if raw_key:
|
|
136
|
+
ephemeral_account = f"llm-test/{provider}/{uuid.uuid4().hex[:8]}"
|
|
137
|
+
set_secret(ephemeral_account, raw_key)
|
|
138
|
+
body["api_key_account"] = ephemeral_account
|
|
139
|
+
body["api_key_set"] = True
|
|
140
|
+
cfg, warnings = validate_config(body or default_config())
|
|
141
|
+
provider_obj = build_provider(cfg)
|
|
142
|
+
real_key = getattr(provider_obj, "api_key", None) or ""
|
|
143
|
+
base_info = {
|
|
144
|
+
"provider": cfg.get("provider"),
|
|
145
|
+
"model": cfg.get("model"),
|
|
146
|
+
"base_url": getattr(provider_obj, "base_url", None),
|
|
147
|
+
"key_prefix": (real_key[:10] + "...") if len(real_key) > 10 else real_key,
|
|
148
|
+
"key_len": len(real_key),
|
|
149
|
+
"warnings": warnings,
|
|
150
|
+
}
|
|
151
|
+
# Placeholder detection before we even hit the network.
|
|
152
|
+
# Only flag *clearly fake* placeholders, not real keys that happen to
|
|
153
|
+
# contain 'x'. Common patterns: "sk-xxxx...", "your-api-key",
|
|
154
|
+
# "REPLACE_ME", "<API_KEY>", or fewer than 8 non-whitespace chars.
|
|
155
|
+
if (not real_key
|
|
156
|
+
or len(real_key.strip()) < 8
|
|
157
|
+
or re.search(r"x{4,}", real_key)
|
|
158
|
+
or re.search(r"(your[-_ ]?(api[-_ ]?)?key|replace[-_ ]?me|<api[-_ ]?key>|placeholder)", real_key, re.I)):
|
|
159
|
+
return {
|
|
160
|
+
**base_info,
|
|
161
|
+
"ok": False,
|
|
162
|
+
"elapsed_ms": 0,
|
|
163
|
+
"error": {
|
|
164
|
+
"status": 0,
|
|
165
|
+
"provider_code": None,
|
|
166
|
+
"provider_message": "API key not set or looks like a placeholder",
|
|
167
|
+
"hint": "Paste a real API key in the field above and try again.",
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
from ..llm.base import ChatHistory, Message
|
|
171
|
+
t0 = time.time()
|
|
172
|
+
try:
|
|
173
|
+
reply = provider_obj.complete(
|
|
174
|
+
ChatHistory(
|
|
175
|
+
system="You are a connectivity probe. Reply with one word: ok",
|
|
176
|
+
messages=[Message(role="user", content="Reply with the single word: ok")],
|
|
177
|
+
),
|
|
178
|
+
temperature=0.0,
|
|
179
|
+
max_tokens=10,
|
|
180
|
+
)
|
|
181
|
+
ok = bool((reply or "").strip())
|
|
182
|
+
if scheduler is not None:
|
|
183
|
+
try:
|
|
184
|
+
scheduler.record_test_result(ok, "ok" if ok else "empty reply")
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
return {
|
|
188
|
+
**base_info,
|
|
189
|
+
"ok": ok,
|
|
190
|
+
"elapsed_ms": round((time.time() - t0) * 1000, 1),
|
|
191
|
+
"reply": (reply or "")[:200],
|
|
192
|
+
}
|
|
193
|
+
except LLMHttpError as e:
|
|
194
|
+
hint = _hint_for_llm_error(
|
|
195
|
+
base_info["provider"] or "?",
|
|
196
|
+
e.status,
|
|
197
|
+
e.provider_code or "",
|
|
198
|
+
e.provider_message or "",
|
|
199
|
+
)
|
|
200
|
+
if scheduler is not None:
|
|
201
|
+
try:
|
|
202
|
+
scheduler.record_test_result(False, e.provider_message or hint)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
return {
|
|
206
|
+
**base_info,
|
|
207
|
+
"ok": False,
|
|
208
|
+
"elapsed_ms": round((time.time() - t0) * 1000, 1),
|
|
209
|
+
"error": {
|
|
210
|
+
"status": e.status,
|
|
211
|
+
"provider_code": e.provider_code,
|
|
212
|
+
"provider_message": e.provider_message,
|
|
213
|
+
"hint": hint,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
except Exception as e:
|
|
217
|
+
if scheduler is not None:
|
|
218
|
+
try:
|
|
219
|
+
scheduler.record_test_result(False, str(e)[:200])
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
return {
|
|
223
|
+
**base_info,
|
|
224
|
+
"ok": False,
|
|
225
|
+
"elapsed_ms": round((time.time() - t0) * 1000, 1),
|
|
226
|
+
"error": {
|
|
227
|
+
"status": 0,
|
|
228
|
+
"provider_code": None,
|
|
229
|
+
"provider_message": f"{type(e).__name__}: {e}"[:200],
|
|
230
|
+
"hint": "Could not reach the provider. Check network connectivity.",
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
finally:
|
|
234
|
+
if ephemeral_account:
|
|
235
|
+
try:
|
|
236
|
+
delete_secret(ephemeral_account)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _hint_for_llm_error(provider: str, status: int, code: str, msg: str) -> str:
|
|
242
|
+
"""Map an LLM HTTP error to a user-facing hint.
|
|
243
|
+
|
|
244
|
+
MiniMax codes: 1004 missing auth header, 2049 invalid api key,
|
|
245
|
+
1002 rate limit, 1008 insufficient balance, 1026 model not found.
|
|
246
|
+
OpenAI / Anthropic surface standard HTTP statuses. We map both so
|
|
247
|
+
the dashboard banner reads the same regardless of provider.
|
|
248
|
+
"""
|
|
249
|
+
code = (code or "").strip()
|
|
250
|
+
m = (msg or "").lower()
|
|
251
|
+
if status in (401, 403) or "2049" in code or "1004" in code \
|
|
252
|
+
or "unauthorized" in m or "authorized_error" in m \
|
|
253
|
+
or "invalid api key" in m:
|
|
254
|
+
return (
|
|
255
|
+
f"{provider} rejected the API key. "
|
|
256
|
+
"Verify it in your provider console (MiniMax: console.minimaxi.chat), "
|
|
257
|
+
"then re-paste it here. The key prefix in the test result should match what you copied."
|
|
258
|
+
)
|
|
259
|
+
if status == 404 or ("model" in m and "not found" in m) or "1026" in code:
|
|
260
|
+
return (
|
|
261
|
+
f"The model name is wrong or not available on your {provider} plan. "
|
|
262
|
+
"Pick another model from the dropdown."
|
|
263
|
+
)
|
|
264
|
+
if status == 429 or "rate" in m or "1002" in code:
|
|
265
|
+
return (
|
|
266
|
+
f"{provider} is rate-limiting. Wait a minute and retry, or upgrade your plan."
|
|
267
|
+
)
|
|
268
|
+
if status == 402 or "balance" in m or "1008" in code:
|
|
269
|
+
return f"{provider} says your account is out of credit. Top up and retry."
|
|
270
|
+
if status >= 500:
|
|
271
|
+
return f"{provider} server error ({status}). Retry in a few seconds."
|
|
272
|
+
if status == 0:
|
|
273
|
+
return "Network error reaching the provider. Check connectivity."
|
|
274
|
+
return f"{provider} call failed (HTTP {status}" + (f", code {code}" if code else "") + ")."
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def memory_to_dict(m) -> dict:
|
|
278
|
+
return {
|
|
279
|
+
"id": m.id,
|
|
280
|
+
"kind": m.kind,
|
|
281
|
+
"text": m.text,
|
|
282
|
+
"importance": round(m.importance, 3),
|
|
283
|
+
"score": round(m.score, 4),
|
|
284
|
+
"source": m.source,
|
|
285
|
+
"session_id": m.session_id,
|
|
286
|
+
"created_at": m.created_at,
|
|
287
|
+
"updated_at": getattr(m, "updated_at", None) or m.created_at,
|
|
288
|
+
"tags": m.tags,
|
|
289
|
+
"agent_id": getattr(m, "agent_id", None),
|
|
290
|
+
"user_id": getattr(m, "user_id", None),
|
|
291
|
+
"external_id": getattr(m, "external_id", None),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def session_to_dict(s) -> dict:
|
|
296
|
+
return {
|
|
297
|
+
"id": s.id,
|
|
298
|
+
"source": s.source,
|
|
299
|
+
"external_id": s.external_id,
|
|
300
|
+
"title": s.title,
|
|
301
|
+
"started_at": s.started_at,
|
|
302
|
+
"ended_at": s.ended_at,
|
|
303
|
+
"message_count": s.message_count,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def require_scheduler(app) -> Any:
|
|
308
|
+
"""Return the running scheduler or raise 503 if it isn't attached.
|
|
309
|
+
|
|
310
|
+
Used by endpoints that *only* make sense when the serve process
|
|
311
|
+
has booted with a scheduler (most admin actions).
|
|
312
|
+
"""
|
|
313
|
+
sched = getattr(app.state, "scheduler", None)
|
|
314
|
+
if sched is None:
|
|
315
|
+
raise HTTPException(503, "scheduler not running")
|
|
316
|
+
return sched
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Helpers shared across route modules.
|
|
2
|
+
|
|
3
|
+
The original ``serve/app.py`` (3226 lines) had every route defined as a
|
|
4
|
+
closure inside ``create_app``. To keep the central file small (audit O1)
|
|
5
|
+
we lifted each route group into its own ``routes/<bucket>.py`` module
|
|
6
|
+
and emit ``register(app, store, scheduler=None)``. Closures over
|
|
7
|
+
``_memory_to_dict`` and ``_export_safe_segment`` still work because
|
|
8
|
+
both are imported here under the same names.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from ..handlers import (
|
|
16
|
+
memory_to_dict as _memory_to_dict, # noqa: F401
|
|
17
|
+
session_to_dict as _session_to_dict, # noqa: F401
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["_memory_to_dict", "_session_to_dict", "_export_safe_segment"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_THINK_RE = re.compile(r"<(think|reasoning)>(.*?)</\1>", re.DOTALL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _split_think(text: str) -> tuple[str, str]:
|
|
27
|
+
"""Pull LLM thinking/reasoning blocks out of a model reply.
|
|
28
|
+
|
|
29
|
+
Several providers (MiniMax reasoning, Anthropic extended thinking)
|
|
30
|
+
emit a ``<think>...</think>`` or ``<reasoning>...</reasoning>``
|
|
31
|
+
block before the user-visible answer. We surface the block under a
|
|
32
|
+
separate field so the UI can render the report as Markdown while
|
|
33
|
+
still letting power users peek at the chain-of-thought in a
|
|
34
|
+
collapsed ``<details>``.
|
|
35
|
+
"""
|
|
36
|
+
if not text:
|
|
37
|
+
return "", ""
|
|
38
|
+
thinking_parts = _THINK_RE.findall(text)
|
|
39
|
+
cleaned = _THINK_RE.sub("", text).strip()
|
|
40
|
+
thinking = "\n\n".join(p[1].strip() for p in thinking_parts if p[1].strip())
|
|
41
|
+
return cleaned, thinking
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _export_safe_segment(text: str, *, fallback: str = "", kind: str = "line") -> str:
|
|
45
|
+
"""Sanitise a user-controlled wiki field for the markdown export.
|
|
46
|
+
|
|
47
|
+
Audit M2: a malicious title ``"My\\n## Pwned"`` would otherwise
|
|
48
|
+
create a second ``## Pwned`` heading on re-import. ``title`` /
|
|
49
|
+
``summary`` fields are collapsed to a single line and stripped of
|
|
50
|
+
any leading ``## `` prefix; body content (legitimate markdown) is
|
|
51
|
+
left alone.
|
|
52
|
+
"""
|
|
53
|
+
if not text:
|
|
54
|
+
return fallback
|
|
55
|
+
s = text
|
|
56
|
+
if kind in ("title", "summary"):
|
|
57
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
58
|
+
s = re.sub(r"^#+\s*", "", s).strip()
|
|
59
|
+
return s or fallback
|