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,295 @@
1
+ """Wiki synthesis prompts (multi-locale) and length-floor helpers.
2
+
3
+ Why this module exists
4
+ ----------------------
5
+ The legacy prompt strings lived inline at the top of ``evolution.py``
6
+ and ``llm_consolidate.py``. Two consequences:
7
+
8
+ 1. They were written in English without any directive about output
9
+ language, so even Chinese-language users got distilled wiki pages
10
+ in English (the assistant's "default" tone). The user reported
11
+ "知识库中浓缩的知识为何全是英文,单个知识偏短不成体系" — every
12
+ distilled page came out in English, individual pages were 1-2
13
+ bullets long, and there was no systematic structure across the
14
+ wiki.
15
+
16
+ 2. There was no length floor — the LLM could produce a 2-bullet page
17
+ ("- we use SQLite") even when a richer answer was in the source
18
+ memories.
19
+
20
+ This module centralises the wiki-synthesis prompts in three
21
+ languages (Chinese, English, with a Japanese fallback when the user's
22
+ ``lang`` is ja). All Stage-4 callers should pick a prompt through
23
+ ``wiki_system_prompt(lang)`` rather than referencing the legacy
24
+ ``_WIKI_SYSTEM`` constant directly.
25
+
26
+ The length / bullet floors (``MIN_BULLETS_PER_PAGE`` /
27
+ ``MIN_BODY_CHARS``) are exported so a post-processing pass in
28
+ ``evolution.py`` can re-prompt the LLM when a returned page comes
29
+ back too thin to be useful. The "completeness over compactness"
30
+ v2 invariant now lives in code, not just in the prompt text.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re as _re
36
+
37
+ # ----------------------------------------------------------------------------
38
+ # Length / bullet floors (v3 — code-level guarantee on top of prompt-level)
39
+ # ----------------------------------------------------------------------------
40
+
41
+ #: Minimum number of bullets the LLM must emit for a non-trivial wiki
42
+ #: page. ``evolution._stage4_wiki_synthesis`` will re-issue one
43
+ #: expansion prompt when a returned page falls below this floor before
44
+ #: falling back to the deterministic rule-based synthesizer.
45
+ MIN_BULLETS_PER_PAGE = 6
46
+
47
+ #: Minimum body length in characters. Mirrors ``MIN_BULLETS_PER_PAGE``
48
+ #: for prose-style summaries so a page can't sneak past the bullet
49
+ #: count by emitting six 3-character bullets.
50
+ MIN_BODY_CHARS = 600
51
+
52
+ #: When the first LLM pass returns a page below the floor, the
53
+ #: consolidator retries once with an expansion instruction before
54
+ #: giving up. ``MAX_WIKI_PROMPTS_PER_PAGE = 2`` keeps the worst-case
55
+ #: LLM bill under control even on a chatty session.
56
+ MAX_WIKI_PROMPTS_PER_PAGE = 2
57
+
58
+
59
+ # ----------------------------------------------------------------------------
60
+ # Locale helpers
61
+ # ----------------------------------------------------------------------------
62
+
63
+ #: Supported output locales. Anything outside this set falls back to
64
+ #: English. The default user-facing locale is ``zh`` because the
65
+ #: default UI language is Chinese; English pages come back when the
66
+ #: user explicitly switches the toggle.
67
+ SUPPORTED_LOCALES = ("zh", "en", "ja")
68
+
69
+
70
+ def normalise_lang(lang: object | None) -> str:
71
+ """Return one of :data:`SUPPORTED_LOCALES`. Unknown / empty → ``zh``."""
72
+ if not lang:
73
+ return "zh"
74
+ s = str(lang).strip().lower()
75
+ if s.startswith("zh"):
76
+ return "zh"
77
+ if s.startswith("en"):
78
+ return "en"
79
+ if s.startswith("ja") or s.startswith("jp"):
80
+ return "ja"
81
+ return "zh"
82
+
83
+
84
+ # ----------------------------------------------------------------------------
85
+ # Shared prompt structure
86
+ # ----------------------------------------------------------------------------
87
+ #
88
+ # Both locales stress the same two invariants:
89
+ #
90
+ # * **Completeness over compactness.** Every atomic fact (number,
91
+ # name, decision, error message, workaround) in the source MUST
92
+ # land on at least one bullet.
93
+ # * **Systematic, not chatty.** Each page is a 6-20 bullet cluster
94
+ # grouped by *user-profile dimension* (preferences, decisions,
95
+ # projects, domain, feedback). Sub-bullets (`` -`` indent) are
96
+ # encouraged when a single sentence carries two related facts.
97
+ #
98
+ # Locale-specific wordings live in small f-strings so a contributor
99
+ # can read both side by side and catch drift.
100
+
101
+ _EN_PROMPT = (
102
+ "You maintain a personal knowledge base for ONE user. You receive the user's "
103
+ "existing wiki pages plus a batch of distilled cluster summaries (each already "
104
+ "filtered for noise).\n"
105
+ "GOAL: each page must be a COMPLETE, ACTIONABLE, SYSTEMATIC note the user "
106
+ "would actually want to recall later — NOT a quote of the original "
107
+ "conversation, and NOT a half-fact that loses the actionable detail.\n"
108
+ "OUTPUT LANGUAGE: ENGLISH. Every value of `title`, `summary`, `body`, "
109
+ "`tags`, `key_facts`, and `slug` MUST be in English. Do NOT switch to "
110
+ "Chinese or any other language even if the source memories were mixed.\n"
111
+ "COMPLETENESS OVER COMPACTNESS: if a cluster contains a number, a name, a "
112
+ "decision, a constraint, an error message, or a workaround, that detail "
113
+ "MUST land on the relevant page. The user will rely on this knowledge base "
114
+ "to skip re-deriving facts. Losing a fact is much worse than a longer page.\n"
115
+ "ALWAYS bucket into one of these dimensions, and make the slug reflect the topic:\n"
116
+ " preferences-<topic>, decision-<topic>, project-<topic>, domain-<topic>, "
117
+ "feedback-<topic>. Examples: 'prefers-dark-mode', 'decision-batch-size-50', "
118
+ "'project-loop-memory', 'domain-crypto-swing-trades', 'feedback-no-mixed-lang'.\n"
119
+ "Each page MUST have:\n"
120
+ " slug: lowercase, hyphen-separated, prefixed with the dimension; no hard "
121
+ "length cap, but keep it readable in a URL. NEVER a truncated user prompt.\n"
122
+ " title: a real noun phrase that names the topic; no hard length cap, but "
123
+ "keep it under ~12 words. NEVER a truncated user prompt.\n"
124
+ " summary: 1-3 sentence definition that stands on its own; no hard length "
125
+ "cap, no truncation mid-clause, MUST be understandable without the source.\n"
126
+ " body: bullet-point markdown. Each bullet MUST start with '- '. One "
127
+ "atomic fact per bullet. NO hard cap on bullet count — use as many bullets "
128
+ "as the source clusters justify. SYSTEMATIC pages MUST have at least "
129
+ f"{MIN_BULLETS_PER_PAGE} bullets and at least {MIN_BODY_CHARS} characters "
130
+ "of body content; if the source is thinner, prefer to MERGE it into an "
131
+ "existing page rather than emit a half-fact single-bullet stub. Sub-bullets "
132
+ "(indented with two spaces: ' -') are encouraged when one fact has two "
133
+ "halves (e.g. 'uses X for Y' / 'uses Z for W'). Every decision, number, "
134
+ "name, error, constraint, or workaround from the source MUST appear in at "
135
+ "least one bullet. No prose paragraphs. No 'Outcome: ...' echoes. Code "
136
+ "fences ONLY when the fact is literally a command or config snippet.\n"
137
+ " tags: 3-6 lowercase tags, snake_case\n"
138
+ " importance: 0..1 (1 = critical user preference/project, 0.3 = transient detail)\n"
139
+ " evidence_ids: list of memory ids that back this page (cite real ids from the input)\n"
140
+ "SKIP a cluster summary if it is just a user prompt, status update, or repeats "
141
+ "another cluster. PREFER updating an existing page (same slug) over creating "
142
+ "a near-duplicate — when updating, APPEND new atomic facts rather than "
143
+ "rewriting existing ones, so cumulative knowledge is preserved. Reply with "
144
+ "JSON: {\"pages\": [...]}. If nothing adds new info, reply {\"pages\": []}. "
145
+ "No prose, no markdown outside the JSON."
146
+ )
147
+
148
+
149
+ _ZH_PROMPT = (
150
+ "你为同一名用户维护一份个人长期知识库。你会拿到用户的已有 wiki 页面 + 一组 "
151
+ "经过过滤的「簇摘要」(Stage 3 已剔除噪音)。\n"
152
+ "目标:每一页都必须是【完整 + 可执行 + 成体系】的笔记,用户日后回查能直接用 "
153
+ "—— 不要复述对话,也不要丢掉任何关键事实。\n"
154
+ "**输出语言必须使用简体中文(zh-CN)**。所有的 title / summary / body / "
155
+ "tags / key_facts / slug 字段都必须用中文。即便源记忆里有英文术语或代码 "
156
+ "片段,正文叙述仍用中文表达(专有名词可保留英文原文,但不要整页变成英文)。\n"
157
+ "**完整性优先于简洁性**:簇里出现的数字、人名、决定、约束、报错、workaround "
158
+ "必须落到对应页的某一条 bullet 上。用户依赖这份知识库减少重复推导,丢掉任 "
159
+ "何一条事实都比写长一点更糟糕。\n"
160
+ "**结构化成体系**:每页都按以下 5 个用户画像维度之一归类,slug 直接拼上主题:\n"
161
+ " preferences-<主题>、decision-<主题>、project-<主题>、domain-<主题>、"
162
+ "feedback-<主题>。例如 'prefers-dark-mode'、'decision-batch-size-50'、"
163
+ "'project-loop-memory'、'domain-crypto-swing-trades'、'feedback-no-mixed-lang'。\n"
164
+ "每一页必须包含以下字段:\n"
165
+ " slug:小写,连字符分隔,前缀为维度;不做硬切,但 URL 要可读;禁止截断的原始用户输入。\n"
166
+ " title:能直接命名主题的名词短语,不做硬切,控制在 12 个词以内;禁止截断的原始用户输入。\n"
167
+ " summary:1-3 句能独立成立的概要;不做硬切、不在分句中间截断;脱离原文也要能读懂。\n"
168
+ f" body:项目式 Markdown。每条 bullet 必须以 '- ' 开头。一条 bullet 一个原子事实。"
169
+ f"不做硬性 bullet 数上限,但**系统性页面至少 {MIN_BULLETS_PER_PAGE} 条 bullet、"
170
+ f"正文至少 {MIN_BODY_CHARS} 个字符**;如果源材料偏薄,宁可并入已有页面,也不要"
171
+ f"生成只有一两条 bullet 的单薄页面。当一条事实包含两半内容(例如'用 X 做 Y,用 Z 做 W')"
172
+ f"时,强烈推荐用两层 bullet 缩进(' -')。源材料里的每一个决定、数字、"
173
+ f"名字、报错、约束、workaround 必须出现在至少一条 bullet 上。禁止整段散文;"
174
+ f"禁止 'Outcome: ...' 之类回声;只有在事实本身就是命令/配置片段时才用代码块。\n"
175
+ " tags:3-6 个小写标签,snake_case 风格。\n"
176
+ " importance:0..1(1=关键用户偏好/项目决定,0.3=临时细节)。\n"
177
+ " evidence_ids:引用来源记忆 ID 列表(用输入中真实存在的 ID)。\n"
178
+ "**跳过**单纯复述用户问题、状态更新或重复其他簇的摘要。**优先更新已有页面**"
179
+ "(同 slug)而不是新建近似页面;更新时只追加新事实,不要重写已有事实,"
180
+ "以保留累积知识。回复 JSON: {\"pages\": [...]}。若没有新增信息,回复 "
181
+ "{\"pages\": []}。不要在 JSON 之外输出任何说明文字或 Markdown。"
182
+ )
183
+
184
+
185
+ _JA_PROMPT = (
186
+ "あなたは一人のユーザーのための個人ナレッジベースを維持します。入力はユーザーの"
187
+ "既存 wiki ページ群と、Stage 3 でノイズ除去済みのクラスター要約のバッチです。\n"
188
+ "ゴール: 各ページはユーザーが後で再参照したい【完全で・実用的・体系的な】"
189
+ "メモでなければなりません。元の発言の引き写しでも、要点だけ抜いた薄い"
190
+ "ものでもいけません。\n"
191
+ "**出力言語は日本語 (ja-JP) とします**。title / summary / body / tags / "
192
+ "key_facts / slug のすべての値を日本語で記述してください。クラスター内に"
193
+ "英語の専門用語が混ざっていても、説明は日本語で書いてください(固有名詞は"
194
+ "原文のまま可)。\n"
195
+ "**完全性 > 簡潔性**: 数字・名前・決定・制約・エラーメッセージ・回避策は"
196
+ "全て、該当ページの bullet に必ず 1 ヶ所以上載せてください。\n"
197
+ "**体系化**: 以下の 5 つのユーザープロファイル次元のいずれかに分類し、"
198
+ "slug にその次元を付けてください: preferences-, decision-, project-, "
199
+ "domain-, feedback-。\n"
200
+ "各ページに必須のフィールド:\n"
201
+ " slug: 小文字ハイフン区切り、次元を前置。URL として読める長さに。"
202
+ " title: トピックを直接表す名詞句、12 語以内。\n"
203
+ f" body: Markdown の箇条書き。各 bullet は '- ' で始める。一つの bullet に"
204
+ f"一つのアトミックな事実。**体系的なページでは最低 {MIN_BULLETS_PER_PAGE} "
205
+ f"個の bullet と {MIN_BODY_CHARS} 文字以上**を必須とします。"
206
+ )
207
+
208
+
209
+ # Length-floor instruction injected into a second-pass expansion prompt when
210
+ # the first LLM reply comes back thinner than the floors. We keep it short
211
+ # and locale-aware because the synthesiser copies it verbatim onto the
212
+ # end of the first reply's conversation.
213
+
214
+ def expansion_prompt(lang: str) -> str:
215
+ """Locale-specific text that asks the LLM to expand an under-floor page."""
216
+ loc = normalise_lang(lang)
217
+ if loc == "zh":
218
+ return (
219
+ "上面给出的页面太短,未达到成体系的最低要求(bullet 数 / 正文字符数)。"
220
+ "请在该页面下追加新的 bullet,每条 bullet 仍以 '- ' 开头,承载一条原子事实,"
221
+ "尽量从所有提供的 cluster summary 里抽取尚未落入本页的关键细节(数字、"
222
+ "名字、决定、报错、workaround)。不要重写或删除已有 bullet,只在末尾追加。"
223
+ "回复 JSON:{\"pages\": [{...原页面 + 新 bullet + 同一 slug/title/summary...}]}。"
224
+ )
225
+ if loc == "ja":
226
+ return (
227
+ "前述ページは最低要件 (bullet 数 / 本文文字数) を満たしていません。"
228
+ "ページ末尾に新しい bullet を追加してください。'- ' 始まり、各 bullet 1 事実。"
229
+ "提供された全クラスター要約から未取込みの重要事項(数値・名前・決定・"
230
+ "エラー・回避策)を抽出すること。既存 bullet の書き換えや削除は禁止、"
231
+ "末尾追加のみ。JSON のみ返す。"
232
+ )
233
+ # English / default fallback
234
+ return (
235
+ "The page returned above is below the systematic floor (bullet count "
236
+ "and/or body character count). Please append new bullets to that same "
237
+ "page. Each new bullet MUST start with '- ' and MUST carry ONE atomic "
238
+ "fact. Pull from every cluster summary that has details not yet on the "
239
+ "page (numbers, names, decisions, errors, workarounds). Do NOT rewrite "
240
+ "or drop existing bullets — only append. Reply as JSON: "
241
+ "{\"pages\": [{...the same page with new bullets appended, "
242
+ "keeping slug / title / summary unchanged...}]}."
243
+ )
244
+
245
+
246
+ # ----------------------------------------------------------------------------
247
+ # Public entry points
248
+ # ----------------------------------------------------------------------------
249
+
250
+ _PROMPTS = {"zh": _ZH_PROMPT, "en": _EN_PROMPT, "ja": _JA_PROMPT}
251
+
252
+
253
+ def wiki_system_prompt(lang: object | None = None) -> str:
254
+ """Return the Stage-4 wiki system prompt for the requested locale.
255
+
256
+ Pass ``store.lang`` / behaviour lang through here. Falls back to
257
+ Chinese (the default UI language) when ``lang`` is missing or
258
+ unsupported. Result is cached so repeated calls in the same process
259
+ don't re-tokenise the prompt.
260
+ """
261
+ key = normalise_lang(lang)
262
+ return _PROMPTS[key]
263
+
264
+
265
+ # ----------------------------------------------------------------------------
266
+ # Body-floor measurement
267
+ # ----------------------------------------------------------------------------
268
+
269
+ _BULLET_LINE_RE = _re.compile(r"^\s*-\s+\S", _re.MULTILINE)
270
+
271
+
272
+ def count_bullets(body: str) -> int:
273
+ """Count user-visible bullet items in a wiki body.
274
+
275
+ Recognises both ``- `` (most wiki pages) and ``* `` bullets. Indented
276
+ sub-bullets count too — they're still atomic facts the user wants
277
+ to recall later. Numbered lists don't count here; the prompt asks
278
+ for ``-`` bullets only.
279
+ """
280
+ if not body:
281
+ return 0
282
+ n = 0
283
+ for line in body.splitlines():
284
+ if line.lstrip().startswith(("- ", "* ")):
285
+ n += 1
286
+ return n
287
+
288
+
289
+ def meets_body_floor(body: str, *, min_bullets: int = MIN_BULLETS_PER_PAGE,
290
+ min_chars: int = MIN_BODY_CHARS) -> bool:
291
+ """True when ``body`` clears both bullet and character floors."""
292
+ body = body or ""
293
+ if len(body) < min_chars:
294
+ return False
295
+ return count_bullets(body) >= min_bullets
@@ -0,0 +1,227 @@
1
+ """Scope parsing and safe per-client defaults for wiki pages."""
2
+ from __future__ import annotations
3
+
4
+ from collections import Counter
5
+ from typing import Any, Iterable
6
+
7
+
8
+ VALID_SCOPE_TOKENS = frozenset({
9
+ "global",
10
+ "all",
11
+ "codex",
12
+ "claude",
13
+ "hermes",
14
+ "openclaw",
15
+ })
16
+ CLIENT_SCOPE_TOKENS = frozenset({"codex", "claude", "hermes", "openclaw"})
17
+ DEFAULT_CLIENT_SCOPE = "codex"
18
+
19
+ _SOURCE_ALIASES = {
20
+ "claude-code": "claude",
21
+ "claude_code": "claude",
22
+ "chatgpt": "codex",
23
+ "open-claw": "openclaw",
24
+ "open_claw": "openclaw",
25
+ "hermes-cli": "hermes",
26
+ "hermes_agent": "hermes",
27
+ }
28
+
29
+
30
+ def auto_scope_config(store: Any) -> dict[str, object]:
31
+ """Read the auto-scope settings with compatibility fallbacks."""
32
+ raw = store.get_setting("wiki_auto_scope", {}) or {}
33
+ if not isinstance(raw, dict):
34
+ raw = {}
35
+ enabled = raw.get(
36
+ "enabled",
37
+ store.get_setting("wiki_auto_scope_enabled", True),
38
+ )
39
+ mode = raw.get(
40
+ "mode",
41
+ store.get_setting("wiki_auto_scope_mode", "pattern"),
42
+ )
43
+ mode = str(mode or "pattern").strip().lower()
44
+ if mode not in {"pattern", "llm", "off"}:
45
+ mode = "pattern"
46
+ if isinstance(enabled, str):
47
+ enabled = enabled.strip().lower() in {"1", "true", "yes", "on"}
48
+ return {"enabled": bool(enabled), "mode": mode}
49
+
50
+
51
+ def parse_scope(value: Any) -> list[str]:
52
+ """Parse a scope string/list into deduplicated lowercase tokens.
53
+
54
+ Validation is intentionally separate: callers can use this helper to
55
+ normalise a trusted value, while API routes can reject unknown tokens
56
+ before persisting them.
57
+ """
58
+ if value is None:
59
+ return []
60
+ if isinstance(value, str):
61
+ raw_values: Iterable[Any] = value.split(",")
62
+ elif isinstance(value, (list, tuple, set)):
63
+ raw_values = value
64
+ else:
65
+ raw_values = [value]
66
+ tokens: list[str] = []
67
+ for raw in raw_values:
68
+ token = str(raw or "").strip().lower()
69
+ if not token:
70
+ continue
71
+ if token == "all":
72
+ token = "global"
73
+ if token not in tokens:
74
+ tokens.append(token)
75
+ return tokens
76
+
77
+
78
+ def normalise_scope(value: Any, *, allow_auto: bool = False) -> str:
79
+ """Return a canonical scope or raise ``ValueError`` for bad input."""
80
+ if allow_auto and isinstance(value, str) and value.strip().lower() == "auto":
81
+ return "auto"
82
+ tokens = parse_scope(value)
83
+ if not tokens or any(token not in VALID_SCOPE_TOKENS for token in tokens):
84
+ raise ValueError(f"invalid scope: {value!r}")
85
+ if "global" in tokens:
86
+ return "global"
87
+ return ",".join(tokens)
88
+
89
+
90
+ def source_token(value: Any) -> str | None:
91
+ """Map a loader/client source name to a supported scope token."""
92
+ raw = str(value or "").strip().lower()
93
+ if not raw:
94
+ return None
95
+ raw = raw.replace("\\", "/")
96
+ for separator in ("/", ":"):
97
+ if separator in raw:
98
+ raw = raw.split(separator, 1)[0]
99
+ break
100
+ raw = _SOURCE_ALIASES.get(raw, raw)
101
+ if raw in CLIENT_SCOPE_TOKENS:
102
+ return raw
103
+ for token in CLIENT_SCOPE_TOKENS:
104
+ if raw.startswith(token + "-") or raw.startswith(token + "_"):
105
+ return token
106
+ return None
107
+
108
+
109
+ def _evidence_sources(evidence_ids: Iterable[Any] | None, store: Any) -> list[str]:
110
+ if not evidence_ids or store is None:
111
+ return []
112
+ ids = [str(item) for item in evidence_ids if str(item).strip()]
113
+ if not ids:
114
+ return []
115
+ try:
116
+ rows = store.list_memories(ids=ids, limit=max(1, len(ids)))
117
+ except Exception:
118
+ return []
119
+ sources: list[str] = []
120
+ for memory in rows or []:
121
+ raw = getattr(memory, "source", None)
122
+ token = source_token(raw)
123
+ if token and token not in sources:
124
+ sources.append(token)
125
+ return sources
126
+
127
+
128
+ def derive_default_scope(
129
+ source_hint: Any = None,
130
+ evidence_ids: Iterable[Any] | None = None,
131
+ store: Any = None,
132
+ *,
133
+ fallback: str = DEFAULT_CLIENT_SCOPE,
134
+ ) -> str:
135
+ """Derive a non-global scope for knowledge not promoted globally.
136
+
137
+ An explicit client hint wins. Otherwise all recognised clients that
138
+ contributed evidence are retained, so a page distilled from Codex and
139
+ Claude is available to both without silently becoming global. The
140
+ fallback is deliberately a real client token rather than ``global``;
141
+ this is the privacy-preserving behavior for manually-created pages with
142
+ no evidence metadata.
143
+ """
144
+ hinted = source_token(source_hint)
145
+ if hinted:
146
+ return hinted
147
+ evidence_sources = _evidence_sources(evidence_ids, store)
148
+ return derive_scope_from_sources(
149
+ source_hint=source_hint,
150
+ evidence_sources=evidence_sources,
151
+ fallback=fallback,
152
+ )
153
+
154
+
155
+ def derive_scope_from_sources(
156
+ source_hint: Any = None,
157
+ evidence_sources: Iterable[Any] | None = None,
158
+ *,
159
+ fallback: str = DEFAULT_CLIENT_SCOPE,
160
+ ) -> str:
161
+ """Derive a scope when source strings are already loaded by a caller."""
162
+ if evidence_sources:
163
+ tokens = [source_token(value) for value in evidence_sources]
164
+ tokens = [token for token in tokens if token]
165
+ else:
166
+ tokens = []
167
+ hinted = source_token(source_hint)
168
+ if hinted:
169
+ return hinted
170
+ if tokens:
171
+ counts = Counter(tokens)
172
+ ordered = sorted(
173
+ tokens,
174
+ key=lambda token: (-counts[token], tokens.index(token)),
175
+ )
176
+ return ",".join(dict.fromkeys(ordered))
177
+ return source_token(fallback) or DEFAULT_CLIENT_SCOPE
178
+
179
+
180
+ def build_scope_audit(
181
+ classification: Any,
182
+ *,
183
+ scope: str,
184
+ decision: str,
185
+ source_hint: Any = None,
186
+ enabled: bool = True,
187
+ mode: str = "pattern",
188
+ existing: dict | None = None,
189
+ ) -> dict:
190
+ """Attach the applied scope and decision to a classifier result."""
191
+ if hasattr(classification, "to_dict"):
192
+ audit = dict(classification.to_dict())
193
+ elif isinstance(classification, dict):
194
+ audit = dict(classification)
195
+ else:
196
+ audit = {}
197
+ audit.update({
198
+ "scope_applied": scope,
199
+ "scope_decision": decision,
200
+ "source_hint": str(source_hint).strip() if source_hint else None,
201
+ "auto_scope_enabled": bool(enabled),
202
+ "mode": str(mode or "pattern"),
203
+ })
204
+ if existing:
205
+ previous = existing.get("auto_classification")
206
+ if isinstance(previous, dict):
207
+ prior_history = previous.get("history")
208
+ if isinstance(prior_history, list):
209
+ audit["history"] = [*prior_history[-19:], {k: v for k, v in previous.items() if k != "history"}]
210
+ else:
211
+ audit["history"] = [{k: v for k, v in previous.items() if k != "history"}]
212
+ audit.setdefault("history", [])
213
+ return audit
214
+
215
+
216
+ __all__ = [
217
+ "CLIENT_SCOPE_TOKENS",
218
+ "DEFAULT_CLIENT_SCOPE",
219
+ "VALID_SCOPE_TOKENS",
220
+ "auto_scope_config",
221
+ "build_scope_audit",
222
+ "derive_default_scope",
223
+ "derive_scope_from_sources",
224
+ "normalise_scope",
225
+ "parse_scope",
226
+ "source_token",
227
+ ]