memplex 3.2.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.
- memnex/__init__.py +31 -0
- memnex/__main__.py +6 -0
- memnex/_plugin/.claude-plugin/plugin.json +24 -0
- memnex/_plugin/.mcp.json +9 -0
- memnex/_plugin/__init__.py +0 -0
- memnex/_plugin/hooks/hooks.json +43 -0
- memnex/_plugin/scripts/hook-runner.py +166 -0
- memnex/_plugin/skills/mem-explore/SKILL.md +83 -0
- memnex/_plugin/skills/mem-manage/SKILL.md +92 -0
- memnex/_plugin/skills/mem-search/SKILL.md +85 -0
- memnex/_plugin/skills/mem-write/SKILL.md +78 -0
- memnex/adapters/__init__.py +14 -0
- memnex/adapters/claude_skill.py +169 -0
- memnex/adapters/cli.py +525 -0
- memnex/adapters/http_api.py +314 -0
- memnex/adapters/mcp_server.py +448 -0
- memnex/compaction.py +563 -0
- memnex/config.py +366 -0
- memnex/core/__init__.py +13 -0
- memnex/core/associator/__init__.py +8 -0
- memnex/core/associator/domain_classifier.py +75 -0
- memnex/core/associator/entity_aligner.py +127 -0
- memnex/core/associator/ref_linker.py +197 -0
- memnex/core/associator/term_mapper.py +77 -0
- memnex/core/dictionaries/__init__.py +50 -0
- memnex/core/engine.py +667 -0
- memnex/core/extractors/__init__.py +15 -0
- memnex/core/extractors/docx.py +97 -0
- memnex/core/extractors/image.py +233 -0
- memnex/core/extractors/markdown.py +139 -0
- memnex/core/extractors/pdf.py +133 -0
- memnex/core/extractors/vision_mapper.py +131 -0
- memnex/core/handlers/__init__.py +7 -0
- memnex/core/handlers/clipboard.py +40 -0
- memnex/core/handlers/file_handler.py +62 -0
- memnex/core/handlers/url_handler.py +132 -0
- memnex/llm/__init__.py +25 -0
- memnex/llm/enhancer.py +226 -0
- memnex/llm/fallback_chain.py +87 -0
- memnex/llm/injection_guard.py +178 -0
- memnex/llm/provider.py +130 -0
- memnex/llm/providers/__init__.py +22 -0
- memnex/llm/providers/anthropic.py +135 -0
- memnex/llm/providers/local.py +135 -0
- memnex/llm/providers/rule_based.py +68 -0
- memnex/llm/sanitizer.py +67 -0
- memnex/models/__init__.py +68 -0
- memnex/models/feedback.py +42 -0
- memnex/models/graph.py +33 -0
- memnex/models/memory.py +102 -0
- memnex/models/misc.py +185 -0
- memnex/models/paragraph.py +45 -0
- memnex/models/search.py +51 -0
- memnex/models/source.py +23 -0
- memnex/models/task.py +62 -0
- memnex/processing/__init__.py +1 -0
- memnex/processing/graph_builder.py +278 -0
- memnex/processing/merger/__init__.py +6 -0
- memnex/processing/merger/confidence_calculator.py +127 -0
- memnex/processing/merger/conflict_resolver.py +116 -0
- memnex/retrieval/__init__.py +1 -0
- memnex/retrieval/dedup.py +386 -0
- memnex/retrieval/embedding.py +289 -0
- memnex/retrieval/reranker.py +299 -0
- memnex/service.py +902 -0
- memnex/storage/__init__.py +65 -0
- memnex/storage/base.py +132 -0
- memnex/storage/changelog.py +106 -0
- memnex/storage/feedback.py +486 -0
- memnex/storage/lite/__init__.py +5 -0
- memnex/storage/lite/store.py +606 -0
- memnex/storage/vector.py +265 -0
- memnex/wiki/__init__.py +11 -0
- memnex/wiki/community.py +221 -0
- memnex/wiki/compiler.py +545 -0
- memnex/wiki/generator.py +270 -0
- memnex/wiki/search.py +282 -0
- memnex/worker.py +412 -0
- memplex-3.2.0.dist-info/METADATA +37 -0
- memplex-3.2.0.dist-info/RECORD +83 -0
- memplex-3.2.0.dist-info/WHEEL +5 -0
- memplex-3.2.0.dist-info/entry_points.txt +2 -0
- memplex-3.2.0.dist-info/top_level.txt +1 -0
memnex/wiki/compiler.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""WikiCompiler -- compile MemoryNode instances into Wiki markdown pages.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
- Convert Function / Fact / Preference / Observation into WikiPage objects
|
|
5
|
+
- Generate index.md (directory of all pages)
|
|
6
|
+
- Lint all wiki pages for consistency
|
|
7
|
+
- Delegate search to DualIndexSearch with fallback to filename+grep
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from memnex.models import (
|
|
20
|
+
Fact,
|
|
21
|
+
FieldValue,
|
|
22
|
+
Function,
|
|
23
|
+
LintIssue,
|
|
24
|
+
LintResult,
|
|
25
|
+
MemoryNode,
|
|
26
|
+
Observation,
|
|
27
|
+
Preference,
|
|
28
|
+
SearchResult,
|
|
29
|
+
SourceType,
|
|
30
|
+
WikiPage,
|
|
31
|
+
validate_func_id,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from memnex.storage.base import MemoryStore
|
|
36
|
+
from memnex.wiki.search import DualIndexSearch
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# Default wiki directory
|
|
41
|
+
DEFAULT_WIKI_DIR = Path("~/.memnex/wiki/").expanduser()
|
|
42
|
+
|
|
43
|
+
# Maximum log lines before rotation
|
|
44
|
+
MAX_LOG_LINES = 1000
|
|
45
|
+
MAX_LOG_ARCHIVES = 5
|
|
46
|
+
|
|
47
|
+
# Wikilink pattern
|
|
48
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WikiCompiler:
|
|
52
|
+
"""Compile memory nodes into Wiki markdown pages.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
store:
|
|
57
|
+
MemoryStore backend for reading memory nodes.
|
|
58
|
+
wiki_dir:
|
|
59
|
+
Root directory for wiki files (default ``~/.memnex/wiki/``).
|
|
60
|
+
dual_index:
|
|
61
|
+
Optional :class:`DualIndexSearch` for hybrid search.
|
|
62
|
+
When ``None``, search falls back to filename + grep.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
store: MemoryStore,
|
|
68
|
+
wiki_dir: Path = DEFAULT_WIKI_DIR,
|
|
69
|
+
dual_index: Optional["DualIndexSearch"] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._store = store
|
|
72
|
+
self.wiki_dir = wiki_dir
|
|
73
|
+
self.dual_index = dual_index
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
|
|
76
|
+
# ── Page compilation ──────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def compile_function(self, func: Function) -> WikiPage:
|
|
79
|
+
"""Generate an Entity Page from a Function.
|
|
80
|
+
|
|
81
|
+
The page includes YAML frontmatter and a structured body with
|
|
82
|
+
trigger / condition / action / benefit sections plus cross-references
|
|
83
|
+
as ``[[target_name]]`` wikilinks.
|
|
84
|
+
"""
|
|
85
|
+
now = datetime.utcnow().isoformat()
|
|
86
|
+
frontmatter = self._build_frontmatter(
|
|
87
|
+
page_id=func.id,
|
|
88
|
+
name=func.name,
|
|
89
|
+
domain=func.domain or "uncategorized",
|
|
90
|
+
memory_type=func.memory_type,
|
|
91
|
+
confidence=func.confidence,
|
|
92
|
+
created_at=func.created_at or now,
|
|
93
|
+
updated_at=func.updated_at or now,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
body_lines: list[str] = [f"# {func.id}", ""]
|
|
97
|
+
body_lines.append(f"**Domain:** {func.domain or 'uncategorized'}")
|
|
98
|
+
body_lines.append(f"**Confidence:** {func.confidence:.2f}")
|
|
99
|
+
if func.source_paragraphs:
|
|
100
|
+
body_lines.append(
|
|
101
|
+
f"**Source Paragraphs:** [{', '.join(func.source_paragraphs)}]"
|
|
102
|
+
)
|
|
103
|
+
body_lines.append("")
|
|
104
|
+
|
|
105
|
+
# Field sections
|
|
106
|
+
body_lines.extend(
|
|
107
|
+
self._field_section("Trigger", func.trigger)
|
|
108
|
+
)
|
|
109
|
+
body_lines.extend(
|
|
110
|
+
self._field_section("Condition", func.condition)
|
|
111
|
+
)
|
|
112
|
+
body_lines.extend(
|
|
113
|
+
self._field_section("Action", func.action)
|
|
114
|
+
)
|
|
115
|
+
body_lines.extend(
|
|
116
|
+
self._field_section("Benefit", func.benefit)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Cross-references
|
|
120
|
+
if func.cross_references:
|
|
121
|
+
body_lines.append("## Cross-References")
|
|
122
|
+
for ref in func.cross_references:
|
|
123
|
+
if isinstance(ref, dict):
|
|
124
|
+
target = ref.get("target", ref.get("target_id", ""))
|
|
125
|
+
reason = ref.get("reason", "")
|
|
126
|
+
if target:
|
|
127
|
+
link = f"[[{target}]]"
|
|
128
|
+
body_lines.append(
|
|
129
|
+
f"- {link}"
|
|
130
|
+
+ (f" -- {reason}" if reason else "")
|
|
131
|
+
)
|
|
132
|
+
body_lines.append("")
|
|
133
|
+
|
|
134
|
+
content = frontmatter + "\n".join(body_lines)
|
|
135
|
+
return WikiPage(
|
|
136
|
+
page_id=func.id,
|
|
137
|
+
content=content,
|
|
138
|
+
metadata={
|
|
139
|
+
"type": "entity",
|
|
140
|
+
"domain": func.domain,
|
|
141
|
+
"confidence": func.confidence,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def compile_fact(self, fact: Fact) -> WikiPage:
|
|
146
|
+
"""Generate a Wiki page for a Fact node."""
|
|
147
|
+
now = datetime.utcnow().isoformat()
|
|
148
|
+
frontmatter = self._build_frontmatter(
|
|
149
|
+
page_id=fact.id,
|
|
150
|
+
name=fact.name or f"{fact.subject} {fact.predicate} {fact.object_}",
|
|
151
|
+
domain=fact.domain or "uncategorized",
|
|
152
|
+
memory_type=fact.memory_type,
|
|
153
|
+
confidence=fact.confidence,
|
|
154
|
+
created_at=fact.created_at or now,
|
|
155
|
+
updated_at=fact.updated_at or now,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
body_lines: list[str] = [f"# {fact.id}", ""]
|
|
159
|
+
body_lines.append(f"**Domain:** {fact.domain or 'uncategorized'}")
|
|
160
|
+
body_lines.append(f"**Confidence:** {fact.confidence:.2f}")
|
|
161
|
+
if fact.valid_until:
|
|
162
|
+
body_lines.append(f"**Valid Until:** {fact.valid_until}")
|
|
163
|
+
body_lines.append("")
|
|
164
|
+
body_lines.append("## Fact")
|
|
165
|
+
body_lines.append(
|
|
166
|
+
f"**{fact.subject}** {fact.predicate} **{fact.object_}**"
|
|
167
|
+
)
|
|
168
|
+
body_lines.append("")
|
|
169
|
+
|
|
170
|
+
content = frontmatter + "\n".join(body_lines)
|
|
171
|
+
return WikiPage(
|
|
172
|
+
page_id=fact.id,
|
|
173
|
+
content=content,
|
|
174
|
+
metadata={
|
|
175
|
+
"type": "entity",
|
|
176
|
+
"memory_type": "fact",
|
|
177
|
+
"domain": fact.domain,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def compile_preference(self, pref: Preference) -> WikiPage:
|
|
182
|
+
"""Generate a Wiki page for a Preference node."""
|
|
183
|
+
now = datetime.utcnow().isoformat()
|
|
184
|
+
frontmatter = self._build_frontmatter(
|
|
185
|
+
page_id=pref.id,
|
|
186
|
+
name=pref.name or f"pref_{pref.aspect}",
|
|
187
|
+
domain=pref.domain or "uncategorized",
|
|
188
|
+
memory_type=pref.memory_type,
|
|
189
|
+
confidence=pref.confidence,
|
|
190
|
+
created_at=pref.created_at or now,
|
|
191
|
+
updated_at=pref.updated_at or now,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
body_lines: list[str] = [f"# {pref.id}", ""]
|
|
195
|
+
body_lines.append(f"**Domain:** {pref.domain or 'uncategorized'}")
|
|
196
|
+
body_lines.append(f"**Confidence:** {pref.confidence:.2f}")
|
|
197
|
+
if pref.subject_id:
|
|
198
|
+
body_lines.append(f"**Subject:** {pref.subject_id}")
|
|
199
|
+
body_lines.append("")
|
|
200
|
+
body_lines.append("## Preference")
|
|
201
|
+
body_lines.append(f"**Aspect:** {pref.aspect}")
|
|
202
|
+
body_lines.append(f"**Preference:** {pref.preference}")
|
|
203
|
+
body_lines.append("")
|
|
204
|
+
|
|
205
|
+
content = frontmatter + "\n".join(body_lines)
|
|
206
|
+
return WikiPage(
|
|
207
|
+
page_id=pref.id,
|
|
208
|
+
content=content,
|
|
209
|
+
metadata={
|
|
210
|
+
"type": "entity",
|
|
211
|
+
"memory_type": "preference",
|
|
212
|
+
"domain": pref.domain,
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def compile_observation(self, obs: Observation) -> WikiPage:
|
|
217
|
+
"""Generate a Wiki page for an Observation node."""
|
|
218
|
+
now = datetime.utcnow().isoformat()
|
|
219
|
+
frontmatter = self._build_frontmatter(
|
|
220
|
+
page_id=obs.id,
|
|
221
|
+
name=obs.name or f"obs_{obs.event[:30]}",
|
|
222
|
+
domain=obs.domain or "uncategorized",
|
|
223
|
+
memory_type=obs.memory_type,
|
|
224
|
+
confidence=obs.confidence,
|
|
225
|
+
created_at=obs.created_at or now,
|
|
226
|
+
updated_at=obs.updated_at or now,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
body_lines: list[str] = [f"# {obs.id}", ""]
|
|
230
|
+
body_lines.append(f"**Domain:** {obs.domain or 'uncategorized'}")
|
|
231
|
+
body_lines.append(f"**Confidence:** {obs.confidence:.2f}")
|
|
232
|
+
body_lines.append(f"**Actor:** {obs.actor}")
|
|
233
|
+
if obs.observed_at:
|
|
234
|
+
body_lines.append(f"**Observed At:** {obs.observed_at}")
|
|
235
|
+
body_lines.append("")
|
|
236
|
+
body_lines.append("## Event")
|
|
237
|
+
body_lines.append(obs.event)
|
|
238
|
+
if obs.context:
|
|
239
|
+
body_lines.append("")
|
|
240
|
+
body_lines.append("## Context")
|
|
241
|
+
body_lines.append(obs.context)
|
|
242
|
+
body_lines.append("")
|
|
243
|
+
|
|
244
|
+
content = frontmatter + "\n".join(body_lines)
|
|
245
|
+
return WikiPage(
|
|
246
|
+
page_id=obs.id,
|
|
247
|
+
content=content,
|
|
248
|
+
metadata={
|
|
249
|
+
"type": "entity",
|
|
250
|
+
"memory_type": "observation",
|
|
251
|
+
"domain": obs.domain,
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def compile_index(self) -> WikiPage:
|
|
256
|
+
"""Generate the ``index.md`` directory page.
|
|
257
|
+
|
|
258
|
+
Lists all Functions in a table, grouped by domain, with recent changes.
|
|
259
|
+
"""
|
|
260
|
+
all_funcs = self._store.list_functions(limit=100000)
|
|
261
|
+
|
|
262
|
+
lines: list[str] = [
|
|
263
|
+
"# MemNex Knowledge Base",
|
|
264
|
+
"",
|
|
265
|
+
"## Entities (sorted by name)",
|
|
266
|
+
"",
|
|
267
|
+
"| Name | Domain | Confidence | Last Updated |",
|
|
268
|
+
"|------|--------|------------|--------------|",
|
|
269
|
+
]
|
|
270
|
+
for func in sorted(all_funcs, key=lambda f: f.name):
|
|
271
|
+
updated = func.updated_at or "-"
|
|
272
|
+
lines.append(
|
|
273
|
+
f"| [[{func.id}]] | {func.domain or '-'} "
|
|
274
|
+
f"| {func.confidence:.2f} | {updated} |"
|
|
275
|
+
)
|
|
276
|
+
lines.append("")
|
|
277
|
+
|
|
278
|
+
# Domains
|
|
279
|
+
domain_groups: dict[str, list[Function]] = {}
|
|
280
|
+
for func in all_funcs:
|
|
281
|
+
domain = func.domain or "uncategorized"
|
|
282
|
+
domain_groups.setdefault(domain, []).append(func)
|
|
283
|
+
|
|
284
|
+
lines.append("## Domains")
|
|
285
|
+
lines.append("")
|
|
286
|
+
for domain, funcs in sorted(domain_groups.items()):
|
|
287
|
+
lines.append(f"- [[domain_{domain}]] ({len(funcs)} functions)")
|
|
288
|
+
lines.append("")
|
|
289
|
+
|
|
290
|
+
# Recent changes
|
|
291
|
+
lines.append("## Recent Changes")
|
|
292
|
+
lines.append("")
|
|
293
|
+
sorted_funcs = sorted(
|
|
294
|
+
all_funcs, key=lambda f: f.updated_at or "", reverse=True
|
|
295
|
+
)[:10]
|
|
296
|
+
for func in sorted_funcs:
|
|
297
|
+
lines.append(
|
|
298
|
+
f"- {func.updated_at or '-'}: "
|
|
299
|
+
f"Updated `{func.name}` ({func.memory_type})"
|
|
300
|
+
)
|
|
301
|
+
lines.append("")
|
|
302
|
+
|
|
303
|
+
now = datetime.utcnow().isoformat()
|
|
304
|
+
frontmatter = self._build_frontmatter(
|
|
305
|
+
page_id="index",
|
|
306
|
+
name="MemNex Knowledge Base",
|
|
307
|
+
domain="",
|
|
308
|
+
memory_type="index",
|
|
309
|
+
confidence=1.0,
|
|
310
|
+
created_at=now,
|
|
311
|
+
updated_at=now,
|
|
312
|
+
)
|
|
313
|
+
content = frontmatter + "\n".join(lines)
|
|
314
|
+
return WikiPage(
|
|
315
|
+
page_id="index",
|
|
316
|
+
content=content,
|
|
317
|
+
metadata={"type": "index", "total_entities": len(all_funcs)},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def compile_all(self) -> List[WikiPage]:
|
|
321
|
+
"""Compile all memory nodes from the store into Wiki pages."""
|
|
322
|
+
pages: List[WikiPage] = []
|
|
323
|
+
|
|
324
|
+
# Compile functions
|
|
325
|
+
funcs = self._store.list_functions(limit=100000)
|
|
326
|
+
for func in funcs:
|
|
327
|
+
pages.append(self.compile_function(func))
|
|
328
|
+
|
|
329
|
+
# Compile index
|
|
330
|
+
pages.append(self.compile_index())
|
|
331
|
+
return pages
|
|
332
|
+
|
|
333
|
+
# ── Search ────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
def search(self, query: str, top_k: int = 10) -> List[SearchResult]:
|
|
336
|
+
"""Search wiki pages via DualIndexSearch or fallback to file scan."""
|
|
337
|
+
if self.dual_index is not None:
|
|
338
|
+
return self.dual_index.search(query, top_k)
|
|
339
|
+
return self._fallback_search(query, top_k)
|
|
340
|
+
|
|
341
|
+
# ── Lint ──────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
def lint(self) -> LintResult:
|
|
344
|
+
"""Validate all wiki pages for consistency.
|
|
345
|
+
|
|
346
|
+
Checks:
|
|
347
|
+
- Every wiki page has valid YAML frontmatter
|
|
348
|
+
- Wikilinks point to existing pages or store entries
|
|
349
|
+
- Orphaned pages (no inbound links) produce warnings
|
|
350
|
+
"""
|
|
351
|
+
issues: List[LintIssue] = []
|
|
352
|
+
|
|
353
|
+
# Read all wiki pages from disk
|
|
354
|
+
pages = self._read_all_pages()
|
|
355
|
+
page_ids = {p.page_id for p in pages}
|
|
356
|
+
all_funcs = {
|
|
357
|
+
f.id for f in self._store.list_functions(limit=100000)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for page in pages:
|
|
361
|
+
content = page.content
|
|
362
|
+
|
|
363
|
+
# Check frontmatter
|
|
364
|
+
if not content.startswith("---"):
|
|
365
|
+
issues.append(LintIssue(
|
|
366
|
+
page_id=page.page_id,
|
|
367
|
+
severity="error",
|
|
368
|
+
message="Missing YAML frontmatter",
|
|
369
|
+
))
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
# Parse frontmatter boundaries
|
|
373
|
+
parts = content.split("---")
|
|
374
|
+
if len(parts) < 3:
|
|
375
|
+
issues.append(LintIssue(
|
|
376
|
+
page_id=page.page_id,
|
|
377
|
+
severity="error",
|
|
378
|
+
message="Malformed frontmatter (missing closing ---)",
|
|
379
|
+
))
|
|
380
|
+
|
|
381
|
+
# Check wikilinks
|
|
382
|
+
links = _WIKILINK_RE.findall(content)
|
|
383
|
+
for link_target in links:
|
|
384
|
+
# Skip domain links and non-function links
|
|
385
|
+
if link_target.startswith("domain_"):
|
|
386
|
+
continue
|
|
387
|
+
if link_target not in page_ids and link_target not in all_funcs:
|
|
388
|
+
issues.append(LintIssue(
|
|
389
|
+
page_id=page.page_id,
|
|
390
|
+
severity="warning",
|
|
391
|
+
message=f"Broken wikilink: [[{link_target}]]",
|
|
392
|
+
))
|
|
393
|
+
|
|
394
|
+
total = len(pages)
|
|
395
|
+
return LintResult(
|
|
396
|
+
total_pages=total,
|
|
397
|
+
issues=issues,
|
|
398
|
+
passed=all(i.severity != "error" for i in issues),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# ── File I/O helpers ──────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
def write_page(self, page: WikiPage) -> Path:
|
|
404
|
+
"""Write a WikiPage to disk under ``wiki_dir/entities/``."""
|
|
405
|
+
entities_dir = self.wiki_dir / "entities"
|
|
406
|
+
entities_dir.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
path = entities_dir / f"{page.page_id}.md"
|
|
408
|
+
with self._lock:
|
|
409
|
+
path.write_text(page.content, encoding="utf-8")
|
|
410
|
+
return path
|
|
411
|
+
|
|
412
|
+
def write_index(self, page: WikiPage) -> Path:
|
|
413
|
+
"""Write the index page to ``wiki_dir/index.md``."""
|
|
414
|
+
self.wiki_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
path = self.wiki_dir / "index.md"
|
|
416
|
+
with self._lock:
|
|
417
|
+
path.write_text(page.content, encoding="utf-8")
|
|
418
|
+
return path
|
|
419
|
+
|
|
420
|
+
def append_log(self, message: str) -> Path:
|
|
421
|
+
"""Append a line to ``wiki_dir/log.md`` with rotation.
|
|
422
|
+
|
|
423
|
+
If log.md exceeds ``MAX_LOG_LINES``, rotate into archives
|
|
424
|
+
(log.archive.1.md, log.archive.2.md, ...). Keep at most
|
|
425
|
+
``MAX_LOG_ARCHIVES`` archives.
|
|
426
|
+
"""
|
|
427
|
+
self.wiki_dir.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
log_path = self.wiki_dir / "log.md"
|
|
429
|
+
|
|
430
|
+
with self._lock:
|
|
431
|
+
# Rotate if needed
|
|
432
|
+
if log_path.exists():
|
|
433
|
+
line_count = sum(1 for _ in open(log_path, encoding="utf-8"))
|
|
434
|
+
if line_count >= MAX_LOG_LINES:
|
|
435
|
+
self._rotate_log(log_path)
|
|
436
|
+
|
|
437
|
+
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
438
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
439
|
+
f.write(f"- {timestamp}: {message}\n")
|
|
440
|
+
|
|
441
|
+
return log_path
|
|
442
|
+
|
|
443
|
+
# ── Private helpers ───────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _build_frontmatter(
|
|
447
|
+
*,
|
|
448
|
+
page_id: str,
|
|
449
|
+
name: str,
|
|
450
|
+
domain: str,
|
|
451
|
+
memory_type: str,
|
|
452
|
+
confidence: float,
|
|
453
|
+
created_at: str,
|
|
454
|
+
updated_at: str,
|
|
455
|
+
) -> str:
|
|
456
|
+
"""Build YAML frontmatter block for a wiki page."""
|
|
457
|
+
escaped_name = name.replace('"', '\\"').replace("\n", "\\n")
|
|
458
|
+
lines = [
|
|
459
|
+
"---",
|
|
460
|
+
f'id: "{page_id}"',
|
|
461
|
+
f'name: "{escaped_name}"',
|
|
462
|
+
f'domain: "{domain}"',
|
|
463
|
+
f'memory_type: "{memory_type}"',
|
|
464
|
+
f"confidence: {confidence}",
|
|
465
|
+
f'created_at: "{created_at}"',
|
|
466
|
+
f'updated_at: "{updated_at}"',
|
|
467
|
+
"---",
|
|
468
|
+
"",
|
|
469
|
+
]
|
|
470
|
+
return "\n".join(lines)
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _field_section(heading: str, values: List[FieldValue]) -> list[str]:
|
|
474
|
+
"""Build markdown lines for a FieldValue list."""
|
|
475
|
+
if not values:
|
|
476
|
+
return []
|
|
477
|
+
lines: list[str] = [f"## {heading}"]
|
|
478
|
+
if len(values) == 1:
|
|
479
|
+
lines.append(values[0].desc)
|
|
480
|
+
else:
|
|
481
|
+
for fv in values:
|
|
482
|
+
lines.append(f"- {fv.desc}")
|
|
483
|
+
lines.append("")
|
|
484
|
+
return lines
|
|
485
|
+
|
|
486
|
+
def _fallback_search(self, query: str, top_k: int) -> List[SearchResult]:
|
|
487
|
+
"""Degraded search via filename matching and grep.
|
|
488
|
+
|
|
489
|
+
Used when DualIndexSearch is not available (e.g. Lite backend).
|
|
490
|
+
"""
|
|
491
|
+
results: List[SearchResult] = []
|
|
492
|
+
query_lower = query.lower()
|
|
493
|
+
|
|
494
|
+
# Search in store
|
|
495
|
+
funcs = self._store.list_functions(limit=100000)
|
|
496
|
+
for func in funcs:
|
|
497
|
+
score = 0.0
|
|
498
|
+
if query_lower in func.name.lower():
|
|
499
|
+
score += 0.8
|
|
500
|
+
if func.domain and query_lower in func.domain.lower():
|
|
501
|
+
score += 0.4
|
|
502
|
+
for fv in func.trigger + func.action + func.benefit:
|
|
503
|
+
if query_lower in fv.desc.lower():
|
|
504
|
+
score += 0.3
|
|
505
|
+
break
|
|
506
|
+
if score > 0:
|
|
507
|
+
results.append(SearchResult(
|
|
508
|
+
func_id=func.id,
|
|
509
|
+
name=func.name,
|
|
510
|
+
domain=func.domain or "",
|
|
511
|
+
relevance_score=min(score, 1.0),
|
|
512
|
+
summary="; ".join(
|
|
513
|
+
fv.desc for fv in func.action[:2]
|
|
514
|
+
),
|
|
515
|
+
source_type=SourceType.WIKI,
|
|
516
|
+
created_at=func.created_at,
|
|
517
|
+
updated_at=func.updated_at,
|
|
518
|
+
))
|
|
519
|
+
|
|
520
|
+
results.sort(key=lambda r: r.relevance_score, reverse=True)
|
|
521
|
+
return results[:top_k]
|
|
522
|
+
|
|
523
|
+
def _read_all_pages(self) -> List[WikiPage]:
|
|
524
|
+
"""Read all entity pages from ``wiki_dir/entities/``."""
|
|
525
|
+
pages: List[WikiPage] = []
|
|
526
|
+
entities_dir = self.wiki_dir / "entities"
|
|
527
|
+
if not entities_dir.exists():
|
|
528
|
+
return pages
|
|
529
|
+
for md_file in entities_dir.glob("*.md"):
|
|
530
|
+
page_id = md_file.stem
|
|
531
|
+
content = md_file.read_text(encoding="utf-8")
|
|
532
|
+
pages.append(WikiPage(page_id=page_id, content=content))
|
|
533
|
+
return pages
|
|
534
|
+
|
|
535
|
+
def _rotate_log(self, log_path: Path) -> None:
|
|
536
|
+
"""Rotate log.md into numbered archives."""
|
|
537
|
+
for i in range(MAX_LOG_ARCHIVES - 1, 0, -1):
|
|
538
|
+
src = self.wiki_dir / f"log.archive.{i}.md"
|
|
539
|
+
dst = self.wiki_dir / f"log.archive.{i + 1}.md"
|
|
540
|
+
if src.exists():
|
|
541
|
+
if i + 1 > MAX_LOG_ARCHIVES:
|
|
542
|
+
src.unlink()
|
|
543
|
+
else:
|
|
544
|
+
src.rename(dst)
|
|
545
|
+
log_path.rename(self.wiki_dir / "log.archive.1.md")
|