memkernel 0.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.
- memkernel/__init__.py +5 -0
- memkernel/api/__init__.py +2 -0
- memkernel/api/app.py +64 -0
- memkernel/api/routes/__init__.py +2 -0
- memkernel/api/routes/admin.py +44 -0
- memkernel/config.py +56 -0
- memkernel/core/__init__.py +2 -0
- memkernel/core/models.py +143 -0
- memkernel/core/ports.py +95 -0
- memkernel/core/services.py +485 -0
- memkernel/factory.py +71 -0
- memkernel/mcp/__init__.py +2 -0
- memkernel/mcp/server.py +94 -0
- memkernel/mcp/tools/__init__.py +2 -0
- memkernel/mcp/tools/memory.py +148 -0
- memkernel/mcp/tools/wiki.py +59 -0
- memkernel/memory/__init__.py +2 -0
- memkernel/memory/dedup.py +12 -0
- memkernel/memory/lifecycle.py +92 -0
- memkernel/memory/types.py +40 -0
- memkernel/py.typed +0 -0
- memkernel/retrieval/__init__.py +2 -0
- memkernel/retrieval/chunking.py +244 -0
- memkernel/retrieval/embeddings.py +39 -0
- memkernel/retrieval/search.py +235 -0
- memkernel/retrieval/sparse.py +137 -0
- memkernel/storage/__init__.py +3 -0
- memkernel/storage/chroma.py +256 -0
- memkernel/wiki/__init__.py +14 -0
- memkernel/wiki/pages.py +165 -0
- memkernel/wiki/sync.py +228 -0
- memkernel-0.2.0.dist-info/METADATA +327 -0
- memkernel-0.2.0.dist-info/RECORD +37 -0
- memkernel-0.2.0.dist-info/WHEEL +4 -0
- memkernel-0.2.0.dist-info/entry_points.txt +3 -0
- memkernel-0.2.0.dist-info/licenses/LICENSE +201 -0
- memkernel-0.2.0.dist-info/licenses/NOTICE +5 -0
memkernel/wiki/pages.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Wiki page model plus a dependency-free frontmatter parser/renderer.
|
|
4
|
+
|
|
5
|
+
The page format follows the schema used by ``code-copilot-team/knowledge/wiki``
|
|
6
|
+
(page_type, slug, title, status, last_reviewed, sources) so that pages compiled
|
|
7
|
+
by MemKernel pass that wiki's structural linter unchanged.
|
|
8
|
+
|
|
9
|
+
Only the YAML subset those pages use is supported: top-level scalars and a
|
|
10
|
+
``sources:`` list whose items are flat ``key: value`` mappings.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
|
|
21
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WikiPage(BaseModel):
|
|
25
|
+
"""A single markdown page with its frontmatter."""
|
|
26
|
+
|
|
27
|
+
page_type: str
|
|
28
|
+
slug: str
|
|
29
|
+
title: str
|
|
30
|
+
status: str = "draft"
|
|
31
|
+
last_reviewed: str = ""
|
|
32
|
+
sources: list[dict[str, str]] = Field(default_factory=list)
|
|
33
|
+
body: str = ""
|
|
34
|
+
extra: dict[str, str] = Field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def slugify(text: str, max_len: int = 60) -> str:
|
|
38
|
+
"""Return a kebab-case slug suitable for a wiki filename."""
|
|
39
|
+
slug = _SLUG_RE.sub("-", text.lower()).strip("-")
|
|
40
|
+
if len(slug) > max_len:
|
|
41
|
+
slug = slug[:max_len].rstrip("-")
|
|
42
|
+
return slug or "untitled"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _unquote(value: str) -> str:
|
|
46
|
+
value = value.strip()
|
|
47
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
48
|
+
return value[1:-1]
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_frontmatter(block: str) -> tuple[dict[str, str], list[dict[str, str]]]:
|
|
53
|
+
"""Parse the supported YAML subset into (scalars, sources)."""
|
|
54
|
+
scalars: dict[str, str] = {}
|
|
55
|
+
sources: list[dict[str, str]] = []
|
|
56
|
+
in_sources = False
|
|
57
|
+
current: dict[str, str] | None = None
|
|
58
|
+
for raw in block.splitlines():
|
|
59
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
60
|
+
continue
|
|
61
|
+
indented = raw[0] in " \t"
|
|
62
|
+
line = raw.strip()
|
|
63
|
+
if not indented:
|
|
64
|
+
in_sources = False
|
|
65
|
+
current = None
|
|
66
|
+
key, sep, value = line.partition(":")
|
|
67
|
+
if not sep:
|
|
68
|
+
continue
|
|
69
|
+
key = key.strip()
|
|
70
|
+
if key == "sources":
|
|
71
|
+
in_sources = True
|
|
72
|
+
continue
|
|
73
|
+
scalars[key] = _unquote(value)
|
|
74
|
+
continue
|
|
75
|
+
if not in_sources:
|
|
76
|
+
continue
|
|
77
|
+
if line.startswith("- "):
|
|
78
|
+
current = {}
|
|
79
|
+
sources.append(current)
|
|
80
|
+
line = line[2:].strip()
|
|
81
|
+
if current is None:
|
|
82
|
+
continue
|
|
83
|
+
key, sep, value = line.partition(":")
|
|
84
|
+
if sep:
|
|
85
|
+
current[key.strip()] = _unquote(value)
|
|
86
|
+
return scalars, sources
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_page(text: str) -> WikiPage | None:
|
|
90
|
+
"""Parse a markdown document with YAML frontmatter. Returns None if it has none."""
|
|
91
|
+
match = _FRONTMATTER_RE.match(text)
|
|
92
|
+
if match is None:
|
|
93
|
+
return None
|
|
94
|
+
scalars, sources = _parse_frontmatter(match.group(1))
|
|
95
|
+
body = text[match.end() :]
|
|
96
|
+
known = {"page_type", "slug", "title", "status", "last_reviewed"}
|
|
97
|
+
page_type = scalars.get("page_type", "")
|
|
98
|
+
slug = scalars.get("slug", "")
|
|
99
|
+
if not page_type or not slug:
|
|
100
|
+
return None
|
|
101
|
+
return WikiPage(
|
|
102
|
+
page_type=page_type,
|
|
103
|
+
slug=slug,
|
|
104
|
+
title=scalars.get("title", slug),
|
|
105
|
+
status=scalars.get("status", "draft"),
|
|
106
|
+
last_reviewed=scalars.get("last_reviewed", ""),
|
|
107
|
+
sources=sources,
|
|
108
|
+
body=body.strip("\n") + "\n" if body.strip() else "",
|
|
109
|
+
extra={k: v for k, v in scalars.items() if k not in known},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _quote(value: str) -> str:
|
|
114
|
+
"""Quote a scalar when YAML would otherwise misread it."""
|
|
115
|
+
if value == "" or any(ch in value for ch in ":#\"'[]{}") or value != value.strip():
|
|
116
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def render_page(page: WikiPage) -> str:
|
|
121
|
+
"""Render a WikiPage back to markdown with frontmatter."""
|
|
122
|
+
lines = [
|
|
123
|
+
"---",
|
|
124
|
+
f"page_type: {page.page_type}",
|
|
125
|
+
f"slug: {page.slug}",
|
|
126
|
+
f"title: {_quote(page.title)}",
|
|
127
|
+
f"status: {page.status}",
|
|
128
|
+
f"last_reviewed: {page.last_reviewed}",
|
|
129
|
+
]
|
|
130
|
+
for key, value in page.extra.items():
|
|
131
|
+
lines.append(f"{key}: {_quote(value)}")
|
|
132
|
+
if page.sources:
|
|
133
|
+
lines.append("sources:")
|
|
134
|
+
for source in page.sources:
|
|
135
|
+
first = True
|
|
136
|
+
for key, value in source.items():
|
|
137
|
+
prefix = " - " if first else " "
|
|
138
|
+
lines.append(f"{prefix}{key}: {_quote(value)}")
|
|
139
|
+
first = False
|
|
140
|
+
lines.append("---")
|
|
141
|
+
lines.append("")
|
|
142
|
+
return "\n".join(lines) + page.body
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def body_sections(body: str) -> dict[str, str]:
|
|
146
|
+
"""Split a page body into its ``##`` sections (heading -> text)."""
|
|
147
|
+
sections: dict[str, str] = {}
|
|
148
|
+
current: str | None = None
|
|
149
|
+
buffer: list[str] = []
|
|
150
|
+
for line in body.splitlines():
|
|
151
|
+
if line.startswith("## "):
|
|
152
|
+
if current is not None:
|
|
153
|
+
sections[current] = "\n".join(buffer).strip()
|
|
154
|
+
current = line[3:].strip()
|
|
155
|
+
buffer = []
|
|
156
|
+
elif current is not None:
|
|
157
|
+
buffer.append(line)
|
|
158
|
+
if current is not None:
|
|
159
|
+
sections[current] = "\n".join(buffer).strip()
|
|
160
|
+
return sections
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def page_to_dict(page: WikiPage) -> dict[str, Any]:
|
|
164
|
+
"""JSON-friendly view of a page (used by the MCP tools)."""
|
|
165
|
+
return page.model_dump()
|
memkernel/wiki/sync.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Copyright (c) 2026 EGOGE
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Export long-term memories to wiki pages and import wiki pages as memories.
|
|
4
|
+
|
|
5
|
+
Direction 1 — ``WikiExporter`` ("compile"): every long-term memory that has no
|
|
6
|
+
wiki page yet becomes a *draft* page in the matching section, with the page
|
|
7
|
+
sections the wiki schema requires. The exporter is deliberately LLM-free: it
|
|
8
|
+
produces a reviewable draft (the memory content in the primary section,
|
|
9
|
+
placeholders elsewhere) and leaves synthesis to the agent or curator, mirroring
|
|
10
|
+
the proposal → promote flow of ``code-copilot-team/scripts/wiki``.
|
|
11
|
+
|
|
12
|
+
Direction 2 — ``WikiImporter`` ("index"): every curated page becomes a
|
|
13
|
+
long-term memory (type by page_type) so ``recall`` can find it. Re-running the
|
|
14
|
+
importer is idempotent: unchanged pages dedup on content hash, changed pages
|
|
15
|
+
supersede their previous version.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from memkernel.core.models import MemoryTier, MemoryType
|
|
27
|
+
from memkernel.wiki.pages import WikiPage, parse_page, render_page, slugify
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from memkernel.core.models import Memory
|
|
31
|
+
from memkernel.core.services import MemoryService
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
WIKI_TAG = "wiki"
|
|
36
|
+
INDEX_FILE = "index.md"
|
|
37
|
+
LOG_FILE = "log.md"
|
|
38
|
+
|
|
39
|
+
# MemoryType -> (page_type, directory). CODE is not exported: code lives in the repo.
|
|
40
|
+
EXPORT_MAP: dict[MemoryType, tuple[str, str]] = {
|
|
41
|
+
MemoryType.DECISION: ("decision", "decisions"),
|
|
42
|
+
MemoryType.CONVENTION: ("concept", "concepts"),
|
|
43
|
+
MemoryType.FACT: ("concept", "concepts"),
|
|
44
|
+
MemoryType.PROCEDURE: ("workflow", "workflows"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# page_type -> MemoryType for import. Pages without a mapping are skipped.
|
|
48
|
+
IMPORT_MAP: dict[str, MemoryType] = {
|
|
49
|
+
"decision": MemoryType.DECISION,
|
|
50
|
+
"concept": MemoryType.FACT,
|
|
51
|
+
"overview": MemoryType.FACT,
|
|
52
|
+
"glossary": MemoryType.FACT,
|
|
53
|
+
"incident": MemoryType.FACT,
|
|
54
|
+
"open-question": MemoryType.FACT,
|
|
55
|
+
"workflow": MemoryType.PROCEDURE,
|
|
56
|
+
"playbook": MemoryType.PROCEDURE,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Required H2 sections per page type (first one receives the memory content).
|
|
60
|
+
PAGE_SECTIONS: dict[str, list[str]] = {
|
|
61
|
+
"decision": ["Decision", "Context", "Alternatives considered", "Consequences"],
|
|
62
|
+
"concept": ["Summary", "Key ideas", "Where this shows up", "Related"],
|
|
63
|
+
"workflow": ["When to use this", "Steps", "Verification", "Related"],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
PLACEHOLDER = "_Not recorded in memory — curate before marking this page stable._"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _today() -> str:
|
|
70
|
+
return datetime.now(UTC).date().isoformat()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ExportReport:
|
|
75
|
+
"""Outcome of an export run."""
|
|
76
|
+
|
|
77
|
+
written: list[str] = field(default_factory=list)
|
|
78
|
+
skipped: int = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ImportReport:
|
|
83
|
+
"""Outcome of an import run."""
|
|
84
|
+
|
|
85
|
+
imported: list[str] = field(default_factory=list)
|
|
86
|
+
skipped: list[str] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WikiExporter:
|
|
90
|
+
"""Compile long-term memories into draft wiki pages under ``wiki_dir``."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, service: MemoryService, wiki_dir: Path) -> None:
|
|
93
|
+
self._service = service
|
|
94
|
+
self._wiki_dir = Path(wiki_dir)
|
|
95
|
+
|
|
96
|
+
def export(self, memories: list[Memory] | None = None, update_index: bool = True) -> ExportReport:
|
|
97
|
+
"""Write one page per exportable memory that is not already a wiki page.
|
|
98
|
+
|
|
99
|
+
Memories that came from the wiki (tagged ``wiki``), chunk records, or
|
|
100
|
+
memories a newer memory supersedes are skipped. Existing pages with
|
|
101
|
+
the same slug are left untouched: the wiki, once written, is curated
|
|
102
|
+
by humans/agents, not overwritten by the store.
|
|
103
|
+
"""
|
|
104
|
+
report = ExportReport()
|
|
105
|
+
candidates = memories if memories is not None else self._service.list_memories(tier=MemoryTier.LONG_TERM)
|
|
106
|
+
superseded = {m.supersedes for m in candidates if m.supersedes}
|
|
107
|
+
for memory in candidates:
|
|
108
|
+
mapping = EXPORT_MAP.get(memory.type)
|
|
109
|
+
if mapping is None or WIKI_TAG in memory.tags or memory.ref_id in superseded or memory.chunk_refs:
|
|
110
|
+
report.skipped += 1
|
|
111
|
+
continue
|
|
112
|
+
page_type, directory = mapping
|
|
113
|
+
page = self._page_for(memory, page_type)
|
|
114
|
+
target = self._wiki_dir / directory / f"{page.slug}.md"
|
|
115
|
+
if target.exists():
|
|
116
|
+
report.skipped += 1
|
|
117
|
+
continue
|
|
118
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
target.write_text(render_page(page), encoding="utf-8")
|
|
120
|
+
rel = f"{directory}/{page.slug}.md"
|
|
121
|
+
report.written.append(rel)
|
|
122
|
+
if update_index:
|
|
123
|
+
self._append_index(rel, page)
|
|
124
|
+
self._append_log(page, "created")
|
|
125
|
+
return report
|
|
126
|
+
|
|
127
|
+
def _page_for(self, memory: Memory, page_type: str) -> WikiPage:
|
|
128
|
+
title = memory.content.strip().splitlines()[0][:80] if memory.content.strip() else memory.ref_id
|
|
129
|
+
slug = slugify(title)
|
|
130
|
+
sections = PAGE_SECTIONS[page_type]
|
|
131
|
+
body_lines = [f"# {title}", ""]
|
|
132
|
+
for i, heading in enumerate(sections):
|
|
133
|
+
body_lines.append(f"## {heading}")
|
|
134
|
+
body_lines.append("")
|
|
135
|
+
body_lines.append(memory.content.strip() if i == 0 else PLACEHOLDER)
|
|
136
|
+
body_lines.append("")
|
|
137
|
+
sources: list[dict[str, str]] = [
|
|
138
|
+
{"url": f"memkernel://{memory.project_id}/{memory.ref_id}", "retrieved": _today()}
|
|
139
|
+
]
|
|
140
|
+
if memory.source:
|
|
141
|
+
sources.append({"path": memory.source})
|
|
142
|
+
return WikiPage(
|
|
143
|
+
page_type=page_type,
|
|
144
|
+
slug=slug,
|
|
145
|
+
title=title,
|
|
146
|
+
status="draft",
|
|
147
|
+
last_reviewed=_today(),
|
|
148
|
+
sources=sources,
|
|
149
|
+
body="\n".join(body_lines),
|
|
150
|
+
extra={"memkernel_ref": memory.ref_id, "memory_type": memory.type.value},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _append_index(self, rel: str, page: WikiPage) -> None:
|
|
154
|
+
index = self._wiki_dir / INDEX_FILE
|
|
155
|
+
line = f"- [{page.title}]({rel}) — draft compiled from memkernel ({page.extra.get('memory_type', '')})."
|
|
156
|
+
if not index.exists():
|
|
157
|
+
index.write_text(
|
|
158
|
+
"---\npage_type: index\nslug: index\ntitle: Wiki Index\nstatus: draft\n"
|
|
159
|
+
f"last_reviewed: {_today()}\n---\n\n# Wiki Index\n\n## Compiled from memkernel\n\n",
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
)
|
|
162
|
+
text = index.read_text(encoding="utf-8")
|
|
163
|
+
if f"]({rel})" in text:
|
|
164
|
+
return
|
|
165
|
+
if not text.endswith("\n"):
|
|
166
|
+
text += "\n"
|
|
167
|
+
index.write_text(text + line + "\n", encoding="utf-8")
|
|
168
|
+
|
|
169
|
+
def _append_log(self, page: WikiPage, verb: str) -> None:
|
|
170
|
+
log = self._wiki_dir / LOG_FILE
|
|
171
|
+
if not log.exists():
|
|
172
|
+
log.write_text(
|
|
173
|
+
"---\npage_type: log\nslug: log\ntitle: Wiki Edit Log\nstatus: draft\n"
|
|
174
|
+
f"last_reviewed: {_today()}\n---\n\n# Wiki Edit Log\n\n## Entries\n\n",
|
|
175
|
+
encoding="utf-8",
|
|
176
|
+
)
|
|
177
|
+
ref = page.extra.get("memkernel_ref", "")
|
|
178
|
+
entry = f"- {_today()} — {verb} `{page.slug}` ({page.page_type}): compiled from memkernel {ref}\n"
|
|
179
|
+
with log.open("a", encoding="utf-8") as fh:
|
|
180
|
+
fh.write(entry)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class WikiImporter:
|
|
184
|
+
"""Index curated wiki pages as long-term memories."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, service: MemoryService, wiki_dir: Path) -> None:
|
|
187
|
+
self._service = service
|
|
188
|
+
self._wiki_dir = Path(wiki_dir)
|
|
189
|
+
|
|
190
|
+
def import_pages(self, session_id: str | None = None) -> ImportReport:
|
|
191
|
+
"""Retain every page with a mapped page_type; return what was imported/skipped."""
|
|
192
|
+
report = ImportReport()
|
|
193
|
+
existing = {
|
|
194
|
+
self._page_tag(m): m
|
|
195
|
+
for m in self._service.list_memories(tier=MemoryTier.LONG_TERM)
|
|
196
|
+
if WIKI_TAG in m.tags and self._page_tag(m)
|
|
197
|
+
}
|
|
198
|
+
for path in sorted(self._wiki_dir.rglob("*.md")):
|
|
199
|
+
rel = path.relative_to(self._wiki_dir).as_posix()
|
|
200
|
+
if rel.startswith(("schema/", "scripts/", ".")):
|
|
201
|
+
continue
|
|
202
|
+
page = parse_page(path.read_text(encoding="utf-8"))
|
|
203
|
+
memory_type = IMPORT_MAP.get(page.page_type) if page else None
|
|
204
|
+
if page is None or memory_type is None or page.status == "deprecated":
|
|
205
|
+
report.skipped.append(rel)
|
|
206
|
+
continue
|
|
207
|
+
content = f"{page.title}\n\n{page.body}".strip()
|
|
208
|
+
tags = [WIKI_TAG, f"page:{page.slug}", f"page_type:{page.page_type}"]
|
|
209
|
+
previous = existing.get(f"page:{page.slug}")
|
|
210
|
+
supersedes = previous.ref_id if previous is not None and previous.content != content else None
|
|
211
|
+
self._service.retain(
|
|
212
|
+
content=content,
|
|
213
|
+
type=memory_type,
|
|
214
|
+
session_id=session_id,
|
|
215
|
+
source=rel,
|
|
216
|
+
importance=0.9 if page.status == "stable" else 0.7,
|
|
217
|
+
tags=tags,
|
|
218
|
+
supersedes=supersedes,
|
|
219
|
+
)
|
|
220
|
+
report.imported.append(rel)
|
|
221
|
+
return report
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _page_tag(memory: Memory) -> str:
|
|
225
|
+
for tag in memory.tags:
|
|
226
|
+
if tag.startswith("page:"):
|
|
227
|
+
return tag
|
|
228
|
+
return ""
|