Spaces:
Running
Running
| """ | |
| KNOWLEDGE STORE β Multi-Container Persistent Knowledge Base | |
| Docker SDK / FastAPI β no Gradio, no CSP | |
| Containers & their knowledge decay models: | |
| medical β fast decay (outdated = dangerous). Half-life 180 days. | |
| legal β slow decay (laws change rarely). Half-life 730 days. | |
| company β mixed: SOPs stable (HL 365), market/people data volatile (HL 30). | |
| research β citation boost on create, then slow decay. HL 540 days. | |
| tech β very fast decay (versions). HL 90 days. | |
| prompts β no decay (prompts are reusable). | |
| history β ANTI-decay: value increases with age. | |
| personal β moderate decay (preferences drift). HL 180 days. | |
| finance β extreme decay (market data). HL 7 days. | |
| operations β moderate. HL 180 days. | |
| Knowledge Value Score = base_importance * time_factor(container) * access_bonus | |
| Time factor varies per container and uses exponential decay / growth. | |
| Search types: | |
| keyword β simple full-text (TF-IDF-like scoring) | |
| time β recency or historical filter | |
| tag β exact/prefix tag match | |
| container β container-scoped list | |
| semantic β keyword with cosine-like tf scoring (no embeddings, pure Python) | |
| value β sorted by current knowledge value score | |
| MCP tools: ks_write, ks_read, ks_search, ks_list, ks_delete, | |
| ks_containers, ks_stats, ks_top_value | |
| """ | |
| import os, uuid, json, math, time, re, asyncio | |
| from pathlib import Path | |
| from datetime import datetime, timezone | |
| from typing import Optional, List | |
| from collections import defaultdict, Counter | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.responses import JSONResponse, HTMLResponse, StreamingResponse | |
| BASE = Path(__file__).parent | |
| STORE = BASE / "store" | |
| STORE.mkdir(exist_ok=True) | |
| # ββ Container definitions βββββββββββββββββββββββββββββββββββββββββ | |
| CONTAINERS = { | |
| "medical": { | |
| "label": "Medical", | |
| "icon": "⚕", # caduceus-ish | |
| "color": "#ef4444", | |
| "description": "Clinical guidelines, drug refs, protocols, case notes", | |
| "decay_model": "exponential", | |
| "half_life_days": 180, | |
| "warn_after_days": 90, | |
| "folders": ["guidelines", "drugs", "protocols", "cases", "research"], | |
| "note": "Outdated medical info can be dangerous. Review regularly.", | |
| "badge": "CRITICAL-DECAY", | |
| }, | |
| "legal": { | |
| "label": "Legal", | |
| "icon": "⚖", | |
| "color": "#8b5cf6", | |
| "description": "Contracts, regulations, compliance, case law, GDPR", | |
| "decay_model": "slow_exponential", | |
| "half_life_days": 730, | |
| "warn_after_days": 365, | |
| "folders": ["contracts", "regulations", "gdpr", "caselaw", "templates"], | |
| "note": "Laws change slowly but verify jurisdiction and amendment dates.", | |
| "badge": "SLOW-DECAY", | |
| }, | |
| "company": { | |
| "label": "Company", | |
| "icon": "🏢", | |
| "color": "#0ea5e9", | |
| "description": "SOPs, org charts, projects, market intel, people", | |
| "decay_model": "tiered", # folder-dependent | |
| "half_life_days": 180, | |
| "warn_after_days": 90, | |
| "folders": ["sop", "projects", "people", "market", "strategy"], | |
| "folder_half_lives": {"sop":365, "projects":90, "people":60, "market":14, "strategy":180}, | |
| "note": "Market and people data decay fast. SOPs are more stable.", | |
| "badge": "TIERED-DECAY", | |
| }, | |
| "research": { | |
| "label": "Research", | |
| "icon": "🔬", | |
| "color": "#06b6d4", | |
| "description": "Papers, experiments, hypotheses, datasets, findings", | |
| "decay_model": "citation_curve", # peaks at 30 days then slow decay | |
| "half_life_days": 540, | |
| "peak_days": 30, | |
| "warn_after_days": 365, | |
| "folders": ["papers", "experiments", "datasets", "hypotheses", "notes"], | |
| "note": "New research has highest relevance. Classic papers retain value.", | |
| "badge": "CITATION-CURVE", | |
| }, | |
| "tech": { | |
| "label": "Tech / Docs", | |
| "icon": "💻", | |
| "color": "#22d3ee", | |
| "description": "API docs, code snippets, architecture, DevOps, configs", | |
| "decay_model": "versioned_decay", | |
| "half_life_days": 90, | |
| "warn_after_days": 45, | |
| "folders": ["api", "snippets", "architecture", "devops", "configs"], | |
| "note": "Software versions change fast. Tag with version numbers.", | |
| "badge": "FAST-DECAY", | |
| }, | |
| "prompts": { | |
| "label": "Prompts", | |
| "icon": "⚡", | |
| "color": "#f59e0b", | |
| "description": "LLM prompts, system instructions, few-shot examples, chains", | |
| "decay_model": "stable", # no decay | |
| "half_life_days": None, | |
| "warn_after_days": None, | |
| "folders": ["system", "chains", "fewshot", "templates", "experiments"], | |
| "note": "Prompts are reusable. Value does not decay.", | |
| "badge": "STABLE", | |
| }, | |
| "history": { | |
| "label": "History / Archive", | |
| "icon": "🕮", | |
| "color": "#d97706", | |
| "description": "Historical records, past decisions, retrospectives, logs", | |
| "decay_model": "anti_decay", # increases in value with age | |
| "half_life_days": None, | |
| "warn_after_days": None, | |
| "folders": ["decisions", "retrospectives", "logs", "milestones", "archive"], | |
| "note": "Historical context becomes MORE valuable over time.", | |
| "badge": "ANTI-DECAY", | |
| }, | |
| "personal": { | |
| "label": "Personal", | |
| "icon": "👤", | |
| "color": "#ec4899", | |
| "description": "Goals, notes, preferences, journals, ideas", | |
| "decay_model": "drift_decay", | |
| "half_life_days": 180, | |
| "warn_after_days": 120, | |
| "folders": ["goals", "notes", "ideas", "journal", "preferences"], | |
| "note": "Preferences and goals drift over time. Review periodically.", | |
| "badge": "DRIFT-DECAY", | |
| }, | |
| "finance": { | |
| "label": "Finance", | |
| "icon": "📈", | |
| "color": "#10b981", | |
| "description": "Market data, reports, forecasts, invoices, budgets", | |
| "decay_model": "extreme_decay", | |
| "half_life_days": 7, | |
| "warn_after_days": 3, | |
| "folders": ["market", "reports", "forecasts", "invoices", "budgets"], | |
| "note": "Market data decays within hours. Financial reports within weeks.", | |
| "badge": "EXTREME-DECAY", | |
| }, | |
| "operations": { | |
| "label": "Operations", | |
| "icon": "⚙", | |
| "color": "#84cc16", | |
| "description": "Runbooks, incidents, on-call, monitoring, deployments", | |
| "decay_model": "operational_decay", | |
| "half_life_days": 180, | |
| "warn_after_days": 60, | |
| "folders": ["runbooks", "incidents", "oncall", "monitoring", "deployments"], | |
| "note": "Runbooks age fast in fast-moving infra. Keep versioned.", | |
| "badge": "MODERATE-DECAY", | |
| }, | |
| } | |
| # ββ Knowledge value scoring βββββββββββββββββββββββββββββββββββββββ | |
| def knowledge_value(doc: dict) -> float: | |
| """Compute 0-100 current value score for a document.""" | |
| container = doc.get("container", "tech") | |
| cfg = CONTAINERS.get(container, CONTAINERS["tech"]) | |
| base = float(doc.get("importance", 5)) / 10.0 # 0..1 | |
| access_bonus = min(1.0, math.log1p(doc.get("access_count", 0)) / 10) | |
| age_days = (time.time() - doc.get("created_at", time.time())) / 86400 | |
| model = cfg.get("decay_model", "exponential") | |
| hl = cfg.get("half_life_days") or 365 | |
| if model == "stable": | |
| t_factor = 1.0 | |
| elif model == "anti_decay": | |
| # value grows: tanh curve from 0 to 1 over ~2 years | |
| t_factor = 0.5 + 0.5 * math.tanh(age_days / 365) | |
| elif model == "citation_curve": | |
| peak = cfg.get("peak_days", 30) | |
| if age_days <= peak: | |
| t_factor = 0.6 + 0.4 * (age_days / peak) | |
| else: | |
| t_factor = math.exp(-math.log(2) * (age_days - peak) / hl) | |
| elif model == "tiered": | |
| folder = doc.get("folder", "") | |
| folder_hl = cfg.get("folder_half_lives", {}).get(folder, hl) | |
| t_factor = math.exp(-math.log(2) * age_days / folder_hl) | |
| elif model == "extreme_decay": | |
| t_factor = math.exp(-math.log(2) * age_days / max(1, hl)) | |
| else: | |
| # standard exponential decay | |
| t_factor = math.exp(-math.log(2) * age_days / hl) | |
| t_factor = max(0.0, min(1.0, t_factor)) | |
| score = (base * 0.5 + access_bonus * 0.1 + t_factor * 0.4) * 100 | |
| return round(score, 1) | |
| def freshness_label(doc: dict) -> str: | |
| container = doc.get("container", "tech") | |
| cfg = CONTAINERS.get(container, {}) | |
| warn = cfg.get("warn_after_days") | |
| model = cfg.get("decay_model", "exponential") | |
| age_days = (time.time() - doc.get("created_at", time.time())) / 86400 | |
| if model == "stable": return "STABLE" | |
| if model == "anti_decay": return "ARCHIVAL" | |
| if not warn: return "OK" | |
| if age_days > warn * 2: return "STALE" | |
| if age_days > warn: return "AGING" | |
| return "FRESH" | |
| # ββ Storage utils βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def now_ts(): return int(time.time()) | |
| def doc_path(container: str, folder: str, did: str) -> Path: | |
| d = STORE / container / folder | |
| d.mkdir(parents=True, exist_ok=True) | |
| return d / f"{did}.json" | |
| def read_doc(container: str, folder: str, did: str) -> Optional[dict]: | |
| p = doc_path(container, folder, did) | |
| return json.loads(p.read_text()) if p.exists() else None | |
| def write_doc(doc: dict): | |
| doc["updated_at"] = now_ts() | |
| doc_path(doc["container"], doc["folder"], doc["id"]).write_text( | |
| json.dumps(doc, indent=2, ensure_ascii=False) | |
| ) | |
| def all_docs(container: str = "", folder: str = "", limit: int = 500) -> List[dict]: | |
| out = [] | |
| base = STORE / container if container else STORE | |
| for p in sorted(base.rglob("*.json"), reverse=True): | |
| try: | |
| d = json.loads(p.read_text()) | |
| if folder and d.get("folder") != folder: continue | |
| out.append(d) | |
| except: pass | |
| if len(out) >= limit: break | |
| return out | |
| def new_doc(data: dict) -> dict: | |
| did = uuid.uuid4().hex[:10] | |
| container = data.get("container", "tech") | |
| cfg = CONTAINERS.get(container, {}) | |
| folders = cfg.get("folders", ["general"]) | |
| folder = data.get("folder", folders[0] if folders else "general") | |
| doc = { | |
| "id": did, | |
| "container": container, | |
| "folder": folder, | |
| "title": (data.get("title") or "Untitled").strip(), | |
| "body": (data.get("body") or data.get("content") or "").strip(), | |
| "summary": (data.get("summary") or "").strip(), | |
| "tags": [t.strip().lower() for t in data.get("tags", []) if str(t).strip()], | |
| "importance": max(0, min(10, int(data.get("importance", 5)))), | |
| "author": (data.get("author") or "").strip(), | |
| "source": (data.get("source") or "").strip(), | |
| "version": (data.get("version") or "").strip(), | |
| "expires_hint": data.get("expires_hint"), # ISO date string, optional | |
| "links": data.get("links", []), # related doc IDs | |
| "metadata": data.get("metadata", {}), | |
| "access_count": 0, | |
| "created_at": now_ts(), | |
| "updated_at": now_ts(), | |
| "last_accessed": None, | |
| } | |
| write_doc(doc) | |
| return doc | |
| # ββ Search engine βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def tokenize(text: str) -> List[str]: | |
| return re.findall(r"[a-zA-Z0-9\u00C0-\u024F]+", text.lower()) | |
| def tf_score(query_tokens: List[str], doc: dict) -> float: | |
| text = " ".join([doc.get("title",""), doc.get("body",""), | |
| doc.get("summary",""), " ".join(doc.get("tags",[]))]).lower() | |
| doc_tokens = tokenize(text) | |
| tf = Counter(doc_tokens) | |
| total = len(doc_tokens) or 1 | |
| score = sum(tf.get(t, 0) / total for t in query_tokens) | |
| # boost: title matches worth 3x | |
| title_tokens = tokenize(doc.get("title","").lower()) | |
| title_tf = Counter(title_tokens) | |
| score += sum(title_tf.get(t, 0) * 2 for t in query_tokens) | |
| return score | |
| def search_docs(query: str = "", container: str = "", folder: str = "", | |
| tag: str = "", author: str = "", sort_by: str = "relevance", | |
| freshness: str = "", limit: int = 20) -> List[dict]: | |
| docs = all_docs(container, folder, 500) | |
| query_tokens = tokenize(query) if query else [] | |
| results = [] | |
| for doc in docs: | |
| # Tag filter | |
| if tag and tag.lower() not in doc.get("tags", []): continue | |
| # Author filter | |
| if author and doc.get("author","").lower() != author.lower(): continue | |
| # Freshness filter | |
| if freshness: | |
| fl = freshness_label(doc) | |
| if freshness == "fresh" and fl != "FRESH": continue | |
| if freshness == "stale" and fl not in ("STALE","AGING"): continue | |
| score = tf_score(query_tokens, doc) if query_tokens else 1.0 | |
| if query_tokens and score == 0: continue | |
| results.append((score, doc)) | |
| # Sort | |
| if sort_by == "value": | |
| results.sort(key=lambda x: -knowledge_value(x[1])) | |
| elif sort_by == "newest": | |
| results.sort(key=lambda x: -x[1].get("created_at", 0)) | |
| elif sort_by == "oldest": | |
| results.sort(key=lambda x: x[1].get("created_at", 0)) | |
| elif sort_by == "importance": | |
| results.sort(key=lambda x: (-x[1].get("importance", 5), -x[0])) | |
| else: | |
| results.sort(key=lambda x: (-x[0], -knowledge_value(x[1]))) | |
| return [d for _, d in results[:limit]] | |
| # ββ Seed data βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def seed(): | |
| if any(STORE.rglob("*.json")): return | |
| seeds = [ | |
| # TECH | |
| {"container":"tech","folder":"architecture","title":"ki-fusion-labs.de GPU Worker Architecture", | |
| "body":"GPU workers use a polling architecture. Workers call GET /api/queue every 2 seconds to check for pending jobs. On job acquisition, worker POSTs result to /api/results/{job_id}. No inbound connections required β fully firewall-friendly. LM Studio listens on localhost:1234. Jobs include: model_id, prompt, max_tokens, temperature, stream flag.", | |
| "summary":"Firewall-friendly polling design for GPU inference workers", | |
| "tags":["ki-fusion-labs","gpu","architecture","llm","inference"],"importance":9,"author":"christof","version":"v2"}, | |
| {"container":"tech","folder":"api","title":"FORGE Skill Registry API Reference", | |
| "body":"POST /api/v1/skills β create skill\nGET /api/v1/skills β list (filter: ?category=&tag=)\nGET /api/v1/skills/{id} β get\nPATCH /api/v1/skills/{id} β update\nDELETE /api/v1/skills/{id} β delete\nGET /mcp/sse β MCP SSE stream\nPOST /mcp β MCP JSON-RPC\n\nSkill schema: {id, name, description, category, code, input_schema, output_schema, tags, version, author}", | |
| "summary":"FORGE MCP skill registry REST endpoints","tags":["forge","api","mcp","skills"],"importance":8,"author":"christof","version":"1.0"}, | |
| {"container":"tech","folder":"devops","title":"HF Spaces Docker SDK Deployment Guide", | |
| "body":"CRITICAL: Use sdk: docker in README.md, NOT sdk: gradio.\nGradio SDK CSP blocks ALL <script> tags inside gr.HTML() and all iframes (frame-src: none).\nDockerfile pattern:\n FROM python:3.11-slim\n RUN useradd -m -u 1000 user\n USER user\n EXPOSE 7860\n CMD [\"uvicorn\", \"main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"7860\"]\nNo Gradio dependency. Pure FastAPI serves HTML as string. No StaticFiles.\nSurrogate chars: never use \\uD83D in Python strings β use HTML entities 📄", | |
| "summary":"How to deploy FastAPI on HF Spaces without CSP issues","tags":["hf-spaces","docker","fastapi","deployment"],"importance":10,"author":"christof"}, | |
| # LEGAL | |
| {"container":"legal","folder":"gdpr","title":"Fusion Labs GDPR Deletion Architecture β Concept v3", | |
| "body":"Decentralized pull-based deletion across 14+ systems in 6 countries (DE, AT, CH, IT, FR, NL).\nCore flow: DPO triggers deletion request -> orchestrator creates deletion ticket -> each system polls /api/deletions/pending -> system processes and POSTs Proof of Deletion (PoD) certificate -> orchestrator tracks completion.\nPoD schema: {request_id, system_id, subject_id, deleted_fields[], timestamp, checksum, processor_name}.\nArchitecture Board sign-off required. DPO must countersign each PoD batch.", | |
| "summary":"Pull-based GDPR deletion design for Fusion Labs 14-system landscape","tags":["gdpr","Fusion Labs","deletion","architecture","pod"],"importance":10,"author":"christof","version":"v3"}, | |
| {"container":"legal","folder":"regulations","title":"GDPR Article 17 β Right to Erasure (Key Points)", | |
| "body":"Art. 17 GDPR: Data subject has right to erasure without undue delay when: (a) no longer necessary for original purpose, (b) consent withdrawn, (c) data subject objects under Art. 21, (d) unlawful processing, (e) legal obligation.\nExceptions: freedom of expression, legal obligation, public interest (Art. 17(3)).\nTimeline: respond within 1 month (extendable 2 months for complex cases).\nLogging: document all erasure requests and outcomes.", | |
| "summary":"GDPR Art. 17 erasure right summary","tags":["gdpr","erasure","regulation","art17"],"importance":9,"author":"christof"}, | |
| # MEDICAL | |
| {"container":"medical","folder":"protocols","title":"Burnout Prevention Protocol β Knowledge Worker", | |
| "body":"Early indicators: decision fatigue after <2h deep work, >3 context switches/hour, sleep quality drop, emotional blunting.\nInterventions: (1) 90-min deep work blocks, no interruptions. (2) Hard stop at 18:00. (3) Single daily priority written before 09:00. (4) Weekly 30-min review: energy vs output. (5) Physical activity 3x/week minimum.\nEscalation: if indicators persist 3+ weeks, consult occupational health.\nFor AI project leaders: especially watch for 'always on' patterns with LLM tools.", | |
| "summary":"Burnout prevention for knowledge workers managing AI projects","tags":["burnout","health","productivity","mental-health"],"importance":8,"author":"christof"}, | |
| # RESEARCH | |
| {"container":"research","folder":"experiments","title":"BitNet 1.58-bit Trainer β Stability Fixes Log", | |
| "body":"Problem history:\n- NaN loss: fixed with gradient clipping (max_norm=1.0) + LR warmup (500 steps)\n- Dead layers: fixed with initialization scale 0.02 instead of default\n- FlipRate spike: STE gradient scaling tuned to 0.3 β above 0.5 causes oscillation\n- Dataset distribution mismatch: balanced sampling per domain required\n- Quantization death spiral: add 1e-8 epsilon to weight norm denominator\n\nFinal config: LR=2e-4, warmup=500, clip=1.0, batch=32, accumulation=4\nHardware: RTX 5090 24GB VRAM, bfloat16", | |
| "summary":"BitNet stable training recipe after systematic debugging","tags":["bitnet","training","rtx5090","quantization","stability"],"importance":9,"author":"christof"}, | |
| {"container":"research","folder":"hypotheses","title":"JARVIS TurnClassifier β Ambiguous Intent Routing", | |
| "body":"Hypothesis: DST context window of 5 turns is insufficient for long multi-domain conversations.\nProposed fix: sliding window with semantic anchor β if slot confidence < 0.6, expand window to 10 turns and inject last confirmed intent as prior.\nTesting plan: 200 synthetic conversations, 3 ambiguity categories: topic-switch, implicit-reference, negation.\nExpected: +12% routing accuracy, +8ms latency overhead acceptable.", | |
| "summary":"Hypothesis for improving JARVIS intent routing with adaptive DST window","tags":["jarvis","dst","nlp","routing","hypothesis"],"importance":8,"author":"christof"}, | |
| # PROMPTS | |
| {"container":"prompts","folder":"system","title":"JARVIS TheCore System Prompt v3", | |
| "body":"You are JARVIS, an advanced AI assistant operating within TheCore architecture. You have access to: (1) multi-tier memory system, (2) FORGE skill registry, (3) DISPATCH task board, (4) RELAY message bus, (5) this Knowledge Store.\n\nRouting rules:\n- Simple factual queries -> direct answer\n- Tasks requiring external data -> capability routing to appropriate tool\n- Ambiguous intent (confidence < 0.7) -> clarification before action\n- Urgent flags -> DISPATCH high-priority queue\n\nTone: precise, concise, no filler. Always show reasoning for non-trivial decisions.", | |
| "summary":"Core system prompt for JARVIS TheCore v3","tags":["jarvis","system-prompt","thecore","routing"],"importance":10,"author":"christof","version":"v3"}, | |
| # COMPANY | |
| {"container":"company","folder":"projects","title":"ki-fusion-labs.de β Active Project Status", | |
| "body":"Status: ACTIVE development\nStack: PHP frontend + Python FastAPI backend + LM Studio local inference\nGPU: RTX 5090, CUDA 12.4\nActive components: LLM API queue, worker polling, result streaming\nRecent: SSL cert renewed (Let's Encrypt, 90-day auto-renew configured)\nPending: persistent queue (survive restarts), rate limiting per API key, usage dashboard\nHF Spaces deployed: FORGE, DISPATCH, RELAY, MEMORY, KNOWLEDGE", | |
| "summary":"Current status of ki-fusion-labs.de platform","tags":["ki-fusion-labs","status","projects"],"importance":9,"author":"christof"}, | |
| # FINANCE | |
| {"container":"finance","folder":"budgets","title":"AI Infrastructure Cost Baseline β 2026", | |
| "body":"Monthly recurring:\n- HF Spaces (free tier): 0 EUR\n- OpenRouter free models: 0 EUR\n- Oracle Cloud Always Free: 0 EUR\n- Domain ki-fusion-labs.de: ~1.50 EUR/mo\n- Electricity RTX 5090 training (est. 10h/mo @ 400W): ~0.80 EUR\nTotal infra: ~2.30 EUR/month\n\nNote: HF Spaces may incur costs if Spaces upgraded to GPU. Budget 20 EUR/mo buffer.", | |
| "summary":"AI infra cost breakdown β essentially free tier stack","tags":["budget","infrastructure","costs","2026"],"importance":6,"author":"christof"}, | |
| # OPERATIONS | |
| {"container":"operations","folder":"runbooks","title":"HF Space Recovery Runbook", | |
| "body":"When a Space goes red:\n1. Check logs: Space Settings -> Logs\n2. Common errors:\n - UnicodeEncodeError: surrogate chars in SPA string -> use HTML entities\n - ModuleNotFoundError: check requirements.txt, rebuild\n - Port error: ensure CMD uses --port 7860\n - Permission denied: ensure USER user in Dockerfile + chown\n3. Force rebuild: Settings -> Factory reset (loses state!)\n4. For persistent data: use HF Datasets API, not local filesystem\n5. SDK confusion: gradio SDK = CSP nightmare. Always use sdk: docker", | |
| "summary":"Step-by-step HF Space debugging and recovery","tags":["hf-spaces","runbook","debugging","recovery"],"importance":10,"author":"christof"}, | |
| # HISTORY | |
| {"container":"history","folder":"decisions","title":"ADR-001: Why Docker SDK over Gradio SDK", | |
| "body":"Date: 2026-03\nContext: Building agent UI tools on HuggingFace Spaces.\nDecision: Use sdk: docker for all custom web UIs.\nRationale: Gradio SDK injects CSP headers that block ALL <script> tags in gr.HTML() components. Frame-src: none also blocks iframes. No workaround exists via custom_headers in README.\nConsequences: Pure FastAPI, HTML served as string, no Gradio dependency, full CSP control.\nStatus: ACCEPTED. Applied to FORGE, DISPATCH, RELAY, MEMORY, KNOWLEDGE.", | |
| "summary":"Architecture decision: Docker over Gradio for HF Spaces UIs","tags":["adr","architecture","hf-spaces","docker","decision"],"importance":10,"author":"christof"}, | |
| ] | |
| for s in seeds: | |
| new_doc(s) | |
| seed() | |
| # ββ FastAPI βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="Knowledge Store") | |
| def jresp(data, status=200): return JSONResponse(content=data, status_code=status) | |
| async def get_containers(): | |
| result = {} | |
| for k, v in CONTAINERS.items(): | |
| docs = all_docs(k, limit=500) | |
| result[k] = {**v, "count": len(docs), | |
| "avg_value": round(sum(knowledge_value(d) for d in docs)/len(docs), 1) if docs else 0} | |
| return jresp(result) | |
| async def list_docs(container: str = "", folder: str = "", tag: str = "", | |
| author: str = "", sort: str = "newest", limit: int = 100): | |
| docs = search_docs(container=container, folder=folder, tag=tag, | |
| author=author, sort_by=sort, limit=limit) | |
| for d in docs: | |
| d["_value"] = knowledge_value(d) | |
| d["_freshness"] = freshness_label(d) | |
| return jresp(docs) | |
| async def search(q: str = "", container: str = "", folder: str = "", tag: str = "", | |
| sort: str = "relevance", freshness: str = "", limit: int = 20): | |
| docs = search_docs(q, container, folder, tag, sort_by=sort, freshness=freshness, limit=limit) | |
| for d in docs: | |
| d["_value"] = knowledge_value(d) | |
| d["_freshness"] = freshness_label(d) | |
| return jresp(docs) | |
| async def top_value(container: str = "", limit: int = 20): | |
| docs = all_docs(container, limit=500) | |
| scored = sorted(docs, key=lambda d: -knowledge_value(d)) | |
| for d in scored[:limit]: | |
| d["_value"] = knowledge_value(d) | |
| d["_freshness"] = freshness_label(d) | |
| return jresp(scored[:limit]) | |
| async def get_doc(container: str, folder: str, did: str): | |
| d = read_doc(container, folder, did) | |
| if not d: raise HTTPException(404) | |
| d["access_count"] = d.get("access_count", 0) + 1 | |
| d["last_accessed"] = now_ts() | |
| write_doc(d) | |
| d["_value"] = knowledge_value(d) | |
| d["_freshness"] = freshness_label(d) | |
| return jresp(d) | |
| async def create_doc(request: Request): | |
| data = await request.json() | |
| if not data.get("title","").strip(): raise HTTPException(400, "title required") | |
| if not data.get("body","").strip() and not data.get("content","").strip(): | |
| raise HTTPException(400, "body required") | |
| d = new_doc(data) | |
| d["_value"] = knowledge_value(d) | |
| d["_freshness"] = freshness_label(d) | |
| return jresp({"status":"created","id":d["id"],"doc":d}, 201) | |
| async def update_doc(container: str, folder: str, did: str, request: Request): | |
| d = read_doc(container, folder, did) | |
| if not d: raise HTTPException(404) | |
| data = await request.json() | |
| for k in ("title","body","summary","tags","importance","author","source","version","links","metadata"): | |
| if k in data: d[k] = data[k] | |
| write_doc(d) | |
| d["_value"] = knowledge_value(d) | |
| d["_freshness"] = freshness_label(d) | |
| return jresp({"status":"updated","doc":d}) | |
| async def delete_doc(container: str, folder: str, did: str): | |
| p = doc_path(container, folder, did) | |
| if not p.exists(): raise HTTPException(404) | |
| p.unlink() | |
| return jresp({"status":"deleted"}) | |
| async def stats(): | |
| all_d = all_docs(limit=2000) | |
| by_container: dict = {} | |
| stale_count = 0 | |
| total_value = 0.0 | |
| by_freshness: dict = {"FRESH":0,"AGING":0,"STALE":0,"STABLE":0,"ARCHIVAL":0} | |
| for d in all_d: | |
| c = d.get("container","?") | |
| by_container[c] = by_container.get(c,0) + 1 | |
| v = knowledge_value(d) | |
| total_value += v | |
| fl = freshness_label(d) | |
| by_freshness[fl] = by_freshness.get(fl,0) + 1 | |
| if fl == "STALE": stale_count += 1 | |
| return jresp({ | |
| "total": len(all_d), | |
| "by_container": by_container, | |
| "by_freshness": by_freshness, | |
| "stale_count": stale_count, | |
| "avg_value": round(total_value/len(all_d), 1) if all_d else 0, | |
| }) | |
| # ββ MCP βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MCP_TOOLS = [ | |
| {"name":"ks_write","description":"Write a knowledge document to a container/folder", | |
| "inputSchema":{"type":"object","required":["container","title","body"],"properties":{ | |
| "container": {"type":"string","enum":list(CONTAINERS.keys())}, | |
| "folder": {"type":"string"}, | |
| "title": {"type":"string"}, | |
| "body": {"type":"string"}, | |
| "summary": {"type":"string"}, | |
| "tags": {"type":"array","items":{"type":"string"}}, | |
| "importance": {"type":"integer","minimum":0,"maximum":10}, | |
| "author": {"type":"string"}, | |
| "version": {"type":"string"}, | |
| }}}, | |
| {"name":"ks_read","description":"Read a document by container/folder/id", | |
| "inputSchema":{"type":"object","required":["container","folder","id"],"properties":{ | |
| "container":{"type":"string"},"folder":{"type":"string"},"id":{"type":"string"}}}}, | |
| {"name":"ks_search","description":"Search knowledge base by query, tag, container, author", | |
| "inputSchema":{"type":"object","properties":{ | |
| "query": {"type":"string"}, | |
| "container": {"type":"string"}, | |
| "folder": {"type":"string"}, | |
| "tag": {"type":"string"}, | |
| "sort": {"type":"string","enum":["relevance","value","newest","oldest","importance"]}, | |
| "freshness": {"type":"string","enum":["fresh","stale",""]}, | |
| "limit": {"type":"integer","default":10}, | |
| }}}, | |
| {"name":"ks_list","description":"List documents in a container/folder", | |
| "inputSchema":{"type":"object","properties":{ | |
| "container":{"type":"string"},"folder":{"type":"string"},"limit":{"type":"integer"}}}}, | |
| {"name":"ks_delete","description":"Delete a knowledge document", | |
| "inputSchema":{"type":"object","required":["container","folder","id"],"properties":{ | |
| "container":{"type":"string"},"folder":{"type":"string"},"id":{"type":"string"}}}}, | |
| {"name":"ks_containers","description":"List all containers with counts and avg value", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| {"name":"ks_stats","description":"Overall knowledge base statistics", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| {"name":"ks_top_value","description":"Get highest-value documents right now", | |
| "inputSchema":{"type":"object","properties":{ | |
| "container":{"type":"string"},"limit":{"type":"integer","default":10}}}}, | |
| ] | |
| async def mcp_call(name, args): | |
| if name == "ks_write": | |
| if not args.get("title") or not args.get("body"): | |
| return json.dumps({"error":"title and body required"}) | |
| d = new_doc(args) | |
| d["_value"] = knowledge_value(d) | |
| return json.dumps({"created":d["id"],"container":d["container"],"folder":d["folder"],"value":d["_value"]}) | |
| if name == "ks_read": | |
| d = read_doc(args["container"], args["folder"], args["id"]) | |
| if not d: return json.dumps({"error":"not found"}) | |
| d["access_count"] = d.get("access_count",0)+1 | |
| d["last_accessed"] = now_ts() | |
| write_doc(d) | |
| d["_value"] = knowledge_value(d); d["_freshness"] = freshness_label(d) | |
| return json.dumps(d) | |
| if name == "ks_search": | |
| docs = search_docs(args.get("query",""), args.get("container",""), | |
| args.get("folder",""), args.get("tag",""), | |
| args.get("sort","relevance"), args.get("freshness",""), args.get("limit",10)) | |
| for d in docs: d["_value"]=knowledge_value(d); d["_freshness"]=freshness_label(d) | |
| return json.dumps({"count":len(docs),"results":docs}) | |
| if name == "ks_list": | |
| docs = all_docs(args.get("container",""), args.get("folder",""), args.get("limit",20)) | |
| for d in docs: d["_value"]=knowledge_value(d); d["_freshness"]=freshness_label(d) | |
| return json.dumps({"count":len(docs),"docs":docs}) | |
| if name == "ks_delete": | |
| p = doc_path(args["container"], args["folder"], args["id"]) | |
| if not p.exists(): return json.dumps({"error":"not found"}) | |
| p.unlink(); return json.dumps({"deleted":args["id"]}) | |
| if name == "ks_containers": | |
| result = {} | |
| for k, v in CONTAINERS.items(): | |
| docs = all_docs(k, limit=500) | |
| result[k] = {"label":v["label"],"count":len(docs),"decay_model":v["decay_model"], | |
| "badge":v["badge"],"avg_value":round(sum(knowledge_value(d) for d in docs)/len(docs),1) if docs else 0} | |
| return json.dumps(result) | |
| if name == "ks_stats": | |
| all_d = all_docs(limit=2000) | |
| by_c = {} | |
| for d in all_d: by_c[d.get("container","?")] = by_c.get(d.get("container","?"),0)+1 | |
| return json.dumps({"total":len(all_d),"by_container":by_c}) | |
| if name == "ks_top_value": | |
| docs = all_docs(args.get("container",""), limit=500) | |
| scored = sorted(docs, key=lambda d:-knowledge_value(d))[:args.get("limit",10)] | |
| for d in scored: d["_value"]=knowledge_value(d); d["_freshness"]=freshness_label(d) | |
| return json.dumps({"count":len(scored),"docs":scored}) | |
| return json.dumps({"error":f"unknown: {name}"}) | |
| async def mcp_sse(): | |
| async def stream(): | |
| init = {"jsonrpc":"2.0","method":"notifications/initialized", | |
| "params":{"serverInfo":{"name":"knowledge-store","version":"1.0"},"capabilities":{"tools":{}}}} | |
| yield f"data: {json.dumps(init)}\n\n" | |
| await asyncio.sleep(0.1) | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools/list_changed','params':{}})}\n\n" | |
| while True: | |
| await asyncio.sleep(25) | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'ping'})}\n\n" | |
| return StreamingResponse(stream(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"}) | |
| async def mcp_rpc(request: Request): | |
| body = await request.json() | |
| method = body.get("method",""); rid = body.get("id",1) | |
| if method == "initialize": | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{ | |
| "serverInfo":{"name":"knowledge-store","version":"1.0"},"capabilities":{"tools":{}}}}) | |
| if method == "tools/list": | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"tools":MCP_TOOLS}}) | |
| if method == "tools/call": | |
| p = body.get("params",{}) | |
| res = await mcp_call(p.get("name",""), p.get("arguments",{})) | |
| return jresp({"jsonrpc":"2.0","id":rid,"result":{"content":[{"type":"text","text":res}]}}) | |
| return jresp({"jsonrpc":"2.0","id":rid,"error":{"code":-32601,"message":"Method not found"}}) | |
| # ββ SPA βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def ui(): | |
| return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8") | |
| SPA = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>KNOWLEDGE STORE</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --bg:#08080f;--s1:#0f0f1a;--s2:#141428;--s3:#1a1a35; | |
| --bd:#1e1e35;--bd2:#282850;--bd3:#323260; | |
| --acc:#ff6b00;--acc2:#ff9500;--acc3:#ffb347; | |
| --txt:#d8d8f0;--sub:#5a5a88;--dim:#282850; | |
| --lo:#2ed573;--cr:#ff2244;--warn:#f59e0b; | |
| --c-med:#ef4444;--c-leg:#8b5cf6;--c-com:#0ea5e9; | |
| --c-res:#06b6d4;--c-tec:#22d3ee;--c-pro:#f59e0b; | |
| --c-his:#d97706;--c-per:#ec4899;--c-fin:#10b981;--c-ops:#84cc16; | |
| --font:'Space Mono',monospace;--body:'Inter',sans-serif; | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| html,body{height:100%;overflow:hidden;} | |
| body{font-family:var(--body);background:var(--bg);color:var(--txt);display:flex;flex-direction:column;height:100vh;} | |
| body::after{content:'';position:fixed;inset:0;pointer-events:none; | |
| background-image:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(255,107,0,.004) 2px,rgba(255,107,0,.004) 3px);} | |
| /* HEADER */ | |
| #hdr{flex-shrink:0;display:flex;align-items:center;padding:.8rem 1.6rem;gap:1.2rem; | |
| border-bottom:1px solid var(--bd);background:linear-gradient(180deg,#0c0c1e,var(--bg));z-index:10;} | |
| #logo{font-family:var(--font);font-size:1.2rem;font-weight:700;letter-spacing:2px; | |
| background:linear-gradient(90deg,var(--acc),var(--acc3)); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} | |
| #logo-sub{font-family:var(--font);font-size:.5rem;color:var(--sub);letter-spacing:.25em;text-transform:uppercase;margin-top:2px;} | |
| #hdr-stats{display:flex;gap:.45rem;flex:1;flex-wrap:wrap;} | |
| .hs{display:flex;align-items:center;gap:.35rem;background:var(--s1);border:1px solid var(--bd); | |
| border-radius:5px;padding:.22rem .5rem;font-family:var(--font);font-size:.5rem;color:var(--sub);} | |
| .hs-n{font-size:.82rem;font-weight:700;line-height:1;} | |
| .freshbadge{font-size:.46rem;padding:1px 5px;border-radius:3px;font-family:var(--font);font-weight:700;letter-spacing:.06em;} | |
| .fb-FRESH{background:#02130a;color:var(--lo);border:1px solid rgba(46,213,115,.15);} | |
| .fb-AGING{background:#181400;color:var(--warn);border:1px solid rgba(245,158,11,.15);} | |
| .fb-STALE{background:#1a0308;color:var(--cr);border:1px solid rgba(255,34,68,.15);} | |
| .fb-STABLE{background:#0a0a18;color:var(--sub);border:1px solid var(--bd);} | |
| .fb-ARCHIVAL{background:#1a0d00;color:var(--c-his);border:1px solid rgba(217,119,6,.15);} | |
| #btn-new{background:var(--acc);color:#000;border:none;padding:.4rem 1rem; | |
| font-family:var(--font);font-size:.65rem;font-weight:700;letter-spacing:.1em; | |
| text-transform:uppercase;border-radius:4px;cursor:pointer;flex-shrink:0; | |
| transition:background .12s,transform .1s;} | |
| #btn-new:hover{background:var(--acc2);transform:translateY(-1px);} | |
| /* 3-COLUMN LAYOUT */ | |
| #main{flex:1;display:flex;min-height:0;overflow:hidden;} | |
| /* LEFT: container sidebar */ | |
| #sidebar{width:210px;flex-shrink:0;border-right:1px solid var(--bd); | |
| overflow-y:auto;background:var(--s1);} | |
| #sidebar::-webkit-scrollbar{width:3px;} | |
| #sidebar::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px;} | |
| .sb-section{padding:.55rem .7rem .2rem;} | |
| .sb-label{font-family:var(--font);font-size:.47rem;color:var(--sub);text-transform:uppercase; | |
| letter-spacing:.15em;margin-bottom:.3rem;padding-bottom:.25rem;border-bottom:1px solid var(--bd);} | |
| .ctr-item{display:flex;align-items:center;gap:.42rem;padding:.38rem .55rem; | |
| border-radius:6px;cursor:pointer;margin-bottom:.12rem;transition:background .1s;} | |
| .ctr-item:hover{background:var(--s2);} | |
| .ctr-item.active{background:var(--s2);border-left:2px solid var(--acc);} | |
| .ctr-icon{font-size:.85rem;width:1.4rem;text-align:center;flex-shrink:0;} | |
| .ctr-info{flex:1;min-width:0;} | |
| .ctr-name{font-size:.65rem;font-weight:600;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .ctr-meta{display:flex;align-items:center;gap:.3rem;margin-top:.12rem;} | |
| .ctr-count{font-family:var(--font);font-size:.5rem;color:var(--sub);} | |
| .ctr-badge{font-family:var(--font);font-size:.42rem;padding:0 4px;border-radius:3px; | |
| font-weight:700;letter-spacing:.06em;flex-shrink:0;} | |
| .ctr-value{font-family:var(--font);font-size:.48rem;font-weight:700;margin-left:auto;} | |
| /* folder sub-items */ | |
| .folder-item{display:flex;align-items:center;gap:.35rem;padding:.28rem .55rem .28rem 1.5rem; | |
| border-radius:5px;cursor:pointer;margin-bottom:.06rem;transition:background .1s;font-size:.6rem;color:var(--sub);} | |
| .folder-item:hover{background:var(--s2);color:var(--txt);} | |
| .folder-item.active{color:var(--acc);background:var(--s2);} | |
| /* CENTER: doc list */ | |
| #list-col{width:360px;flex-shrink:0;border-right:1px solid var(--bd); | |
| display:flex;flex-direction:column;overflow:hidden;} | |
| #list-toolbar{flex-shrink:0;padding:.42rem .7rem;border-bottom:1px solid var(--bd); | |
| background:var(--s1);display:flex;gap:.38rem;flex-wrap:wrap;align-items:center;} | |
| #search-inp{background:var(--s2);border:1px solid var(--bd2);border-radius:5px; | |
| padding:.34rem .6rem;font-family:var(--font);font-size:.65rem;color:var(--txt); | |
| outline:none;width:180px;transition:border-color .12s;} | |
| #search-inp:focus{border-color:var(--acc);} | |
| #search-btn{background:var(--acc);color:#000;border:none;padding:.34rem .6rem; | |
| font-family:var(--font);font-size:.6rem;font-weight:700;border-radius:4px;cursor:pointer;} | |
| .sort-sel{background:var(--s2);border:1px solid var(--bd2);border-radius:4px; | |
| padding:.3rem .5rem;font-family:var(--font);font-size:.58rem;color:var(--txt);outline:none;} | |
| #list-scroll{flex:1;overflow-y:auto;padding:.45rem;} | |
| #list-scroll::-webkit-scrollbar{width:3px;} | |
| #list-scroll::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px;} | |
| /* DOC CARD */ | |
| .dc{background:var(--s1);border:1px solid var(--bd);border-radius:8px; | |
| padding:.58rem .75rem .58rem .95rem;margin-bottom:.35rem;cursor:pointer; | |
| position:relative;animation:cin .14s ease;transition:border-color .1s,transform .08s;} | |
| @keyframes cin{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}} | |
| .dc:hover{border-color:var(--bd2);transform:translateY(-1px);} | |
| .dc.active{border-color:var(--acc);background:var(--s2);} | |
| .dc::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;border-radius:8px 0 0 8px;} | |
| .dc-top{display:flex;align-items:flex-start;gap:.35rem;margin-bottom:.22rem;} | |
| .dc-title{flex:1;font-size:.7rem;font-weight:600;color:var(--txt);line-height:1.3;word-break:break-word;} | |
| .dc-val{font-family:var(--font);font-size:.52rem;font-weight:700;flex-shrink:0;margin-top:1px;} | |
| .dc-preview{font-size:.6rem;color:var(--sub);line-height:1.45; | |
| max-height:36px;overflow:hidden;position:relative;margin-bottom:.28rem;} | |
| .dc-preview::after{content:'';position:absolute;bottom:0;left:0;right:0;height:12px; | |
| background:linear-gradient(transparent,var(--s1));} | |
| .dc.active .dc-preview::after{background:linear-gradient(transparent,var(--s2));} | |
| .dc-foot{display:flex;align-items:center;gap:.28rem;flex-wrap:wrap;} | |
| .dc-tag{font-size:.47rem;background:var(--s2);border:1px solid var(--bd); | |
| border-radius:3px;padding:0 4px;color:var(--sub);} | |
| .dc-folder{font-size:.5rem;color:var(--sub);opacity:.6;} | |
| .dc-date{font-size:.47rem;color:var(--dim);margin-left:auto;} | |
| /* VALUE GAUGE */ | |
| .vg{display:inline-flex;align-items:center;gap:.25rem;} | |
| .vg-bar{width:32px;height:3px;background:var(--bd2);border-radius:2px;overflow:hidden;} | |
| .vg-fill{height:100%;border-radius:2px;transition:width .3s;} | |
| /* RIGHT: detail */ | |
| #detail-col{flex:1;display:flex;flex-direction:column;overflow:hidden;} | |
| #detail-scroll{flex:1;overflow-y:auto;padding:1.3rem 1.7rem;} | |
| #detail-scroll::-webkit-scrollbar{width:4px;} | |
| #detail-scroll::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px;} | |
| #d-empty{display:flex;flex-direction:column;align-items:center;justify-content:center; | |
| height:100%;gap:.7rem;} | |
| #d-empty .big{font-size:3rem;opacity:.12;} | |
| #d-empty .msg{font-family:var(--font);font-size:.62rem;color:var(--sub); | |
| letter-spacing:.12em;text-transform:uppercase;opacity:.4;} | |
| #d-content{display:none;} | |
| /* VALUE METER */ | |
| .value-meter{background:var(--s1);border:1px solid var(--bd);border-radius:8px; | |
| padding:.7rem 1rem;margin-bottom:1rem;display:flex;gap:1.2rem;align-items:center;} | |
| .vm-score{font-family:var(--font);font-size:2.2rem;font-weight:700;line-height:1;} | |
| .vm-label{font-family:var(--font);font-size:.5rem;text-transform:uppercase; | |
| letter-spacing:.15em;color:var(--sub);margin-top:.2rem;} | |
| .vm-info{flex:1;} | |
| .vm-model{font-family:var(--font);font-size:.55rem;color:var(--sub);margin-bottom:.35rem;} | |
| .vm-bar-wrap{height:6px;background:var(--bd2);border-radius:3px;} | |
| .vm-bar-fill{height:100%;border-radius:3px;transition:width .5s;} | |
| .vm-meta{display:flex;justify-content:space-between;margin-top:.3rem;font-family:var(--font);font-size:.48rem;color:var(--sub);} | |
| .decay-chips{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.5rem;} | |
| .decay-chip{font-family:var(--font);font-size:.48rem;padding:1px 6px;border-radius:3px;background:var(--s2);color:var(--sub);border:1px solid var(--bd);} | |
| .d-ctr-hdr{display:flex;align-items:center;gap:.55rem;margin-bottom:.65rem;} | |
| .d-ctr-icon{font-size:1.2rem;} | |
| .d-ctr-info .d-ctr-label{font-family:var(--font);font-size:.52rem;font-weight:700; | |
| letter-spacing:.15em;text-transform:uppercase;margin-bottom:.1rem;} | |
| .d-ctr-info .d-ctr-note{font-size:.58rem;color:var(--sub);} | |
| #d-title{font-size:1.1rem;font-weight:600;color:var(--txt);line-height:1.4;margin-bottom:.55rem;word-break:break-word;} | |
| #d-body{font-size:.76rem;color:var(--txt);line-height:1.72; | |
| background:var(--s1);border:1px solid var(--bd);border-radius:7px;padding:.9rem 1rem; | |
| white-space:pre-wrap;margin-bottom:.9rem;font-family:var(--body);} | |
| .d-meta-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem .7rem;margin-bottom:.9rem;} | |
| .dml{font-size:.48rem;font-family:var(--font);color:var(--sub);text-transform:uppercase;letter-spacing:.1em;margin-bottom:.15rem;} | |
| .dmv{font-size:.62rem;color:var(--txt);} | |
| .d-tags{display:flex;flex-wrap:wrap;gap:.28rem;margin-bottom:.9rem;} | |
| .d-tag{background:var(--s2);border:1px solid var(--bd2);border-radius:4px;padding:1px 8px;font-size:.57rem;color:var(--sub);} | |
| .d-acts{display:flex;gap:.42rem;} | |
| .d-btn{background:var(--s2);border:1px solid var(--bd2);color:var(--sub); | |
| padding:.34rem .68rem;font-family:var(--font);font-size:.6rem;border-radius:4px;cursor:pointer;transition:all .1s;} | |
| .d-btn:hover{background:var(--bd2);color:var(--txt);} | |
| .d-btn.danger:hover{background:#1e0508;color:var(--cr);} | |
| .d-btn.acc{background:var(--acc);color:#000;border-color:var(--acc);} | |
| .d-btn.acc:hover{background:var(--acc2);} | |
| /* MODAL */ | |
| #modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:100; | |
| backdrop-filter:blur(5px);align-items:center;justify-content:center;} | |
| #modal.open{display:flex;} | |
| .mdl{background:var(--s1);border:1px solid var(--bd2);border-top:2px solid var(--acc); | |
| border-radius:12px;padding:1.4rem;width:640px;max-width:97vw;max-height:92vh; | |
| overflow-y:auto;animation:mdin .17s ease;position:relative;} | |
| @keyframes mdin{from{opacity:0;transform:scale(.96) translateY(-8px)}to{opacity:1;transform:none}} | |
| #mdl-title{font-family:var(--font);font-size:.82rem;font-weight:700;letter-spacing:3px; | |
| color:var(--acc);margin-bottom:.9rem;} | |
| #mdl-close{position:absolute;top:.85rem;right:.85rem;background:none;border:none;color:var(--sub); | |
| width:26px;height:26px;border-radius:4px;cursor:pointer;font-size:.85rem; | |
| display:flex;align-items:center;justify-content:center;transition:all .1s;} | |
| #mdl-close:hover{background:var(--bd2);color:var(--txt);} | |
| .fg2{display:grid;grid-template-columns:1fr 1fr;gap:.6rem;} | |
| .fg3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.6rem;} | |
| .fl{margin-bottom:.6rem;} | |
| .fl label{display:block;font-family:var(--font);font-size:.48rem;color:var(--sub); | |
| text-transform:uppercase;letter-spacing:.12em;margin-bottom:.2rem;} | |
| .fl input,.fl textarea,.fl select{width:100%;background:var(--s2);border:1px solid var(--bd2); | |
| border-radius:5px;padding:.4rem .58rem;font-family:var(--body);font-size:.72rem;color:var(--txt); | |
| outline:none;transition:border-color .12s;} | |
| .fl input:focus,.fl textarea:focus,.fl select:focus{border-color:var(--acc);} | |
| .fl textarea{min-height:130px;line-height:1.65;resize:vertical;} | |
| .fl select option{background:var(--s2);} | |
| #folder-sel option{background:var(--s2);} | |
| #mdl-actions{display:flex;gap:.42rem;margin-top:.85rem;} | |
| #btn-save{flex:1;background:var(--acc);color:#000;border:none;padding:.48rem 1rem; | |
| font-family:var(--font);font-size:.65rem;font-weight:700;letter-spacing:.1em; | |
| text-transform:uppercase;border-radius:5px;cursor:pointer;transition:background .1s;} | |
| #btn-save:hover{background:var(--acc2);} | |
| #btn-mcancel{background:var(--s2);color:var(--sub);border:1px solid var(--bd2);padding:.48rem .9rem; | |
| font-family:var(--font);font-size:.65rem;letter-spacing:.1em;text-transform:uppercase; | |
| border-radius:5px;cursor:pointer;transition:all .1s;} | |
| #btn-mcancel:hover{background:var(--bd2);color:var(--txt);} | |
| #decay-preview{background:var(--s2);border:1px solid var(--bd2);border-radius:6px; | |
| padding:.55rem .75rem;margin-top:.6rem;font-family:var(--font);font-size:.58rem;color:var(--sub);} | |
| #decay-preview strong{color:var(--acc);} | |
| /* TOAST */ | |
| #toasts{position:fixed;bottom:1rem;right:1rem;z-index:200;display:flex;flex-direction:column;gap:.35rem;} | |
| .tst{background:var(--s1);border:1px solid var(--bd2);border-left:3px solid var(--acc); | |
| padding:.42rem .78rem;font-size:.62rem;border-radius:6px;animation:tin .15s ease; | |
| color:var(--txt);max-width:280px;font-family:var(--font);} | |
| .tst.ok{border-left-color:var(--lo);}.tst.err{border-left-color:var(--cr);} | |
| @keyframes tin{from{opacity:0;transform:translateX(12px)}to{opacity:1;transform:none}} | |
| #mcp-hint{position:fixed;bottom:1rem;left:.8rem;z-index:10;background:var(--s1); | |
| border:1px solid var(--bd2);border-left:3px solid var(--sub);border-radius:6px; | |
| padding:.38rem .72rem;font-family:var(--font);font-size:.52rem;color:var(--sub);} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="hdr"> | |
| <div> | |
| <div id="logo">KNOWLEDGE STORE</div> | |
| <div id="logo-sub">10 Containers · Temporal Value Engine · MCP · ki-fusion-labs.de</div> | |
| </div> | |
| <div id="hdr-stats"> | |
| <div class="hs"><span class="hs-n" id="s-total" style="color:var(--txt)">0</span>DOCS</div> | |
| <div class="hs"><span class="hs-n" id="s-avg-val" style="color:var(--acc)">0</span>AVG VALUE</div> | |
| <div class="hs"><span class="freshbadge fb-STALE" id="s-stale">0</span>STALE</div> | |
| <div class="hs"><span class="freshbadge fb-FRESH" id="s-fresh">0</span>FRESH</div> | |
| </div> | |
| <button id="btn-new">+ New Document</button> | |
| </div> | |
| <div id="main"> | |
| <!-- SIDEBAR --> | |
| <div id="sidebar"> | |
| <div class="sb-section"> | |
| <div class="sb-label">Containers</div> | |
| <div class="ctr-item active" id="ctr-all" data-ctr=""> | |
| <div class="ctr-icon">📄</div> | |
| <div class="ctr-info"> | |
| <div class="ctr-name">All Documents</div> | |
| <div class="ctr-meta"><span class="ctr-count" id="cnt-all">0 docs</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="sb-section" id="ctr-list"></div> | |
| <div class="sb-section" id="folder-list" style="display:none"> | |
| <div class="sb-label" id="folder-label">Folders</div> | |
| <div id="folder-items"></div> | |
| </div> | |
| </div> | |
| <!-- LIST --> | |
| <div id="list-col"> | |
| <div id="list-toolbar"> | |
| <input type="text" id="search-inp" placeholder="Search..."> | |
| <button id="search-btn">🔍</button> | |
| <select class="sort-sel" id="sort-sel"> | |
| <option value="relevance">Relevance</option> | |
| <option value="value">Value Score</option> | |
| <option value="newest" selected>Newest</option> | |
| <option value="oldest">Oldest</option> | |
| <option value="importance">Importance</option> | |
| </select> | |
| </div> | |
| <div id="list-scroll"><div id="list-empty" style="font-size:.6rem;color:var(--dim);text-align:center;padding:2rem;">Loading...</div></div> | |
| </div> | |
| <!-- DETAIL --> | |
| <div id="detail-col"> | |
| <div id="detail-scroll"> | |
| <div id="d-empty"> | |
| <div class="big">📄</div> | |
| <div class="msg">Select a document</div> | |
| </div> | |
| <div id="d-content"></div> | |
| </div> | |
| </div> | |
| </div><!-- /main --> | |
| <!-- COMPOSE MODAL --> | |
| <div id="modal"> | |
| <div class="mdl"> | |
| <button id="mdl-close">✕</button> | |
| <div id="mdl-title">NEW KNOWLEDGE DOCUMENT</div> | |
| <div class="fg2"> | |
| <div class="fl"><label>Container *</label> | |
| <select id="m-container"></select></div> | |
| <div class="fl"><label>Folder</label> | |
| <select id="m-folder"></select></div> | |
| </div> | |
| <div class="fl"><label>Title *</label> | |
| <input type="text" id="m-title" placeholder="Document title"></div> | |
| <div class="fl"><label>Body *</label> | |
| <textarea id="m-body" placeholder="Knowledge content (markdown supported in display)..."></textarea></div> | |
| <div class="fl"><label>Summary (one-liner)</label> | |
| <input type="text" id="m-summary" placeholder="Brief description for search results"></div> | |
| <div class="fg3"> | |
| <div class="fl"><label>Author</label> | |
| <input type="text" id="m-author" placeholder="christof"></div> | |
| <div class="fl"><label>Version</label> | |
| <input type="text" id="m-version" placeholder="v1.0"></div> | |
| <div class="fl"><label>Importance (0-10)</label> | |
| <input type="number" id="m-importance" value="5" min="0" max="10"></div> | |
| </div> | |
| <div class="fl"><label>Tags (comma separated)</label> | |
| <input type="text" id="m-tags" placeholder="gdpr, architecture, v3"></div> | |
| <div id="decay-preview">Select a container to see its decay model...</div> | |
| <div id="mdl-actions"> | |
| <button id="btn-save">⚡ Save Document</button> | |
| <button id="btn-mcancel">Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toasts"></div> | |
| <div id="mcp-hint">MCP: <code>ks_write</code> | <code>ks_search</code> | <code>ks_top_value</code></div> | |
| <script> | |
| var CONTAINERS_META = {}; | |
| var ALL_DOCS = []; | |
| var ACTIVE_CTR = ''; | |
| var ACTIVE_FOLDER = ''; | |
| var ACTIVE_ID = null; | |
| var SORT = 'newest'; | |
| var SEARCH_Q = ''; | |
| function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| function tsDate(ts){if(!ts)return ''; return new Date(ts*1000).toLocaleDateString('de-DE',{day:'2-digit',month:'short',year:'2-digit'});} | |
| function tsAgo(ts){ | |
| if(!ts)return ''; | |
| var d=Math.floor((Date.now()/1000)-ts); | |
| if(d<60)return d+'s ago'; if(d<3600)return Math.floor(d/60)+'m ago'; | |
| if(d<86400)return Math.floor(d/3600)+'h ago'; | |
| if(d<86400*30)return Math.floor(d/86400)+'d ago'; | |
| return Math.floor(d/86400/30)+'mo ago'; | |
| } | |
| function toast(msg,t){ | |
| var el=document.createElement('div');el.className='tst'+(t?' '+t:'');el.textContent=msg; | |
| document.getElementById('toasts').appendChild(el);setTimeout(function(){el.remove();},2700); | |
| } | |
| function valueColor(v){ | |
| if(v>=70)return 'var(--lo)'; | |
| if(v>=40)return 'var(--warn)'; | |
| return 'var(--cr)'; | |
| } | |
| function post(url,data){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});} | |
| // ββ Load ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function loadAll(){ | |
| Promise.all([ | |
| fetch('/api/containers').then(function(r){return r.json();}), | |
| fetch('/api/stats').then(function(r){return r.json();}), | |
| ]).then(function(res){ | |
| CONTAINERS_META = res[0]; | |
| var stats = res[1]; | |
| document.getElementById('s-total').textContent = stats.total; | |
| document.getElementById('s-avg-val').textContent = stats.avg_value; | |
| document.getElementById('s-stale').textContent = stats.by_freshness.STALE||0; | |
| document.getElementById('s-fresh').textContent = stats.by_freshness.FRESH||0; | |
| document.getElementById('cnt-all').textContent = stats.total+' docs'; | |
| buildSidebar(); | |
| populateContainerSelect(); | |
| loadDocs(); | |
| }); | |
| } | |
| function buildSidebar(){ | |
| var list = document.getElementById('ctr-list'); | |
| list.innerHTML=''; | |
| var order=['medical','legal','company','research','tech','prompts','history','personal','finance','operations']; | |
| order.forEach(function(key){ | |
| var c = CONTAINERS_META[key]; if(!c) return; | |
| var avgV = c.avg_value||0; | |
| var vc = valueColor(avgV); | |
| var badgeCol = {'FAST-DECAY':'var(--cr)','EXTREME-DECAY':'var(--cr)','CRITICAL-DECAY':'var(--cr)', | |
| 'SLOW-DECAY':'var(--lo)','STABLE':'var(--lo)','ANTI-DECAY':'var(--c-his)', | |
| 'CITATION-CURVE':'var(--c-res)','TIERED-DECAY':'var(--c-com)', | |
| 'MODERATE-DECAY':'var(--warn)','DRIFT-DECAY':'var(--warn)','VERSIONED-DECAY':'var(--warn)'}[c.badge]||'var(--sub)'; | |
| var item = document.createElement('div'); | |
| item.className='ctr-item'+(ACTIVE_CTR==key?' active':''); | |
| item.dataset.ctr=key; | |
| item.innerHTML= | |
| '<div class="ctr-icon">'+c.icon+'</div>' | |
| +'<div class="ctr-info">' | |
| +'<div class="ctr-name">'+esc(c.label)+'</div>' | |
| +'<div class="ctr-meta">' | |
| +'<span class="ctr-count">'+c.count+' docs</span>' | |
| +'<span class="ctr-badge" style="background:'+badgeCol+'18;color:'+badgeCol+';border-color:'+badgeCol+'30">'+c.badge+'</span>' | |
| +'</div>' | |
| +'</div>' | |
| +'<span class="ctr-value" style="color:'+vc+'">'+avgV+'</span>'; | |
| item.addEventListener('click',function(){selectContainer(key);}); | |
| list.appendChild(item); | |
| }); | |
| } | |
| function selectContainer(key){ | |
| ACTIVE_CTR=key; ACTIVE_FOLDER=''; | |
| document.querySelectorAll('.ctr-item,.folder-item').forEach(function(el){ | |
| el.classList.toggle('active',el.dataset.ctr==key||el.dataset.folder==key); | |
| }); | |
| document.getElementById('ctr-all').classList.toggle('active',!key); | |
| // Show folders | |
| var flist=document.getElementById('folder-list'); | |
| var fitems=document.getElementById('folder-items'); | |
| var cfg=CONTAINERS_META[key]; | |
| if(cfg&&cfg.folders&&cfg.folders.length){ | |
| document.getElementById('folder-label').textContent=(cfg.label||key)+' folders'; | |
| flist.style.display='block'; | |
| fitems.innerHTML=''; | |
| cfg.folders.forEach(function(f){ | |
| var fi=document.createElement('div'); | |
| fi.className='folder-item';fi.dataset.folder=f; | |
| fi.textContent='/'+f; | |
| fi.addEventListener('click',function(e){ | |
| e.stopPropagation(); | |
| ACTIVE_FOLDER=f; | |
| document.querySelectorAll('.folder-item').forEach(function(el){el.classList.toggle('active',el.dataset.folder==f);}); | |
| loadDocs(); | |
| }); | |
| fitems.appendChild(fi); | |
| }); | |
| } else { | |
| flist.style.display='none'; | |
| } | |
| loadDocs(); | |
| } | |
| document.getElementById('ctr-all').addEventListener('click',function(){ | |
| ACTIVE_CTR='';ACTIVE_FOLDER=''; | |
| document.querySelectorAll('.ctr-item,.folder-item').forEach(function(el){el.classList.remove('active');}); | |
| document.getElementById('ctr-all').classList.add('active'); | |
| document.getElementById('folder-list').style.display='none'; | |
| loadDocs(); | |
| }); | |
| function loadDocs(){ | |
| var url='/api/docs?sort='+SORT+'&limit=200' | |
| +(ACTIVE_CTR?'&container='+ACTIVE_CTR:'') | |
| +(ACTIVE_FOLDER?'&folder='+ACTIVE_FOLDER:''); | |
| if(SEARCH_Q) url='/api/docs/search?q='+encodeURIComponent(SEARCH_Q)+'&sort='+SORT | |
| +(ACTIVE_CTR?'&container='+ACTIVE_CTR:'')+(ACTIVE_FOLDER?'&folder='+ACTIVE_FOLDER:''); | |
| fetch(url).then(function(r){return r.json();}).then(function(docs){ | |
| ALL_DOCS=docs;renderList(); | |
| }); | |
| } | |
| // ββ Render list βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderList(){ | |
| var scroll=document.getElementById('list-scroll'); | |
| scroll.innerHTML=''; | |
| if(!ALL_DOCS.length){ | |
| var e=document.createElement('div'); | |
| e.style.cssText='font-size:.6rem;color:var(--dim);text-align:center;padding:2rem;'; | |
| e.textContent='No documents';scroll.appendChild(e);return; | |
| } | |
| ALL_DOCS.forEach(function(d){scroll.appendChild(makeCard(d));}); | |
| } | |
| function makeCard(d){ | |
| var ctr=CONTAINERS_META[d.container]||{}; | |
| var col=ctr.color||'var(--acc)'; | |
| var val=d._value||0; | |
| var vc=valueColor(val); | |
| var fl=d._freshness||'FRESH'; | |
| var card=document.createElement('div'); | |
| card.className='dc'+(ACTIVE_ID==d.id?' active':''); | |
| card.id='dc-'+d.id; | |
| card.style.setProperty('--ctr-color',col); | |
| card.style.borderLeft='3px solid '+col+'55'; | |
| var tags=(d.tags||[]).slice(0,2).map(function(t){return '<span class="dc-tag">'+esc(t)+'</span>';}).join(''); | |
| var valBar='<div class="vg"><div class="vg-bar"><div class="vg-fill" style="width:'+val+'%;background:'+vc+'"></div></div></div>'; | |
| card.innerHTML= | |
| '<div class="dc-top">' | |
| +'<div class="dc-title">'+esc(d.title)+'</div>' | |
| +'<div class="dc-val" style="color:'+vc+'">'+val+'</div>' | |
| +'</div>' | |
| +(d.summary?'<div class="dc-preview">'+esc(d.summary)+'</div>':'<div class="dc-preview">'+esc((d.body||'').substring(0,90))+'</div>') | |
| +'<div class="dc-foot">' | |
| +'<span class="freshbadge fb-'+fl+'">'+fl+'</span>' | |
| +tags | |
| +'<span class="dc-folder">'+esc(d.folder)+'</span>' | |
| +'<span class="dc-date">'+tsAgo(d.created_at)+'</span>' | |
| +'</div>'; | |
| card.addEventListener('click',function(){selectDoc(d);}); | |
| return card; | |
| } | |
| // ββ Detail ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function selectDoc(d){ | |
| ACTIVE_ID=d.id; | |
| document.querySelectorAll('.dc').forEach(function(c){c.classList.toggle('active',c.id=='dc-'+d.id);}); | |
| // Fetch fresh with access bump | |
| fetch('/api/docs/'+d.container+'/'+d.folder+'/'+d.id) | |
| .then(function(r){return r.json();}).then(function(doc){renderDetail(doc);}) | |
| .catch(function(){renderDetail(d);}); | |
| } | |
| function renderDetail(d){ | |
| document.getElementById('d-empty').style.display='none'; | |
| var dc=document.getElementById('d-content');dc.style.display='block'; | |
| var ctr=CONTAINERS_META[d.container]||{}; | |
| var col=ctr.color||'var(--acc)'; | |
| var val=d._value||0; | |
| var vc=valueColor(val); | |
| var fl=d._freshness||'FRESH'; | |
| var hl=ctr.half_life_days; | |
| var model=ctr.decay_model||'exponential'; | |
| var tags=(d.tags||[]).map(function(t){return '<span class="d-tag">'+esc(t)+'</span>';}).join(''); | |
| var ageDays=Math.floor((Date.now()/1000-d.created_at)/86400); | |
| // Value meter | |
| var modelDesc={ | |
| 'stable':'No time decay — value remains constant.', | |
| 'anti_decay':'Value INCREASES with age (archival pattern).', | |
| 'citation_curve':'Peaks at day '+((ctr.peak_days||30))+', then slow decay.', | |
| 'extreme_decay':'Extreme decay. Half-life: '+(hl||7)+' days.', | |
| 'exponential':'Exponential decay. Half-life: '+(hl||180)+' days.', | |
| 'slow_exponential':'Slow decay. Half-life: '+(hl||730)+' days.', | |
| 'tiered':'Folder-dependent half-life (people 60d, market 14d, sop 365d).', | |
| 'versioned_decay':'Fast versioned decay. Half-life: '+(hl||90)+' days.', | |
| 'drift_decay':'Preference drift decay. Half-life: '+(hl||180)+' days.', | |
| 'operational_decay':'Operational decay. Half-life: '+(hl||180)+' days.', | |
| }[model]||'Standard decay model.'; | |
| var chips='<div class="decay-chips">' | |
| +'<div class="decay-chip">age: '+ageDays+'d</div>' | |
| +(hl?'<div class="decay-chip">half-life: '+hl+'d</div>':'') | |
| +'<div class="decay-chip">model: '+esc(model)+'</div>' | |
| +'<div class="decay-chip">accessed: '+(d.access_count||0)+'x</div>' | |
| +(d.version?'<div class="decay-chip">ver: '+esc(d.version)+'</div>':'') | |
| +'</div>'; | |
| dc.innerHTML= | |
| '<div class="d-ctr-hdr">' | |
| +'<div class="d-ctr-icon">'+ctr.icon+'</div>' | |
| +'<div class="d-ctr-info">' | |
| +'<div class="d-ctr-label" style="color:'+col+'">'+esc(ctr.label||d.container)+'</div>' | |
| +'<div class="d-ctr-note">'+esc(ctr.note||'')+'</div>' | |
| +'</div>' | |
| +'<span class="freshbadge fb-'+fl+'">'+fl+'</span>' | |
| +'</div>' | |
| +'<div class="value-meter">' | |
| +'<div><div class="vm-score" style="color:'+vc+'">'+val+'</div><div class="vm-label">value score</div></div>' | |
| +'<div class="vm-info">' | |
| +'<div class="vm-model">'+modelDesc+'</div>' | |
| +'<div class="vm-bar-wrap"><div class="vm-bar-fill" style="width:'+val+'%;background:'+vc+'"></div></div>' | |
| +'<div class="vm-meta"><span>0 (worthless)</span><span>50 (relevant)</span><span>100 (critical)</span></div>' | |
| +chips | |
| +'</div>' | |
| +'</div>' | |
| +'<div id="d-title">'+esc(d.title)+'</div>' | |
| +'<div id="d-body">'+esc(d.body||'')+'</div>' | |
| +(tags?'<div class="d-tags">'+tags+'</div>':'') | |
| +'<div class="d-meta-grid">' | |
| +'<div><div class="dml">Author</div><div class="dmv">'+(d.author||'—')+'</div></div>' | |
| +'<div><div class="dml">Folder</div><div class="dmv">'+esc(d.folder)+'</div></div>' | |
| +'<div><div class="dml">Importance</div><div class="dmv">'+d.importance+'/10</div></div>' | |
| +'<div><div class="dml">Created</div><div class="dmv">'+tsDate(d.created_at)+'</div></div>' | |
| +'<div><div class="dml">Updated</div><div class="dmv">'+tsDate(d.updated_at)+'</div></div>' | |
| +'<div><div class="dml">Source</div><div class="dmv">'+(d.source||'—')+'</div></div>' | |
| +'</div>' | |
| +'<div class="d-acts">' | |
| +'<button class="d-btn acc" id="d-edit">✎ Edit</button>' | |
| +'<button class="d-btn danger" id="d-del">🗑 Delete</button>' | |
| +'</div>'; | |
| document.getElementById('d-edit').addEventListener('click',function(){openModal(d);}); | |
| document.getElementById('d-del').addEventListener('click',function(){deleteDoc(d);}); | |
| } | |
| function deleteDoc(d){ | |
| if(!confirm('Delete "'+d.title+'"?'))return; | |
| fetch('/api/docs/'+d.container+'/'+d.folder+'/'+d.id,{method:'DELETE'}).then(function(){ | |
| toast('Deleted','ok');ACTIVE_ID=null; | |
| document.getElementById('d-empty').style.display='flex'; | |
| document.getElementById('d-content').style.display='none'; | |
| loadAll(); | |
| }); | |
| } | |
| // ββ Search / sort βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('search-btn').addEventListener('click',function(){ | |
| SEARCH_Q=document.getElementById('search-inp').value.trim();loadDocs();}); | |
| document.getElementById('search-inp').addEventListener('keydown',function(e){ | |
| if(e.key=='Enter'){SEARCH_Q=this.value.trim();loadDocs();} | |
| if(e.key=='Escape'){this.value='';SEARCH_Q='';loadDocs();} | |
| }); | |
| document.getElementById('sort-sel').addEventListener('change',function(){SORT=this.value;loadDocs();}); | |
| // ββ Modal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function populateContainerSelect(){ | |
| var sel=document.getElementById('m-container'); | |
| sel.innerHTML=''; | |
| var order=['medical','legal','company','research','tech','prompts','history','personal','finance','operations']; | |
| order.forEach(function(k){ | |
| var c=CONTAINERS_META[k]||{}; | |
| var o=document.createElement('option');o.value=k;o.textContent=c.label||k;sel.appendChild(o); | |
| }); | |
| updateFolderSelect(); | |
| updateDecayPreview(); | |
| } | |
| function updateFolderSelect(){ | |
| var ctr=document.getElementById('m-container').value; | |
| var c=CONTAINERS_META[ctr]||{}; | |
| var sel=document.getElementById('m-folder'); | |
| sel.innerHTML=''; | |
| (c.folders||['general']).forEach(function(f){ | |
| var o=document.createElement('option');o.value=f;o.textContent=f;sel.appendChild(o); | |
| }); | |
| } | |
| function updateDecayPreview(){ | |
| var ctr=document.getElementById('m-container').value; | |
| var c=CONTAINERS_META[ctr]||{}; | |
| var model=c.decay_model||'exponential'; | |
| var hl=c.half_life_days; | |
| var warn=c.warn_after_days; | |
| var msgs={ | |
| 'stable':'<strong>STABLE</strong> — Value does not decay over time. Great for reference prompts and fixed facts.', | |
| 'anti_decay':'<strong>ANTI-DECAY</strong> — Value INCREASES over time. Historical records become more valuable as context.', | |
| 'citation_curve':'<strong>CITATION CURVE</strong> — Peaks at day '+(c.peak_days||30)+', then slow exponential decay (HL: '+hl+'d). Like academic papers.', | |
| 'extreme_decay':'<strong>EXTREME DECAY</strong> — Half-life '+(hl||7)+' days. Market data is near-worthless after a week.', | |
| 'tiered':'<strong>TIERED</strong> — Half-life depends on folder: market=14d, people=60d, projects=90d, sop=365d.', | |
| 'versioned_decay':'<strong>VERSIONED DECAY</strong> — Half-life '+(hl||90)+' days. Tag with version number to track relevance.', | |
| 'exponential':'<strong>EXPONENTIAL</strong> — Standard decay. Half-life '+(hl||180)+' days. Warning after '+(warn||90)+' days.', | |
| 'slow_exponential':'<strong>SLOW DECAY</strong> — Half-life '+(hl||730)+' days. Laws change slowly.', | |
| 'drift_decay':'<strong>DRIFT DECAY</strong> — Preferences drift. Half-life '+(hl||180)+' days.', | |
| 'operational_decay':'<strong>OPERATIONAL</strong> — Runbooks age with infra. Half-life '+(hl||180)+' days. Keep versioned.', | |
| }; | |
| document.getElementById('decay-preview').innerHTML= | |
| (msgs[model]||'Standard decay model.') | |
| +'<br><span style="color:var(--acc)">Container: '+esc(c.label||ctr)+'</span> — ' | |
| +esc(c.note||''); | |
| } | |
| document.getElementById('m-container').addEventListener('change',function(){updateFolderSelect();updateDecayPreview();}); | |
| function openModal(doc){ | |
| document.getElementById('mdl-title').textContent=doc?'EDIT DOCUMENT':'NEW KNOWLEDGE DOCUMENT'; | |
| if(doc){ | |
| document.getElementById('m-container').value=doc.container; | |
| updateFolderSelect(); | |
| document.getElementById('m-folder').value=doc.folder; | |
| document.getElementById('m-title').value=doc.title; | |
| document.getElementById('m-body').value=doc.body||''; | |
| document.getElementById('m-summary').value=doc.summary||''; | |
| document.getElementById('m-author').value=doc.author||''; | |
| document.getElementById('m-version').value=doc.version||''; | |
| document.getElementById('m-importance').value=doc.importance||5; | |
| document.getElementById('m-tags').value=(doc.tags||[]).join(', '); | |
| document.getElementById('btn-save').dataset.editId=doc.container+'|'+doc.folder+'|'+doc.id; | |
| } else { | |
| document.getElementById('btn-save').dataset.editId=''; | |
| ['m-title','m-body','m-summary','m-author','m-version','m-tags'].forEach(function(id){ | |
| document.getElementById(id).value='';}); | |
| document.getElementById('m-importance').value='5'; | |
| } | |
| updateDecayPreview(); | |
| document.getElementById('modal').classList.add('open'); | |
| setTimeout(function(){document.getElementById('m-title').focus();},80); | |
| } | |
| function closeModal(){document.getElementById('modal').classList.remove('open');} | |
| document.getElementById('btn-new').addEventListener('click',function(){openModal();}); | |
| document.getElementById('mdl-close').addEventListener('click',closeModal); | |
| document.getElementById('btn-mcancel').addEventListener('click',closeModal); | |
| document.getElementById('modal').addEventListener('click',function(e){if(e.target===this)closeModal();}); | |
| document.getElementById('btn-save').addEventListener('click',function(){ | |
| var title=document.getElementById('m-title').value.trim(); | |
| var body=document.getElementById('m-body').value.trim(); | |
| if(!title){document.getElementById('m-title').focus();toast('Title required','err');return;} | |
| if(!body){document.getElementById('m-body').focus();toast('Body required','err');return;} | |
| var tags=document.getElementById('m-tags').value.split(',').map(function(t){return t.trim();}).filter(Boolean); | |
| var pay={ | |
| container:document.getElementById('m-container').value, | |
| folder:document.getElementById('m-folder').value, | |
| title:title,body:body, | |
| summary:document.getElementById('m-summary').value.trim(), | |
| author:document.getElementById('m-author').value.trim(), | |
| version:document.getElementById('m-version').value.trim(), | |
| importance:parseInt(document.getElementById('m-importance').value)||5, | |
| tags:tags | |
| }; | |
| var editKey=this.dataset.editId; | |
| if(editKey){ | |
| var parts=editKey.split('|'); | |
| fetch('/api/docs/'+parts[0]+'/'+parts[1]+'/'+parts[2], | |
| {method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(pay)}) | |
| .then(function(r){return r.json();}).then(function(d){ | |
| toast('Updated','ok');closeModal();loadAll(); | |
| setTimeout(function(){if(d.doc)renderDetail(d.doc);},300); | |
| }).catch(function(e){toast('Error: '+e.message,'err');}); | |
| } else { | |
| post('/api/docs',pay).then(function(r){return r.json();}).then(function(d){ | |
| toast('Saved to '+pay.container+'/'+pay.folder,'ok');closeModal();loadAll(); | |
| }).catch(function(e){toast('Error: '+e.message,'err');}); | |
| } | |
| }); | |
| document.addEventListener('keydown',function(e){ | |
| if(e.key==='Escape')closeModal(); | |
| var a=document.activeElement; | |
| var typing=a&&(a.tagName==='INPUT'||a.tagName==='TEXTAREA'||a.tagName==='SELECT'); | |
| if(e.key==='n'&&!typing&&!e.ctrlKey&&!e.metaKey)openModal(); | |
| if((e.ctrlKey||e.metaKey)&&e.key==='Enter'&&document.getElementById('modal').classList.contains('open')) | |
| document.getElementById('btn-save').click(); | |
| if(e.key==='/'&&!typing){e.preventDefault();document.getElementById('search-inp').focus();} | |
| }); | |
| loadAll(); | |
| </script> | |
| </body> | |
| </html>""" | |