diff --git a/packages/openclaw-plugin/src/hooks/recall.ts b/packages/openclaw-plugin/src/hooks/recall.ts index 148977a..44046c1 100644 --- a/packages/openclaw-plugin/src/hooks/recall.ts +++ b/packages/openclaw-plugin/src/hooks/recall.ts @@ -374,10 +374,11 @@ export function rerankByTypeWeight( const type = r.type || "memory"; const weight = weights[type] ?? 1.0; const recency = calcRecencyBoost(r.created, recencyBoost); - // Manual entries (no auto-capture tag) get a boost over auto-captured ones. + // Human-created entries (webui/cli tags) get a boost over auto-captured and agent entries. // This ensures intentionally stored knowledge ranks higher than conversation noise. - const isAutoCapture = r.tags?.includes("auto-capture") ?? false; - const sourceBoost = isAutoCapture ? 1.0 : manualEntryBoost; + const tags = r.tags ?? []; + const isHumanCreated = tags.includes("webui") || tags.includes("cli"); + const sourceBoost = isHumanCreated ? manualEntryBoost : 1.0; return { id: r.id, body: r.content || r.body || "", diff --git a/palaia/cli.py b/palaia/cli.py index 2703ec5..9f72518 100644 --- a/palaia/cli.py +++ b/palaia/cli.py @@ -212,14 +212,17 @@ def cmd_write(args): root = get_root() agent = _resolve_agent(args) instance = _resolve_instance_for_write(args) - tags = args.tags.split(",") if args.tags else None + tags = args.tags.split(",") if args.tags else [] + # Tag entries created via CLI so recall can distinguish source + if "cli" not in tags: + tags.append("cli") result = write_entry( root, body=args.text, scope=args.scope, agent=agent, - tags=tags, + tags=tags or None, title=args.title, project=getattr(args, "project", None), entry_type=getattr(args, "type", None), diff --git a/palaia/web/routes/entries.py b/palaia/web/routes/entries.py index e32ebdc..fcee801 100644 --- a/palaia/web/routes/entries.py +++ b/palaia/web/routes/entries.py @@ -1,9 +1,10 @@ """Entry CRUD routes. -v2.6 semantics: +v2.6+ semantics: - Tasks are post-its: setting status=done/wontfix on a task deletes it. -- Auto-capture entries carry the 'auto-capture' tag; manual entries do not. - Manual entries rank 30% higher in recall (visible via is_manual flag). +- Source tags: 'webui' (created in browser), 'cli' (palaia add/write), + 'auto-capture' (passive capture). No source tag = agent (MCP/tool). + Human-created entries (webui, cli) rank 30% higher in recall. """ from __future__ import annotations @@ -57,10 +58,21 @@ def _validate_enum(value: str | None, valid: set[str], field: str) -> str | None return value +def _detect_source(tags: list[str]) -> str: + """Detect entry source from tags: webui, cli, auto-capture, or agent.""" + if "webui" in tags: + return "webui" + if "cli" in tags: + return "cli" + if "auto-capture" in tags: + return "auto" + return "agent" + + def _entry_to_dict(meta: dict, body: str, tier: str, *, preview: bool = True) -> dict: - """Convert store entry to JSON-serializable dict with v2.6 flags.""" + """Convert store entry to JSON-serializable dict with source flags.""" tags = meta.get("tags", []) or [] - is_auto = "auto-capture" in tags + source = _detect_source(tags) return { "id": meta.get("id", ""), "title": meta.get("title", ""), @@ -78,8 +90,9 @@ def _entry_to_dict(meta: dict, body: str, tier: str, *, preview: bool = True) -> "accessed": meta.get("accessed", ""), "access_count": meta.get("access_count", 0), "decay_score": meta.get("decay_score", 0), - "is_auto_capture": is_auto, - "is_manual": not is_auto, + "source": source, + "is_auto_capture": source == "auto", + "is_manual": source in ("webui", "cli"), "body_preview": (body[:200] + "…") if preview and len(body) > 200 else body, } @@ -164,11 +177,13 @@ def get_entry(request: Request, entry_id: str) -> dict: if "error" in result: return JSONResponse(status_code=404, content=result) - # Augment with v2.6 flags + # Augment with source flags meta = result.get("meta", {}) or {} tags = meta.get("tags", []) or [] - result["is_auto_capture"] = "auto-capture" in tags - result["is_manual"] = not result["is_auto_capture"] + source = _detect_source(tags) + result["source"] = source + result["is_auto_capture"] = source == "auto" + result["is_manual"] = source in ("webui", "cli") return result @@ -196,13 +211,18 @@ def create_entry(request: Request, payload: EntryCreate) -> dict: store = Store(root) store.recover() + # Tag entries created via WebUI so recall can distinguish source + tags = list(payload.tags) if payload.tags else [] + if "webui" not in tags: + tags.append("webui") + try: entry_id = store.write( body=payload.body, title=payload.title, entry_type=payload.type, scope=payload.scope, - tags=payload.tags or None, + tags=tags or None, project=payload.project, status=payload.status, priority=payload.priority, diff --git a/palaia/web/routes/search.py b/palaia/web/routes/search.py index d89184c..b05c7b0 100644 --- a/palaia/web/routes/search.py +++ b/palaia/web/routes/search.py @@ -78,11 +78,15 @@ def _run(): logger.error("BM25 fallback failed: %s", exc) result = {"results": [], "has_embeddings": False, "bm25_only": True} - # Augment results with manual/auto flags + # Augment results with source flags + from palaia.web.routes.entries import _detect_source + for r in result.get("results", []): tags = r.get("tags", []) or [] - r["is_auto_capture"] = "auto-capture" in tags - r["is_manual"] = not r["is_auto_capture"] + source = _detect_source(tags) + r["source"] = source + r["is_auto_capture"] = source == "auto" + r["is_manual"] = source in ("webui", "cli") return { "query": q, diff --git a/palaia/web/routes/status.py b/palaia/web/routes/status.py index b03bb1d..49d99a7 100644 --- a/palaia/web/routes/status.py +++ b/palaia/web/routes/status.py @@ -92,6 +92,23 @@ def list_agents(request: Request) -> dict: return {"agents": sorted(agents)} +@router.get("/tags") +def list_tags(request: Request) -> dict: + """List distinct tags found in entries.""" + from palaia.store import Store + + root = request.app.state.palaia_root + store = Store(root) + store.recover() + tags: set[str] = set() + for meta, _body, _tier in store.all_entries_unfiltered(include_cold=True): + for tag in meta.get("tags") or []: + tag = tag.strip() + if tag and tag not in ("auto-capture", "webui", "cli"): + tags.add(tag) + return {"tags": sorted(tags)} + + @router.get("/doctor") def run_doctor(request: Request) -> dict: """Run palaia doctor and return results for the UI banner. diff --git a/palaia/web/static/app.js b/palaia/web/static/app.js index 68ea577..6dcd89b 100644 --- a/palaia/web/static/app.js +++ b/palaia/web/static/app.js @@ -80,6 +80,50 @@ toast._timer = setTimeout(() => { t.className = "toast"; }, 3000); } + // ── Simple Markdown → HTML ──────────────────────────────────────────────── + function renderMarkdown(text) { + if (!text) return ""; + // Extract code blocks BEFORE escaping so their content stays raw. + const codeBlocks = []; + let safe = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => { + const idx = codeBlocks.length; + codeBlocks.push("
" + esc(code) + ""); + return "\x00CB" + idx + "\x00"; + }); + // Extract inline code before escaping + const inlineCode = []; + safe = safe.replace(/`([^`]+)`/g, (_m, code) => { + const idx = inlineCode.length; + inlineCode.push("
" + esc(code) + "");
+ return "\x00IC" + idx + "\x00";
+ });
+ // Now escape remaining HTML
+ safe = esc(safe);
+ // Headings
+ safe = safe.replace(/^### (.+)$/gm, "$1"); + // Unordered lists + safe = safe.replace(/^- (.+)$/gm, "
");
+ // Single newlines →
+ safe = safe.replace(/\n/g, "
");
+ // Restore code blocks and inline code
+ safe = safe.replace(/\x00CB(\d+)\x00/g, (_m, idx) => codeBlocks[idx]);
+ safe = safe.replace(/\x00IC(\d+)\x00/g, (_m, idx) => inlineCode[idx]);
+ return "
" + safe + "
"; + } + // ── Date formatting ────────────────────────────────────────────────────── function fmtDate(iso) { if (!iso) return "—"; @@ -103,8 +147,10 @@ try { const d = await apiGet("/api/projects"); const sel = $("filter-project"); + const dl = $("project-options"); for (const name of Object.keys(d.projects || {})) { sel.appendChild(el("option", { value: name }, name)); + dl.appendChild(el("option", { value: name })); } } catch (e) { /* non-fatal */ } } @@ -113,8 +159,20 @@ try { const d = await apiGet("/api/agents"); const sel = $("filter-agent"); + const dl = $("agent-options"); for (const name of d.agents || []) { sel.appendChild(el("option", { value: name }, name)); + dl.appendChild(el("option", { value: name })); + } + } catch (e) { /* non-fatal */ } + } + + async function loadTags() { + try { + const d = await apiGet("/api/tags"); + const dl = $("tag-options"); + for (const tag of d.tags || []) { + dl.appendChild(el("option", { value: tag })); } } catch (e) { /* non-fatal */ } } @@ -196,7 +254,7 @@ id: r.id, title: r.title, type: r.type, scope: r.scope, tier: r.tier, tags: r.tags || [], project: r.project, status: r.status, priority: r.priority, decay_score: r.decay_score, body_preview: r.body || r.content, - score: r.score, is_manual: r.is_manual, is_auto_capture: r.is_auto_capture, + score: r.score, source: r.source, is_manual: r.is_manual, is_auto_capture: r.is_auto_capture, agent: r.agent, })); state.entries = entries; @@ -261,15 +319,17 @@ const meta = el("div", { class: "entry-meta" }); meta.appendChild(el("span", { class: "badge badge-tier badge-" + (e.tier || "hot") }, e.tier || "hot")); meta.appendChild(el("span", { class: "badge badge-type badge-" + (e.type || "memory") }, e.type || "memory")); - meta.appendChild(el("span", { class: "badge badge-source " + (e.is_manual ? "badge-manual" : "badge-auto") }, - e.is_manual ? "manual ✦" : "auto")); + const sourceLabel = { webui: "webui", cli: "cli", auto: "auto", agent: "agent" }[e.source] || "auto"; + const sourceBadge = e.is_manual ? "badge-manual" : (e.source === "agent" ? "badge-agent-source" : "badge-auto"); + meta.appendChild(el("span", { class: "badge badge-source " + sourceBadge }, sourceLabel)); if (e.priority) meta.appendChild(el("span", { class: "badge badge-priority-" + e.priority }, e.priority)); if (e.status) meta.appendChild(el("span", { class: "badge badge-status" }, e.status)); if (e.scope && e.scope !== "team") meta.appendChild(el("span", { class: "badge badge-scope" }, e.scope)); if (e.project) meta.appendChild(el("span", { class: "badge badge-project" }, e.project)); if (e.agent) meta.appendChild(el("span", { class: "badge badge-agent" }, "@" + e.agent)); + const sourceTags = new Set(["auto-capture", "webui", "cli"]); for (const tag of (e.tags || []).slice(0, 4)) { - if (tag === "auto-capture") continue; // already shown via source badge + if (sourceTags.has(tag)) continue; // already shown via source badge meta.appendChild(el("span", { class: "tag" }, tag)); } card.appendChild(meta); @@ -313,17 +373,25 @@ function renderDetailPanel(id, d) { const m = d.meta || {}; - const isManual = d.is_manual ?? !(m.tags || []).includes("auto-capture"); + const source = d.source || (d.is_manual ? "cli" : "auto"); + const sourceLabels = { + webui: "webui (1.3× boost)", + cli: "cli (1.3× boost)", + auto: "auto-capture", + agent: "agent", + }; + const sourceTags = new Set(["auto-capture", "webui", "cli"]); + const displayTags = (m.tags || []).filter(t => !sourceTags.has(t)); const rows = [ ["Type", m.type || "memory"], ["Scope", m.scope || "team"], ["Tier", m.tier || "—"], - ["Source", isManual ? "manual (1.3× boost)" : "auto-capture"], + ["Source", sourceLabels[source] || source], ["Created", fmtDate(m.created)], ["Accessed", fmtDate(m.accessed)], ["Decay", Number(m.decay_score || 0).toFixed(4)], - ["Tags", (m.tags || []).join(", ") || "—"], + ["Tags", displayTags.join(", ") || "—"], ["Agent", m.agent || "—"], ]; if (m.priority) rows.push(["Priority", m.priority]); @@ -347,7 +415,8 @@ ), ); - const body = el("pre", { class: "detail-body" }, d.content || ""); + const body = el("div", { class: "detail-body" }); + body.innerHTML = renderMarkdown(d.content || ""); return el("div", { id: "inline-detail-" + id, class: "inline-detail" }, toolbar, dl, body); } @@ -443,7 +512,7 @@ $("form-type").value = m.type || "memory"; $("form-scope").value = m.scope || "team"; $("form-project").value = m.project || ""; - $("form-tags").value = (m.tags || []).filter(t => t !== "auto-capture").join(", "); + $("form-tags").value = (m.tags || []).filter(t => !["auto-capture", "webui", "cli"].includes(t)).join(", "); $("form-agent").value = m.agent || ""; $("form-priority").value = m.priority || ""; $("form-status").value = m.status || ""; @@ -598,6 +667,7 @@ loadStatus(); loadProjects(); loadAgents(); + loadTags(); loadDoctor(); loadEntries(); loadTasks(); diff --git a/palaia/web/static/index.html b/palaia/web/static/index.html index be7b42a..6c66a90 100644 --- a/palaia/web/static/index.html +++ b/palaia/web/static/index.html @@ -39,6 +39,9 @@ @@ -115,18 +130,6 @@ - - - @@ -168,18 +171,18 @@